refactor: rename list to search, load to import_file, split copy from move and use keyword flags

This commit is contained in:
randogoth 2026-10-24 21:57:01 +03:00
parent 3a9f3a2fcd
commit 78528abd3a
3 changed files with 55 additions and 44 deletions

View file

@ -173,7 +173,7 @@ def main():
elif args.command in ['list', 'ls', 'l']:
query = [args.file] + args.context
if query:
r = orvani.list(args.path, args.exact, args.either, args.recursive, args.cache, args.delete_cache, *query)
r = orvani.search(*query, path=args.path, exact=args.exact, either=args.either, recursive=args.recursive, cache=args.cache, delete_cache=args.delete_cache)
filter = None
if args.files:
filter = 'files'
@ -184,12 +184,13 @@ def main():
elif args.command in ['copy', 'cp', 'move', 'mv']:
if args.file and args.context:
copy = args.command in ['copy', 'cp']
context = orvani.move(args.file, args.context[0], copy, args.file_operation)
transfer = orvani.copy if copy else orvani.move
context = transfer(args.file, args.context[0], with_file=args.file_operation)
console.print(f':dancers: File [strong]{args.file}[/strong] with entry {nice_context(context)}successfully {"copied" if copy else "moved"} to [strong]{args.context[0]}[/strong]')
elif args.command in ['del', 'rm']:
if args.file:
context_file = orvani.delete(args.file, args.file_operation)
context_file = orvani.delete(args.file, with_file=args.file_operation)
status = f':wastebasket: Entry for [strong]{args.file}[/strong] successfully removed.'
if args.file_operation:
status += f'\n:wastebasket: File [strong]{args.file}[/strong] successfully deleted.'
@ -234,7 +235,7 @@ def main():
console.print(f':floppy_disk: All entries successfully written to file [strong]{filename}[/strong].')
elif args.command in ['import', 'i']:
count = orvani.load(args.file)
count = orvani.import_file(args.file)
console.print(f':inbox_tray: [strong]{count}[/strong] entries successfully imported from file [strong]{args.file or "context.txt"}[/strong].')
elif args.command in ['scan', 's']:

View file

