refactor: rebuild the CLI with argparse subcommands and one handler per command
This commit is contained in:
parent
d879c6f871
commit
e4fc6c0a59
3 changed files with 211 additions and 142 deletions
|
|
@ -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
|
||||
|
|
|
|||
288
orvanitag/cmd.py
288
orvanitag/cmd.py
|
|
@ -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
59
tests/test_cmd.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue