From 98044782e55035e9bddcaba5307a45a42df0b26b Mon Sep 17 00:00:00 2001 From: randogoth Date: Sun, 25 Oct 2026 10:09:46 +0200 Subject: [PATCH] feat: read file paths from stdin and print plain paths from piped ls --- README.md | 12 +++++++ orvanitag/cmd.py | 73 +++++++++++++++++++++++++++++++------------ tests/test_cmd.py | 53 +++++++++++++++++++++++++++++++ tests/test_plugins.py | 1 + 4 files changed, 119 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index f04e865..32b9079 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,18 @@ $ otag write --clear id3 song.mp3 | `frontmatter` | `.md` `.markdown` `.txt` | `projects`, `contexts`, `tags` and `note` keys of the YAML front matter; other keys, comments and formatting are left untouched | | `comment` | Source code such as `.py` `.sh` `.toml` `.js` `.c` `.go` `.rs` `.php` `.html` | An `otag: +project @context #tag note` comment line within the first ten lines, written in the file's comment style below any shebang, encoding, `` or ` list: + """The found paths that pass the --files or --dirs filter""" + if filter == 'files': + return [file for file in found if Path(file).is_file()] + if filter == 'dirs': + return [file for file in found if Path(file).is_dir()] + return found + def mktable(query: str, index: dict, filter=None, trunc_len: int=25): """Creates a rich table from `otag ls` search result @@ -46,13 +54,8 @@ def mktable(query: str, index: dict, filter=None, trunc_len: int=25): table.add_column(ctx[i].title(), style=styles[i], overflow='fold') # Create Rows Based on Search Results - for file in index['found']: + for file in shown(index['found'], filter): item = Path(file) - - if filter == 'files' and not item.is_file(): # Only show directories (--dirs flag) - continue - if filter == 'dirs' and not item.is_dir(): # Only show files (--files flag) - continue # Our own overflow method for filenames longer than 25 characters (file…name.ext) long_file_pre = math.floor(trunc_len/2-1) @@ -115,7 +118,13 @@ def run_remove(orvani, args, console): 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) + filter = 'files' if args.files else 'dirs' if args.dirs else None + if sys.stdout.isatty(): + mktable(' '.join(args.terms), result, filter) + return + # Piped output is one bare path per line; plain print, since rich wraps long lines off a terminal + for file in shown(result['found'], filter): + print(file) def run_copy(orvani, args, console): @@ -198,14 +207,18 @@ def parser() -> ArgumentParser: def with_file(sub, help): sub.add_argument('-f', '--with-file', action='store_true', help=help) + def file_arg(sub): + sub.add_argument('file', help='file or folder, or - to read paths from stdin, one per line') + sub.set_defaults(stdin=True) + 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') + file_arg(sub) 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') + file_arg(sub) 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) @@ -226,7 +239,7 @@ def parser() -> ArgumentParser: 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') + file_arg(sub) with_file(sub, 'also delete the file itself') sub = command('clean', ['c'], 'remove entries of files that no longer exist', run_clean) @@ -243,7 +256,7 @@ def parser() -> ArgumentParser: sub = command('write', ['w'], 'write the entry of a file into the file itself', run_write) sub.add_argument('plugin', help='comment, ebook, frontmatter, id3, office or pdf') - sub.add_argument('file') + file_arg(sub) sub.add_argument('-c', '--clear', action='store_true', help="remove the plugin's tags from the file instead") sub = command('export', ['e'], 'write all entries of a directory tree to a text file', run_export) @@ -254,6 +267,19 @@ def parser() -> ArgumentParser: return p +def stdin_paths() -> list: + """The paths piped into otag, one per line; not stripped, since names may end with spaces""" + return [line for line in sys.stdin.read().splitlines() if line] + + +def report(error: Exception, console): + if isinstance(error, OrvaniError): + icon = next(icon for kind, icon in ERROR_ICONS.items() if isinstance(error, kind)) + console.print(f'{icon} {escape(str(error))}') + else: + console.print(f":scream: An Exception occurred: [italic]{escape(str(error))}[/italic]") + + def main(): """otag CLI""" console = Console() @@ -263,14 +289,21 @@ def main(): p.print_help() return - try: - 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))}') - sys.exit(1) - except Exception as e: - console.print(f":scream: An Exception occurred: [italic]{escape(str(e))}[/italic]") + # FILE `-` runs the command once per piped path, reporting failures without stopping + if getattr(args, 'stdin', False) and args.file == '-': + runs = [Namespace(**{**vars(args), 'file': path}) for path in stdin_paths()] + else: + runs = [args] + + orvani = OrvaniTag() + failed = False + for run in runs: + try: + run.run(orvani, run, console) + except Exception as e: + report(e, console) + failed = True + if failed: sys.exit(1) if __name__ == '__main__': diff --git a/tests/test_cmd.py b/tests/test_cmd.py index bf23c79..1b24104 100644 --- a/tests/test_cmd.py +++ b/tests/test_cmd.py @@ -1,3 +1,4 @@ +import io import sys import pytest @@ -64,3 +65,55 @@ def test_scan_replace(otag, tmp_path): (tmp_path / orvani.CONTEXT_FILE).write_text('a.py --- #one #gone\n') otag('s', '--replace') assert (tmp_path / orvani.CONTEXT_FILE).read_text() == 'a.py --- #one\n' + + +def test_add_reads_piped_paths(otag, tmp_path, monkeypatch): + for name in ['a.mp3', 'b c.mp3']: + (tmp_path / name).touch() + monkeypatch.setattr(sys, 'stdin', io.StringIO('a.mp3\n\nb c.mp3\n')) + otag('a', '-', '#lofi') + assert (tmp_path / orvani.CONTEXT_FILE).read_text() == 'a.mp3 --- #lofi\nb c.mp3 --- #lofi\n' + + +def test_piped_failures_do_not_stop_the_rest(otag, tmp_path, monkeypatch, capsys): + (tmp_path / 'b.mp3').touch() + monkeypatch.setattr(sys, 'stdin', io.StringIO('missing.mp3\nb.mp3\n')) + monkeypatch.setattr(sys, 'argv', ['otag', 'a', '-', '#lofi']) + with pytest.raises(SystemExit) as exit: + main() + assert exit.value.code == 1 + assert "File missing.mp3 doesn't exist." in capsys.readouterr().out + assert (tmp_path / orvani.CONTEXT_FILE).read_text() == 'b.mp3 --- #lofi\n' + + +def test_remove_and_write_read_piped_paths(otag, tmp_path, monkeypatch): + for name in ['a.py', 'b.py']: + (tmp_path / name).write_text('x = 1\n') + otag('a', name, '#one') + monkeypatch.setattr(sys, 'stdin', io.StringIO('a.py\nb.py\n')) + otag('w', 'comment', '-') + assert (tmp_path / 'a.py').read_text().startswith('# otag: #one') + assert (tmp_path / 'b.py').read_text().startswith('# otag: #one') + + monkeypatch.setattr(sys, 'stdin', io.StringIO('a.py\nb.py\n')) + otag('rm', '-') + assert not (tmp_path / orvani.CONTEXT_FILE).exists() + + +def test_export_dash_is_a_file_name(otag, tmp_path, monkeypatch): + (tmp_path / 'a.mp3').touch() + otag('a', 'a.mp3', '#lofi') + monkeypatch.setattr(sys, 'stdin', io.StringIO('a.mp3\n')) + otag('e', '-') + assert (tmp_path / '-').read_text() == 'a.mp3 --- #lofi\n' + + +def test_piped_list_prints_bare_paths(otag, tmp_path): + (tmp_path / 'sub').mkdir() + (tmp_path / 'sub' / 'a long name that a table would wrap.mp3').touch() + otag('a', 'sub', '#lofi') + otag('a', 'sub/a long name that a table would wrap.mp3', '#lofi', 'a note') + assert otag('ls', '#lofi') == 'sub\nsub/a long name that a table would wrap.mp3\n' + assert otag('ls', '--files') == 'sub/a long name that a table would wrap.mp3\n' + assert otag('ls', '-d') == 'sub\n' + assert otag('ls', '#none') == '' diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 9e1188a..bb3db6c 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -175,6 +175,7 @@ def test_cmd_user_errors_exit_with_1(cwd, monkeypatch, capsys): def test_cmd_list_shows_square_brackets(cwd, monkeypatch, capsys): (cwd / 'a [demo].mp3').touch() OrvaniTag().add('a [demo].mp3', '#lofi', 'note', '[with]', 'brackets') + monkeypatch.setattr(sys.stdout, 'isatty', lambda: True) # the table, not piped output monkeypatch.setattr(sys, 'argv', ['otag', 'ls', '#lofi']) main() out = capsys.readouterr().out