{{ entry.data.added }}
{% for t in entry.data.tags %}
- {{ t }}
{% endfor %}
{% endfor %}
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.7375417
lesana-0.9.1/docs/examples/books/ 0000755 0001777 0001777 00000000000 00000000000 017024 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/examples/books/README.txt 0000644 0001777 0001777 00000000312 00000000000 020516 0 ustar 00valhalla valhalla This is an example lesana collection for books.
The file ``import/from_tellico.yaml`` can be used with ``lesana export``
from a book collection generated by ``tellico2lesana`` from a tellico 3.x
file.
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.7375417
lesana-0.9.1/docs/examples/books/import/ 0000755 0001777 0001777 00000000000 00000000000 020336 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/examples/books/import/from_tellico.yaml 0000644 0001777 0001777 00000003036 00000000000 023702 0 ustar 00valhalla valhalla asd: '{{ comments }}'
title: '{{ title if title }}'
subtitle: '{ subtitle if subtitle }}'
authors: {% if authors %}{% for a in authors %}
- "{{ a }}"{% endfor %}{% else %}[]{% endif %}
editors: {% if editors %}{% for e in editors %}
- "{{ e }}"{% endfor %}{% else %}[]{% endif %}
binding: '{{ binding if binding}}'
purchase_date: '{{ pur_date if pur_date }}'
purchase_price: '{{ pur_price if pur_price }}'
publisher: '{{ publisher if publisher }}'
edition: '{{ edition if edition }}'
copyright_year: {% if cr_years %}{% for y in cr_years %}
- "{{ y }}"{% endfor %}{% else %}[]{% endif %}
publication_year: {{ pub_year if pub_year }}
isbn: '{{ isbn if isbn }}'
lccn: '{{ iccn if iccn }}'
pages: {{ pages if pages}}
translators: {% if translators %}{% for t in translators %}
- "{{ t }}"{% endfor %}{% else %}[]{% endif %}
languages: {% if languages %}{% for l in languages %}
- "{{ l }}"{% endfor %}{% else %}[]{% endif %}
genres: {% if genres %}{% for g in genres %}
- "{{ g }}"{% endfor %}{% else %}[]{% endif %}
keywords: {% if keywords %}{% for k in keywords %}
- "{{ k }}"{% endfor %}{% else %}[]{% endif %}
series: '{{ series if series}}'
series_number: {{ series_num if series_num }}
condition: '{{ condition if condition}}'
signed: {{ signed if signed}}
read: {{ read if read }}
gift: {{ gift if gift }}
loaned: {{ loaned if loaned }}
rating: {{ rating if rating }}
cover: {{ cover if cover }}
plot: |
{{ plot if plot else '.' | indent(width=2, first=False) }}
comments: |
{{ comments if comments else '.' | indent(width=2, first=False) }}
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/examples/books/settings.yaml 0000644 0001777 0001777 00000002704 00000000000 021553 0 ustar 00valhalla valhalla name: My Books
lang: english
entry_label: '{{ short_id }}: {{ authors | join("; ") }} - {{ title }}'
git: false
fields:
- name: title
type: string
index: free
- name: subtitle
type: string
index: free
- name: authors
type: list
list: string
index: free
- name: editors
type: list
list: string
index: free
- name: binding
type: string
- name: purchase_date
type: string
index: field
- name: purchase_price
type: string
index: field
- name: publisher
type: string
index: field
- name: edition
type: string
- name: copyright_year
type: list
list: integer
index: field
- name: publication_year
type: integer
index: field
- name: isbn
type: string
index: field
- name: lccn
type: string
index: field
- name: pages
type: integer
- name: translators
type: list
list: string
- name: languages
type: list
list: string
index: field
- name: genres
type: list
list: string
index: field
- name: keywords
type: list
list: string
index: field
- name: series
type: string
index: field
- name: series_number
type: integer
- name: condition
type: string
index: field
- name: signed
type: boolean
index: field
- name: read
type: boolean
index: field
- name: gift
type: boolean
index: field
- name: loaned
type: boolean
index: field
- name: rating
type: integer
index: field
- name: cover
type: file
- name: plot
type: text
index: field
- name: comments
type: text
index: field
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.7375417
lesana-0.9.1/docs/examples/books/templates/ 0000755 0001777 0001777 00000000000 00000000000 021022 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1631823357.0
lesana-0.9.1/docs/examples/books/templates/from_openlibrary.yaml 0000644 0001777 0001777 00000001272 00000000000 025261 0 ustar 00valhalla valhalla title: '{{ edition.title }}'
subtitle: ''
authors: {% if authors %}{% for a in authors %}
- {{ a.name }}{% endfor %}{% else %}[]{% endif %}
editors: []
binding: ''
purchase_date: ''
purchase_price: ''
publisher: '{{ edition.publishers | join(", ") }}'
edition: ''
copyright_year: []
publication_year: {{ pub_date.year }}
isbn: '{{ edition.isbn_13.0 }}'
lccn: ''
openlibrary: 'https://openlibrary.org{{ edition.key }}'
pages: {{ edition.number_of_pages }}
translators: []
languages: {% if langs %}{% for l in langs %}
- {{ l }}{% endfor %}{% else %}[]{% endif %}
genres: []
keywords: []
series: ''
series_number: 0
condition: ''
signed:
read:
gift:
loaned:
rating: 0
cover: ''
plot: ''
comments: ''
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.7415416
lesana-0.9.1/docs/examples/music/ 0000755 0001777 0001777 00000000000 00000000000 017027 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/examples/music/README.txt 0000644 0001777 0001777 00000000305 00000000000 020523 0 ustar 00valhalla valhalla This is an example lesana collection for music.
The file ``import/from_tellico.yaml`` can be used with ``lesana export``
from a collection generated by ``tellico2lesana`` from a tellico 3.x file.
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.7415416
lesana-0.9.1/docs/examples/music/import/ 0000755 0001777 0001777 00000000000 00000000000 020341 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/examples/music/import/from_tellico.yaml 0000644 0001777 0001777 00000001411 00000000000 023700 0 ustar 00valhalla valhalla title: "{{ title }}"
artists: {% if artists %}{% for a in artists %}
- "{{ a }}"{% endfor %}{% else %}[]{% endif %}
label: {% if labels %}"{{ labels | join("; ") }}"{% else %}''{% endif %}
year: '{{ year }}'
genres: {% if genres %}{% for g in genres %}
- "{{ g }}"{% endfor %}{% endif %}
# medium (string): CD|DVD|Cassette|Vynil
medium: '{{ medium }}'
purchase_date: '{{ pur_date }}'
purchase_price: '{{ pur_price }}'
gift: "{{ gift }}"
tracks: {% for t in tracks %}
- title: "{{ t[0] }}"
artist: "{{ t[1] | default('') | replace('None', '') }}"
length: "{{ t[2] | default('') | replace('None', '') }}"
side: "{{ t[3] | default('') | replace('None', '') }}"{% else %}[]{% endfor %}
comments: |
{{ comments | default('.') | indent(width=4, first=False) }}
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/examples/music/settings.yaml 0000644 0001777 0001777 00000001175 00000000000 021557 0 ustar 00valhalla valhalla name: My Music
lang: english
entry_label: '{{ short_id }}: {{ artists | join("; ") }} / {{ title }}'
git: true
fields:
- name: title
type: string
index: free
- name: artists
type: list
list: string
index: free
- name: label
type: string
index: field
- name: year
type: integer
index: field
- name: genres
type: list
list: string
index: field
- name: medium
type: string
help: 'CD|DVD|Cassette|Vinyl|File'
index: field
- name: purchase_date
type: string
- name: purchase_price
type: string
- name: gift
type: boolean
- name: tracks
type: list
list: yaml
- name: comments
type: text
index: free
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.7495415
lesana-0.9.1/docs/examples/ticket_tracker/ 0000755 0001777 0001777 00000000000 00000000000 020705 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1631823357.0
lesana-0.9.1/docs/examples/ticket_tracker/README.rst 0000644 0001777 0001777 00000000342 00000000000 022373 0 ustar 00valhalla valhalla ###########
Our Tasks
###########
This is an example on how to use lesana to manage a personal / few users
ticket tracker.
The file ``aliases.sh`` can be sourced (``. ./aliases.sh``) to provide
helpers for common searches.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1631980831.0
lesana-0.9.1/docs/examples/ticket_tracker/aliases.sh 0000755 0001777 0001777 00000000556 00000000000 022673 0 ustar 00valhalla valhalla #!/bin/sh
LTT_STS_OPEN='\(status:planned OR status:ready OR status:"in progress" OR status:comments\)'
LTT_STS_ACTIVE='\(status:"in progress" OR status:comments\)'
LTT_STS_CLOSED='\(status:done OR status:invalid\)'
alias ltt-s-open="lesana search $LTT_STS_OPEN"
alias ltt-s-active="lesana search $LTT_STS_ACTIVE"
alias ltt-s-closed="lesana search $LTT_STS_CLOSED"
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640337836.0
lesana-0.9.1/docs/examples/ticket_tracker/settings.yaml 0000644 0001777 0001777 00000001316 00000000000 023432 0 ustar 00valhalla valhalla name: Our tasks
lang: english
entry_label: '{{ short_id }}: {{ name }} [{{ status }}]'
git: true
search_aliases:
open: '(status:planned OR status:ready OR status:"in progress" OR status:comments)'
active: '(status:"in progress" OR status:comments)'
closed: '(status:done OR status:invalid)'
fields:
- name: name
type: string
prefix: S
index: free
- name: description
type: text
index: free
- name: status
type: text
index: field
sortable: true
values:
- planned
- ready
- in progress
- comments
- done
- invalid
- name: assignee
type: string
index: field
help: insert your list of potential assignees here
- name: comments
type: list
list: text
index: free
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/field_types.rst 0000644 0001777 0001777 00000000445 00000000000 017135 0 ustar 00valhalla valhalla -------------
Field Types
-------------
string:
a string of unicode text.
text:
a longer block of unicode text;
integer:
.
float:
.
decimal:
.
timestamp:
.
datetime:
.
date:
.
boolean:
.
file:
.
url:
.
geo:
A Geo URI.
list:
.
yaml:
.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/make.bat 0000644 0001777 0001777 00000001437 00000000000 015503 0 ustar 00valhalla valhalla @ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.7695413
lesana-0.9.1/docs/source/ 0000755 0001777 0001777 00000000000 00000000000 015371 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640962752.0
lesana-0.9.1/docs/source/conf.py 0000644 0001777 0001777 00000006315 00000000000 016675 0 ustar 00valhalla valhalla # Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
# import os
# import sys
# sys.path.insert(0, os.path.abspath('.'))
# -- Project information -----------------------------------------------------
project = 'lesana'
copyright = "2020-2021, Elena Grandi"
author = "Elena ``of Valhalla''"
# The full version, including alpha/beta/rc tags
release = '0.9.1'
# The major project version
version = '0.10'
# compatibility with sphinx 1.8 on buster
master_doc = 'index'
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.viewcode',
]
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = []
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = 'alabaster'
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
man_pages = [
(
'man/lesana', 'lesana',
'manages collection inventories',
'valhalla@trueelena.org', 1
),
(
'man/lesana-edit', 'lesana-edit',
'edits an existing lesana entry',
'valhalla@trueelena.org', 1
),
(
'man/lesana-export', 'lesana-export',
'export data from one lesana collection to another',
'valhalla@trueelena.org', 1
),
(
'man/lesana-index', 'lesana-index',
'Index some entries',
'valhalla@trueelena.org', 1
),
(
'man/lesana-init', 'lesana-init',
'initialize a lesana collection',
'valhalla@trueelena.org', 1
),
(
'man/lesana-new', 'lesana-new',
'create a new lesana entry',
'valhalla@trueelena.org', 1
),
(
'man/lesana-rm', 'lesana-rm',
'remove an entry from a lesana collection',
'valhalla@trueelena.org', 1
),
(
'man/lesana-search', 'lesana-search',
'search inside a lesana collection',
'valhalla@trueelena.org', 1
),
(
'man/lesana-show', 'lesana-show',
'show a lesana entry',
'valhalla@trueelena.org', 1
),
]
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.7695413
lesana-0.9.1/docs/source/contrib/ 0000755 0001777 0001777 00000000000 00000000000 017031 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/contrib/index.rst 0000644 0001777 0001777 00000000330 00000000000 020666 0 ustar 00valhalla valhalla ###########################
Contributor Documentation
###########################
Documentation that is useful for contributors of lesana.
.. toctree::
:maxdepth: 2
:caption: Contents:
release_procedure
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640342378.0
lesana-0.9.1/docs/source/contrib/release_procedure.rst 0000644 0001777 0001777 00000001337 00000000000 023257 0 ustar 00valhalla valhalla *******************
Release procedure
*******************
* Check that the version number in setup.py and in docs/source/conf.py
is correct.
* Check that the changelog is up to date.
* Generate the distribution files::
$ python3 setup.py sdist bdist_wheel
* Upload ::
$ twine upload -s dist/*
* Tag the uploaded version::
$ git tag -s v$VERSION
$ git push
$ git push --tags
for the tag content use something like::
Version $VERSION
* contents
* of the relevant
* changelog
* Send the release announce to::
valhalla/lesana-announce@lists.sr.ht, ~valhalla/lesana-discuss@lists.sr.ht
* Close the bugs marked as pending_release on
https://todo.sr.ht/~valhalla/lesana.
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.8015406
lesana-0.9.1/docs/source/devel/ 0000755 0001777 0001777 00000000000 00000000000 016470 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/devel/index.rst 0000644 0001777 0001777 00000000340 00000000000 020326 0 ustar 00valhalla valhalla #########################
Developer Documentation
#########################
Documentation that is useful for developers who are using lesana as a
library.
.. toctree::
:maxdepth: 2
:caption: Contents:
promises
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/devel/promises.rst 0000644 0001777 0001777 00000001752 00000000000 021070 0 ustar 00valhalla valhalla ********
Promises
********
Semantic versioning
===================
This project uses semver_.
.. _semver: http://semver.org/
Collection format stability
===========================
Future versions of lesana will be able to read collections written by
older versions.
Older versions in the same mayor release will also be able to work
concurrently on the same repository.
If in the future a change of formats will be required, conversions
scripts will be written in a way that will make them as stable as
possibile, and will have enought test data to keep them maintained for
the time being.
Disposable cache
================
Contrary to the yaml files, the xapian cache is considered disposable:
from time to time there may be a need to delete the cache and reindex
everything, either because of an upgrade or to perform repository
mainteinance.
Of course, effort will be made to reduce the need for this so that it
only happens sporadically, but it will probably never completely
disappear.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640348093.0
lesana-0.9.1/docs/source/index.rst 0000644 0001777 0001777 00000002447 00000000000 017241 0 ustar 00valhalla valhalla .. lesana documentation master file, created by
sphinx-quickstart on Thu Oct 1 22:28:26 2020.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
.. include:: ../../README.rst
Documentation
-------------
The documentation for the latest development version of lesana can be
browsed online at https://lesana.trueelena.org; `PDF
`_ and `epub
`_ versions are also
available [#onion]_.
.. [#onion] Everything is also available via onion, at
http://aublvconhsld6cvcf3dbibffzih2un5bicp3s3b5qmkskof26p3pssqd.onion/
The author can be contacted via email: webmaster AT trueelena DOT org.
.. only:: html
Status Badges
-------------
Packaging
^^^^^^^^^
.. image:: https://repology.org/badge/vertical-allrepos/lesana.svg
:target: https://repology.org/project/lesana/versions
CI
^^
.. image:: https://builds.sr.ht/~valhalla/lesana.svg
:target: https://builds.sr.ht/~valhalla/lesana
Contents
--------
.. toctree::
:maxdepth: 2
:caption: Contents:
user/index
devel/index
contrib/index
man/index
reference/modules
Indices and tables
------------------
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.8135405
lesana-0.9.1/docs/source/man/ 0000755 0001777 0001777 00000000000 00000000000 016144 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/man/Makefile 0000644 0001777 0001777 00000000214 00000000000 017601 0 ustar 00valhalla valhalla
MAN_TARGETS = $(patsubst %.rst,%.1,$(wildcard *.rst))
all: $(MAN_TARGETS)
%.1: %.rst
rst2man $< > $@
clean:
rm *.1
.PHONY: all clean
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/man/index.rst 0000644 0001777 0001777 00000000355 00000000000 020010 0 ustar 00valhalla valhalla *********
Man Pages
*********
.. toctree::
:maxdepth: 2
:caption: Contents:
lesana-edit
lesana-export
lesana-index
lesana-init
lesana-new
lesana-rm
lesana
lesana-search
lesana-get-values
lesana-show
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/man/lesana-edit.rst 0000644 0001777 0001777 00000001146 00000000000 021066 0 ustar 00valhalla valhalla ===========
lesana-edit
===========
SYNOPSIS
========
lesana edit [--help] [--collection ] [--no-git]
DESCRIPTION
===========
Lesana edit will open an existing entry (specified by id or partial id)
in an editor, so that it can be changed.
If the collection is configured to use git, after the editor has been
closed, it will add the file to the git staging area, unless
``--no-git`` is given.
OPTIONS
=======
-h, --help
Prints an help message and exits.
--collection COLLECTION, -c COLLECTION
The collection to work on. Default is ``.``
--no-git
Don't add the new entry to git.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/man/lesana-export.rst 0000644 0001777 0001777 00000000746 00000000000 021467 0 ustar 00valhalla valhalla =============
lesana-export
=============
SYNOPSIS
========
lesana export [-h] [--collection COLLECTION] [--query QUERY] destination template
DESCRIPTION
===========
Lesana export converts entries from one lesana collection to another,
using a jinja2 template.
OPTIONS
=======
-h, --help
Prints an help message and exits.
--collection COLLECTION, -c COLLECTION
The collection to work on. Default is ``.``
--query QUERY, -q QUERY
Xapian query to search in the collection
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/man/lesana-get-values.rst 0000644 0001777 0001777 00000001514 00000000000 022214 0 ustar 00valhalla valhalla =================
lesana-get-values
=================
SYNOPSIS
========
lesana search [--help] [--collection COLLECTION] [--template TEMPLATE] \
--field FIELD [query [query ...]]
DESCRIPTION
===========
Lesana get-values will list all values found in a field and the number
of entries where that value has been found.
A template can be specified to format the results.
Extracting the values from a sortable field is significantly more
efficient than doing so from a non-sortable field, but adding too many
sortable fields can make general searches and indexing slower.
OPTIONS
=======
-h, --help
Prints an help message and exits.
--collection COLLECTION, -c COLLECTION
The collection to work on. Default is ``.``
--template TEMPLATE, -t TEMPLATE
Template to use when displaying results
--field
Name of the desired field.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/man/lesana-index.rst 0000644 0001777 0001777 00000000762 00000000000 021253 0 ustar 00valhalla valhalla ============
lesana-index
============
SYNOPSIS
========
lesana index [--help] [--collection COLLECTION] [files [files ...]]
DESCRIPTION
===========
Lesana index adds some entries to the xapian cache, listed by filename
(by default all of the files found in the items directory).
OPTIONS
=======
-h, --help
Prints an help message and exits.
--collection COLLECTION, -c COLLECTION
The collection to work on. Default is ``.``
--reset
Delete the existing xapian cache before indexing.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/man/lesana-init.rst 0000644 0001777 0001777 00000002141 00000000000 021100 0 ustar 00valhalla valhalla ===========
lesana-init
===========
SYNOPSIS
========
lesana init [--help] [--collection ] [--no-git]
DESCRIPTION
===========
lesana init initializes a new lesana collection.
It will create the directory (if it does not exist) and, unless
``--no-git`` is specified it will initialize it as a git repository,
create a ``.gitignore`` file with some relevant contents and add hooks
to update the local cache when the files are changed via git.
It will then create a skeleton ``settings.yaml`` file and open it in an
editor to start configuring the collection.
When leaving the editor, again unless ``--no-git`` is used, it will add
this file to the git staging area, but not commit it.
It is safe to run this command on an existing repository, e.g. to
install the hooks on a new clone, but it will overwrite the hooks
themselves even if they have been changed by the user.
OPTIONS
=======
--help, -h
Prints an help message and exits.
--collection COLLECTION, -c COLLECTION
The directory where the collection will be initialized. Default is .
--no-git
Do not use git in the current collection.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/man/lesana-new.rst 0000644 0001777 0001777 00000001134 00000000000 020727 0 ustar 00valhalla valhalla ==========
lesana-new
==========
SYNOPSIS
========
lesana new [--help] [--collection ] [--no-git]
DESCRIPTION
===========
Lesana new creates a new lesana entry.
It will create an empty entry and open an editor so that it can be
filled.
If the collection is configured to use git, after the editor has been
closed, it will add the file to the git staging area, unless
``--no-git`` is given.
OPTIONS
=======
-h, --help
Prints an help message and exits.
--collection COLLECTION, -c COLLECTION
The collection to work on. Default is ``.``
--no-git
Don't add the new entry to git.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/man/lesana-rm.rst 0000644 0001777 0001777 00000000601 00000000000 020552 0 ustar 00valhalla valhalla =========
lesana-rm
=========
SYNOPSIS
========
lesana rm [-h] [--collection COLLECTION] entries [entries ...]
DESCRIPTION
===========
Lesana rm removes an entry from the collection, removing both the file
and the cached entry.
OPTIONS
=======
-h, --help
Prints an help message and exits.
--collection COLLECTION, -c COLLECTION
The collection to work on. Default is ``.``
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640337421.0
lesana-0.9.1/docs/source/man/lesana-search.rst 0000644 0001777 0001777 00000003213 00000000000 021403 0 ustar 00valhalla valhalla =============
lesana-search
=============
SYNOPSIS
========
lesana search [--help] [--collection COLLECTION] [--template TEMPLATE] \
[--offset OFFSET] [--pagesize PAGESIZE] [--all] \
[--expand-query-template] [--sort FIELD1 [--sort FIELD2 ...]] \
[query [query ...]]
DESCRIPTION
===========
Lesana search allows one to make searches in the collection and render
the results.
The section :doc:`/user/search` in the full documentation describes
the query syntax in more detail; it is available online at
https://lesana.trueelena.org/user/search.html or it may be installed on
your system (e.g. in Debian and derivatives it will be at
``/usr/share/doc/lesana/html/user/search.html``).
By default entries are printed according to the ``entry_label`` from the
``settings.yaml`` file, but they can be rendered according to a jinja2
template.
If no query is specified, it will default to ``'*'``, i.e. search all
entries: thus ``lesana search --all`` will print all entries, while just
``lesana search`` will print the first 12 entries, possibly according to
the relevant sorting options.
OPTIONS
=======
-h, --help
Prints an help message and exits.
--collection COLLECTION, -c COLLECTION
The collection to work on. Default is ``.``
--template TEMPLATE, -t TEMPLATE
Template to use when displaying results
--offset OFFSET
.
--pagesize PAGESIZE
.
--all
Return all available results
--sort
Sort the results by a sortable field.
This option can be added multiple times; prefix the name of the field
with ``-`` to reverse the results (e.g. ``--sort='-date'``).
expand-query-template
Render search_aliases in the query as a jinja2 template
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/man/lesana-show.rst 0000644 0001777 0001777 00000001546 00000000000 021125 0 ustar 00valhalla valhalla ===========
lesana-show
===========
SYNOPSIS
========
lesana show [--help] [--collection COLLECTION] [--template TEMPLATE]
DESCRIPTION
===========
``lesana show`` will print an entry (specified by id or partial id) to
stdout.
A template can be specified with ``--template `` to pretty
print entries.
OPTIONS
=======
-h, --help
Prints an help message and exits.
--collection COLLECTION, -c COLLECTION
The collection to work on. Default is ``.``
--template TEMPLATE, -t TEMPLATE
Use the specified template to display results.
TEMPLATES
=========
The templates used by ``lesana show`` are jinja2 templates.
The entry fields are available as variables, and the full entry is
available as the variable ``entry`` and can be used to give access to
fields with names that aren't valid jinja2 variables e.g. as
``entry.data[]``.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/man/lesana.rst 0000644 0001777 0001777 00000003352 00000000000 020144 0 ustar 00valhalla valhalla ======
lesana
======
SYNOPSIS
========
lesana [--help]
DESCRIPTION
===========
lesana is a tool to organize collections of various kinds. It is
designed to have a data storage / serialization format that is friendly
to git and other VCSs, but decent performances.
To reach this aim it uses yaml_ as its serialization format, which is
easy to store in a VCS, share between people and synchronize between
different computers, but it also keeps an index of this data in a local
xapian_ database in order to allow for fast searches.
.. _yaml: http://yaml.org/
.. _xapian: https://xapian.org/
lesana supports collections of any kind, as long as their entries can be
described with a mostly flat dictionary of fields of the types described
in the documentation file ``field_types``.
Some example collection schemas are provided, but one big strength of
lesana is the ability to customize your collection with custom fields
either by simply writing a personalized ``settings.yaml``.
OPTIONS
=======
-h, --help
Prints an help message and exits.
COMMANDS
========
new(1)
Creates a new entry.
edit(1)
Edits an existing entry.
show(1)
Shows an existing entry.
index(1)
Index some entries in the xapian cache.
search(1)
Searches for entries in the xapian cache.
export(1)
Exports entries from one lesana collection to another
init(1)
Initialize a new lesana collection
rm(1)
Removes an entry.
TEXT EDITOR
===========
Many lesana subcommands will try to open files in a text editor chosen
as follows:
* first, the value of $EDITOR is tried
* then the command ``sensible-editor``, as available under e.g. Debian
and its derivatives
* lastly, it will try to fallback to ``vi``, which should be available
under any posix system.
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.8175404
lesana-0.9.1/docs/source/reference/ 0000755 0001777 0001777 00000000000 00000000000 017327 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/reference/lesana.collection.rst 0000644 0001777 0001777 00000000213 00000000000 023452 0 ustar 00valhalla valhalla lesana.collection module
========================
.. automodule:: lesana.collection
:members:
:undoc-members:
:show-inheritance:
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/reference/lesana.command.rst 0000644 0001777 0001777 00000000202 00000000000 022733 0 ustar 00valhalla valhalla lesana.command module
=====================
.. automodule:: lesana.command
:members:
:undoc-members:
:show-inheritance:
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/reference/lesana.data.rst 0000644 0001777 0001777 00000000173 00000000000 022235 0 ustar 00valhalla valhalla lesana.data package
===================
.. automodule:: lesana.data
:members:
:undoc-members:
:show-inheritance:
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/reference/lesana.rst 0000644 0001777 0001777 00000000465 00000000000 021331 0 ustar 00valhalla valhalla lesana package
==============
.. automodule:: lesana
:members:
:undoc-members:
:show-inheritance:
Subpackages
-----------
.. toctree::
:maxdepth: 4
lesana.data
Submodules
----------
.. toctree::
:maxdepth: 4
lesana.collection
lesana.command
lesana.templating
lesana.types
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/reference/lesana.templating.rst 0000644 0001777 0001777 00000000213 00000000000 023463 0 ustar 00valhalla valhalla lesana.templating module
========================
.. automodule:: lesana.templating
:members:
:undoc-members:
:show-inheritance:
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/reference/lesana.types.rst 0000644 0001777 0001777 00000000174 00000000000 022471 0 ustar 00valhalla valhalla lesana.types module
===================
.. automodule:: lesana.types
:members:
:undoc-members:
:show-inheritance:
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/reference/modules.rst 0000644 0001777 0001777 00000000113 00000000000 021524 0 ustar 00valhalla valhalla lesana reference
================
.. toctree::
:maxdepth: 4
lesana
././@PaxHeader 0000000 0000000 0000000 00000000032 00000000000 010210 x ustar 00 26 mtime=1640962916.84154
lesana-0.9.1/docs/source/user/ 0000755 0001777 0001777 00000000000 00000000000 016347 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/user/derivatives.rst 0000644 0001777 0001777 00000000651 00000000000 021430 0 ustar 00valhalla valhalla ******************
lesana derivatives
******************
Front-ends
==========
Collector
---------
Collector_ is a Gtk3 app to manage collection inventories throught yaml
files, which also works on GNU/Linux mobile devices.
.. _Collector: https://git.sr.ht/~fabrixxm/Collector
linkopedia
----------
linkopedia_ is a read-only web interface to Lesana collections.
.. _linkopedia: https://git.sr.ht/~fabrixxm/linkopedia
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/user/getting_started_command_line.rst 0000644 0001777 0001777 00000005122 00000000000 024775 0 ustar 00valhalla valhalla ******************************
Getting Started (Command Line)
******************************
lesana can be used from the command line through the ``lesana`` command;
for more details run ``lesana help``.
Many commands will try to open some file in an editor: they will attempt
to use, in this order, ``$EDITOR``, ``sensible-editor`` or as a fallback
``vi``, which should be installed on any POSIX-like system.
To start a new collection, create a directory and run ``lesana
init`` into it::
mkdir $DIRECTORY
cd $DIRECTORY
lesana init
It will create the basic file structure of a lesana collection,
including a ``settings.yaml`` skeleton and it will initialize a git
repository (use ``--no-git`` to skip this part and ignore all further
git commands).
It will then open ``settings.yaml`` in an editor: fill in your values
for the available variables, and define your list of fields; see
:doc:`settings` for details. Then save and exit, and you are now ready to
commit the configuration for your new collection, as the changes have
already been added to git::
git commit -m 'Collection settings'
An empty collection is not very interesting: let us start adding new
entries::
lesana new
It will again open an editor on a skeleton of entry where you can fill
in the values. When you close the editor it will print the entry id,
that you can use e.g. to edit again the same entry::
lesana edit $ENTRY_ID
After you've added a few entries, you can now search for some word that
you entered in one of the indexed fields::
lesana search some words
this will also print the entry ids of matching items, so that you can
open them with ``lesana edit``. See :doc:`search` for more details on
the search syntax.
If you're using git, entries will be autoadded to the staging area, but
you need to commit them, so that you can choose how often you do so.
Search results are limited by default to 12 matches; to get all results
for your query you can use the option ``--all``. This is especially
useful when passing the results to a template::
lesana search --template templates/my_report.html --all \
\
> some_search_terms-report.html
will generate an html file based on the jinja2 template
``templates/my_report.html`` with all the entries found for those search
terms.
If later on you want to clone the repository elsewhere (using regular
git commands) you can use ``git init`` in the new repository to install
the hooks to manage updating the local cache every time the repository
is updated via git, and then run ``lesana index`` to prepare the first
version of the cache.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/user/index.rst 0000644 0001777 0001777 00000000412 00000000000 020205 0 ustar 00valhalla valhalla ####################
User Documentation
####################
Documentation that is useful for everybody.
.. toctree::
:maxdepth: 2
:caption: Contents:
getting_started_command_line
settings
search
moving_data_between_collections
derivatives
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/docs/source/user/moving_data_between_collections.rst 0000644 0001777 0001777 00000002651 00000000000 025504 0 ustar 00valhalla valhalla *******************************
Moving Data between Collections
*******************************
Entries can be exported from a lesana collection to another using the
``lesana export`` command and a jinja2 template.
The template should generate a yaml file that is a valid lesana entry
for the destination collection and it can use the fields of the starting
collection as variables. The variable ``entry`` is also available and
gives complete access to the entry of the original collection, so fields
with names that aren't valid jinja templates can be accessed as
``entry.data[]``.
E.g. to convert between a collection with fields ``name``,
``short-desc``, ``desc`` to a collection with fields ``name``,
``description`` one could use the following template::
name: {{ name }}
description: |
{{ entry.data.[short-desc] }}
{{ desc | indent(width=4, first=False) }}
From the origin collection you can then run the command::
lesana export
to export all entries.
You can also export just a subset of entries by adding a xapian query
with the parameter ``--query``; you can test the search using::
lesana search --all
and then actually run the export with::
lesana search --query ''
note that in this second command the spaces in the search query have to
be protected from the shell.
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640337421.0
lesana-0.9.1/docs/source/user/search.rst 0000644 0001777 0001777 00000002665 00000000000 020357 0 ustar 00valhalla valhalla ***************
Search syntax
***************
Searches in lesana use the human readable query string format defined by
xapian.
The simplest search is just a list of terms: e.g. searching for
``thing object`` will find entries where either ``thing`` or ``object``
is present in one of the fields with ``free`` indexing.
It is also possible to specify that a term must be in one specific
field: the syntax for this is the name of the field follwed by ``:`` and
the term, e.g. ``name:object`` will search for entries with the term
``object`` in the ``name`` field.
Search queries can of course include the usual logical operators
``AND``, ``OR`` and ``NOT``.
More modifiers are available; see the `Query Parser`_ documentation from
xapian for details.
.. _`Query Parser`: https://getting-started-with-xapian.readthedocs.io/en/latest/concepts/search/queryparser.html
.. _search aliases:
Search templates and ``search_aliases``
=======================================
In some contexts, search queries are rendered as jinja2 templates with
the contents of the ``search_aliases`` property as set in
``settings.yaml``.
The values of those search aliases should be valid search snippets with
the syntax documented above; it's usually a good idea to wrap them in
parenthesis, so that they are easier to use in complex queries; e.g.::
my_alias: '(name:object OR name:thing)'
can correctly be used in a query like::
{{ my_alias }} AND description:shiny
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640337421.0
lesana-0.9.1/docs/source/user/settings.rst 0000644 0001777 0001777 00000010743 00000000000 020746 0 ustar 00valhalla valhalla *******************
The settings file
*******************
The file ``settings.yaml`` defines the properties of a collection.
It is a yaml file with a dict of properties and their values.
``name``:
the human readable name of the collection.
``lang``:
the language of the collection; valid values are listed in the
`xapian stemmer`_ documentation and are usually either the English
name or the two letter ISO639 code of a language.
``entry_label``:
a jinja2 template used to show an entry in the interface; beside the
entry fields two useful variables are ``eid`` for the full entry ID
and ``short_id`` for the short version.
``default_sort``:
a list of field names (possibly prefixed by + or -) that are used by
default to sort results of searches in the collection.
The fields must be marked as sortable in their definition, see below.
``search_aliases``:
a dict of : which can be used in a query
template. For more details see :ref:`search aliases`
``fields``:
The list of fields used by the collection, as described below.
.. _`xapian stemmer`: https://xapian.org/docs/apidoc/html/classXapian_1_1Stem.html
Field definitions
=================
``name``:
a name for the field (computer readable: keeping it lowercase
alphabetic ascii is probably safer).
``type``:
the type of the field: valid fields are listed in
:doc:`/reference/lesana.types` (see the ``name`` property for each
field)
``index``:
whether this field should be indexed: valid values are ``free`` for
fields that should be available in the free text search and ``field``
for fields that should only be available by specifying the field name
in the search.
``sortable``:
boolean; whether this field is sortable. Sortable fields enable
sorting the results and search by ranges, but having too many
sortable fields make the search more resurce intensive.
``help``:
a description for the field; this is e.g. added to new entries as a
comment.
``default``:
the default value to use when creating an entry.
``prefix``:
the optional term prefix used inside xapian: if you don't know what
this means you should avoid using this, otherwise see `Term
Prefixes`_ on the xapian documentation for details.
.. _`Term Prefixes`: https://xapian.org/docs/omega/termprefixes.html
Some field types may add other custom properties.
``list`` properties
-------------------
``list``:
the type of the entries in the list; note that neither lists of non
uniform values nor lists of lists are supported (if you need those
you can use the ``yaml`` generic type, or write your own derivative
with an additional type).
``integer`` properties
----------------------
``auto``:
automatic manipulation of the field contents.
The value of ``increment`` will autoincrement the value at every
update.
The reference command-line client will run this update before editing
an entry, allowing further changes from the user; a command line user
can then decide to abort this change through the usual git commands.
Other clients may decide to use a different workflow.
``increment``:
the amount by which an ``auto: increment`` field is incremented
(negative values are of course allowed). Default is 1.
``decimal`` properties
----------------------
``precision``:
if this property is set, every value in this field will get rounded
to the given number of decimals.
With this property it is possible to store decimal values as YAML
floats instead of strings.
``date`` and ``datetime`` properties
------------------------------------
``auto``:
automatic manipulation of the field contents.
The following values are supported.
``creation``
autofill the field at creation time with the current UTC time
(``datetime``) or local zone day (``date``).
``update``
autofill the field when it is updated with the current UTC time
(``datetime``) or local zone day (``date``).
The reference command line client will run this update before
editing an entry, allowing further changes from the user; a
command line user can then decide to abort this change through the
usual git commands.
Other clients may decide to use a different workflow.
``values``
----------
The ``string``, ``text``, ``list`` and numeric types can have a property
``value`` with a list of valid values for that field.
An empty value is always allowed.
For the ``list`` type, each element of the list is checked, not the
whole list.
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.8615398
lesana-0.9.1/lesana/ 0000755 0001777 0001777 00000000000 00000000000 014404 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/lesana/__init__.py 0000644 0001777 0001777 00000000234 00000000000 016514 0 ustar 00valhalla valhalla from .collection import Collection, Entry, TemplatingError
# prevent spurious warnings from pyflakes
assert Collection
assert Entry
assert TemplatingError
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640770425.0
lesana-0.9.1/lesana/collection.py 0000644 0001777 0001777 00000055043 00000000000 017120 0 ustar 00valhalla valhalla import collections
import io
import logging
import os
import shutil
import uuid
import ruamel.yaml
import xapian
import jinja2
from pkg_resources import resource_string, resource_filename
from . import types, templating
logger = logging.getLogger(__name__)
try:
import git
git_available = True
except ImportError:
git_available = False
class Entry(object):
def __init__(self, collection, data={}, fname=None):
self.collection = collection
self.data = data or self.empty_data()
self.fname = fname
self.eid = self.data.get('eid', None)
if not self.eid:
if self.fname:
self.eid, ext = os.path.splitext(os.path.basename(self.fname))
else:
self.eid = uuid.uuid4().hex
if not self.fname:
self.fname = self.eid + '.yaml'
def __str__(self):
label = self.collection.settings.get('entry_label', None)
if label:
t = jinja2.Template(label)
return t.render(**self.get_data())
else:
return self.eid
def get_data(self):
d = self.data.copy()
d['eid'] = self.eid
d['fname'] = self.fname
d['short_id'] = self.short_id
return d
def empty_data(self):
data = self.collection.yaml.load("{}")
for name, field in self.collection.fields.items():
if field.field.get('default', None):
data[name] = field.field['default']
else:
data[name] = field.empty()
if field.field.get('help', None) is not None:
comment = "{name} ({type}): {help}\n".format(**field.field)
try:
data.yaml_set_comment_before_after_key(
key=name,
before=comment,
indent=0
)
except AttributeError:
logger.warning(
"Not adding comments because they are not"
"supported by the yaml loader."
)
valid_values = field.field.get('values', [])
if valid_values:
comment = "{name} ({type}): {valid_values}".format(
valid_values="|".join(valid_values),
**field.field
)
try:
data.yaml_set_comment_before_after_key(
key=name,
before=comment,
indent=0
)
except AttributeError:
logger.warning(
"Not adding comments because they are not"
"supported by the yaml loader."
)
return data
@property
def yaml_data(self):
to_dump = self.data.copy()
# Decimal fields can't be represented by
# ruamel.yaml.RoundTripDumper, but transforming them to strings
# should be enough for all cases that we need.
for field in self.collection.settings['fields']:
if field['type'] == 'decimal':
v = to_dump.get(field['name'], '')
if v is not None:
to_dump[field['name']] = str(v)
s_io = io.StringIO()
self.collection.yaml.dump(to_dump, s_io)
return s_io.getvalue()
@property
def idterm(self):
return "Q" + self.eid
@property
def short_id(self):
return self.eid[:8]
def validate(self):
errors = []
valid = True
for name, field in self.collection.fields.items():
value = self.data.get(name, None)
try:
self.data[name] = field.load(value)
except types.LesanaValueError as e:
valid = False
errors.append(
{
'field': name,
'error': e,
}
)
return valid, errors
def render(self, template, searchpath='.'):
jtemplate = self.collection.get_template(template, searchpath)
try:
return jtemplate.render(entry=self)
except jinja2.exceptions.TemplateSyntaxError as e:
raise TemplatingError('Template Syntax Error: ' + str(e))
def auto(self):
"""
Update all fields of this entry, as required by the field settings.
This is called by the reference client before an edit, so that
the user can make further changes.
Note that the stored file is not changed: if you need it you
need to save the entry yourself.
"""
for name, field in self.collection.fields.items():
self.data[name] = field.auto(self.data.get(name, None))
class Collection(object):
"""
"""
PARSER_FLAGS = (
xapian.QueryParser.FLAG_BOOLEAN
| xapian.QueryParser.FLAG_PHRASE # noqa: W503
| xapian.QueryParser.FLAG_LOVEHATE # noqa: W503
| xapian.QueryParser.FLAG_WILDCARD # noqa: W503
)
def __init__(self, directory=None, itemdir='items'):
self.basedir = directory or os.getcwd()
self.itemdir = os.path.join(self.basedir, itemdir)
self.yaml = ruamel.yaml.YAML()
self.yaml.preserve_quotes = True
self.yaml.typ = 'rt'
try:
with open(os.path.join(self.basedir, 'settings.yaml')) as fp:
self.settings = self.yaml.load(fp)
except FileNotFoundError:
self.settings = self.yaml.load("{}")
self.fields = self._load_field_types()
os.makedirs(os.path.join(self.basedir, '.lesana'), exist_ok=True)
if 'lang' in self.settings:
try:
self.stemmer = xapian.Stem(self.settings['lang'])
except xapian.InvalidArgumentError:
logger.warning(
"Invalid language %s, in settings.yaml: using english.",
self.settings['lang'],
)
self.stemmer = xapian.Stem('english')
else:
self.stemmer = xapian.Stem('english')
self._enquire = None
self.entry_class = Entry
def _get_subsubclasses(self, cls):
for c in cls.__subclasses__():
yield c
yield from self._get_subsubclasses(c)
def _load_field_types(self):
type_loaders = {}
for t in self._get_subsubclasses(types.LesanaType):
type_loaders[t.name] = t
fields = {}
for i, field in enumerate(self.settings.get('fields', [])):
try:
fields[field['name']] = type_loaders[field['type']](
field,
type_loaders,
# value slot 0 is used to store the filename, and we
# reserve a few more slots just in case they are
# needed by lesana or some derivative
value_index=i + 16,
)
except KeyError:
# unknown fields are treated as if they were
# (unvalidated) generic YAML to support working with
# collections based on lesana derivatives
logger.warning(
"Unknown field type %s in field %s",
field['type'],
field['name'],
)
fields[field['name']] = types.LesanaYAML(field, type_loaders)
return fields
def _index_file(self, fname, cache):
with open(os.path.join(self.itemdir, fname)) as fp:
data = self.yaml.load(fp)
entry = self.entry_class(self, data, fname)
valid, errors = entry.validate()
if not valid:
logger.warning(
"Not indexing {fname}: invalid data".format(fname=fname)
)
return False, errors
doc = xapian.Document()
self.indexer.set_document(doc)
for field, loader in self.fields.items():
loader.index(doc, self.indexer, entry.data.get(field))
doc.set_data(entry.yaml_data)
doc.add_boolean_term(entry.idterm)
doc.add_value(0, entry.fname.encode('utf-8'))
cache.replace_document(entry.idterm, doc)
return True, []
@property
def indexed_fields(self):
fields = []
for field in self.settings['fields']:
if field.get('index', '') in ['free', 'field']:
prefix = field.get('prefix', 'X' + field['name'].upper())
fields.append(
{
'prefix': prefix,
'name': field['name'],
'free_search': field['index'] == 'free',
'multi': field['type'] in ['list'],
}
)
return fields
def update_cache(self, fnames=None, reset=False):
"""
Update the xapian db with the data in files.
``fnames`` is a list of *basenames* of files in ``self.itemdir``.
If no files have been passed, add everything.
if ``reset`` the existing xapian db is deleted before indexing
Return the number of files that have been added to the cache.
"""
if reset:
shutil.rmtree(os.path.join(self.basedir, '.lesana'))
os.makedirs(os.path.join(self.basedir, '.lesana'), exist_ok=True)
cache = xapian.WritableDatabase(
os.path.join(self.basedir, '.lesana/xapian'),
xapian.DB_CREATE_OR_OPEN,
)
self.indexer = xapian.TermGenerator()
self.indexer.set_stemmer(self.stemmer)
if not fnames:
try:
fnames = os.listdir(self.itemdir)
except FileNotFoundError:
logger.warning(
"No such file or directory: {}, not updating cache".format(
self.itemdir
)
)
return 0
updated = 0
for fname in fnames:
try:
valid, errors = self._index_file(fname, cache)
except IOError as e:
logger.warning(
"Could not load file {}: {}".format(fname, str(e))
)
else:
if valid:
updated += 1
else:
logger.warning(
"File {fname} could not be indexed: {errors}".format(
fname=fname, errors=errors
)
)
return updated
def save_entries(self, entries=[]):
for e in entries:
complete_name = os.path.join(self.itemdir, e.fname)
with open(complete_name, 'w') as fp:
fp.write(e.yaml_data)
def git_add_files(self, files=[]):
if not git_available:
logger.warning(
"python3-git not available, could not initalise "
+ "the git repository." # noqa: W503
)
return False
if not self.settings.get('git', False):
logger.info("This collection is configured not to use git")
return False
try:
repo = git.Repo(self.basedir, search_parent_directories=True)
except git.exc.InvalidGitRepositoryError:
logger.warning(
"Could not find a git repository in {}".format(self.basedir)
)
return False
repo.index.add(files)
return True
def _get_cache(self):
try:
cache = xapian.Database(
os.path.join(self.basedir, '.lesana/xapian'),
)
except xapian.DatabaseOpeningError:
logger.info("No database found, indexing entries.")
self.update_cache()
cache = xapian.Database(
os.path.join(self.basedir, '.lesana/xapian'),
)
return cache
def render_query_template(self, query):
"""
Render a query template, filling it with search_aliases.
"""
t = jinja2.Template(query)
return t.render(**self.settings.get('search_aliases', {}))
def start_search(self, querystring, sort_by=None):
"""
Prepare a search for querystring.
"""
cache = self._get_cache()
queryparser = xapian.QueryParser()
queryparser.set_stemmer(self.stemmer)
queryparser.set_database(cache)
for field in self.indexed_fields:
queryparser.add_prefix(field['name'], field['prefix'])
if querystring == '*':
query = xapian.Query.MatchAll
else:
query = queryparser.parse_query(querystring, self.PARSER_FLAGS)
self._enquire = xapian.Enquire(cache)
self._enquire.set_query(query)
if not sort_by and self.settings.get('default_sort', False):
sort_by = self.settings['default_sort']
if sort_by:
keymaker = xapian.MultiValueKeyMaker()
for k in sort_by:
if k.startswith('+'):
reverse = False
slot = self.fields[k[1:]].value_index
elif k.startswith('-'):
reverse = True
slot = self.fields[k[1:]].value_index
else:
reverse = False
slot = self.fields[k].value_index
keymaker.add_value(slot, reverse)
self._enquire.set_sort_by_key_then_relevance(keymaker, False)
def get_search_results(self, offset=0, pagesize=12):
if not self._enquire:
return
for match in self._enquire.get_mset(offset, pagesize):
yield self._match_to_entry(match)
def get_all_search_results(self):
if not self._enquire:
return
offset = 0
pagesize = 100
while True:
mset = self._enquire.get_mset(offset, pagesize)
if mset.size() == 0:
break
for match in mset:
yield self._match_to_entry(match)
offset += pagesize
def get_all_documents(self):
"""
Yield all documents in the collection.
Note that the results can't be sorted, even if the collection
has a default_sort; if you need sorted values you need to use a
regular search with a query of '*'
"""
cache = self._get_cache()
postlist = cache.postlist("")
for post in postlist:
doc = cache.get_document(post.docid)
yield self._doc_to_entry(doc)
def get_field_values(self, field, querystring='*'):
field = self.fields[field]
if field.field.get('sortable', False):
self.start_search(querystring)
spy = xapian.ValueCountMatchSpy(field.value_index)
self._enquire.add_matchspy(spy)
cache = self._get_cache()
self._enquire.get_mset(0, cache.get_doccount())
for v in spy.values():
yield {
'value': v.term,
'frequency': v.termfreq,
}
else:
logger.info(
"Trying to get the list of values for a non sortable field."
)
logger.info(
"This is going to be pretty inefficient."
)
if field.field['type'] == 'list':
values = []
for e in self.get_all_documents():
values.extend(e.data[field.field['name']])
else:
values = (
e.data[field.field['name']]
for e in self.get_all_documents()
)
logger.info("Values are %s", str(values))
counter = collections.Counter(values)
for v in counter.most_common():
yield {
'value': v[0],
'frequency': v[1],
}
def _match_to_entry(self, match):
return self._doc_to_entry(match.document)
def _doc_to_entry(self, doc):
fname = doc.get_value(0).decode('utf-8')
data = self.yaml.load(doc.get_data())
entry = self.entry_class(self, data=data, fname=fname,)
return entry
def entry_from_eid(self, eid):
cache = self._get_cache()
postlist = cache.postlist('Q' + eid)
for pitem in postlist:
return self._doc_to_entry(cache.get_document(pitem.docid))
return None
def entries_from_short_eid(self, seid):
# It would be better to search for partial UIDs inside xapian,
# but I still can't find a way to do it, so this is a workable
# workaround on repos where the eids are stored in the
# filenames.
potential_eids = [
os.path.splitext(f)[0]
for f in os.listdir(self.itemdir)
if f.startswith(seid)
]
return [self.entry_from_eid(u) for u in potential_eids if u]
def remove_entries(self, eids):
cache = xapian.WritableDatabase(
os.path.join(self.basedir, '.lesana/xapian'),
xapian.DB_CREATE_OR_OPEN,
)
for eid in eids:
for entry in self.entries_from_short_eid(eid):
if entry is not None:
cache.delete_document(entry.idterm)
self.remove_file(entry.fname)
else:
logger.warning("Not removing {}: no such entry".format(
eid
))
cache.commit()
cache.close()
def remove_file(self, fname):
f_path = os.path.join(self.itemdir, fname)
if git_available and self.settings.get('git', False):
try:
repo = git.Repo(self.basedir, search_parent_directories=True)
except git.exc.InvalidGitRepositoryError:
logger.warning(
"Could not find a git repository in {}".format(
self.basedir
)
)
return False
repo.index.remove([f_path])
os.remove(f_path)
def update_field(self, query, field, value):
self.start_search(query)
changed = []
for e in self.get_all_search_results():
e.data[field] = value
changed.append(e)
self.save_entries(changed)
self.git_add_files(
[os.path.join(self.itemdir, e.fname) for e in changed]
)
self.update_cache([e.fname for e in changed])
def get_template(self, template_fname, searchpath='.'):
env = templating.Environment(
loader=jinja2.FileSystemLoader(
searchpath=searchpath, followlinks=True,
),
# TODO: add autoescaping settings
)
try:
template = env.get_template(template_fname)
except jinja2.exceptions.TemplateNotFound as e:
raise TemplatingError('Could not find template ' + str(e))
return template
def entry_from_rendered_template(self, template, data):
try:
template = self.get_template(template)
rendered = template.render(**data)
except jinja2.exceptions.TemplateSyntaxError as e:
raise TemplatingError(e)
try:
data = self.yaml.load(rendered)
except ruamel.yaml.YAMLError as e:
logger.warning(
"The following data failed to load as YAML: \n{}".format(
rendered
)
)
raise TemplatingError(e)
entry = self.entry_class(self, data=data)
self.save_entries([entry])
return entry
@classmethod
def init(
cls, directory=None, git_enabled=True, edit_file=None, settings={}
):
"""
Initialize a lesana repository
directory defaults to .
if git_enabled is True, git support is enabled and if possible a git
repository is initalized.
edit_file is a syncronous function that runs on a filename
(possibly opening the file in an editor) and should manage its
own errors.
"""
c_dir = os.path.abspath(directory or '.')
os.makedirs(c_dir, exist_ok=True)
if git_enabled:
# Try to initalize a git repo
if git_available:
repo = git.Repo.init(c_dir, bare=False)
else:
logger.warning(
"python3-git not available, could not initalise "
+ "the git repository." # noqa: W503
)
repo = None
# Add .lesana directory to .gitignore and add it to the
# staging
lesana_ignored = False
try:
with open(os.path.join(c_dir, '.gitignore'), 'r') as fp:
for line in fp:
if '.lesana' in line:
lesana_ignored = True
continue
except FileNotFoundError:
pass
if not lesana_ignored:
with open(os.path.join(c_dir, '.gitignore'), 'a') as fp:
fp.write('#Added by lesana init\n.lesana')
if repo:
repo.index.add(['.gitignore'])
# Add post-checkout and post-merge hook
hook_source = resource_filename('lesana', 'data/post-checkout')
hooks_dir = os.path.join(
c_dir,
'.git',
'hooks',
)
checkout_hook = os.path.join(hooks_dir, 'post-checkout')
merge_hook = os.path.join(hooks_dir, 'post-merge')
shutil.copy(hook_source, checkout_hook)
if not os.path.islink(merge_hook):
os.symlink(checkout_hook, merge_hook)
# If it doesn't exist, create a skeleton of settings.yaml file
# then open settings.yaml for editing
filepath = os.path.join(c_dir, 'settings.yaml')
if not os.path.exists(filepath):
skel = resource_string('lesana', 'data/settings.yaml').decode(
'utf-8'
)
yaml = ruamel.yaml.YAML()
skel_dict = yaml.load(skel)
skel_dict['git'] = git_enabled
skel_dict.update(settings)
with open(filepath, 'w') as fp:
yaml.dump(skel_dict, stream=fp)
if edit_file:
edit_file(filepath)
if git_enabled and repo:
repo.index.add(['settings.yaml'])
coll = cls(c_dir)
os.makedirs(os.path.join(coll.basedir, coll.itemdir), exist_ok=True)
return coll
class TemplatingError(Exception):
"""
Raised when there are errors rendering a jinja template
"""
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640769360.0
lesana-0.9.1/lesana/command.py 0000644 0001777 0001777 00000036544 00000000000 016410 0 ustar 00valhalla valhalla import argparse
import logging
import os
import subprocess
import sys
try:
import argcomplete
except ImportError:
argcomplete = False
from . import Collection, Entry, TemplatingError
logger = logging.getLogger(__name__)
def _get_first_docstring_line(obj):
try:
return obj.__doc__.split('\n')[1].strip()
except (AttributeError, IndexError):
return None
class MainCommand:
commands = ()
def _main(self, args):
self.parser.print_help()
def main(self):
desc = _get_first_docstring_line(self)
self.parser = argparse.ArgumentParser(description=desc)
self.parser.add_argument('--verbose', '-v',
action='store_true',
help="Display debug messages")
self.parser.set_defaults(func=self._main)
self.subparsers = self.parser.add_subparsers()
for name, sub in self.commands:
sub_help = _get_first_docstring_line(sub)
s_parser = self.subparsers.add_parser(
name,
help=sub_help,
description=sub.__doc__,
)
for arg in sub.arguments:
s_parser.add_argument(*arg[0], **arg[1])
s_parser.set_defaults(func=sub._main)
if argcomplete:
argcomplete.autocomplete(self.parser)
self.args = self.parser.parse_args()
if self.args.verbose:
logging.getLogger('lesana').setLevel(logging.DEBUG)
self.args.func(self.args)
class Command:
def __init__(self, collection_class=Collection, entry_class=Entry):
self.collection_class = collection_class
self.entry_class = entry_class
def _main(self, args):
self.args = args
self.main()
def edit_file_in_external_editor(self, filepath):
# First we try to use $EDITOR
editor = os.environ.get('EDITOR')
if editor:
try:
subprocess.call([editor, filepath])
except FileNotFoundError as e:
if editor in str(e):
logger.info(
'Could not open file {} with $EDITOR (currently {})'
.format(
filepath, editor
)
)
else:
logger.warning("Could not open file {}".format(filepath))
return False
else:
return True
# then we try to use sensible-editor (which should be available on
# debian and derivatives)
try:
subprocess.call(['sensible-editor', filepath])
except FileNotFoundError as e:
if 'sensible-editor' in e.strerror:
logger.debug(
"Could not open file {} with editor: sensible-editor"
.format(filepath)
)
else:
logger.warning("Could not open file {}".format(filepath))
return False
else:
return True
# and finally we fallback to vi, because ed is the standard editor,
# but that would be way too cruel, and vi is also in posix
try:
subprocess.call(['vi', filepath])
except FileNotFoundError as e:
if 'vi' in e.strerror:
logger.warning(
"Could not open file {} with any known editor".format(
filepath
)
)
return False
else:
logger.warning("Could not open file {}".format(filepath))
return False
else:
return True
class New(Command):
"""
Create a new entry
"""
arguments = [
(
['--collection', '-c'],
dict(help='The collection to work on (default .)'),
),
(
['--no-git'],
dict(
help="Don't add the new entry to git",
action="store_false",
dest='git',
),
),
]
def main(self):
collection = self.collection_class(self.args.collection)
new_entry = self.entry_class(collection)
collection.save_entries([new_entry])
filepath = os.path.join(collection.itemdir, new_entry.fname)
if self.edit_file_in_external_editor(filepath):
collection.update_cache([filepath])
if self.args.git:
collection.git_add_files([filepath])
saved_entry = collection.entry_from_eid(new_entry.eid)
print(saved_entry)
class Edit(Command):
"""
Edit a lesana entry
"""
arguments = [
(
['--collection', '-c'],
dict(help='The collection to work on (default .)'),
),
(
['--no-git'],
dict(
help="Don't add the new entry to git",
action="store_false",
dest='git',
),
),
(['eid'], dict(help='eid of an entry to edit',)),
]
def main(self):
collection = self.collection_class(self.args.collection)
entries = collection.entries_from_short_eid(self.args.eid)
if len(entries) > 1:
return "{} is not an unique eid".format(self.args.eid)
if not entries:
return "Could not find an entry with eid starting with: {}".format(
self.args.eid
)
entry = entries[0]
# update the entry before editing it
entry.auto()
collection.save_entries([entry])
# and then edit the updated file
filepath = os.path.join(collection.itemdir, entry.fname)
if self.edit_file_in_external_editor(filepath):
collection.update_cache([filepath])
if self.args.git:
collection.git_add_files([filepath])
saved_entry = collection.entry_from_eid(entry.eid)
print(saved_entry)
class Show(Command):
"""
Show a lesana entry
"""
arguments = [
(
['--collection', '-c'],
dict(help='The collection to work on (default .)'),
),
(
['--template', '-t'],
dict(help='Use the specified template to display results.',),
),
(['eid'], dict(help='eid of an entry to edit',)),
]
def main(self):
collection = self.collection_class(self.args.collection)
entries = collection.entries_from_short_eid(self.args.eid)
if len(entries) > 1:
return "{} is not an unique eid".format(self.args.eid)
if not entries:
return "Could not find an entry with eid starting with: {}".format(
self.args.eid
)
entry = entries[0]
if self.args.template:
try:
print(entry.render(self.args.template))
except TemplatingError as e:
logger.error("{}".format(e))
sys.exit(1)
else:
print(entry.yaml_data)
class Index(Command):
"""
Index entries in a lesana collection
"""
arguments = [
(
['--collection', '-c'],
dict(help='The collection to work on (default .)'),
),
(
['--reset'],
dict(
action='store_true',
help='Delete the existing index and reindex from scratch.',
),
),
(
['files'],
dict(
help='List of files to index (default: everything)',
default=None,
nargs='*',
),
),
]
def main(self):
collection = self.collection_class(self.args.collection)
if self.args.files:
files = (os.path.basename(f) for f in self.args.files)
else:
files = None
indexed = collection.update_cache(
fnames=files,
reset=self.args.reset
)
print("Found and indexed {} entries".format(indexed))
class Search(Command):
"""
Search for entries
"""
arguments = [
(
['--collection', '-c'],
dict(help='The collection to work on (default .)'),
),
(
['--template', '-t'],
dict(help='Template to use when displaying results',),
),
(['--offset'], dict(type=int,)),
(['--pagesize'], dict(type=int,)),
(
['--all'],
dict(action='store_true', help='Return all available results'),
),
(
['--sort'],
dict(action='append', help='Sort results by a sortable field'),
),
(
['--expand-query-template', '-e'],
{
'action': 'store_true',
'help':
'Render search_aliases in the query as a jinja2 template',
},
),
(
['query'],
{
'help': 'Xapian query to search in the collection',
'nargs': '*',
'default': '*',
},
),
]
def main(self):
# TODO: implement "searching" for everything
if self.args.offset:
logger.warning(
"offset exposes an internal knob and MAY BE REMOVED "
+ "from a future release of lesana" # noqa: W503
)
if self.args.pagesize:
logger.warning(
"pagesize exposes an internal knob and MAY BE REMOVED "
+ "from a future release of lesana" # noqa: W503
)
offset = self.args.offset or 0
pagesize = self.args.pagesize or 12
collection = self.collection_class(self.args.collection)
query = self.args.query
if self.args.expand_query_template:
query = collection.render_query_template(query)
# sorted results require a less efficient full search rather
# than being able to use the list of all documents.
if query == ['*'] and not (
self.args.sort
or getattr(collection.settings, 'default_sort', False)
):
results = collection.get_all_documents()
else:
collection.start_search(
' '.join(query),
sort_by=self.args.sort
)
if self.args.all:
results = collection.get_all_search_results()
else:
results = collection.get_search_results(offset, pagesize)
if self.args.template:
try:
template = collection.get_template(self.args.template)
print(template.render(entries=results))
except TemplatingError as e:
logger.error("{}".format(e))
sys.exit(1)
else:
for entry in results:
print("{entry}".format(entry=entry,))
class GetValues(Command):
"""
List all values for one field, with entry counts.
"""
arguments = [
(
['--collection', '-c'],
{
'help': 'The collection to work on (default .)'
},
),
(
['--field', '-f'],
{
'help': 'Name of the field',
'required': True,
},
),
(
['--template', '-t'],
{
'help': 'Template to use when displaying results',
},
),
(
['query'],
{
'help': 'Xapian query to limit the count search " \
+ "in the collection',
'nargs': '*',
'default': '*'
},
),
]
def main(self):
collection = self.collection_class(self.args.collection)
counts = collection.get_field_values(
self.args.field,
' '.join(self.args.query)
)
if self.args.template:
try:
template = collection.get_template(self.args.template)
print(template.render(counts=counts))
except TemplatingError as e:
logger.error("{}".format(e))
sys.exit(1)
else:
for v in counts:
print("{value}: {count}".format(
value=v['value'],
count=v['frequency']
))
class Export(Command):
"""
Export entries to a different collection
"""
arguments = [
(
['--collection', '-c'],
dict(help='The collection to work on (default .)'),
),
(
['--query', '-q'],
dict(help='Xapian query to search in the collection',),
),
(['destination'], dict(help='The collection to export entries to')),
(['template'], dict(help='Template to convert entries',)),
]
def main(self):
collection = self.collection_class(self.args.collection)
destination = self.collection_class(self.args.destination)
if not self.args.query:
results = collection.get_all_documents()
else:
collection.start_search(' '.join(self.args.query))
results = collection.get_all_search_results()
for entry in results:
data = {
"entry": entry
}
data.update(entry.data)
try:
destination.entry_from_rendered_template(
self.args.template,
data
)
except TemplatingError as e:
logger.error("Error converting entry: {}".format(entry))
logger.error("{}".format(e))
sys.exit(1)
class Init(Command):
"""
Initialize a lesana collection
"""
arguments = [
(
['--collection', '-c'],
dict(help='The directory to work on (default .)', default='.'),
),
(
['--no-git'],
dict(
help='Skip setting up git in this directory',
action="store_false",
dest='git',
),
),
]
def main(self):
self.collection_class.init(
self.args.collection,
git_enabled=self.args.git,
edit_file=self.edit_file_in_external_editor,
)
class Remove(Command):
"""
Remove an entry from a collection
"""
arguments = [
(
['--collection', '-c'],
dict(help='The collection to work on (default .)',),
),
(['entries'], dict(help='List of entries to remove', nargs='+',)),
]
def main(self):
collection = self.collection_class(self.args.collection)
collection.remove_entries(eids=self.args.entries)
class Update(Command):
"""
Update a field in multiple entries
"""
arguments = [
(
['--collection', '-c'],
dict(help='The collection to work on (default .)',),
),
(['--field', '-f'], dict(help='The field to change',)),
(['--value', '-t'], dict(help='The value to set',)),
(
['query'],
dict(help='Xapian query to search in the collection', nargs='+'),
),
]
def main(self):
collection = self.collection_class(self.args.collection)
collection.update_field(
' '.join(self.args.query),
field=self.args.field,
value=self.args.value,
)
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.8975394
lesana-0.9.1/lesana/data/ 0000755 0001777 0001777 00000000000 00000000000 015315 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/lesana/data/__init__.py 0000644 0001777 0001777 00000000000 00000000000 017414 0 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1633250257.0
lesana-0.9.1/lesana/data/post-checkout 0000755 0001777 0001777 00000001227 00000000000 020035 0 ustar 00valhalla valhalla #!/bin/sh
#
# Update a lesana cache when the repository is changed with git
#
# To use this hook add it to .git/hooks under the names "post-checkout" and
# "post-merge"
if [ $(basename $0) = "post-checkout" ] ; then
diff_cmd="git diff --name-status $1 $2"
elif [ $(basename $0) = "post-merge" ] ; then
diff_cmd="git diff --name-status --no-commit-id ORIG_HEAD HEAD"
else
echo "This hook can only work as post-checkout or post-merge"
exit 1
fi
for F in $( $diff_cmd | grep "^D" | grep "items/.*.yaml" | cut -c 2-) ; do
lesana rm $(basename $F .yaml)
done
lesana index $( $diff_cmd | grep "^[AM]" | grep "items/.*.yaml" | cut -c 2- | xargs)
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/lesana/data/settings.yaml 0000644 0001777 0001777 00000000247 00000000000 020044 0 ustar 00valhalla valhalla name: 'My Collection'
lang: english
entry_label: '{{ short_id }}: {{ name }}'
git: true
fields:
- name: name
type: string
prefix: S
index: free
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/lesana/templating.py 0000644 0001777 0001777 00000002140 00000000000 017117 0 ustar 00valhalla valhalla """
Custom jinja2 filters and other templating helpers
"""
import decimal
import io
import jinja2
import ruamel.yaml
class Environment(jinja2.Environment):
"""
A customized jinja2 environment that includes our filters.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.filters['to_yaml'] = to_yaml
def to_yaml(data):
"""
Return the yaml representation of data.
"""
if isinstance(data, str):
if len(data) > 75 or "\n" in data:
try:
data = ruamel.yaml.scalarstring.LiteralScalarString(
data + "\n"
)
except AttributeError:
data = ruamel.yaml.scalarstring.PreservedScalarString(
data + "\n"
)
elif isinstance(data, decimal.Decimal):
data = str(data)
elif data is None:
return 'null'
yaml = ruamel.yaml.YAML()
s_io = io.StringIO()
yaml.dump({'data': data}, s_io)
res = s_io.getvalue()
res = res.lstrip('{data:').lstrip().strip('...\n').strip()
return res
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640456280.0
lesana-0.9.1/lesana/types.py 0000644 0001777 0001777 00000025327 00000000000 016133 0 ustar 00valhalla valhalla """
Type checkers for lesana fields.
Warning: this part of the code is still in flux and it may change
significantly in a future release.
"""
import datetime
import decimal
import logging
import dateutil.parser
import xapian
logger = logging.getLogger(__name__)
class LesanaType:
"""
Base class for lesana field types.
"""
def __init__(self, field, types, value_index=None):
self.field = field
self.value_index = value_index
def load(self, data):
raise NotImplementedError
def empty(self):
raise NotImplementedError
def auto(self, value):
"""
Return an updated value, as appropriate for the field.
Default is to return the value itself, but types can use their
configuration to e.g. increment a numerical value or return the
current date(time).
"""
return value
def allowed_value(self, value):
"""
Check whether a value is allowed in this field.
Return the value itself or raise LesanaValueError if the value
isn't valid.
"""
if not value:
return value
valid_values = self.field.get('values')
if not valid_values:
return value
if value in valid_values:
return value
raise LesanaValueError("Value {} is not allowed in field {}".format(
value,
self.field.get('name')
))
def _to_index_text(self, value):
"""
Prepare a value for indexing.
"""
return str(value)
def _to_value(self, value):
"""
Prepare a value for indexing in a value slot
"""
return str(value)
def index(self, doc, indexer, value):
"""
Index a value for this field type.
Override this for types that need any kind of special treatment
to be indexed.
See LesanaList for an idea on how to do so.
"""
to_index = self.field.get('index', False)
if not to_index:
return
if not value:
logger.debug(
"Not indexing empty value {}".format(value)
)
return
prefix = self.field.get('prefix', 'X' + self.field['name'].upper())
indexer.index_text(self._to_index_text(value), 1, prefix)
if to_index == 'free':
indexer.index_text(self._to_index_text(value))
indexer.increase_termpos()
if self.field.get('sortable', False):
if self.value_index and self.value_index >= 16:
doc.add_value(self.value_index, self._to_value(value))
else:
logger.debug(
"Index values up to 15 are reserved for internal use"
)
class LesanaString(LesanaType):
"""
A string of unicode text
"""
name = 'string'
def load(self, data):
if not data:
return data
return self.allowed_value(str(data))
def empty(self):
return ""
class LesanaText(LesanaString):
"""
A longer block of unicode text
"""
name = 'text'
class LesanaInt(LesanaType):
"""
An integer number
"""
name = "integer"
def load(self, data):
if not data:
return data
try:
return self.allowed_value(int(data))
except ValueError:
raise LesanaValueError(
"Invalid value for integer field: {}".format(data)
)
def empty(self):
return 0
def _to_index_text(self, value):
"""
Prepare a value for indexing.
"""
return str(value)
def _to_value(self, value):
"""
Prepare a value for indexing in a value slot
"""
return xapian.sortable_serialise(value)
def auto(self, value):
"""
Return an updated value.
If the field settings ``auto`` is ``increment`` return the value
incremented by the value of the field setting ``increment``
(default 1).
"""
if self.field.get('auto', False) == 'increment':
increment = self.field.get('increment', 1)
if int(increment) == increment:
return value + increment
else:
logger.warning(
"Invalid configuration value for increment in field %s: "
+ "%s",
self.field['name'],
increment,
)
return value
class LesanaFloat(LesanaType):
"""
A floating point number
"""
name = "float"
def load(self, data):
if not data:
return data
try:
return self.allowed_value(float(data))
except ValueError:
raise LesanaValueError(
"Invalid value for float field: {}".format(data)
)
def empty(self):
return 0.0
class LesanaDecimal(LesanaType):
"""
A fixed point number
Because of a limitation of the yaml format, these should be stored
quoted as a string, to avoid being loaded back as floats.
Alternatively, the property ``precision`` can be used to force all
values to be rounded to that number of decimals.
"""
name = "decimal"
def load(self, data):
if not data:
return data
try:
value = decimal.Decimal(data)
except decimal.InvalidOperation:
raise LesanaValueError(
"Invalid value for decimal field: {}".format(data)
)
precision = self.field.get('precision')
if precision:
value = round(value, precision)
return self.allowed_value(value)
def empty(self):
return decimal.Decimal(0)
class LesanaTimestamp(LesanaType):
"""
A unix timestamp, assumed to be UTC
"""
name = "timestamp"
def load(self, data):
if not data:
return data
if isinstance(data, datetime.datetime):
return data
try:
return datetime.datetime.fromtimestamp(
int(data),
datetime.timezone.utc,
)
except (TypeError, ValueError):
raise LesanaValueError(
"Invalid value for timestamp field: {}".format(data)
)
def empty(self):
return None
class LesanaDatetime(LesanaType):
"""
A datetime
"""
name = "datetime"
def load(self, data):
if not data:
return data
if isinstance(data, datetime.datetime):
return data
if isinstance(data, datetime.date):
return datetime.datetime(data.year, data.month, data.day)
# compatibility with dateutil before 2.8
ParserError = getattr(dateutil.parser, 'ParserError', ValueError)
try:
return dateutil.parser.parse(data)
except ParserError:
raise LesanaValueError(
"Invalid value for datetime field: {}".format(data)
)
def empty(self):
if self.field.get('auto', False) in ('creation', 'update'):
return datetime.datetime.now(datetime.timezone.utc)
return None
def auto(self, value):
"""
Return an updated value.
If the field settings ``auto`` is ``update`` return the current
datetime, otherwise the old value.
"""
if self.field.get('auto', False) == 'update':
return datetime.datetime.now(datetime.timezone.utc)
return value
class LesanaDate(LesanaType):
"""
A date
"""
name = "date"
def load(self, data):
if not data:
return data
if isinstance(data, datetime.date):
return data
# compatibility with dateutil before 2.8
ParserError = getattr(dateutil.parser, 'ParserError', ValueError)
try:
return dateutil.parser.parse(data).date()
except ParserError:
raise LesanaValueError(
"Invalid value for date field: {}".format(data)
)
def empty(self):
if self.field.get('auto', False) in ('creation', 'update'):
return datetime.date.today()
return None
def auto(self, value):
"""
Return an updated value.
If the field settings ``auto`` is ``update`` return the current
date, otherwise the old value.
"""
if self.field.get('auto', False) == 'update':
return datetime.date.today()
return value
class LesanaBoolean(LesanaType):
"""
A boolean value
"""
name = 'boolean'
def load(self, data):
if not data:
return data
if isinstance(data, bool):
return data
else:
raise LesanaValueError(
"Invalid value for boolean field: {}".format(data)
)
def empty(self):
return None
class LesanaFile(LesanaString):
"""
A path to a local file.
Relative paths are assumed to be relative to the base lesana
directory (i.e. where .lesana lives)
"""
name = 'file'
class LesanaURL(LesanaString):
"""
An URL
"""
name = 'url'
class LesanaGeo(LesanaString):
"""
A Geo URI
"""
name = 'geo'
def load(self, data):
data = super().load(data)
if data and not data.startswith("geo:"):
raise LesanaValueError("{} does not look like a geo URI".format(
data
))
return data
class LesanaYAML(LesanaType):
"""
Free YAML contents (no structure is enforced)
"""
name = 'yaml'
def load(self, data):
return data
def empty(self):
return None
class LesanaList(LesanaType):
"""
A list of other values
"""
name = 'list'
def __init__(self, field, types, value_index=None):
super().__init__(field, types, value_index)
try:
self.sub_type = types[field['list']](field, types)
except KeyError:
logger.warning(
"Unknown field type %s in field %s",
field['type'],
field['name'],
)
self.sub_type = types['yaml'](field, types)
def load(self, data):
if data is None:
# empty for this type means an empty list
return []
try:
return [self.allowed_value(self.sub_type.load(x)) for x in data]
except TypeError:
raise LesanaValueError(
"Invalid value for list field: {}".format(data)
)
def empty(self):
return []
def index(self, doc, indexer, value):
for v in value:
self.sub_type.index(doc, indexer, v)
class LesanaValueError(ValueError):
"""
Raised in case of validation errors.
"""
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.8695397
lesana-0.9.1/lesana.egg-info/ 0000755 0001777 0001777 00000000000 00000000000 016076 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640962915.0
lesana-0.9.1/lesana.egg-info/PKG-INFO 0000644 0001777 0001777 00000010474 00000000000 017201 0 ustar 00valhalla valhalla Metadata-Version: 2.1
Name: lesana
Version: 0.9.1
Summary: Manage collection inventories throught yaml files.
Home-page: https://lesana.trueelena.org/
Author: Elena ``of Valhalla'' Grandi
Author-email: valhalla@trueelena.org
License: GPLv3+
Project-URL: Source, https://git.sr.ht/~valhalla/lesana
Project-URL: Documentation, https://lesana.trueelena.org/
Project-URL: Tracker, https://todo.sr.ht/~valhalla/lesana
Project-URL: Mailing lists, https://sr.ht/~valhalla/lesana/lists
Keywords: collection inventory
Platform: UNKNOWN
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: End Users/Desktop
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.5
Classifier: Programming Language :: Python :: 3.6
Classifier: Programming Language :: Python :: 3.7
Classifier: Programming Language :: Python :: 3.8
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Topic :: Utilities
Requires-Python: >=3
Description-Content-Type: text/x-rst
License-File: COPYING.txt
===============================
lesana - a collection manager
===============================
lesana is a python3 library to organize collections of various kinds.
It is designed to have a data storage / serialization format that is
friendly to git and other VCSs, but decent performances.
To reach this aim it uses yaml_ as its serialization format, which is
easy to store in a VCS, share between people and syncronize between
different computers, but it also keeps an index of this data in a local
xapian_ database in order to allow for fast searches.
.. _yaml: http://yaml.org/
.. _xapian: https://xapian.org/
lesana supports collections of any kind, as long as their entries can be
described with a mostly flat dictionary of fields of the types described
in the documentation file ``field_types``.
Some example collection schemas are provided, but one big strenght of
lesana is the ability to customize your collection with custom fields
by simply writing a personalized ``settings.yaml``.
Installation
------------
The recommended way to install lesana is to use the packages available
for your distribution; see e.g. the `list of distributions that provide
lesana on repology `_.
Alternatively, the source code for lesana can be downloaded from the git
repository at https://git.sr.ht/~valhalla/lesana; and releases are made
on `pypi `_.
lesana expects to run on a POSIX-like system and requires the following
dependencies:
* python3
* xapian_
* `ruamel.yaml `_
* `jinja2 `_
* `dateutil `_
* `GitPython `_
optional, to add git support.
Under debian (and derivatives), the packages to install are::
apt install python3-jinja2 python3-ruamel.yaml python3-xapian \
python3-dateutil python3-git
lesana can be run in place from the git checkout / extracted tarball; to
use ``setup.py`` you will also need setuptools (e.g. from the
``python3-setuptools`` package under debian and derivatives).
Contributing
------------
Lesana is `hosted on sourcehut `_:
* `bug tracker `_
* `mailing lists `_
* `git repository `_
* `CI `_
License
-------
Copyright (C) 2016-2021 Elena Grandi
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640962916.0
lesana-0.9.1/lesana.egg-info/SOURCES.txt 0000644 0001777 0001777 00000010035 00000000000 017761 0 ustar 00valhalla valhalla CHANGELOG.rst
COPYING.txt
MANIFEST.in
README.rst
TODO.rst
run_coverage
run_qa
run_tests
setup.py
.builds/archlinux.yml
.builds/debian_oldstable.yml
.builds/debian_stable.yml
docs/.gitignore
docs/Makefile
docs/field_types.rst
docs/make.bat
docs/examples/bookmarks/README.rst
docs/examples/bookmarks/settings.yaml
docs/examples/bookmarks/templates/page.html
docs/examples/books/README.txt
docs/examples/books/settings.yaml
docs/examples/books/import/from_tellico.yaml
docs/examples/books/templates/from_openlibrary.yaml
docs/examples/music/README.txt
docs/examples/music/settings.yaml
docs/examples/music/import/from_tellico.yaml
docs/examples/ticket_tracker/README.rst
docs/examples/ticket_tracker/aliases.sh
docs/examples/ticket_tracker/settings.yaml
docs/source/conf.py
docs/source/index.rst
docs/source/contrib/index.rst
docs/source/contrib/release_procedure.rst
docs/source/devel/index.rst
docs/source/devel/promises.rst
docs/source/man/Makefile
docs/source/man/index.rst
docs/source/man/lesana-edit.rst
docs/source/man/lesana-export.rst
docs/source/man/lesana-get-values.rst
docs/source/man/lesana-index.rst
docs/source/man/lesana-init.rst
docs/source/man/lesana-new.rst
docs/source/man/lesana-rm.rst
docs/source/man/lesana-search.rst
docs/source/man/lesana-show.rst
docs/source/man/lesana.rst
docs/source/reference/lesana.collection.rst
docs/source/reference/lesana.command.rst
docs/source/reference/lesana.data.rst
docs/source/reference/lesana.rst
docs/source/reference/lesana.templating.rst
docs/source/reference/lesana.types.rst
docs/source/reference/modules.rst
docs/source/user/derivatives.rst
docs/source/user/getting_started_command_line.rst
docs/source/user/index.rst
docs/source/user/moving_data_between_collections.rst
docs/source/user/search.rst
docs/source/user/settings.rst
lesana/__init__.py
lesana/collection.py
lesana/command.py
lesana/templating.py
lesana/types.py
lesana.egg-info/PKG-INFO
lesana.egg-info/SOURCES.txt
lesana.egg-info/dependency_links.txt
lesana.egg-info/requires.txt
lesana.egg-info/top_level.txt
lesana/data/__init__.py
lesana/data/post-checkout
lesana/data/settings.yaml
scripts/lesana
scripts/openlibrary2lesana
scripts/tellico2lesana
tests/__init__.py
tests/test_collection.py
tests/test_commands.py
tests/test_derivatives.py
tests/test_templating.py
tests/test_types.py
tests/utils.py
tests/data/complex/settings.yaml
tests/data/complex/items/0b33e2b72add4ccab93a8cb7e2014b10.yaml
tests/data/complex/items/28b15099c84b41ab892133cd64876a32.yaml
tests/data/complex/items/5084bc6e94f24dc6976629282ef30419.yaml
tests/data/complex/items/5be0a92b6ad745fc9ffced106c94d221.yaml
tests/data/complex/items/73097121f1874a6ea2f927db7dc4f11e.yaml
tests/data/complex/items/8e9fa1ed3c1b4a30a6be7a98eda0cfa7.yaml
tests/data/complex/items/a4265cc5dfa94c3d8030d7df4a0ab747.yaml
tests/data/complex/items/b4b1feb620aa46f5b6784fbc608e4cd8.yaml
tests/data/complex/items/d35a1a71000e4378a25583e050561355.yaml
tests/data/complex/items/d4f361b0e3e541508eaf82c04451797f.yaml
tests/data/derivative/settings.yaml
tests/data/derivative/items/48d73d796c0b47af964722e154fe879c.yaml
tests/data/empty/.gitignore
tests/data/simple/settings.yaml
tests/data/simple/items/085682ed-6792-499d-a3ab-9aebd683c011.yaml
tests/data/simple/items/11189ee47ddf4796b718a483b379f976.yaml
tests/data/simple/items/8b69b063b2a64db7b5714294a69255c7.yaml
tests/data/simple/templates/collection_template.txt
tests/data/simple/templates/from_self.yaml
tests/data/simple/templates/new_entry_from_data.yaml
tests/data/simple/templates/new_entry_from_data_broken.yaml
tests/data/simple/templates/new_entry_from_data_invalid_yaml.yaml
tests/data/simple/templates/new_entry_from_multiple_data.yaml
tests/data/simple/templates/trivial_template.txt
tests/data/wrong/settings.yaml
tests/data/wrong/items/139770330d344a2f9d73945fab3bf47b.yaml
tests/data/wrong/items/5748adc272534bb699febe2c92ad05d9.yaml
tests/data/wrong/items/7496e7b7763b44d994ed07c134e66bdc.yaml
tests/data/wrong/items/b682034f7e2c454aa927606953680330.yaml
tests/data/wrong/items/b9a832309c984ada9f267471660c1313.yaml
tests/data/wrong/items/c54ae3caf262423d988cdc99ee9d0348.yaml ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640962915.0
lesana-0.9.1/lesana.egg-info/dependency_links.txt 0000644 0001777 0001777 00000000001 00000000000 022144 0 ustar 00valhalla valhalla
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640962915.0
lesana-0.9.1/lesana.egg-info/requires.txt 0000644 0001777 0001777 00000000043 00000000000 020473 0 ustar 00valhalla valhalla jinja2
python-dateutil
ruamel.yaml
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640962915.0
lesana-0.9.1/lesana.egg-info/top_level.txt 0000644 0001777 0001777 00000000015 00000000000 020624 0 ustar 00valhalla valhalla lesana
tests
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/run_coverage 0000755 0001777 0001777 00000000236 00000000000 015547 0 ustar 00valhalla valhalla #!/bin/sh
nosetests3 --with-coverage --cover-erase --cover-package=lesana
#nose2-3 --with-coverage --coverage-report=term --log-level=ERROR -B --log-capture
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/run_qa 0000755 0001777 0001777 00000000072 00000000000 014353 0 ustar 00valhalla valhalla #!/bin/sh
flake8 --select=E,F,W,C90,E123 --ignore=W503 .
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/run_tests 0000755 0001777 0001777 00000000102 00000000000 015106 0 ustar 00valhalla valhalla #!/bin/sh
nosetests3
#nose2-3 --log-level=ERROR -B --log-capture
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1640962916.921539
lesana-0.9.1/scripts/ 0000755 0001777 0001777 00000000000 00000000000 014630 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640256487.0
lesana-0.9.1/scripts/lesana 0000755 0001777 0001777 00000001702 00000000000 016021 0 ustar 00valhalla valhalla #!/usr/bin/env python3
"""
Lesana Command Line interface
"""
import logging
import lesana.command
class Lesana(lesana.command.MainCommand):
"""
Manage collections
"""
commands = (
("new", lesana.command.New()),
("edit", lesana.command.Edit()),
("show", lesana.command.Show()),
("index", lesana.command.Index()),
("search", lesana.command.Search()),
("get-values", lesana.command.GetValues()),
("update", lesana.command.Update()),
("export", lesana.command.Export()),
("init", lesana.command.Init()),
("rm", lesana.command.Remove()),
)
if __name__ == "__main__":
# setup logging for lesana cli
logger = logging.getLogger('lesana')
ch = logging.StreamHandler()
formatter = logging.Formatter('%(levelname)s:%(name)s: %(message)s')
ch.setFormatter(formatter)
logger.addHandler(ch)
logger.setLevel(logging.INFO)
Lesana().main()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1633278062.0
lesana-0.9.1/scripts/openlibrary2lesana 0000755 0001777 0001777 00000005346 00000000000 020362 0 ustar 00valhalla valhalla #!/usr/bin/env python3
import argparse
import logging
import os
import sys
import requests
import dateutil.parser
import lesana
import lesana.command
OPENAPI_BASE = "https://openlibrary.org"
OPENAPI_ISBN_URL = OPENAPI_BASE + '/isbn/{isbn}.json'
logger = logging.getLogger(__name__)
class OL2L(lesana.command.Command):
"""
Manage collections
"""
arguments = [
(
['--collection', '-c'],
dict(
help='Name of the lesana collection, default is .',
default='.',
),
),
(
['--template', '-t'],
{
"help": "Template, "
+ "default is templates/from_openlibrary.yaml",
"default": 'templates/from_openlibrary.yaml',
},
),
(
['isbn'],
{
'help': "ISBN of the book we want to search"
},
),
]
def _load_args(self):
self.parser = argparse.ArgumentParser()
for arg in self.arguments:
self.parser.add_argument(*arg[0], **arg[1])
self.args = self.parser.parse_args()
def main(self):
self._load_args()
self.collection = self.collection_class(
directory=self.args.collection
)
logging.info("Looking for %s on OpenLibrary", self.args.isbn)
res = requests.get(OPENAPI_ISBN_URL.format(isbn=self.args.isbn))
if res.status_code != 200:
logger.error("No such isbn found on openlibrary")
sys.exit(1)
edition = res.json()
langs = []
for l in edition.get("languages", []):
langs.append(l["key"].split("/")[-1])
pub_date = dateutil.parser.parse(edition.get("publish_date"))
authors = []
for aid in edition.get("authors", []):
logging.info("Retrieving %s from OpenLibrary", aid)
res = requests.get(OPENAPI_BASE + aid["key"] + ".json")
authors.append(res.json())
data = {
"edition": edition,
"langs": langs,
"pub_date": pub_date,
"authors": authors,
}
entry = self.collection.entry_from_rendered_template(
self.args.template,
data
)
filepath = os.path.join(self.collection.itemdir, entry.fname)
filepath = os.path.normpath(filepath)
if self.edit_file_in_external_editor(filepath):
self.collection.update_cache([entry.fname])
if self.collection.settings["git"]:
self.collection.git_add_files([filepath])
saved_entry = self.collection.entry_from_eid(entry.eid)
print(saved_entry)
if __name__ == '__main__':
OL2L().main()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/scripts/tellico2lesana 0000755 0001777 0001777 00000010101 00000000000 017450 0 ustar 00valhalla valhalla #!/usr/bin/env python3
import argparse
import datetime
from xml.etree import ElementTree
import zipfile
import lesana
NS = {'tellico': 'http://periapsis.org/tellico/'}
# https://docs.kde.org/trunk5/en/extragear-office/tellico/field-type-values.html
F_TYPE_MAP = {
'0': 'string', # not in the specs, but seen in the wild
'1': 'string',
'2': 'text',
'3': 'string',
'4': 'boolean',
'6': 'integer',
'7': 'url',
'8': 'list', # single column table
'10': 'file',
'12': 'date', # date
'14': 'integer', # rating
}
class T2L:
"""
Manage collections
"""
arguments = [
(
['-c', '--collection'],
dict(
help='Name of the new lesana collection.' +
' Default is .lesana',
default=None,
),
),
(['file'], dict(help='Tellico file to convert to lesana.',)),
]
def _load_args(self):
self.parser = argparse.ArgumentParser()
for arg in self.arguments:
self.parser.add_argument(*arg[0], **arg[1])
self.args = self.parser.parse_args()
def read_field_data(self, xfield):
if xfield.tag in self.date_fields:
for child in xfield:
if 'year' in child.tag:
year = child.text
elif 'month' in child.tag:
month = child.text
elif 'day' in child.tag:
day = child.text
try:
data = datetime.date(int(year), int(month), int(day))
except ValueError:
data = None
elif xfield.iter().__next__():
data = []
for child in xfield:
data.append(self.read_field_data(child))
else:
data = xfield.text
return data
def main(self):
self._load_args()
with zipfile.ZipFile(self.args.file, 'r') as zp:
tree = ElementTree.parse(zp.open('tellico.xml'))
# open collection
xml_collection = tree.getroot().find('tellico:collection', NS)
# get collection settings
title = xml_collection.attrib['title']
xml_fields = xml_collection.find('tellico:fields', NS)
self.date_fields = []
fields = []
for xf in xml_fields:
if xf.attrib['type'] == '12':
self.date_fields.append(
'{' + NS['tellico'] + '}' + xf.attrib['name']
)
f_type = F_TYPE_MAP.get(xf.attrib['type'])
# TODO: support fields with the multiple values flag
# (they should probably become lists)
try:
flags = int(xf.attrib['flags'])
except ValueError:
flags = 0
if flags % 2 == 1:
l_type = f_type
f_type = 'list'
plural = 's'
else:
l_type = None
plural = ''
field = {
'name': xf.attrib['name'] + plural,
'type': f_type,
'help': xf.attrib['title'],
}
if l_type:
field['list'] = l_type
fields.append(field)
# Create a collection with the settings we have loaded
directory = self.args.collection or self.args.file.replace(
'.tc', '.lesana'
)
self.collection = lesana.collection.Collection.init(
directory=directory,
git_enabled=False,
settings={'name': title, 'fields': fields, },
)
# import data
for xe in xml_collection.findall('tellico:entry', NS):
data = {'eid': xe.attrib['id']}
for xfield in xe:
field_name = xfield.tag.replace('{' + NS['tellico'] + '}', '')
data[field_name] = self.read_field_data(xfield)
new_entry = lesana.collection.Entry(self.collection, data=data)
self.collection.save_entries([new_entry])
self.collection.update_cache()
if __name__ == '__main__':
T2L().main()
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.9975379
lesana-0.9.1/setup.cfg 0000644 0001777 0001777 00000000046 00000000000 014762 0 ustar 00valhalla valhalla [egg_info]
tag_build =
tag_date = 0
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640962722.0
lesana-0.9.1/setup.py 0000644 0001777 0001777 00000003555 00000000000 014663 0 ustar 00valhalla valhalla from setuptools import setup, find_packages
try:
with open("README.rst", 'r') as fp:
long_description = fp.read()
except IOError:
print("Could not read README.rst, long_description will be empty.")
long_description = ""
setup(
name='lesana',
version='0.9.1',
packages=find_packages(),
scripts=['scripts/lesana'],
package_data={
'': ['*.yaml', 'post-checkout']
},
test_suite='tests',
install_requires=[
# 'xapian >= 1.4',
'ruamel.yaml',
'jinja2',
'python-dateutil',
],
python_requires='>=3',
# Metadata
author="Elena ``of Valhalla'' Grandi",
author_email='valhalla@trueelena.org',
description='Manage collection inventories throught yaml files.',
long_description=long_description,
long_description_content_type='text/x-rst',
license='GPLv3+',
keywords='collection inventory',
url='https://lesana.trueelena.org/',
classifiers=[
'Development Status :: 3 - Alpha',
'Environment :: Console',
'Intended Audience :: Developers',
'Intended Audience :: End Users/Desktop',
'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', # noqa: E501
'Operating System :: POSIX',
'Programming Language :: Python :: 3 :: Only',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
'Programming Language :: Python :: 3.8',
'Topic :: Software Development :: Libraries :: Python Modules',
'Topic :: Utilities',
],
project_urls={
'Source': 'https://git.sr.ht/~valhalla/lesana',
'Documentation': 'https://lesana.trueelena.org/',
'Tracker': 'https://todo.sr.ht/~valhalla/lesana',
'Mailing lists': 'https://sr.ht/~valhalla/lesana/lists',
},
)
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1640962916.929539
lesana-0.9.1/tests/ 0000755 0001777 0001777 00000000000 00000000000 014303 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/__init__.py 0000644 0001777 0001777 00000000000 00000000000 016402 0 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.6935422
lesana-0.9.1/tests/data/ 0000755 0001777 0001777 00000000000 00000000000 015214 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1640962916.933539
lesana-0.9.1/tests/data/complex/ 0000755 0001777 0001777 00000000000 00000000000 016663 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.9655383
lesana-0.9.1/tests/data/complex/items/ 0000755 0001777 0001777 00000000000 00000000000 020004 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/complex/items/0b33e2b72add4ccab93a8cb7e2014b10.yaml 0000644 0001777 0001777 00000000277 00000000000 025435 0 ustar 00valhalla valhalla name: 'With amount number two'
description: |
This is an item with an amount of 2
position: ''
something:
tags: []
keywords: []
exists:
with_default: default value
amount: 2
order: charlie
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/complex/items/28b15099c84b41ab892133cd64876a32.yaml 0000644 0001777 0001777 00000000144 00000000000 024751 0 ustar 00valhalla valhalla name: 'A tagless item'
description: |
.
position: 'somewhere'
something: ''
tags: []
keywords: []
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640273501.0
lesana-0.9.1/tests/data/complex/items/5084bc6e94f24dc6976629282ef30419.yaml 0000644 0001777 0001777 00000000747 00000000000 025005 0 ustar 00valhalla valhalla # This entry has a comment at the beginning
name: 'A commented entry'
# ruamel.yaml does not support preserving indent levels, so please leave the
# description indented by two spaces.
description: |
An entry with comments in the yaml data
position: 'there'
# There is a comment above something
something:
tags: []
keywords: []
exists: true
with_default: default value
amount: 1
order: delta
created:
updated:
epoch:
version: 0
category: first
price: '3.50'
# and a comment at the end
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/complex/items/5be0a92b6ad745fc9ffced106c94d221.yaml 0000644 0001777 0001777 00000000300 00000000000 025457 0 ustar 00valhalla valhalla name: 'With amount number ten'
description: |
This is an item with an amount of ten
position: ''
something:
tags: []
keywords: []
exists:
with_default: default value
amount: 10
order: alpha
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/complex/items/73097121f1874a6ea2f927db7dc4f11e.yaml 0000644 0001777 0001777 00000000227 00000000000 025174 0 ustar 00valhalla valhalla name: 'An item'
description: |
multi
line
description
position: 'over there'
something: ''
tags:
- this
- that
exists: true
keywords: []
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640274206.0
lesana-0.9.1/tests/data/complex/items/8e9fa1ed3c1b4a30a6be7a98eda0cfa7.yaml 0000644 0001777 0001777 00000000465 00000000000 025706 0 ustar 00valhalla valhalla name: "An item with a ' in the name"
description: |
This is an item with ' inside the strings.
position: 'Somewhere'
something:
tags:
- the '
- "'with a ' at the beginning"
keywords: []
exists:
with_default: 'default value'
amount: 0
order:
created:
updated:
epoch:
version: 2
category: first
price: '1.00'
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640277039.0
lesana-0.9.1/tests/data/complex/items/a4265cc5dfa94c3d8030d7df4a0ab747.yaml 0000644 0001777 0001777 00000000465 00000000000 025405 0 ustar 00valhalla valhalla name: 'Floaty price'
description: |
An entry with the price stored as a float.
position: ''
something:
tags: []
keywords: []
exists:
with_default: 'default value'
amount: 0
order:
created: 2021-12-23 15:32:22.100470+00:00
updated: 2021-12-23 15:32:22.100486+00:00
epoch:
version: 0
category: ''
price: 1.90
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/complex/items/b4b1feb620aa46f5b6784fbc608e4cd8.yaml 0000644 0001777 0001777 00000000305 00000000000 025466 0 ustar 00valhalla valhalla name: 'With amount number twenty'
description: |
This is an item with an amount of 20
position: ''
something:
tags: []
keywords: []
exists:
with_default: default value
amount: 20
order: zucchini
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/complex/items/d35a1a71000e4378a25583e050561355.yaml 0000644 0001777 0001777 00000000266 00000000000 024570 0 ustar 00valhalla valhalla name: 'With Amount Number fifteen'
description: |
This is an item with an amount of 15
position: ''
something:
tags: []
keywords: []
exists:
with_default: default value
amount: 15
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/complex/items/d4f361b0e3e541508eaf82c04451797f.yaml 0000644 0001777 0001777 00000000174 00000000000 025113 0 ustar 00valhalla valhalla name: 'Empty lists'
description: |
This entry has no tags and no keywords
position: ''
something:
tags: []
keywords: []
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640337421.0
lesana-0.9.1/tests/data/complex/settings.yaml 0000644 0001777 0001777 00000002523 00000000000 021411 0 ustar 00valhalla valhalla name: "Fully featured lesana collection"
lang: 'english'
entry_label: '{{ eid }}: {{ name }} ({{ tags }})'
default_sort:
- order
fields:
- name: name
type: string
prefix: S
index: free
- name: description
type: text
prefix: XD
index: free
- name: position
type: string
index: field
sortable: true
- name: something
type: yaml
- name: tags
type: list
list: string
index: field
- name: keywords
type: list
list: string
index: free
- name: exists
type: boolean
index: field
- name: with_default
type: string
default: 'default value'
- name: amount
type: integer
index: field
sortable: true
- name: order
type: string
index: field
sortable: true
- name: created
type: datetime
auto: creation
- name: updated
type: datetime
auto: update
- name: epoch
type: datetime
auto: false
- name: version
type: integer
auto: increment
increment: 2
- name: category
type: string
values:
- first
- second
- third
index: field
- name: price
type: decimal
precision: 2
search_aliases:
nice: '(category:first OR category:second)'
bad: category:third
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.9655383
lesana-0.9.1/tests/data/derivative/ 0000755 0001777 0001777 00000000000 00000000000 017356 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.9655383
lesana-0.9.1/tests/data/derivative/items/ 0000755 0001777 0001777 00000000000 00000000000 020477 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/derivative/items/48d73d796c0b47af964722e154fe879c.yaml 0000644 0001777 0001777 00000000042 00000000000 025634 0 ustar 00valhalla valhalla name: 'An item'
unknown: 'future'
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/derivative/settings.yaml 0000644 0001777 0001777 00000000255 00000000000 022104 0 ustar 00valhalla valhalla name: "Derivative lesana collection"
lang: 'english'
fields:
- name: name
type: string
index: free
- name: unknown
type: derived
index: free
././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.9655383
lesana-0.9.1/tests/data/empty/ 0000755 0001777 0001777 00000000000 00000000000 016352 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/empty/.gitignore 0000644 0001777 0001777 00000000000 00000000000 020330 0 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.9655383
lesana-0.9.1/tests/data/simple/ 0000755 0001777 0001777 00000000000 00000000000 016505 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.9695382
lesana-0.9.1/tests/data/simple/items/ 0000755 0001777 0001777 00000000000 00000000000 017626 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/simple/items/085682ed-6792-499d-a3ab-9aebd683c011.yaml 0000644 0001777 0001777 00000000311 00000000000 025302 0 ustar 00valhalla valhalla name: One Item
description: |
This is a long block of text
that spans multiple lines.
position: somewhere
quantity: 2
value: 0.8
cost: '1.99'
eid: 085682ed6792499da3ab9aebd683c011
other: some data
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/simple/items/11189ee47ddf4796b718a483b379f976.yaml 0000644 0001777 0001777 00000000122 00000000000 024710 0 ustar 00valhalla valhalla name: Another item
description: with just a short description
position: somewhere
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/simple/items/8b69b063b2a64db7b5714294a69255c7.yaml 0000644 0001777 0001777 00000000122 00000000000 024654 0 ustar 00valhalla valhalla name: 'Mostly empty entry'
description:
position:
quantity:
amount:
price:
other:
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/simple/settings.yaml 0000644 0001777 0001777 00000001046 00000000000 021232 0 ustar 00valhalla valhalla name: "Simple lesana collection"
lang: 'english'
fields:
- name: name
type: string
index: free
- name: description
type: text
index: free
- name: position
type: string
index: field
- name: quantity
type: integer
index: no
help: 'how many items are there'
- name: value
type: float
index: no
help: 'how much each item is'
- name: cost
type: decimal
index: no
help: 'how much this costs'
- name: other
type: yaml
help: ''
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1640962916.981538
lesana-0.9.1/tests/data/simple/templates/ 0000755 0001777 0001777 00000000000 00000000000 020503 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/simple/templates/collection_template.txt 0000644 0001777 0001777 00000000164 00000000000 025273 0 ustar 00valhalla valhalla {% for entry in entries %}
{{ entry.short_id }}: {{ entry.data.name }}
{{ entry.data.description }}
{% endfor %}
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/simple/templates/from_self.yaml 0000644 0001777 0001777 00000000147 00000000000 023345 0 ustar 00valhalla valhalla name: {{ name | to_yaml }}
description: {{ description | to_yaml }}
position: {{ position | to_yaml }}
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1631823357.0
lesana-0.9.1/tests/data/simple/templates/new_entry_from_data.yaml 0000644 0001777 0001777 00000000547 00000000000 025423 0 ustar 00valhalla valhalla name: '{{ name }}'
description: |
{{ description if description else "." | indent(width=2, first=False) }}
position: '{{ position }}'
# # quantity (integer): how many items are there
quantity: {{ quantity if quantity else "0" }}
# # value (float): how much each item is
value: 0.0
# # cost (decimal): how much this costs
cost: '0'
# # other (yaml):
other:
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1631823357.0
lesana-0.9.1/tests/data/simple/templates/new_entry_from_data_broken.yaml 0000644 0001777 0001777 00000000700 00000000000 026752 0 ustar 00valhalla valhalla name: '{{ name }}'
description: |
{{ description if description else "." indent(width=2, first=False) }}
# Note that the lack of | between "." and indent is wanted, to get a proper
# TemplatingError.
position: '{{ position }}'
# quantity (integer): how many items are there
quantity: {{ quantity if quantity else "0" }}
# value (float): how much each item is
value: 0.0
# cost (decimal): how much this costs
cost: '0'
# other (yaml):
other:
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1631823357.0
lesana-0.9.1/tests/data/simple/templates/new_entry_from_data_invalid_yaml.yaml 0000644 0001777 0001777 00000000647 00000000000 030154 0 ustar 00valhalla valhalla # A : in that position should result in invalid yaml
name: '{{ name }}': some text
description: |
{{ description if description else "." | indent(width=2, first=False) }}
position: '{{ position }}'
# # quantity (integer): how many items are there
quantity: {{ quantity if quantity else "0" }}
# # value (float): how much each item is
value: 0.0
# # cost (decimal): how much this costs
cost: '0'
# # other (yaml):
other:
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1631823357.0
lesana-0.9.1/tests/data/simple/templates/new_entry_from_multiple_data.yaml 0000644 0001777 0001777 00000000651 00000000000 027332 0 ustar 00valhalla valhalla name: '{{ data.name }}'
description: |
{{ description if description else "." | indent(width=2, first=False) }}
position: '{{ data.position }}'
# # quantity (integer): how many items are there
quantity: {{ values.quantity if values.quantity else "0" }}
# # value (float): how much each item is
value: 0.0
# # cost (decimal): how much this costs
cost: '{{ values.cost if values.cost else "0.0" }}'
# # other (yaml):
other:
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/data/simple/templates/trivial_template.txt 0000644 0001777 0001777 00000000014 00000000000 024604 0 ustar 00valhalla valhalla {{ entry }}
././@PaxHeader 0000000 0000000 0000000 00000000033 00000000000 010211 x ustar 00 27 mtime=1640962916.985538
lesana-0.9.1/tests/data/wrong/ 0000755 0001777 0001777 00000000000 00000000000 016350 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000034 00000000000 010212 x ustar 00 28 mtime=1640962916.9975379
lesana-0.9.1/tests/data/wrong/items/ 0000755 0001777 0001777 00000000000 00000000000 017471 5 ustar 00valhalla valhalla ././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640272491.0
lesana-0.9.1/tests/data/wrong/items/139770330d344a2f9d73945fab3bf47b.yaml 0000644 0001777 0001777 00000000600 00000000000 024573 0 ustar 00valhalla valhalla name: 'invalid owner'
description: |
This entry has an owner that is not on the list of allowed owners.
position: ''
# # number (integer): Enter an integer here
number: 0
# # float (float): Enter a floating point number here
float: 0.0
# # price (decimal): prices are never float!
price: '0'
things: []
# # cloud (cloud): There is no cloud type
cloud:
category: ''
owners:
- them
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640272491.0
lesana-0.9.1/tests/data/wrong/items/5748adc272534bb699febe2c92ad05d9.yaml 0000644 0001777 0001777 00000000553 00000000000 025033 0 ustar 00valhalla valhalla name: 'Not a float'
description: |
An entry with a string instead of a floating point
position: ''
# # number (integer): Enter an integer here
number: 0
# # float (float): Enter a floating point number here
float: "zero point zero"
# # price (decimal): prices are never float!
price: '0'
things: []
# # cloud (cloud): There is no cloud type
cloud:
category: ''
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640272491.0
lesana-0.9.1/tests/data/wrong/items/7496e7b7763b44d994ed07c134e66bdc.yaml 0000644 0001777 0001777 00000000572 00000000000 024710 0 ustar 00valhalla valhalla name: 'Invalid category value'
description: |
An entry where the category isn't in the list of valid values
position: ''
# # number (integer): Enter an integer here
number: 0
# # float (float): Enter a floating point number here
float: 0.0
# # price (decimal): prices are never float!
price: '0'
things: []
# # cloud (cloud): There is no cloud type
cloud:
category: 'zeroeth'
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640272491.0
lesana-0.9.1/tests/data/wrong/items/b682034f7e2c454aa927606953680330.yaml 0000644 0001777 0001777 00000000535 00000000000 024301 0 ustar 00valhalla valhalla name: 'Not an integer'
description: |
An entry with a string instead of a number
position: ''
# # number (integer): Enter an integer here
number: "zero"
# # float (float): Enter a floating point number here
float: 0.0
# # price (decimal): prices are never float!
price: '0'
things: []
# # cloud (cloud): There is no cloud type
cloud:
category: ''
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640272491.0
lesana-0.9.1/tests/data/wrong/items/b9a832309c984ada9f267471660c1313.yaml 0000644 0001777 0001777 00000000224 00000000000 024436 0 ustar 00valhalla valhalla name: 'Problematic entry'
description: |
.
position: 'somewhere'
number: 'four'
float: 'half and a bit'
price: 'cheap'
things:
category: 'fourth'
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640272491.0
lesana-0.9.1/tests/data/wrong/items/c54ae3caf262423d988cdc99ee9d0348.yaml 0000644 0001777 0001777 00000000553 00000000000 025040 0 ustar 00valhalla valhalla name: 'Not a decimal'
description: |
An entry with an invalid string for a decimal
position: ''
# # number (integer): Enter an integer here
number: 0
# # float (float): Enter a floating point number here
float: 0.0
# # price (decimal): prices are never float!
price: 'nil, nada, nothing'
things: []
# # cloud (cloud): There is no cloud type
cloud:
category: ''
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640272491.0
lesana-0.9.1/tests/data/wrong/settings.yaml 0000644 0001777 0001777 00000001564 00000000000 021102 0 ustar 00valhalla valhalla name: "Lesana collection with certain errors"
lang: 'somethingish'
entry_label: '{{ short_id }}: {{ name }} - {{ things | join("; ") }}'
fields:
- name: name
type: string
index: free
- name: description
type: text
- name: position
type: string
index: field
- name: number
type: integer
help: "Enter an integer here"
- name: float
type: float
help: "Enter a floating point number here"
- name: price
type: decimal
help: 'prices are never float!'
- name: things
type: list
list: string
index: field
- name: cloud
type: cloud
help: 'There is no cloud type'
- name: category
type: string
values:
- first
- second
- third
- name: owners
type: list
list: string
values:
- me
- myself
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640338414.0
lesana-0.9.1/tests/test_collection.py 0000644 0001777 0001777 00000076044 00000000000 020062 0 ustar 00valhalla valhalla import datetime
import decimal
import logging
import os.path
import shutil
import tempfile
import unittest
import git
import ruamel.yaml
import lesana
from . import utils
class testEntries(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
utils.copytree('tests/data/simple', self.tmpdir, dirs_exist_ok=True)
self.collection = lesana.Collection(self.tmpdir)
self.basepath = self.collection.itemdir
self.filenames = []
def tearDown(self):
shutil.rmtree(self.tmpdir)
def test_simple(self):
fname = '085682ed-6792-499d-a3ab-9aebd683c011.yaml'
with open(os.path.join(self.basepath, fname)) as fp:
data = ruamel.yaml.safe_load(fp)
entry = lesana.Entry(self.collection, data=data, fname=fname)
self.assertEqual(entry.idterm, 'Q' + data['eid'])
fname = '11189ee47ddf4796b718a483b379f976.yaml'
eid = '11189ee47ddf4796b718a483b379f976'
with open(os.path.join(self.basepath, fname)) as fp:
data = ruamel.yaml.safe_load(fp)
entry = lesana.Entry(self.collection, data=data, fname=fname)
self.assertEqual(entry.idterm, 'Q' + eid)
self.assertEqual(entry.short_id, eid[:8])
@unittest.skipIf(
ruamel.yaml.version_info < (0, 16, 0),
"Preservation of data on file requires ruamel.yaml >= 0.16",
)
def test_write_new(self):
new_entry = lesana.Entry(self.collection)
self.collection.save_entries(entries=[new_entry])
entry_fname = os.path.join(self.basepath, new_entry.fname)
with open(entry_fname) as fp:
text = fp.read()
self.assertIn('# quantity (integer): how many items are there', text)
self.assertIn('# other (yaml):', text)
self.assertNotIn('# position (string)', text)
self.assertNotIn('# # other (yaml)', text)
written = ruamel.yaml.safe_load(text)
self.assertIsInstance(written['quantity'], int)
self.assertIsInstance(written['name'], str)
def test_entry_representation(self):
eid = '11189ee47ddf4796b718a483b379f976'
entry = self.collection.entry_from_eid(eid)
self.assertEqual(str(entry), eid)
label = '{{ eid }}: {{ name }}'
self.collection.settings['entry_label'] = label
self.assertEqual(
str(entry), '{eid}: {name}'.format(eid=eid, name='Another item')
)
def test_entry_creation_eid_but_no_filename(self):
fname = '11189ee47ddf4796b718a483b379f976.yaml'
with open(os.path.join(self.basepath, fname)) as fp:
data = ruamel.yaml.safe_load(fp)
data['eid'] = '11189ee47ddf4796b718a483b379f976'
entry = lesana.Entry(self.collection, data=data)
self.assertEqual(entry.fname, fname)
def test_entry_creation_no_eid_no_filename(self):
fname = '11189ee47ddf4796b718a483b379f976.yaml'
with open(os.path.join(self.basepath, fname)) as fp:
data = ruamel.yaml.safe_load(fp)
entry = lesana.Entry(self.collection, data=data)
self.assertIsNotNone(entry.eid)
self.assertIsNotNone(entry.fname)
def test_entry_creation_filename_but_no_eid(self):
fname = '11189ee47ddf4796b718a483b379f976.yaml'
eid = '11189ee47ddf4796b718a483b379f976'
with open(os.path.join(self.basepath, fname)) as fp:
data = ruamel.yaml.safe_load(fp)
entry = lesana.Entry(self.collection, data=data, fname=fname)
self.assertEqual(entry.eid, eid)
def test_entry_str_filename_and_eid(self):
fname = '11189ee47ddf4796b718a483b379f976.yaml'
with open(os.path.join(self.basepath, fname)) as fp:
data = ruamel.yaml.safe_load(fp)
data['eid'] = '11189ee47ddf4796b718a483b379f976'
entry = lesana.Entry(self.collection, data=data)
self.assertEqual(str(entry), data['eid'])
self.collection.settings['entry_label'] = '{{ eid }}: {{ name }}'
self.assertEqual(str(entry), data['eid'] + ': Another item')
def test_entry_str_filename_no_eid(self):
fname = '11189ee47ddf4796b718a483b379f976.yaml'
with open(os.path.join(self.basepath, fname)) as fp:
data = ruamel.yaml.safe_load(fp)
entry = lesana.Entry(self.collection, data=data)
eid = entry.eid
self.assertEqual(str(entry), eid)
self.collection.settings['entry_label'] = '{{ eid }}: {{ name }}'
self.assertEqual(str(entry), eid + ': Another item')
def test_render_entry(self):
fname = '11189ee47ddf4796b718a483b379f976.yaml'
with open(os.path.join(self.basepath, fname)) as fp:
data = ruamel.yaml.safe_load(fp)
entry = lesana.Entry(self.collection, data=data)
eid = entry.eid
res = entry.render('tests/data/simple/templates/trivial_template.txt')
self.assertIn(eid, res)
def test_empty_data(self):
entry = lesana.Entry(self.collection)
self.assertIn("name: ''", entry.yaml_data)
self.assertIn('quantity: 0', entry.yaml_data)
def test_update_entry(self):
eid = '11189ee47ddf4796b718a483b379f976'
entry = self.collection.entry_from_eid(eid)
old_data = entry.data.copy()
entry.auto()
self.assertEqual(old_data, entry.data)
class testEmptyCollection(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
utils.copytree('tests/data/empty', self.tmpdir, dirs_exist_ok=True)
self.collection = lesana.Collection(self.tmpdir)
def tearDown(self):
shutil.rmtree(self.tmpdir)
def test_loaded(self):
self.assertEqual(self.collection.settings, {})
indexed = self.collection.update_cache()
self.assertIsNotNone(self.collection.stemmer)
self.assertEqual(indexed, 0)
class testSimpleCollection(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
utils.copytree('tests/data/simple', self.tmpdir, dirs_exist_ok=True)
self.collection = lesana.Collection(self.tmpdir)
def tearDown(self):
shutil.rmtree(self.tmpdir)
def test_loaded(self):
self.assertIsNotNone(self.collection.settings)
self.assertEqual(
self.collection.settings['name'], "Simple lesana collection"
)
self.assertEqual(len(self.collection.settings['fields']), 7)
self.assertEqual(len(self.collection.indexed_fields), 3)
indexed = self.collection.update_cache()
self.assertIsNotNone(self.collection.stemmer)
self.assertEqual(indexed, 3)
def test_full_search(self):
self.collection.start_search('Item')
res = self.collection.get_all_search_results()
matches = list(res)
self.assertEqual(len(matches), 2)
for m in matches:
self.assertIsInstance(m, lesana.Entry)
def test_search(self):
self.collection.start_search('Item')
res = self.collection.get_search_results()
matches = list(res)
self.assertEqual(len(matches), 2)
for m in matches:
self.assertIsInstance(m, lesana.Entry)
def test_search_wildcard(self):
self.collection.start_search('Ite*')
res = self.collection.get_search_results()
matches = list(res)
self.assertEqual(len(matches), 2)
for m in matches:
self.assertIsInstance(m, lesana.Entry)
def test_search_non_init(self):
matches = list(self.collection.get_search_results())
self.assertEqual(matches, [])
matches = list(self.collection.get_all_search_results())
self.assertEqual(matches, [])
def test_all_entries(self):
res = self.collection.get_all_documents()
matches = list(res)
self.assertEqual(len(matches), 3)
for m in matches:
self.assertIsInstance(m, lesana.Entry)
def test_entry_from_eid(self):
entry = self.collection.entry_from_eid(
'11189ee47ddf4796b718a483b379f976'
)
self.assertEqual(entry.eid, '11189ee47ddf4796b718a483b379f976')
self.collection.safe = True
entry = self.collection.entry_from_eid(
'11189ee47ddf4796b718a483b379f976'
)
self.assertEqual(entry.eid, '11189ee47ddf4796b718a483b379f976')
def test_entry_from_short_eid(self):
entries = self.collection.entries_from_short_eid('11189ee4')
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0].eid, '11189ee47ddf4796b718a483b379f976')
entries = self.collection.entries_from_short_eid(
'11189ee47ddf4796b718a483b379f976'
)
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0].eid, '11189ee47ddf4796b718a483b379f976')
entries = self.collection.entries_from_short_eid('12345678')
self.assertEqual(len(entries), 0)
def test_index_missing_file(self):
with self.assertLogs(level=logging.WARNING) as cm:
self.collection.update_cache(['non_existing_file'])
self.assertEqual(len(cm.output), 1)
self.assertIn("non_existing_file", cm.output[0])
def test_index_reset(self):
indexed = self.collection.update_cache(reset=True)
self.assertEqual(indexed, 3)
def test_get_entry_missing_eid(self):
entry = self.collection.entry_from_eid('this is not an eid')
self.assertIsNone(entry)
def test_render_collection(self):
template = self.collection.get_template(
'tests/data/simple/templates/collection_template.txt'
)
res = template.render(entries=self.collection.get_all_documents())
self.assertIn('11189ee4: Another item', res)
def test_update(self):
self.collection.update_field('Item', field="position", value="new_pos")
with open(
os.path.join(
self.collection.basedir,
'items',
'11189ee47ddf4796b718a483b379f976.yaml',
)
) as fp:
self.assertIn("new_pos", fp.read())
pass
self.assertEqual(
self.collection.entry_from_eid(
"11189ee47ddf4796b718a483b379f976"
).data['position'],
"new_pos",
)
self.assertIsNone(
self.collection.entry_from_eid(
"8b69b063b2a64db7b5714294a69255c7"
).data['position']
)
def test_representation_decimal(self):
entry = self.collection.entry_from_eid(
'085682ed6792499da3ab9aebd683c011'
)
data = ruamel.yaml.safe_load(entry.yaml_data)
self.assertEqual(data['cost'], '1.99')
fname = 'tests/data/simple/items/' + \
'085682ed-6792-499d-a3ab-9aebd683c011.yaml'
with open(fname, 'r') as fp:
self.assertEqual(entry.yaml_data, fp.read())
def test_list_values(self):
values = self.collection.get_field_values('position')
values = list(values)
self.assertEqual(len(values), 2)
self.assertEqual(values, [
{'value': 'somewhere', 'frequency': 2},
{'value': None, 'frequency': 1},
])
def test_entry_from_template(self):
# TODO: make finding the templates less prone to breaking and
# then remove the cwd change from here
old_cwd = os.getcwd()
os.chdir(self.tmpdir)
data = {
"name": "This is a name",
}
entry = self.collection.entry_from_rendered_template(
"templates/new_entry_from_data.yaml",
data
)
os.chdir(old_cwd)
self.assertIsInstance(entry, lesana.Entry)
self.assertEqual(entry.data["name"], "This is a name")
def test_entry_from_template_multiple_data_sources(self):
# TODO: make finding the templates less prone to breaking and
# then remove the cwd change from here
old_cwd = os.getcwd()
os.chdir(self.tmpdir)
data = {
"name": "This is a name",
}
values = {
"quantity": 5,
"cost": decimal.Decimal("3.5"),
}
entry = self.collection.entry_from_rendered_template(
"templates/new_entry_from_multiple_data.yaml",
{
"data": data,
"values": values
}
)
os.chdir(old_cwd)
self.assertIsInstance(entry, lesana.Entry)
self.assertEqual(entry.data["name"], "This is a name")
self.assertEqual(entry.data["quantity"], 5)
def test_entry_from_bad_template(self):
# TODO: make finding the templates less prone to breaking and
# then remove the cwd change from here
old_cwd = os.getcwd()
os.chdir(self.tmpdir)
data = {
"name": "This is a name",
}
with self.assertRaises(lesana.collection.TemplatingError):
self.collection.entry_from_rendered_template(
"templates/new_entry_from_data_broken.yaml",
data
)
os.chdir(old_cwd)
def test_entry_from_bad_yaml(self):
# TODO: make finding the templates less prone to breaking and
# then remove the cwd change from here
old_cwd = os.getcwd()
os.chdir(self.tmpdir)
data = {
"name": "This is a name",
}
with self.assertRaises(lesana.collection.TemplatingError):
self.collection.entry_from_rendered_template(
"templates/new_entry_from_data_invalid_yaml.yaml",
data
)
os.chdir(old_cwd)
class testComplexCollection(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
utils.copytree('tests/data/complex', self.tmpdir, dirs_exist_ok=True)
self.collection = lesana.Collection(self.tmpdir)
def tearDown(self):
shutil.rmtree(self.tmpdir)
def test_init(self):
self.assertIsNotNone(self.collection.settings)
self.assertEqual(
self.collection.settings['name'],
"Fully featured lesana collection",
)
self.assertEqual(len(self.collection.settings['fields']), 16)
self.assertIsNotNone(self.collection.stemmer)
self.assertEqual(len(self.collection.indexed_fields), 9)
def test_index(self):
indexed = self.collection.update_cache()
self.assertEqual(indexed, 10)
def test_indexing_list(self):
self.collection.update_cache(['73097121f1874a6ea2f927db7dc4f11e.yaml'])
self.collection.start_search('tags:this')
res = self.collection.get_search_results()
matches = list(res)
self.assertEqual(len(matches), 1)
for m in matches:
self.assertIsInstance(m, lesana.Entry)
def test_boolean_field(self):
entry = self.collection.entry_from_eid(
'73097121f1874a6ea2f927db7dc4f11e'
)
self.assertIsInstance(entry.data['exists'], bool)
def test_empty_data(self):
entry = lesana.Entry(self.collection)
self.assertIn("name: ''", entry.yaml_data)
self.assertIn('with_default', entry.yaml_data)
self.assertIn('amount: 0', entry.yaml_data)
self.assertIn("tags: []", entry.yaml_data)
self.assertIn("exists:\n", entry.yaml_data)
# we just check that created starts with a date and ends with
# the utc timezone to keep the regex short and manageable
self.assertRegex(
entry.yaml_data,
r"created: [\d]{4,4}-[\d]{2,2}-[\d]{2,2} .*\+00\:00"
)
self.assertIn("epoch:\n", entry.yaml_data)
def test_load_field_loaders(self):
# Check that all fields have been loaded, with the right types
to_test = (
('name', lesana.types.LesanaString),
('description', lesana.types.LesanaText),
('position', lesana.types.LesanaString),
('something', lesana.types.LesanaYAML),
('tags', lesana.types.LesanaList),
('keywords', lesana.types.LesanaList),
('exists', lesana.types.LesanaBoolean),
('with_default', lesana.types.LesanaString),
('amount', lesana.types.LesanaInt),
)
for f in to_test:
self.assertIsInstance(self.collection.fields[f[0]], f[1])
@unittest.skipIf(
ruamel.yaml.version_info < (0, 16, 0),
"Preservation of data on file requires ruamel.yaml >= 0.16",
)
def test_comments_are_preserved(self):
e = self.collection.entry_from_eid('5084bc6e94f24dc6976629282ef30419')
yaml_data = e.yaml_data
self.assertTrue(
yaml_data.startswith("# This entry has a comment at the beginning")
)
self.assertTrue(
yaml_data.endswith("# and a comment at the end\n")
)
@unittest.skipIf(
ruamel.yaml.version_info < (0, 16, 0),
"Preservation of data on file requires ruamel.yaml >= 0.16",
)
def test_data_is_stored_as_written_on_file(self):
e = self.collection.entry_from_eid('5084bc6e94f24dc6976629282ef30419')
fname = 'tests/data/complex/items/' + \
'5084bc6e94f24dc6976629282ef30419.yaml'
with open(fname, 'r') as fp:
self.assertEqual(e.yaml_data, fp.read())
def test_data_is_stored_as_written_on_file_with_apices(self):
e = self.collection.entry_from_eid('8e9fa1ed3c1b4a30a6be7a98eda0cfa7')
fname = 'tests/data/complex/items/' + \
'8e9fa1ed3c1b4a30a6be7a98eda0cfa7.yaml'
with open(fname, 'r') as fp:
self.assertEqual(e.yaml_data, fp.read())
def test_sorted_search(self):
# search in ascending order
self.collection.start_search('Amount', sort_by=['amount'])
res = self.collection.get_search_results()
matches = list(res)
self.assertEqual(len(matches), 4)
self.assertEqual(matches[0].data['amount'], 2)
self.assertEqual(matches[1].data['amount'], 10)
self.assertEqual(matches[2].data['amount'], 15)
self.assertEqual(matches[3].data['amount'], 20)
# and in descending order
self.collection.start_search('Amount', sort_by=['-amount'])
res = self.collection.get_search_results()
matches = list(res)
self.assertEqual(len(matches), 4)
self.assertEqual(matches[0].data['amount'], 20)
self.assertEqual(matches[1].data['amount'], 15)
self.assertEqual(matches[2].data['amount'], 10)
self.assertEqual(matches[3].data['amount'], 2)
def test_default_sorted_search(self):
# search in ascending order
self.collection.start_search('Amount')
res = self.collection.get_search_results()
matches = list(res)
self.assertEqual(len(matches), 4)
print([m.data['order'] for m in matches])
self.assertEqual(matches[0].data['order'], None)
self.assertEqual(matches[1].data['order'], 'alpha')
self.assertEqual(matches[2].data['order'], 'charlie')
self.assertEqual(matches[3].data['order'], 'zucchini')
def test_search_all_documents_default_sort(self):
self.collection.start_search('*')
res = self.collection.get_search_results()
matches = list(res)
self.assertEqual(len(matches), 10)
for i in range(5):
self.assertEqual(matches[i].data['order'], None)
self.assertEqual(matches[6].data['order'], 'alpha')
self.assertEqual(matches[7].data['order'], 'charlie')
self.assertEqual(matches[8].data['order'], 'delta')
self.assertEqual(matches[9].data['order'], 'zucchini')
def test_update_entry(self):
eid = '5084bc6e94f24dc6976629282ef30419'
entry = self.collection.entry_from_eid(eid)
# we keep the old data, and check that the updated field is
# empty and the version field is 0
old_data = entry.data.copy()
self.assertEqual(entry.data['updated'], None)
self.assertEqual(entry.data['version'], 0)
entry.auto()
# after the update, fields that were not supposed to be updated
# are equal to what they were before, while updated has been
# changed to a datetime in this year (we don't check too deeply
# to avoid breaking tests too often with race conditions) and
# version has grown to 2.
for field in ('created', 'epoch'):
self.assertEqual(old_data[field], entry.data[field])
now = datetime.datetime.now(datetime.timezone.utc)
self.assertIsInstance(entry.data['updated'], datetime.datetime)
self.assertEqual(entry.data['updated'].year, now.year)
self.assertEqual(entry.data['version'], 2)
def test_list_values(self):
values = self.collection.get_field_values('position')
values = list(values)
self.assertEqual(values, [
{'value': b'Somewhere', 'frequency': 1},
{'value': b'over there', 'frequency': 1},
{'value': b'somewhere', 'frequency': 1},
{'value': b'there', 'frequency': 1},
])
def test_decimal_as_float(self):
eid = 'a4265cc5dfa94c3d8030d7df4a0ab747'
entry = self.collection.entry_from_eid(eid)
self.assertEqual(entry.data['price'], "1.90")
def test_search_aliases(self):
search_query = "{{ nice }}"
search_query = self.collection.render_query_template(search_query)
print("QUERY IS", search_query)
self.collection.start_search(search_query)
res = self.collection.get_search_results()
matches = list(res)
self.assertEqual(len(matches), 2)
matches_ids = [m.eid for m in matches]
self.assertIn('8e9fa1ed3c1b4a30a6be7a98eda0cfa7', matches_ids)
self.assertIn('5084bc6e94f24dc6976629282ef30419', matches_ids)
class testCollectionWithErrors(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
utils.copytree('tests/data/wrong', self.tmpdir, dirs_exist_ok=True)
self.collection = lesana.Collection(self.tmpdir)
def tearDown(self):
shutil.rmtree(self.tmpdir)
def test_load_wrong_language(self):
# We reload this collection, with an invalid value in lang, to
# check that the log contains a warning.
with self.assertLogs(level=logging.WARNING) as cm:
self.collection = lesana.Collection(self.tmpdir)
self.assertEqual(len(cm.output), 2)
self.assertIn("Invalid language", cm.output[1])
# The collection will default to english, but should still work.
self.collection.update_cache()
self.assertIsNotNone(self.collection.settings)
self.assertIsNotNone(self.collection.stemmer)
def test_no_index_for_one_field(self):
# In the “wrong” collection, some of the entries have no "index"
# field.
self.collection.update_cache()
self.assertIsNotNone(self.collection.settings)
self.assertIsNotNone(self.collection.stemmer)
# Fields with no "index" entry are not indexed
self.assertEqual(len(self.collection.settings['fields']), 10)
self.assertEqual(len(self.collection.indexed_fields), 3)
def test_init(self):
self.assertIsNotNone(self.collection.settings)
self.assertEqual(
self.collection.settings['name'],
"Lesana collection with certain errors",
)
self.assertEqual(len(self.collection.settings['fields']), 10)
self.assertIsNotNone(self.collection.stemmer)
self.assertEqual(len(self.collection.indexed_fields), 3)
def test_index(self):
loaded = self.collection.update_cache()
self.assertEqual(loaded, 0)
class testCollectionCreation(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tmpdir)
def test_init(self):
collection = lesana.Collection.init(self.tmpdir)
self.assertIsInstance(collection, lesana.Collection)
self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, '.git')))
self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, '.lesana')))
self.assertTrue(
os.path.isfile(os.path.join(self.tmpdir, 'settings.yaml'))
)
self.assertTrue(
os.path.isfile(os.path.join(self.tmpdir, '.gitignore'))
)
checkout_hook = os.path.join(
self.tmpdir,
'.git',
'hooks',
'post-checkout',
)
merge_hook = os.path.join(
self.tmpdir,
'.git',
'hooks',
'post-merge',
)
self.assertTrue(os.path.isfile(checkout_hook))
self.assertTrue(os.path.islink(merge_hook))
self.assertEqual(
os.path.abspath(checkout_hook),
os.path.abspath(os.readlink(merge_hook))
)
# and then run it twice on the same directory, nothing should break
collection = lesana.Collection.init(self.tmpdir)
self.assertIsInstance(collection, lesana.Collection)
self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, '.git')))
self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, '.lesana')))
self.assertTrue(
os.path.isfile(os.path.join(self.tmpdir, 'settings.yaml'))
)
self.assertTrue(
os.path.isfile(os.path.join(self.tmpdir, '.gitignore'))
)
created = lesana.Collection(self.tmpdir)
self.assertTrue(created.settings['git'])
def _do_nothing(*args, **kwargs):
# A function that does nothing instead of editing a file
pass
def test_init_edit_file(self):
collection = lesana.Collection.init(
self.tmpdir, edit_file=self._do_nothing
)
self.assertIsInstance(collection, lesana.Collection)
self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, '.git')))
self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, '.lesana')))
self.assertTrue(
os.path.isfile(os.path.join(self.tmpdir, 'settings.yaml'))
)
self.assertTrue(
os.path.isfile(os.path.join(self.tmpdir, '.gitignore'))
)
def test_init_no_git(self):
collection = lesana.Collection.init(self.tmpdir, git_enabled=False)
self.assertIsInstance(collection, lesana.Collection)
self.assertFalse(os.path.isdir(os.path.join(self.tmpdir, '.git')))
self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, '.lesana')))
self.assertTrue(
os.path.isfile(os.path.join(self.tmpdir, 'settings.yaml'))
)
self.assertFalse(
os.path.isfile(os.path.join(self.tmpdir, '.gitignore'))
)
# and then run it twice on the same directory, nothing should break
collection = lesana.Collection.init(self.tmpdir, git_enabled=False)
self.assertIsInstance(collection, lesana.Collection)
self.assertFalse(os.path.isdir(os.path.join(self.tmpdir, '.git')))
self.assertTrue(os.path.isdir(os.path.join(self.tmpdir, '.lesana')))
self.assertTrue(
os.path.isfile(os.path.join(self.tmpdir, 'settings.yaml'))
)
self.assertFalse(
os.path.isfile(os.path.join(self.tmpdir, '.gitignore'))
)
created = lesana.Collection(self.tmpdir)
self.assertFalse(created.settings['git'])
def test_deletion(self):
shutil.copy('tests/data/simple/settings.yaml', self.tmpdir)
utils.copytree(
'tests/data/simple/items', os.path.join(self.tmpdir, 'items'),
)
collection = lesana.Collection.init(self.tmpdir)
# We start with one item indexed with the term "another"
collection.start_search('another')
mset = collection._enquire.get_mset(0, 10)
self.assertEqual(mset.get_matches_estimated(), 1)
# Then delete it
collection.remove_entries(['11189ee47ddf4796b718a483b379f976'])
# An now we should have none
self.assertFalse(
os.path.exists(
os.path.join(
self.tmpdir,
'items',
'11189ee47ddf4796b718a483b379f976.yaml',
)
)
)
collection.start_search('another')
mset = collection._enquire.get_mset(0, 10)
self.assertEqual(mset.get_matches_estimated(), 0)
def test_partial_eid_deletion(self):
shutil.copy('tests/data/simple/settings.yaml', self.tmpdir)
utils.copytree(
'tests/data/simple/items', os.path.join(self.tmpdir, 'items'),
)
collection = lesana.Collection.init(self.tmpdir)
# We start with one item indexed with the term "another"
collection.start_search('another')
mset = collection._enquire.get_mset(0, 10)
self.assertEqual(mset.get_matches_estimated(), 1)
# Then delete it, using the short id
collection.remove_entries(['11189ee4'])
# An now we should have none
self.assertFalse(
os.path.exists(
os.path.join(
self.tmpdir,
'items',
'11189ee47ddf4796b718a483b379f976.yaml',
)
)
)
collection.start_search('another')
mset = collection._enquire.get_mset(0, 10)
self.assertEqual(mset.get_matches_estimated(), 0)
def _find_file_in_git_index(self, fname, index):
found = False
for (path, stage) in index.entries:
if fname in path:
found = True
break
return found
def test_git_adding(self):
shutil.copy('tests/data/simple/settings.yaml', self.tmpdir)
utils.copytree(
'tests/data/simple/items', os.path.join(self.tmpdir, 'items'),
)
collection = lesana.Collection.init(self.tmpdir)
fname = '11189ee47ddf4796b718a483b379f976.yaml'
repo = git.Repo(self.tmpdir)
# By default, this collection doesn't have any git entry in the
# settings (but there is a repo)
collection.git_add_files([os.path.join(collection.itemdir, fname)])
self.assertFalse(self._find_file_in_git_index(fname, repo.index))
# Then we set it to false
collection.settings['git'] = False
collection.git_add_files([os.path.join(collection.itemdir, fname)])
self.assertFalse(self._find_file_in_git_index(fname, repo.index))
# And only when it's set to true we should find the file in the
# staging area
collection.settings['git'] = True
collection.git_add_files([os.path.join(collection.itemdir, fname)])
self.assertTrue(self._find_file_in_git_index(fname, repo.index))
def test_init_custom_settings(self):
collection = lesana.Collection.init(
self.tmpdir,
edit_file=self._do_nothing,
settings={
'name': 'A different name',
'fields': [
{'name': 'title', 'type': 'string'},
{'name': 'author', 'type': 'string'},
],
},
)
self.assertIsInstance(collection, lesana.Collection)
self.assertTrue(
os.path.isfile(os.path.join(self.tmpdir, 'settings.yaml'))
)
self.assertEqual(collection.settings['name'], 'A different name')
self.assertEqual(len(collection.settings['fields']), 2)
if __name__ == '__main__':
unittest.main()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1640769481.0
lesana-0.9.1/tests/test_commands.py 0000644 0001777 0001777 00000016741 00000000000 017526 0 ustar 00valhalla valhalla import contextlib
import io
import os
import tempfile
import unittest
from lesana import command
from . import utils
class Args:
def __init__(self, args):
self.args = args
def __getattribute__(self, k):
try:
return super().__getattribute__(k)
except AttributeError:
try:
return self.args[k]
except KeyError as e:
raise AttributeError(e)
class CommandsMixin:
def _edit_file(self, filepath):
return True
def _run_command(self, cmd, args):
stream = {
'stdout': io.StringIO(),
'stderr': io.StringIO(),
}
cmd.edit_file_in_external_editor = self._edit_file
cmd.args = Args(args)
with contextlib.redirect_stdout(stream['stdout']):
with contextlib.redirect_stderr(stream['stderr']):
cmd.main()
return stream
class testCommandsSimple(unittest.TestCase, CommandsMixin):
def setUp(self):
self.tmpdir = tempfile.TemporaryDirectory()
utils.copytree(
'tests/data/simple',
self.tmpdir.name,
dirs_exist_ok=True,
)
# re-index the collection before running each test
args = {
'collection': self.tmpdir.name,
"files": None,
"reset": True,
}
self._run_command(command.Index(), args)
def tearDown(self):
pass
def test_init(self):
args = {
'collection': self.tmpdir.name,
'git': True,
}
streams = self._run_command(command.Init(), args)
self.assertEqual(streams['stdout'].getvalue(), '')
self.assertEqual(streams['stderr'].getvalue(), '')
def test_new(self):
args = {
'collection': self.tmpdir.name,
'git': True,
}
streams = self._run_command(command.New(), args)
self.assertEqual(len(streams['stdout'].getvalue()), 33)
self.assertEqual(streams['stderr'].getvalue(), '')
def test_edit(self):
args = {
'collection': self.tmpdir.name,
'git': True,
'eid': '11189ee4',
}
streams = self._run_command(command.Edit(), args)
self.assertTrue(args['eid'] in streams['stdout'].getvalue())
self.assertEqual(streams['stderr'].getvalue(), '')
def test_show(self):
args = {
'collection': self.tmpdir.name,
'git': True,
'eid': '11189ee4',
'template': False,
}
streams = self._run_command(command.Show(), args)
self.assertTrue(
'name: Another item' in streams['stdout'].getvalue()
)
self.assertEqual(streams['stderr'].getvalue(), '')
def test_index(self):
args = {
'collection': self.tmpdir.name,
'git': True,
'files': None,
'reset': True,
}
streams = self._run_command(command.Index(), args)
self.assertEqual(
streams['stdout'].getvalue(),
'Found and indexed 3 entries\n',
)
self.assertEqual(streams['stderr'].getvalue(), '')
def test_search(self):
args = {
'collection': self.tmpdir.name,
'git': True,
'template': False,
'query': 'Another',
'offset': None,
'pagesize': None,
'sort': None,
'expand_query_template': False,
'all': False,
}
streams = self._run_command(command.Search(), args)
self.assertTrue(
'11189ee4' in streams['stdout'].getvalue()
)
self.assertEqual(streams['stderr'].getvalue(), '')
def test_get_values(self):
args = {
'collection': self.tmpdir.name,
'git': True,
'template': False,
'query': '*',
'field': 'position',
}
streams = self._run_command(command.GetValues(), args)
self.assertIn('somewhere: 2', streams['stdout'].getvalue())
self.assertEqual(streams['stderr'].getvalue(), '')
def test_export(self):
dest_tmpdir = tempfile.TemporaryDirectory()
utils.copytree(
'tests/data/simple',
dest_tmpdir.name,
dirs_exist_ok=True,
)
# TODO: make finding the templates less prone to breaking and
# then remove the cwd change from here
old_cwd = os.getcwd()
os.chdir(self.tmpdir.name)
args = {
'collection': self.tmpdir.name,
'git': True,
'template': 'templates/from_self.yaml',
'query': 'Another',
'destination': dest_tmpdir.name,
}
streams = self._run_command(command.Export(), args)
os.chdir(old_cwd)
self.assertEqual(streams['stdout'].getvalue(), '')
self.assertEqual(streams['stderr'].getvalue(), '')
def test_remove(self):
args = {
'collection': self.tmpdir.name,
'git': True,
'entries': ['11189ee4'],
}
streams = self._run_command(command.Remove(), args)
self.assertEqual(streams['stdout'].getvalue(), '')
self.assertEqual(streams['stderr'].getvalue(), '')
# and check that the entry has been removed
args = {
'collection': self.tmpdir.name,
'git': True,
'eid': '11189ee4',
'template': False,
}
streams = self._run_command(command.Show(), args)
self.assertEqual(streams['stderr'].getvalue(), '')
def test_update(self):
args = {
'collection': self.tmpdir.name,
'git': True,
'query': 'Another',
'field': 'position',
'value': 'here',
}
streams = self._run_command(command.Update(), args)
self.assertEqual(streams['stdout'].getvalue(), '')
self.assertEqual(streams['stderr'].getvalue(), '')
class testCommandsComplex(unittest.TestCase, CommandsMixin):
def setUp(self):
self.tmpdir = tempfile.TemporaryDirectory()
utils.copytree(
'tests/data/complex',
self.tmpdir.name,
dirs_exist_ok=True,
)
# re-index the collection before running each test
args = {
'collection': self.tmpdir.name,
"files": None,
"reset": True,
}
self._run_command(command.Index(), args)
def tearDown(self):
pass
def test_get_values_from_list(self):
args = {
'collection': self.tmpdir.name,
'git': True,
'template': False,
'query': '*',
'field': 'tags',
}
streams = self._run_command(command.GetValues(), args)
self.assertIn('this: 1', streams['stdout'].getvalue())
self.assertEqual(streams['stderr'].getvalue(), '')
def test_search_template(self):
args = {
'collection': self.tmpdir.name,
'git': True,
'template': False,
'query': '{{ nice }}',
'expand_query_template': True,
'offset': None,
'pagesize': None,
'sort': None,
'all': False,
}
streams = self._run_command(command.Search(), args)
self.assertIn('8e9fa1ed', streams['stdout'].getvalue())
self.assertIn('5084bc6e', streams['stdout'].getvalue())
self.assertEqual(streams['stderr'].getvalue(), '')
if __name__ == '__main__':
unittest.main()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/test_derivatives.py 0000644 0001777 0001777 00000001447 00000000000 020247 0 ustar 00valhalla valhalla import shutil
import tempfile
import unittest
import lesana
from lesana import types
from . import utils
class DerivedType(types.LesanaString):
"""
A custom type
"""
name = 'derived'
class Derivative(lesana.Collection):
"""
A class serived from lesana.Collection
"""
class testDerivatives(unittest.TestCase):
def setUp(self):
self.tmpdir = tempfile.mkdtemp()
utils.copytree(
'tests/data/derivative',
self.tmpdir,
dirs_exist_ok=True
)
self.collection = Derivative(self.tmpdir)
def tearDown(self):
shutil.rmtree(self.tmpdir)
def test_load_subclasses(self):
self.assertIsInstance(self.collection.fields['unknown'], DerivedType)
if __name__ == '__main__':
unittest.main()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/test_templating.py 0000644 0001777 0001777 00000002754 00000000000 020070 0 ustar 00valhalla valhalla import decimal
import unittest
from lesana import templating
class testFilters(unittest.TestCase):
def setUp(self):
pass
def tearDown(self):
pass
def test_to_yaml(self):
res = templating.to_yaml(None)
self.assertIsInstance(res, str)
self.assertEqual(res, 'null')
s = "A short string"
res = templating.to_yaml(s)
self.assertEqual(res, s)
s = """
A long, multiline
string
with multiple
lines
"""
res = templating.to_yaml(s)
self.assertIsInstance(res, str)
self.assertTrue(res.startswith('|'))
self.assertIn('\n', res)
s = """
short
multiline
"""
res = templating.to_yaml(s)
self.assertIsInstance(res, str)
self.assertTrue(res.startswith('|'))
self.assertIn('\n', res)
res = templating.to_yaml(10)
self.assertEqual(res, '10')
res = templating.to_yaml(decimal.Decimal('10.1'))
self.assertEqual(res, "'10.1'")
s = "A very long line, but one that has no new lines " \
+ "even if it is definitely longer than a standard " \
+ "80 columns line"
res = templating.to_yaml(s)
self.assertTrue(res.startswith("|\n"))
self.assertNotIn('\n', res.lstrip("|\n"))
for line in res.lstrip("|\n").split('\n'):
self.assertTrue(line.startswith(" "))
if __name__ == '__main__':
unittest.main()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1631823357.0
lesana-0.9.1/tests/test_types.py 0000644 0001777 0001777 00000041750 00000000000 017067 0 ustar 00valhalla valhalla import datetime
import decimal
import unittest
import xapian
from lesana import types
class testTypes(unittest.TestCase):
def setUp(self):
pass
def tearDown(self):
pass
def _get_field_def(self, type_name):
return {
'type': type_name,
'name': 'test_field',
}
def test_base(self):
checker = types.LesanaType(self._get_field_def('base'), {})
# The base class does not implement empty nor load
with self.assertRaises(NotImplementedError):
checker.empty()
with self.assertRaises(NotImplementedError):
checker.load("")
def test_string(self):
checker = types.LesanaString(self._get_field_def('string'), {})
s = checker.empty()
self.assertEqual(s, "")
s = checker.load("Hello World!")
self.assertEqual(s, "Hello World!")
s = checker.load(None)
self.assertEqual(s, None)
v = checker.auto("Hello World!")
self.assertEqual(v, "Hello World!")
def test_text(self):
checker = types.LesanaText(self._get_field_def('text'), {})
s = checker.empty()
self.assertEqual(s, "")
s = checker.load("Hello World!")
self.assertEqual(s, "Hello World!")
s = checker.load(None)
self.assertEqual(s, None)
v = checker.auto("Hello World!")
self.assertEqual(v, "Hello World!")
def test_int(self):
checker = types.LesanaInt(self._get_field_def('integer'), {})
v = checker.empty()
self.assertEqual(v, 0)
v = checker.load("10")
self.assertEqual(v, 10)
v = checker.load(10.5)
self.assertEqual(v, 10)
for d in ("ten", "10.5"):
with self.assertRaises(types.LesanaValueError):
checker.load(d)
v = checker.load(None)
self.assertEqual(v, None)
v = checker.auto(10)
self.assertEqual(v, 10)
def test_datetime_auto_increment(self):
field_def = self._get_field_def('integer')
field_def['auto'] = 'increment'
checker = types.LesanaInt(field_def, {})
v = checker.empty()
self.assertEqual(v, 0)
v = checker.auto(0)
self.assertEqual(v, 1)
field_def['increment'] = -1
checker = types.LesanaInt(field_def, {})
v = checker.auto(0)
self.assertEqual(v, -1)
field_def['increment'] = 0.5
checker = types.LesanaInt(field_def, {})
with self.assertLogs() as cm:
v = checker.auto(0)
self.assertIn('WARNING', cm.output[0])
self.assertIn('Invalid configuration value', cm.output[0])
self.assertEqual(v, 0)
field_def['auto'] = 'false'
checker = types.LesanaInt(field_def, {})
v = checker.auto(0)
self.assertEqual(v, 0)
def test_float(self):
checker = types.LesanaFloat(self._get_field_def('float'), {})
v = checker.empty()
self.assertEqual(v, 0.0)
v = checker.load("10")
self.assertEqual(v, 10)
v = checker.load(10.5)
self.assertEqual(v, 10.5)
v = checker.load("10.5")
self.assertEqual(v, 10.5)
for d in ("ten"):
with self.assertRaises(types.LesanaValueError):
checker.load(d)
v = checker.load(None)
self.assertEqual(v, None)
v = checker.auto(10.5)
self.assertEqual(v, 10.5)
def test_decimal(self):
checker = types.LesanaDecimal(self._get_field_def('decimal'), {})
v = checker.empty()
self.assertEqual(v, decimal.Decimal(0))
v = checker.load("10")
self.assertEqual(v, decimal.Decimal(10))
v = checker.load(10.5)
self.assertEqual(v, decimal.Decimal(10.5))
v = checker.load("10.5")
self.assertEqual(v, decimal.Decimal(10.5))
for d in ("ten"):
with self.assertRaises(types.LesanaValueError):
checker.load(d)
v = checker.load(None)
self.assertEqual(v, None)
v = checker.auto(decimal.Decimal("10.5"))
self.assertEqual(v, decimal.Decimal("10.5"))
def test_timestamp(self):
checker = types.LesanaTimestamp(self._get_field_def('timestamp'), {})
v = checker.empty()
self.assertEqual(v, None)
now = datetime.datetime.now()
v = checker.load(now)
self.assertEqual(v, now)
v = checker.load("1600000000")
wanted = datetime.datetime(
2020, 9, 13, 12, 26, 40, 0,
datetime.timezone.utc,
)
self.assertEqual(v, wanted)
today = datetime.date.today()
for d in (
today,
"today",
"2020-13-01", "2020-01-01",
"2020-01-01 10:00"
):
with self.assertRaises(types.LesanaValueError):
checker.load(d)
v = checker.load(None)
self.assertEqual(v, None)
v = checker.auto(today)
self.assertEqual(v, today)
def test_datetime(self):
checker = types.LesanaDatetime(self._get_field_def('datetime'), {})
v = checker.empty()
self.assertEqual(v, None)
now = datetime.datetime.now()
v = checker.load(now)
self.assertEqual(v, now)
today = datetime.date.today()
v = checker.load(today)
self.assertIsInstance(v, datetime.datetime)
for part in ('year', 'month', 'day'):
self.assertEqual(getattr(v, part), getattr(today, part))
v = checker.load("2020-01-01")
self.assertEqual(v, datetime.datetime(2020, 1, 1))
v = checker.load("2020-01-01 10:00")
self.assertEqual(v, datetime.datetime(2020, 1, 1, 10, 0))
for d in ("today", "2020-13-01"):
with self.assertRaises(types.LesanaValueError):
checker.load(d)
v = checker.load(None)
self.assertEqual(v, None)
v = checker.auto(now)
self.assertEqual(v, now)
def test_datetime_auto(self):
field_def = self._get_field_def('datetime')
field_def['auto'] = 'creation'
checker = types.LesanaDatetime(field_def, {})
now = datetime.datetime.now()
v = checker.empty()
self.assertIsInstance(v, datetime.datetime)
self.assertEqual(v.tzinfo, datetime.timezone.utc)
self.assertEqual(v.year, now.year)
field_def['auto'] = False
checker = types.LesanaDatetime(field_def, {})
v = checker.empty()
self.assertEqual(v, None)
# auto=update fields should also be filled at creation time
field_def['auto'] = 'update'
checker = types.LesanaDatetime(field_def, {})
v = checker.empty()
self.assertIsInstance(v, datetime.datetime)
self.assertEqual(v.tzinfo, datetime.timezone.utc)
self.assertEqual(v.year, now.year)
def test_datetime_auto_update(self):
field_def = self._get_field_def('datetime')
field_def['auto'] = 'update'
checker = types.LesanaDatetime(field_def, {})
now = datetime.datetime.now()
past = datetime.datetime(2016, 12, 10, 21, 2)
# we pass a date in the past
v = checker.auto(past)
self.assertIsInstance(v, datetime.datetime)
self.assertEqual(v.tzinfo, datetime.timezone.utc)
# and we want to get a date in the present
self.assertEqual(v.year, now.year)
# with auto=False we want our old date instead
field_def['auto'] = False
checker = types.LesanaDatetime(field_def, {})
v = checker.auto(past)
self.assertEqual(v, past)
# and the same should happen with auto=creation
field_def['auto'] = 'creation'
checker = types.LesanaDatetime(field_def, {})
v = checker.auto(past)
self.assertEqual(v, past)
def test_date(self):
checker = types.LesanaDate(self._get_field_def('date'), {})
v = checker.empty()
self.assertEqual(v, None)
now = datetime.datetime.now()
v = checker.load(now)
self.assertIsInstance(v, datetime.date)
for part in ('year', 'month', 'day'):
self.assertEqual(getattr(v, part), getattr(now, part))
today = datetime.date.today()
v = checker.load(today)
self.assertEqual(v, today)
v = checker.load("2020-01-01")
self.assertEqual(v, datetime.date(2020, 1, 1))
v = checker.load("2020-01-01 10:00")
self.assertEqual(v, datetime.date(2020, 1, 1))
for d in ("today", "2020-13-01"):
with self.assertRaises(types.LesanaValueError):
checker.load(d)
v = checker.load(None)
self.assertEqual(v, None)
v = checker.auto(today)
self.assertEqual(v, today)
def test_date_auto(self):
field_def = self._get_field_def('date')
field_def['auto'] = 'creation'
checker = types.LesanaDate(field_def, {})
today = datetime.date.today()
v = checker.empty()
self.assertIsInstance(v, datetime.date)
self.assertEqual(v, today)
field_def['auto'] = False
checker = types.LesanaDate(field_def, {})
v = checker.empty()
self.assertEqual(v, None)
# auto=update fields should also be filled at creation time
field_def['auto'] = 'update'
checker = types.LesanaDate(field_def, {})
v = checker.empty()
self.assertIsInstance(v, datetime.date)
self.assertEqual(v, today)
def test_date_auto_update(self):
field_def = self._get_field_def('date')
field_def['auto'] = 'update'
checker = types.LesanaDate(field_def, {})
today = datetime.date.today()
past = datetime.date(2016, 12, 10)
# we pass a date in the past
v = checker.auto(past)
self.assertIsInstance(v, datetime.date)
# and we want to get a date in the present
self.assertEqual(v, today)
# with auto=False we want our old date instead
field_def['auto'] = False
checker = types.LesanaDate(field_def, {})
v = checker.auto(past)
self.assertEqual(v, past)
# and the same should happen with auto=creation
field_def['auto'] = 'creation'
checker = types.LesanaDate(field_def, {})
v = checker.auto(past)
self.assertEqual(v, past)
def test_boolean(self):
checker = types.LesanaBoolean(self._get_field_def('boolean'), {})
v = checker.empty()
self.assertEqual(v, None)
v = checker.load(True)
self.assertEqual(v, True)
for d in ("maybe", "yes", "no"):
with self.assertRaises(types.LesanaValueError):
checker.load(d)
v = checker.load(None)
self.assertEqual(v, None)
v = checker.auto(True)
self.assertEqual(v, True)
def test_file(self):
checker = types.LesanaFile(self._get_field_def('file'), {})
v = checker.empty()
self.assertEqual(v, "")
v = checker.load("relative/path/to/file")
self.assertEqual(v, "relative/path/to/file")
v = checker.load(None)
self.assertEqual(v, None)
# TODO: check for invalid file paths
v = checker.auto("relative/path/to/file")
self.assertEqual(v, "relative/path/to/file")
def test_url(self):
checker = types.LesanaURL(self._get_field_def('url'), {})
v = checker.empty()
self.assertEqual(v, "")
v = checker.load("http://example.org")
self.assertEqual(v, "http://example.org")
v = checker.load(None)
self.assertEqual(v, None)
# TODO: check for invalid URLs
v = checker.auto("http://example.org")
self.assertEqual(v, "http://example.org")
def test_geo(self):
checker = types.LesanaGeo(self._get_field_def('geo'), {})
v = checker.empty()
self.assertEqual(v, "")
v = checker.load("geo:45.81483,9.07524?z=17")
self.assertEqual(v, "geo:45.81483,9.07524?z=17")
v = checker.load(None)
self.assertEqual(v, None)
# TODO: improve check for invalid Geo URIs
for u in ("http://example.org",):
with self.assertRaises(types.LesanaValueError):
checker.load(u)
v = checker.auto("geo:45.81483,9.07524?z=17")
self.assertEqual(v, "geo:45.81483,9.07524?z=17")
def test_yaml(self):
checker = types.LesanaYAML(self._get_field_def('yaml'), {})
v = checker.empty()
self.assertEqual(v, None)
some_data = {
'anything': 'goes',
'everything': 42
}
v = checker.load(some_data)
self.assertEqual(v, some_data)
v = checker.load(None)
self.assertEqual(v, None)
v = checker.auto(some_data)
self.assertEqual(v, some_data)
def test_list(self):
field_def = self._get_field_def('yaml')
# we use one type that is easy to check for correct validation
field_def['list'] = 'int'
checker = types.LesanaList(field_def, {'int': types.LesanaInt})
v = checker.empty()
self.assertEqual(v, [])
some_data = [1, 2, 3]
v = checker.load(some_data)
self.assertEqual(v, some_data)
v = checker.load(None)
self.assertEqual(v, [])
for d in (['hello'], 1):
with self.assertRaises(types.LesanaValueError):
checker.load(d)
v = checker.auto(some_data)
self.assertEqual(v, some_data)
def test_list_unknown_subtype(self):
field_def = self._get_field_def('yaml')
# we use one type that is easy to check for correct validation
field_def['list'] = 'int'
checker = types.LesanaList(field_def, {'yaml': types.LesanaYAML})
v = checker.empty()
self.assertEqual(v, [])
some_data = [1, 2, 3]
v = checker.load(some_data)
self.assertEqual(v, some_data)
some_data = ["hello"]
v = checker.load(some_data)
self.assertEqual(v, some_data)
v = checker.load(None)
self.assertEqual(v, [])
for d in (1, 1.0):
with self.assertRaises(types.LesanaValueError):
checker.load(d)
class testTypeIndexing(unittest.TestCase):
def setUp(self):
self.doc = xapian.Document()
self.indexer = xapian.TermGenerator()
def _get_field_def(self, type_name):
return {
'type': type_name,
'name': 'test_field',
'index': 'field',
'sortable': True,
}
def test_base(self):
checker = types.LesanaType(self._get_field_def('base'), {}, 16)
checker.index(self.doc, self.indexer, "some string")
def test_base_value_index_too_low(self):
checker = types.LesanaType(self._get_field_def('base'), {}, 1)
checker.index(self.doc, self.indexer, "some string")
# TODO: check that the string has not been indexed
def test_string(self):
checker = types.LesanaString(self._get_field_def('string'), {}, 16)
checker.index(self.doc, self.indexer, "some string")
def test_text(self):
checker = types.LesanaText(self._get_field_def('text'), {}, 16)
checker.index(self.doc, self.indexer, "some string")
def test_int(self):
checker = types.LesanaInt(self._get_field_def('integer'), {}, 16)
checker.index(self.doc, self.indexer, 1)
def test_float(self):
checker = types.LesanaFloat(self._get_field_def('float'), {}, 16)
checker.index(self.doc, self.indexer, 1.5)
def test_decimal(self):
checker = types.LesanaDecimal(self._get_field_def('decimal'), {}, 16)
checker.index(self.doc, self.indexer, decimal.Decimal('1.0'))
def test_timestamp(self):
checker = types.LesanaTimestamp(
self._get_field_def('timestamp'), {}, 16
)
checker.index(self.doc, self.indexer, 1600000000)
def test_datetime(self):
checker = types.LesanaDatetime(self._get_field_def('datetime'), {}, 16)
checker.index(self.doc, self.indexer, datetime.datetime.now())
def test_date(self):
checker = types.LesanaDate(self._get_field_def('date'), {}, 16)
checker.index(self.doc, self.indexer, datetime.date.today())
def test_boolean(self):
checker = types.LesanaBoolean(self._get_field_def('boolean'), {}, 16)
checker.index(self.doc, self.indexer, True)
def test_url(self):
checker = types.LesanaURL(self._get_field_def('url'), {}, 16)
checker.index(self.doc, self.indexer, "http://example.org")
def test_yaml(self):
checker = types.LesanaYAML(self._get_field_def('yaml'), {}, 16)
checker.index(self.doc, self.indexer, {'a': 1, 'b': 2})
def test_list(self):
field_def = self._get_field_def('yaml')
# we use one type that is easy to check for correct validation
field_def['list'] = 'int'
checker = types.LesanaList(field_def, {'int': types.LesanaInt}, 16)
checker.index(self.doc, self.indexer, ["some", "thing"])
if __name__ == '__main__':
unittest.main()
././@PaxHeader 0000000 0000000 0000000 00000000026 00000000000 010213 x ustar 00 22 mtime=1622973869.0
lesana-0.9.1/tests/utils.py 0000644 0001777 0001777 00000001015 00000000000 016012 0 ustar 00valhalla valhalla import shutil
import sys
def copytree(src, dest, dirs_exist_ok=False):
"""
Helper function to remove existing directories
Used in the tests for compatibility with python < 3.8
"""
if sys.version_info >= (3, 8):
shutil.copytree(src, dest, dirs_exist_ok=dirs_exist_ok)
else:
if dirs_exist_ok:
if not dest.startswith('/tmp'):
raise ValueError("Refusing to delete a directory outside /tmp")
shutil.rmtree(dest)
shutil.copytree(src, dest)