@ -328,12 +328,12 @@ class OrvaniTag:
return context
def delete(self, file: str, fop:bool=False):
def delete(self, file: str, with_file: bool=False):
"""
Deletes all entries of an existing file and optionally deletes the file
file (str): path to existing file
fop (bool): if True removes the referenced file
with_file (bool): if True removes the referenced file
Returns the folder context file if it was deleted because it became empty
"""
@ -345,26 +345,38 @@ class OrvaniTag:
raise NoEntry(f"No entry for {file} found.")
del folder.entries[target.name] # Delete it
self.__save(folder) # deletes the context file once it is empty
if fop: # delete the referenced file too?
if with_file: # delete the referenced file too?
target.unlink(missing_ok=True)
return None if folder.file.exists() else folder.file
def move(self, source: str, dest: str, retain: bool=False, fop: bool=True):
def move(self, source: str, dest: str, with_file: bool=False):
"""
Copies or moves existing file with contexts to a new destination
Moves the tags of a file to a new destination, with with_file also the file itself.
Returns the moved entry.
"""
return self.__transfer(source, dest, False, with_file)
def copy(self, source: str, dest: str, with_file: bool=False):
"""
Copies the tags of a file to a new destination, with with_file also the file itself.
Returns the copied entry.
"""
return self.__transfer(source, dest, True, with_file)
def __transfer(self, source: str, dest: str, keep_source: bool, with_file: bool):
"""
source (str): existing source file
dest (str): the new file destination
retain (bool): if True then copies, if False then moves
fop (bool): if True also does the file operation, if False only moves context data
Returns the moved context
keep_source (bool): if True then copies, if False then moves
with_file (bool): if True also does the file operation, if False only moves context data
"""
src = Path(source)
target = Path(dest)
# An existing folder as destination of a file operation receives the file under its own name
if fop and target.is_dir():
if with_file and target.is_dir():
target = target / src.name
# Check if source file and destination folder exist
@ -376,17 +388,17 @@ class OrvaniTag:
raise NotFound(f"Destination {dest} doesn't exist.")
sourcedata = self.__load(src.parent).entries.get(src.name) # load its context data
if sourcedata is None and not fop: # without an entry there is nothing to move
if sourcedata is None and not with_file: # without an entry there is nothing to move
raise NoEntry(f"No entry for {source} found.")
if sourcedata is not None:
destination = self.__load(target.parent) # load local context of destination folder
destination.entries[target.name] = sourcedata # with the context data of our source file
self.__save(destination) # and save it
if not retain: # do we copy or move the data?
if not keep_source: # do we copy or move the data?
self.delete(source) # delete for move
if fop: # do we also physically move or copy the reference file?
if with_file: # do we also physically move or copy the reference file?
shutil.copy2(src, target)
if not retain:
if not keep_source:
src.unlink()
return sourcedata if sourcedata is not None else Entry()
@ -420,21 +432,19 @@ class OrvaniTag:
return count
def list(self, path: str='./', exact=True, either=False, recursive: bool=True, cache: bool=False, delete_cache: bool=False, *search: str):
def search(self, *terms: str, path: str='.', exact: bool=False, either: bool=False, recursive: bool=True, cache: bool=False, delete_cache: bool=False):
"""
Returns an index object with query results and optionally saves a cached index
terms (str): the search query; no terms find every tagged file
path (str): the root context for the search and index
filter (str): limit the search to a context
exact (bool): exact search match if True, otherwise partial match
either (bool): match any term instead of all
recursive (bool): if True also searches all subfolder contexts
cache (bool): if True loads index from cache file if exists, writes it if it doesn't exist
search (str): the search query
"""
self.__makeindex(path, recursive, cache, delete_cache) # index the directories
result = self.__search(search, exact, either) # apply the search query
return result
return self.__search(terms, exact, either) # apply the search query
def index(self, path):
self.__makeindex(path) # index the directories
@ -608,7 +618,7 @@ class OrvaniTag:
file = Path(filename)
# Run `otag ls` to create the global context index
index = self.list(file.parent, False, False, True, False, False, '')
index = self.search(path=file.parent)
# Write the index as lines to file
items = index['index']['items']
@ -622,7 +632,7 @@ class OrvaniTag:
raise NoEntry("No entries found.")
def load(self, filename:str):
def import_file(self, filename: str):
"""
Imports lines from a text file created by `otag export` to the global context index,
filtering out the already known entries
@ -640,7 +650,7 @@ class OrvaniTag:
raise NotFound(f"File {filename} doesn't exist.")
# Make an index of all context items that are already known
known = self.list(file.parent, False, False, True, False, False, '')['index']['items']
known = self.search(path=file.parent)['index']['items']
count = 0
for import_line in file.read_text().split('\n'):
if len(import_line) > 5:

View file

@ -17,7 +17,7 @@ def tree(tmp_path, monkeypatch):
def found(o, *query, either=False, path=''):
return o.list(path, True, either, True, False, False, *query)['found']
return o.search(*query, path=path, exact=True, either=either)['found']
def lines(path):
@ -98,13 +98,13 @@ def test_index_is_rebuilt_per_list_call(tree):
def test_move_untagged_file(tree):
with pytest.raises(NoEntry):
OrvaniTag().move('a.mp3', 'sub/a.mp3', False, False)
OrvaniTag().move('a.mp3', 'sub/a.mp3')
def test_move_tagged_file(tree):
o = OrvaniTag()
o.add('a.mp3', '#lofi')
o.move('a.mp3', 'sub/', False, True)
o.move('a.mp3', 'sub/', with_file=True)
assert (tree / 'sub' / 'a.mp3').exists()
assert lines(tree / 'sub') == ['a.mp3 --- #lofi']
assert not (tree / orvani.CONTEXT_FILE).exists()
@ -113,14 +113,14 @@ def test_move_tagged_file(tree):
def test_copy_tags_within_current_directory(tree):
o = OrvaniTag()
o.add('a.mp3', '#lofi')
o.move('a.mp3', 'b.mp3', True, False)
o.copy('a.mp3', 'b.mp3')
assert lines(tree) == ['a.mp3 --- #lofi', 'b.mp3 --- #lofi']
def test_rename_within_current_directory(tree):
o = OrvaniTag()
o.add('a.mp3', '#lofi')
o.move('a.mp3', 'renamed.mp3', False, True)
o.move('a.mp3', 'renamed.mp3', with_file=True)
assert not (tree / 'a.mp3').exists()
assert lines(tree) == ['renamed.mp3 --- #lofi']
@ -128,7 +128,7 @@ def test_rename_within_current_directory(tree):
def test_move_into_directory_without_slash(tree):
o = OrvaniTag()
o.add('a.mp3', '#lofi')
o.move('a.mp3', 'sub', False, True)
o.move('a.mp3', 'sub', with_file=True)
assert (tree / 'sub' / 'a.mp3').exists()
assert lines(tree / 'sub') == ['a.mp3 --- #lofi']
assert not (tree / orvani.CONTEXT_FILE).exists()
@ -138,7 +138,7 @@ def test_copy_tags_from_subfolder_to_folder(tree):
(tree / 'other').mkdir()
o = OrvaniTag()
o.add('sub/d.txt', '#doc')
o.move('sub/d.txt', 'other/', True, False)
o.copy('sub/d.txt', 'other/')
assert lines(tree) == ['other/ --- #doc']
assert lines(tree / 'sub') == ['d.txt --- #doc']
@ -216,13 +216,13 @@ def test_remove_saves_once(tree, monkeypatch):
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 o.search('#lofi', exact=True, cache=True)['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']
assert OrvaniTag().search('#lofi', exact=True, cache=True)['found'] == ['a.mp3', 'b.mp3']
def test_import_untagged_files(tree):
@ -234,7 +234,7 @@ def test_import_untagged_files(tree):
(tree / orvani.CONTEXT_FILE).unlink()
(tree / 'sub' / orvani.CONTEXT_FILE).unlink()
assert OrvaniTag().load('context.txt') == 2
assert OrvaniTag().import_file('context.txt') == 2
assert lines(tree) == ['a.mp3 --- #lofi a note']
assert lines(tree / 'sub') == ['d.txt --- @docs']
@ -243,13 +243,13 @@ 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') == 1
assert OrvaniTag().import_file('context.txt') == 1
assert lines(tree) == ['a.mp3 --- #lofi #snare']
def test_import_keeps_hyphenated_tags(tree):
(tree / 'context.txt').write_text('./a.mp3 --- @home-office #lo-fi rock+roll take-2\n')
OrvaniTag().load('context.txt')
OrvaniTag().import_file('context.txt')
assert lines(tree) == ['a.mp3 --- @home-office #lo-fi rock+roll take-2']
@ -261,11 +261,11 @@ def test_duplicate_and_bare_prefixes(tree):
def test_move_reports_what_is_missing(tree):
o = OrvaniTag()
with pytest.raises(NotFound, match=r"^Source file x\.mp3 doesn't exist\.$"):
o.move('x.mp3', 'sub/', False, True)
o.move('x.mp3', 'sub/', with_file=True)
with pytest.raises(NotFound, match=r"^Destination nope/a\.mp3 doesn't exist\.$"):
o.move('a.mp3', 'nope/a.mp3', False, True)
o.move('a.mp3', 'nope/a.mp3', with_file=True)
with pytest.raises(NotFound, match=r"^Source file x\.mp3 and destination nope/x don't exist\.$"):
o.move('x.mp3', 'nope/x', False, True)
o.move('x.mp3', 'nope/x', with_file=True)
def test_errors_of_remove_delete_export_and_import(tree):
@ -278,10 +278,10 @@ def test_errors_of_remove_delete_export_and_import(tree):
with pytest.raises(NotFound):
o.add('missing.mp3', '#x')
with pytest.raises(NotFound):
o.load('missing.txt')
o.import_file('missing.txt')
(tree / 'bad.txt').write_text('no separator on this line\n')
with pytest.raises(FormatError):
o.load('bad.txt')
o.import_file('bad.txt')
o.delete('a.mp3')
with pytest.raises(NoEntry):
o.export('context.txt')