feat: read file paths from stdin and print plain paths from piped ls
This commit is contained in:
parent
9743103faf
commit
98044782e5
4 changed files with 119 additions and 20 deletions
12
README.md
12
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, `<!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.
|
||||
|
|
|
|||
|
|
@ -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__':
|
||||
|
|
|
|||
|
|
@ -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') == ''
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue