fix: repair search, notes, clean, move, import and write, speed up indexing, migrate packaging to pyproject and uv

This commit is contained in:
randogoth 2026-10-24 21:06:34 +03:00
parent 85d93e3dae
commit f04554a52c
27 changed files with 1147 additions and 1852 deletions

179
tests/test_orvani.py Normal file
View file

@ -0,0 +1,179 @@
import os
import pytest
import yaml
import orvanitag.orvani as orvani
from orvanitag.orvani import OrvaniTag, DEFAULT_PLUGINS
@pytest.fixture
def tree(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
for name in ['a.mp3', 'b.mp3', 'c.mp3']:
(tmp_path / name).touch()
(tmp_path / 'sub').mkdir()
(tmp_path / 'sub' / 'd.txt').touch()
return tmp_path
def found(o, *query, either=False, path=''):
return o.list(path, True, either, True, False, False, *query)['found']
def items(path='.'):
return yaml.safe_load((path / orvani.CONTEXT_FILE).read_text())['items']
def test_and_search_within_one_category(tree):
o = OrvaniTag()
o.add('a.mp3', '#lofi', '#snare')
o.add('b.mp3', '#lofi')
assert found(o, '#lofi', '#snare') == ['./a.mp3']
def test_and_search_across_categories(tree):
o = OrvaniTag()
o.add('a.mp3', '#lofi', '@drums')
o.add('b.mp3', '#lofi')
o.add('c.mp3', '@drums')
assert found(o, '#lofi', '@drums') == ['./a.mp3']
def test_or_search(tree):
o = OrvaniTag()
o.add('a.mp3', '#lofi')
o.add('b.mp3', '#snare')
o.add('c.mp3', '#other')
assert found(o, '#lofi', '#snare', either=True) == ['./a.mp3', './b.mp3']
def test_unprefixed_term_matches_any_category(tree):
o = OrvaniTag()
o.add('a.mp3', '+lofi')
o.add('b.mp3', '#lofi')
o.add('c.mp3', '#other')
assert found(o, 'lofi') == ['./a.mp3', './b.mp3']
def test_empty_query_lists_everything(tree):
o = OrvaniTag()
o.add('a.mp3', '#lofi')
o.add('sub/d.txt', '#doc')
assert found(o, None) == ['./a.mp3', './sub/d.txt']
def test_multi_word_note(tree):
OrvaniTag().add('a.mp3', '#lofi', 'my', 'long', 'note')
assert items(tree)['a.mp3']['note'] == 'my long note'
def test_trailing_commas_are_stripped(tree):
OrvaniTag().add('a.mp3', '#lofi,', '#snare')
assert items(tree)['a.mp3']['tags'] == ['lofi', 'snare']
def test_clean_keeps_tagged_directories(tree):
o = OrvaniTag()
o.add('sub', '#folder')
o.add('a.mp3', '#lofi')
(tree / 'a.mp3').unlink()
assert o.clean('', True)[2] == 1
assert list(items(tree)) == ['sub']
def test_instances_do_not_share_state(tree):
first = OrvaniTag()
first.register_plugin('extra')
assert 'extra' not in OrvaniTag().PLUGINS
assert 'extra' not in DEFAULT_PLUGINS
def test_index_is_rebuilt_per_list_call(tree):
o = OrvaniTag()
o.add('a.mp3', '#lofi')
o.add('sub/d.txt', '#lofi')
assert found(o, '#lofi') == ['./a.mp3', './sub/d.txt']
assert found(o, '#lofi', path='sub') == ['sub/d.txt']
def test_move_untagged_file(tree):
assert OrvaniTag().move('a.mp3', 'sub/a.mp3', False, False)[0] == 2
def test_move_tagged_file(tree):
o = OrvaniTag()
o.add('a.mp3', '#lofi')
assert o.move('a.mp3', 'sub/', False, True)[0] == 0
assert (tree / 'sub' / 'a.mp3').exists()
assert items(tree / 'sub')['a.mp3']['tags'] == ['lofi']
assert not (tree / orvani.CONTEXT_FILE).exists()
def test_save_keeps_config(tree):
(tree / orvani.CONTEXT_FILE).write_text('config:\n plugins: [pdf]\nitems: {}\n')
OrvaniTag().add('a.mp3', '#lofi')
data = yaml.safe_load((tree / orvani.CONTEXT_FILE).read_text())
assert data['config'] == {'plugins': ['pdf']}
assert data['items']['a.mp3']['tags'] == ['lofi']
def test_failed_save_keeps_old_file(tree, monkeypatch):
o = OrvaniTag()
o.add('a.mp3', '#lofi')
before = (tree / orvani.CONTEXT_FILE).read_text()
def broken_dump(*args, **kwargs):
raise RuntimeError('disk full')
monkeypatch.setattr(orvani.yaml, 'dump', broken_dump)
with pytest.raises(RuntimeError):
o.add('a.mp3', '#snare')
assert (tree / orvani.CONTEXT_FILE).read_text() == before
assert sorted(os.listdir(tree)) == [orvani.CONTEXT_FILE, 'a.mp3', 'b.mp3', 'c.mp3', 'sub']
def test_remove_saves_once(tree, monkeypatch):
o = OrvaniTag()
o.add('a.mp3', '#lofi', '#snare', '#keep')
saves = []
save = o._OrvaniTag__save
monkeypatch.setattr(o, '_OrvaniTag__save', lambda: saves.append(1) or save())
assert o.remove('a.mp3', '#lofi', '#snare')[0] == 0
assert len(saves) == 1
assert items(tree)['a.mp3']['tags'] == ['keep']
def test_cache_is_rebuilt_when_context_changes(tree):
o = OrvaniTag()
o.add('a.mp3', '#lofi')
assert o.list('', True, False, True, True, False, '#lofi')['found'] == ['./a.mp3']
assert (tree / orvani.CACHE_FILE).exists()
o.add('b.mp3', '#lofi')
stat = (tree / orvani.CONTEXT_FILE).stat()
os.utime(tree / orvani.CONTEXT_FILE, ns=(stat.st_atime_ns, stat.st_mtime_ns + 10**9))
assert OrvaniTag().list('', True, False, True, True, False, '#lofi')['found'] == ['./a.mp3', './b.mp3']
def test_import_untagged_files(tree):
o = OrvaniTag()
o.add('a.mp3', '#lofi', 'a note')
o.add('sub/d.txt', '@docs')
assert o.export('context.txt')[0] == 0
(tree / orvani.CONTEXT_FILE).unlink()
(tree / 'sub' / orvani.CONTEXT_FILE).unlink()
assert OrvaniTag().load('context.txt') == [0, 'context.txt', 2]
assert items(tree)['a.mp3']['tags'] == ['lofi']
assert items(tree)['a.mp3']['note'] == 'a note'
assert items(tree / 'sub')['d.txt']['contexts'] == ['docs']
def test_import_skips_known_tags_and_empty_lines(tree):
o = OrvaniTag()
o.add('a.mp3', '#lofi')
(tree / 'context.txt').write_text('./a.mp3 --- #lofi #snare\n./b.mp3 --- \n')
assert OrvaniTag().load('context.txt') == [0, 'context.txt', 1]
assert items(tree)['a.mp3']['tags'] == ['lofi', 'snare']
assert 'b.mp3' not in items(tree)

109
tests/test_plugins.py Normal file
View file

@ -0,0 +1,109 @@
import sys
import zipfile
import pytest
import yaml
from pypdf import PdfReader, PdfWriter
import orvanitag.orvani as orvani
from orvanitag.cmd import main
from orvanitag.orvani import OrvaniTag
DOCX_CORE = (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" '
'xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>T</dc:title><dc:description></dc:description>'
'</cp:coreProperties>'
)
ODT_META = (
'<?xml version="1.0" encoding="UTF-8"?>'
'<office:document-meta xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" '
'xmlns:dc="http://purl.org/dc/elements/1.1/"><office:meta><dc:description>old</dc:description>'
'</office:meta></office:document-meta>'
)
@pytest.fixture
def cwd(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
return tmp_path
def make_docx(path):
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr('[Content_Types].xml', '<Types/>')
z.writestr('docProps/core.xml', DOCX_CORE)
def make_odt(path):
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as z:
z.writestr(zipfile.ZipInfo('mimetype'), 'application/vnd.oasis.opendocument.text')
z.writestr('content.xml', '<office:document-content/>')
z.writestr('meta.xml', ODT_META)
def roundtrip(cwd, name):
o = OrvaniTag()
o.add(name, '#lofi', '+proj', 'R&B', 'C:\\1')
assert o.write_metadata(name, 'office')[0] == 0
(cwd / orvani.CONTEXT_FILE).unlink()
OrvaniTag().read_metadata(name)
entry = yaml.safe_load((cwd / orvani.CONTEXT_FILE).read_text())['items'][name]
assert entry['tags'] == ['lofi']
assert entry['projects'] == ['proj']
assert entry['note'] == 'R&B C:\\1'
assert not list(cwd.glob('unzip__*'))
def test_docx_roundtrip(cwd):
make_docx(cwd / 'doc.docx')
roundtrip(cwd, 'doc.docx')
with zipfile.ZipFile(cwd / 'doc.docx') as z:
assert z.testzip() is None
assert '<dc:title>T</dc:title>' in z.read('docProps/core.xml').decode()
def test_odt_roundtrip_keeps_mimetype_first(cwd):
make_odt(cwd / 'doc.odt')
roundtrip(cwd, 'doc.odt')
with zipfile.ZipFile(cwd / 'doc.odt') as z:
first = z.infolist()[0]
assert first.filename == 'mimetype'
assert first.compress_type == zipfile.ZIP_STORED
def test_pdf_roundtrip(cwd):
writer = PdfWriter()
writer.add_blank_page(72, 72)
writer.add_metadata({'/Title': 'T'})
writer.write(cwd / 'doc.pdf')
o = OrvaniTag()
o.add('doc.pdf', '#lofi', '@desk', 'a note')
assert o.write_metadata('doc.pdf', 'pdf')[0] == 0
meta = PdfReader(cwd / 'doc.pdf').metadata
assert meta['/Title'] == 'T'
(cwd / orvani.CONTEXT_FILE).unlink()
OrvaniTag().read_metadata('doc.pdf')
entry = yaml.safe_load((cwd / orvani.CONTEXT_FILE).read_text())['items']['doc.pdf']
assert entry == {'projects': [], 'contexts': ['desk'], 'tags': ['lofi'], 'note': 'a note'}
def test_cmd_write(cwd, monkeypatch):
make_docx(cwd / 'doc.docx')
OrvaniTag().add('doc.docx', '#lofi')
monkeypatch.setattr(sys, 'argv', ['otag', 'w', 'office', 'doc.docx'])
main()
with zipfile.ZipFile(cwd / 'doc.docx') as z:
assert '<dc:description>#lofi</dc:description>' in z.read('docProps/core.xml').decode()
def test_cmd_prints_errors(cwd, monkeypatch, capsys):
(cwd / 'a.mp3').touch()
(cwd / orvani.CONTEXT_FILE).write_text('items: [unclosed')
monkeypatch.setattr(sys, 'argv', ['otag', 'a', 'a.mp3', '#lofi'])
with pytest.raises(SystemExit) as exit:
main()
assert exit.value.code == 1
assert 'expected' in capsys.readouterr().out