feat: read file paths from stdin and print plain paths from piped ls

This commit is contained in:
randogoth 2026-10-25 10:09:46 +02:00
parent 9743103faf
commit 98044782e5
4 changed files with 119 additions and 20 deletions

View file

@ -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, `<!DOCTYPE>` or `<?php` line |
### Piping
`add`, `remove`, `del` and `write` read the paths from stdin, one per line, when the file is given as `-`. A file actually named `-` is written as `./-`. The command runs for every path; a failing path prints its error without stopping the others, and otag exits with 1 at the end. When the output of `list` goes into a pipe or file instead of the terminal, it prints the bare paths, one per line, so otag can feed itself and other tools.
```
$ ls *.mp3 | otag add - +album '#lofi'
$ find . -name '*.pdf' | otag write pdf -
$ otag ls '#draft' | otag remove - '#draft'
```
`ls folder` prints the names without the `folder/` prefix, so use `ls folder/*` or `find folder` for files outside the current folder.
### Import and Export
`export` (`e`) writes all entries of the file's directory tree to a text file (default: `context.txt`), one `path --- tags` line per file. `import` (`i`) reads such a file back and adds the tags that are missing.

View file

@ -1,6 +1,6 @@
import sys
import math
from argparse import ArgumentParser
from argparse import ArgumentParser, Namespace
from pathlib import Path
from rich import box
from rich.console import Console
@ -20,6 +20,14 @@ ERROR_ICONS = {
OrvaniError: ':thinking_face:',
}
def shown(found: list, filter=None) -> 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__':

View file

@ -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') == ''

View file

@ -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