refactor: rebuild the CLI with argparse subcommands and one handler per command

This commit is contained in:
randogoth 2026-10-24 21:59:26 +03:00
parent d879c6f871
commit e4fc6c0a59
3 changed files with 211 additions and 142 deletions

View file

@ -31,10 +31,10 @@ Requires Python 3.11 or newer. The core only needs rich. Reading and writing emb
## Command Line Tool
```
$ otag <command> [arguments ...] [flags]
$ otag <command> [flags] [arguments ...]
```
Every command has a short alias, listed in brackets. `otag -h` prints an overview. Words starting with `+`, `@` or `#` become projects, contexts and tags; all other words form the note. Quote hashtags in the shell (`'#lofi'`), because an unquoted `#` starts a comment.
Every command has a short alias, listed in brackets. `otag -h` prints an overview, `otag <command> -h` the arguments and flags of one command; flags go after the command. Words starting with `+`, `@` or `#` become projects, contexts and tags; all other words form the note. Quote hashtags in the shell (`'#lofi'`), because an unquoted `#` starts a comment.
### Tagging
@ -74,7 +74,7 @@ A term with a prefix only matches that category; a term without a prefix matches
### Copying, Moving and Deleting
`copy` (`cp`) and `move` (`mv`) transfer the tags of a file to another path. By default only the tags are transferred; `-f` (`--file-operation`) also copies or moves the file itself. With `-f`, an existing folder as destination keeps the file name. Without `-f`, a folder as destination gets the tags itself.
`copy` (`cp`) and `move` (`mv`) transfer the tags of a file to another path. By default only the tags are transferred; `-f` (`--with-file`) also copies or moves the file itself. With `-f`, an existing folder as destination keeps the file name. Without `-f`, a folder as destination gets the tags itself.
```
$ otag cp song.mp3 song-copy.mp3

View file

@ -1,6 +1,6 @@
import sys
import math
from argparse import ArgumentParser as argp, SUPPRESS, RawTextHelpFormatter as raw
from argparse import ArgumentParser
from pathlib import Path
from rich import box
from rich.console import Console
@ -98,150 +98,160 @@ def nice_context(entry: Entry):
string += f"[green]'{escape(entry.note)}'[/green] "
return string
def run_add(orvani, args, console):
entry = orvani.add(args.file, *args.tags)
console.print(f':label: Entry {nice_context(entry)}successfully added to file [strong]{escape(args.file)}[/strong].')
def run_remove(orvani, args, console):
entry = orvani.remove(args.file, *args.tags)
if entry is None:
console.print(f':wastebasket: All entries successfully removed from file [strong]{escape(args.file)}[/strong].')
else:
console.print(f':wastebasket: Entry {nice_context(entry)}successfully removed from file [strong]{escape(args.file)}[/strong].')
def run_list(orvani, args, console):
result = orvani.search(*args.terms, path=args.path, exact=args.exact, either=args.either,
recursive=args.recursive, cache=args.cache, delete_cache=args.delete_cache)
mktable(' '.join(args.terms), result, 'files' if args.files else 'dirs' if args.dirs else None)
def run_copy(orvani, args, console):
entry = orvani.copy(args.source, args.dest, with_file=args.with_file)
console.print(f':dancers: File [strong]{escape(args.source)}[/strong] with entry {nice_context(entry)}successfully copied to [strong]{escape(args.dest)}[/strong]')
def run_move(orvani, args, console):
entry = orvani.move(args.source, args.dest, with_file=args.with_file)
console.print(f':dancers: File [strong]{escape(args.source)}[/strong] with entry {nice_context(entry)}successfully moved to [strong]{escape(args.dest)}[/strong]')
def run_delete(orvani, args, console):
context_file = orvani.delete(args.file, with_file=args.with_file)
console.print(f':wastebasket: Entry for [strong]{escape(args.file)}[/strong] successfully removed.')
if args.with_file:
console.print(f':wastebasket: File [strong]{escape(args.file)}[/strong] successfully deleted.')
if context_file:
console.print(f':wastebasket: File [strong]{escape(str(context_file))}[/strong] automatically deleted.')
def run_clean(orvani, args, console):
count = orvani.clean(args.path, args.recursive)
sub = ' and subdirectories' if args.recursive else ''
console.print(f':broom: {count} entries cleaned from [strong]{escape(args.path)}[/strong]{sub}')
def run_purge(orvani, args, console):
path = Path(args.path)
for folder, files in walk(path.parent if path.is_file() else path):
for name in (CONTEXT_FILE, CACHE_FILE):
if name in files:
(folder / name).unlink(missing_ok=True)
console.print(f":wastebasket: File [strong]{escape(str(folder / name))}[/strong] successfully purged.")
def run_write(orvani, args, console):
entry = orvani.write_metadata(args.file, args.plugin)
console.print(f':floppy_disk: Metadata {nice_context(entry)}successfully written to file [strong]{escape(args.file)}[/strong].')
def run_export(orvani, args, console):
filename = orvani.export(args.file)
console.print(f':floppy_disk: All entries successfully written to file [strong]{escape(filename)}[/strong].')
def run_import(orvani, args, console):
count = orvani.import_file(args.file)
console.print(f':inbox_tray: [strong]{count}[/strong] entries successfully imported from file [strong]{escape(args.file)}[/strong].')
def run_scan(orvani, args, console):
orvani.scan(args.path, args.recursive)
console.print(f':mag: Finished scanning subdirectories.')
def parser() -> ArgumentParser:
"""The otag command line: one subcommand per action, each with its own arguments and flags"""
p = ArgumentParser(prog='otag', description='OrvaniTagger: todo.txt style projects, contexts, tags and notes for files and folders.')
commands = p.add_subparsers(title='commands', metavar='COMMAND')
def command(name, aliases, help, run):
sub = commands.add_parser(name, aliases=aliases, help=help, description=help)
sub.set_defaults(run=run)
return sub
def no_recursive(sub):
sub.add_argument('-n', '--no-recursive', dest='recursive', action='store_false', help='skip subdirectories')
def with_file(sub, help):
sub.add_argument('-f', '--with-file', action='store_true', help=help)
tags = '+project, @context, #tag or note word'
sub = command('add', ['a'], 'add tags and a note to an existing file or folder', run_add)
sub.add_argument('file')
sub.add_argument('tags', nargs='+', metavar='TAG', help=tags)
sub = command('remove', ['r'], 'remove tags from a file, or its whole entry without any', run_remove)
sub.add_argument('file')
sub.add_argument('tags', nargs='*', metavar='TAG', help=tags)
sub = command('list', ['ls', 'l'], 'list the tagged files matching all terms, or every tagged file', run_list)
sub.add_argument('terms', nargs='*', metavar='TERM', help='prefixed terms search their category, others all categories and notes')
sub.add_argument('-p', '--path', default='.', help='search another directory tree')
sub.add_argument('-e', '--exact', action='store_true', help='match whole terms only')
sub.add_argument('-or', '--either', action='store_true', help='match any term instead of all')
sub.add_argument('--files', action='store_true', help='only show files')
sub.add_argument('-d', '--dirs', action='store_true', help='only show folders')
sub.add_argument('-c', '--cache', action='store_true', help=f'read the index from {CACHE_FILE}, rebuilt when tags changed')
sub.add_argument('-dc', '--delete-cache', action='store_true', help=f'delete {CACHE_FILE} before searching')
no_recursive(sub)
for name, aliases, verb, run in [('copy', ['cp'], 'copy', run_copy), ('move', ['mv'], 'move', run_move)]:
sub = command(name, aliases, f'{verb} the tags of a file to another path', run)
sub.add_argument('source')
sub.add_argument('dest')
with_file(sub, f'also {verb} the file itself')
sub = command('del', ['rm'], 'remove the entry of a file', run_delete)
sub.add_argument('file')
with_file(sub, 'also delete the file itself')
sub = command('clean', ['c'], 'remove entries of files that no longer exist', run_clean)
sub.add_argument('path', nargs='?', default='.')
no_recursive(sub)
sub = command('purge', ['p'], f'delete all {CONTEXT_FILE} and {CACHE_FILE} files in a directory tree', run_purge)
sub.add_argument('path', nargs='?', default='.')
sub = command('scan', ['s'], 'add tags embedded in files to their entries', run_scan)
sub.add_argument('path', nargs='?', default='.')
no_recursive(sub)
sub = command('write', ['w'], 'write the entry of a file into the file itself', run_write)
sub.add_argument('plugin', help='comment, frontmatter, id3, office or pdf')
sub.add_argument('file')
sub = command('export', ['e'], 'write all entries of a directory tree to a text file', run_export)
sub.add_argument('file', nargs='?', default='context.txt')
sub = command('import', ['i'], 'add the missing tags from a text file written by export', run_import)
sub.add_argument('file', nargs='?', default='context.txt')
return p
def main():
"""otag CLI"""
# Rich Console
console = Console()
# Detailed Help Text for the --help flag
command_help = """{add,a} FILE CONTEXT # add context to an existing file
{remove,r} FILE CONTEXT # remove context from an existing file
{list,ls,l} QUERY # list all files with queried context in current directory tree
{copy,cp} SOURCE DEST # copy file and its context to another directory
{move,mv} SOURCE DEST # move file and its context to another directory
{del,rm} FILE # delete file and its context
{clean,c} PATH # remove all context for nonexisting files
{purge,p} PATH # remove all context for all files in current directory tree
{write,w} PLUGIN FILE # write all context of a file to the file as metadata
{import,i} FILE # import context for files from a text file
{export,e} FILE # export context for files to a text file
"""
# Command Line Argument Setup
p = argp(description="OrvaniTagger (otag)", formatter_class=raw)
p.add_argument("--no-recursive", "-n", dest="recursive", action="store_false", help="(list,clean,scan) Skip subdirectories")
p.add_argument("--file-operation", "-f", action="store_true", help="(copy,move,del) Do file operation, not just context")
p.add_argument("--path", "-p", action="store", type=str, help="(list,export) Use custom path", default='')
p.add_argument("--exact", "-e", action="store_true", help="(list) Match exact search term")
p.add_argument("--files", action="store_true", help="(list) Only show files, ignore folders")
p.add_argument("--dirs", "-d", action="store_true", help="(list) Only show directories, ignore files")
p.add_argument("--either", "-or", action="store_true", help="(list) Combine search terms by OR instead of AND")
p.add_argument("--cache", "-c", action="store_true", help="(list) Cache index search (useful with deep directory trees)")
p.add_argument("--delete-cache", "-dc", action="store_true", help="(list) Delete cache file")
p.add_argument("command", nargs="?", default=None, metavar='Command',
help=command_help, type=str,
choices=[
'add', 'a',
'remove', 'r',
'list', 'ls', 'l',
'copy', 'cp',
'move', 'mv',
'del', 'rm',
'clean', 'c',
'purge', 'p',
'write', 'w',
'import', 'i',
'export', 'e',
'scan', 's'
])
p.add_argument("file", nargs="?", type=str, help=SUPPRESS)
p.add_argument("context", nargs="*", type=str, help=SUPPRESS)
p.print_usage = p.print_help
p = parser()
args = p.parse_args()
if not hasattr(args, 'run'): # no command given
p.print_help()
return
# Parse Arguments and Run otag Methods
try:
# Load Orvani
orvani = OrvaniTag()
if args.command in ['add', 'a']:
if args.file and args.context:
context = orvani.add(args.file, *args.context)
console.print(f':label: Entry {nice_context(context)}successfully added to file [strong]{escape(str(args.file))}[/strong].')
elif args.command in ['remove', 'r']:
if args.file:
context = orvani.remove(args.file, *args.context)
if context is None:
console.print(f':wastebasket: All entries successfully removed from file [strong]{escape(str(args.file))}[/strong].')
else:
console.print(f':wastebasket: Entry {nice_context(context)}successfully removed from file [strong]{escape(str(args.file))}[/strong].')
elif args.command in ['list', 'ls', 'l']:
query = [args.file] + args.context
if 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'
elif args.dirs:
filter = 'dirs'
mktable(' '.join(sys.argv[1:]), r, filter)
elif args.command in ['copy', 'cp', 'move', 'mv']:
if args.file and args.context:
copy = args.command in ['copy', 'cp']
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]{escape(str(args.file))}[/strong] with entry {nice_context(context)}successfully {"copied" if copy else "moved"} to [strong]{escape(str(args.context[0]))}[/strong]')
elif args.command in ['del', 'rm']:
if args.file:
context_file = orvani.delete(args.file, with_file=args.file_operation)
status = f':wastebasket: Entry for [strong]{escape(str(args.file))}[/strong] successfully removed.'
if args.file_operation:
status += f'\n:wastebasket: File [strong]{escape(str(args.file))}[/strong] successfully deleted.'
if context_file:
status += f'\n:wastebasket: File [strong]{escape(str(context_file))}[/strong] automatically deleted.'
console.print(status)
elif args.command in ['purge', 'p']:
def purge(path):
target = Path(path)
if target.is_file():
target = Path(target.parent)
if target.is_dir():
context_file = target / CONTEXT_FILE
if context_file.exists():
context_file.unlink(missing_ok=True)
console.print(f":wastebasket: File [strong]{escape(str(context_file))}[/strong] successfully purged.")
cache_file = target / CACHE_FILE
if cache_file.exists():
cache_file.unlink(missing_ok=True)
console.print(f":wastebasket: File [strong]{escape(str(cache_file))}[/strong] successfully purged.")
if not args.file:
args.file = '.'
for folder, files in walk(args.file):
purge(folder)
elif args.command in ['clean', 'c']:
count = orvani.clean(args.file or '', args.recursive)
sub = ' and subdirectories' if args.recursive else ''
console.print(f':broom: {count} entries cleaned from [strong]{escape(str(args.file or "."))}[/strong]{sub}')
elif args.command in ['write', 'w']:
if args.file and args.context:
context = orvani.write_metadata(args.context[0], args.file)
console.print(f':floppy_disk: Metadata {nice_context(context)}successfully written to file [strong]{escape(str(args.context[0]))}[/strong].')
elif args.command in ['export', 'e']:
filename = orvani.export(args.file)
console.print(f':floppy_disk: All entries successfully written to file [strong]{escape(str(filename))}[/strong].')
elif args.command in ['import', 'i']:
count = orvani.import_file(args.file)
console.print(f':inbox_tray: [strong]{count}[/strong] entries successfully imported from file [strong]{escape(str(args.file or "context.txt"))}[/strong].')
elif args.command in ['scan', 's']:
orvani.scan(args.file or '', args.recursive)
console.print(f':mag: Finished scanning subdirectories.')
args.run(OrvaniTag(), args, console)
except OrvaniError as e:
icon = next(icon for kind, icon in ERROR_ICONS.items() if isinstance(e, kind))
console.print(f'{icon} {escape(str(e))}')

59
tests/test_cmd.py Normal file
View file

@ -0,0 +1,59 @@
import sys
import pytest
import orvanitag.orvani as orvani
from orvanitag.cmd import main
@pytest.fixture
def otag(tmp_path, monkeypatch, capsys):
monkeypatch.chdir(tmp_path)
def run(*argv):
monkeypatch.setattr(sys, 'argv', ['otag', *argv])
main()
return capsys.readouterr().out
return run
def test_no_command_prints_help(otag):
out = otag()
for command in ['add', 'remove', 'list', 'copy', 'move', 'del', 'clean', 'purge', 'scan', 'write', 'export', 'import']:
assert command in out
def test_each_command_has_its_own_help(otag):
with pytest.raises(SystemExit) as exit:
otag('ls', '-h')
assert exit.value.code == 0
def test_copy_move_and_delete_with_file(otag, tmp_path):
(tmp_path / 'a.mp3').touch()
(tmp_path / 'sub').mkdir()
otag('a', 'a.mp3', '#lofi')
otag('cp', 'a.mp3', 'b.mp3')
otag('mv', '-f', 'a.mp3', 'sub')
assert (tmp_path / 'sub' / 'a.mp3').exists() and not (tmp_path / 'a.mp3').exists()
assert (tmp_path / orvani.CONTEXT_FILE).read_text() == 'b.mp3 --- #lofi\n'
otag('rm', '--with-file', 'sub/a.mp3')
assert not (tmp_path / 'sub' / 'a.mp3').exists()
assert not (tmp_path / 'sub' / orvani.CONTEXT_FILE).exists()
def test_add_needs_tags(otag, tmp_path):
(tmp_path / 'a.mp3').touch()
with pytest.raises(SystemExit) as exit:
otag('a', 'a.mp3')
assert exit.value.code == 2
def test_purge_keeps_config(otag, tmp_path):
(tmp_path / 'a.mp3').touch()
(tmp_path / orvani.CONFIG_FILE).write_text('plugins = []\n')
otag('a', 'a.mp3', '#lofi')
otag('p')
assert not (tmp_path / orvani.CONTEXT_FILE).exists()
assert (tmp_path / orvani.CONFIG_FILE).exists()