termish 0.2.0__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {termish-0.2.0/termish.egg-info → termish-0.2.2}/PKG-INFO +3 -3
- {termish-0.2.0 → termish-0.2.2}/README.md +2 -2
- {termish-0.2.0 → termish-0.2.2}/pyproject.toml +1 -1
- termish-0.2.2/termish/interpreter/commands/_regex.py +113 -0
- termish-0.2.2/termish/interpreter/commands/conditional.py +210 -0
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/filesystem.py +113 -89
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/search.py +26 -68
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/sed.py +27 -15
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/core.py +52 -11
- termish-0.2.2/termish/interpreter/pathglob.py +140 -0
- {termish-0.2.0 → termish-0.2.2}/termish/parser.py +4 -3
- {termish-0.2.0 → termish-0.2.2/termish.egg-info}/PKG-INFO +3 -3
- {termish-0.2.0 → termish-0.2.2}/termish.egg-info/SOURCES.txt +7 -0
- {termish-0.2.0 → termish-0.2.2}/termish.egg-info/top_level.txt +1 -0
- termish-0.2.2/tests/test_bre.py +136 -0
- termish-0.2.2/tests/test_conditional.py +92 -0
- termish-0.2.2/tests/test_dev_null.py +65 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_expansion.py +2 -1
- {termish-0.2.0 → termish-0.2.2}/tests/test_grep.py +18 -12
- {termish-0.2.0 → termish-0.2.2}/tests/test_parser.py +11 -0
- termish-0.2.2/tests/test_pathglob.py +152 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_sed.py +8 -2
- {termish-0.2.0 → termish-0.2.2}/LICENSE +0 -0
- {termish-0.2.0 → termish-0.2.2}/setup.cfg +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/__init__.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/ast.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/context.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/errors.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/fs/__init__.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/fs/conformance.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/fs/memory.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/fs/protocol.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/__init__.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/__init__.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/_argparse.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/_util.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/archive.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/control.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/diff.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/file.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/io.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/jq.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/meta.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/interpreter/commands/text.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/jq/__init__.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/jq/ast.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/jq/eval.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/jq/parser.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/py.typed +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish/quote_masker.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish.egg-info/dependency_links.txt +0 -0
- {termish-0.2.0 → termish-0.2.2}/termish.egg-info/requires.txt +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_archive.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_binary.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_command_injection.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_control.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_file.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_find.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_fs_conformance.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_heredoc.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_interpreter.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_jq.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_memory_fs.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_monkeyfs_compat.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_printf.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_quote_masker.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_stderr.py +0 -0
- {termish-0.2.0 → termish-0.2.2}/tests/test_text_commands.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: termish
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Virtual terminal with shell-like commands over a pluggable filesystem.
|
|
5
5
|
Author: ashenfad
|
|
6
6
|
License-Expression: MIT
|
|
@@ -46,7 +46,7 @@ Parses and executes shell scripts (pipelines, redirects, semicolons) against any
|
|
|
46
46
|
- **Variable expansion** -- `$?` (last exit code), `$VAR` / `${VAR}` from an env dict; expands in unquoted and double-quoted contexts, literal in single quotes
|
|
47
47
|
- **Binary-safe pipelines** -- pipes and redirects carry bytes, so `cat bin.dat > copy.dat` is byte-identical and `cat file.gz | zcat` works; text decoding happens at each command's own boundary, and the returned transcript is decoded once for display
|
|
48
48
|
- **Terminal-faithful transcript** -- stderr diagnostics appear in the returned output when execution continues past a failure (`cmd; next`, `cmd || rescue`), like a real terminal screen; a failure with nothing after it raises `TerminalError`. Stderr redirects are honored: `2>file` captures, `2>/dev/null` suppresses, `2>&1` merges into the pipe (`cmd 2>&1 | head` works)
|
|
49
|
-
- **
|
|
49
|
+
- **39 builtins** -- ls, cat, echo, printf, grep, find, sed, tr, sort, uniq, cut, wc, diff, tar, gzip, zcat, zip, jq, xargs, file, test/[, true, false, basename, dirname, ...
|
|
50
50
|
- **Custom commands** -- inject your own command handlers alongside builtins; injected commands override builtins and compose in pipelines
|
|
51
51
|
- **jq engine** -- built-in jq filter parser and evaluator (field access, pipes, functions, conditionals)
|
|
52
52
|
- **Pluggable filesystem** -- `FileSystem` is a `typing.Protocol`; any object with the right methods works
|
|
@@ -203,7 +203,7 @@ That is not a coincidence, and it is a promise: termish's `FileSystem` protocol
|
|
|
203
203
|
| Meta | `xargs` |
|
|
204
204
|
| JSON | `jq` |
|
|
205
205
|
| Inspection | `file` |
|
|
206
|
-
| Control | `true`, `false` |
|
|
206
|
+
| Control | `true`, `false`, `test` / `[` |
|
|
207
207
|
|
|
208
208
|
## Development
|
|
209
209
|
|
|
@@ -10,7 +10,7 @@ Parses and executes shell scripts (pipelines, redirects, semicolons) against any
|
|
|
10
10
|
- **Variable expansion** -- `$?` (last exit code), `$VAR` / `${VAR}` from an env dict; expands in unquoted and double-quoted contexts, literal in single quotes
|
|
11
11
|
- **Binary-safe pipelines** -- pipes and redirects carry bytes, so `cat bin.dat > copy.dat` is byte-identical and `cat file.gz | zcat` works; text decoding happens at each command's own boundary, and the returned transcript is decoded once for display
|
|
12
12
|
- **Terminal-faithful transcript** -- stderr diagnostics appear in the returned output when execution continues past a failure (`cmd; next`, `cmd || rescue`), like a real terminal screen; a failure with nothing after it raises `TerminalError`. Stderr redirects are honored: `2>file` captures, `2>/dev/null` suppresses, `2>&1` merges into the pipe (`cmd 2>&1 | head` works)
|
|
13
|
-
- **
|
|
13
|
+
- **39 builtins** -- ls, cat, echo, printf, grep, find, sed, tr, sort, uniq, cut, wc, diff, tar, gzip, zcat, zip, jq, xargs, file, test/[, true, false, basename, dirname, ...
|
|
14
14
|
- **Custom commands** -- inject your own command handlers alongside builtins; injected commands override builtins and compose in pipelines
|
|
15
15
|
- **jq engine** -- built-in jq filter parser and evaluator (field access, pipes, functions, conditionals)
|
|
16
16
|
- **Pluggable filesystem** -- `FileSystem` is a `typing.Protocol`; any object with the right methods works
|
|
@@ -167,7 +167,7 @@ That is not a coincidence, and it is a promise: termish's `FileSystem` protocol
|
|
|
167
167
|
| Meta | `xargs` |
|
|
168
168
|
| JSON | `jq` |
|
|
169
169
|
| Inspection | `file` |
|
|
170
|
-
| Control | `true`, `false` |
|
|
170
|
+
| Control | `true`, `false`, `test` / `[` |
|
|
171
171
|
|
|
172
172
|
## Development
|
|
173
173
|
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
r"""POSIX basic regular expressions (BRE), read into Python ``re`` syntax.
|
|
2
|
+
|
|
3
|
+
``grep`` and ``sed`` without ``-E`` take BRE, the syntax agents write
|
|
4
|
+
when they write GNU commands, and its escaping is roughly the reverse of
|
|
5
|
+
Python's: ``\(``, ``\{``, ``\|``, ``\+`` and ``\?`` are operators while
|
|
6
|
+
their bare forms are literal characters. Handed to ``re`` unchanged, the
|
|
7
|
+
operators read as literals, so ``s/a\+/X/`` matched nothing and still
|
|
8
|
+
exited 0, and a bare ``(`` was a syntax error rather than a parenthesis.
|
|
9
|
+
|
|
10
|
+
With ``-E`` a pattern is already in the syntax ``re`` speaks and is
|
|
11
|
+
passed through untouched; this module is for the other case.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
#: Escaped in BRE they are operators; bare, they are literal characters.
|
|
17
|
+
_SWAPPED = "(){}|+?"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def bre_to_python(pattern: str) -> str:
|
|
21
|
+
r"""Translate a POSIX BRE ``pattern`` into a Python ``re`` pattern.
|
|
22
|
+
|
|
23
|
+
Outside a bracket expression:
|
|
24
|
+
|
|
25
|
+
- ``\( \) \{ \} \| \+ \?`` become ``( ) { } | + ?``, and their bare
|
|
26
|
+
forms become escaped literals.
|
|
27
|
+
- ``*`` is literal where nothing precedes it: at the start of the
|
|
28
|
+
pattern, of a group, or of an alternative (and after a leading
|
|
29
|
+
``^``).
|
|
30
|
+
- ``^`` anchors only at those same starting points and ``$`` only at
|
|
31
|
+
the end of the pattern, of a group or of an alternative; anywhere
|
|
32
|
+
else each is a literal character.
|
|
33
|
+
- ``\<`` and ``\>`` (GNU word boundaries) become ``\b``.
|
|
34
|
+
- Every other escape — ``\1``-``\9``, ``\.``, ``\*``, ``\w`` — is
|
|
35
|
+
kept as it is, since it means the same to ``re``.
|
|
36
|
+
|
|
37
|
+
Inside ``[...]`` a backslash is a literal character, as in BRE, and
|
|
38
|
+
a ``]`` right after ``[`` or ``[^`` is a member rather than the end.
|
|
39
|
+
"""
|
|
40
|
+
out: list[str] = []
|
|
41
|
+
i = 0
|
|
42
|
+
n = len(pattern)
|
|
43
|
+
# True where nothing precedes this position in the current
|
|
44
|
+
# alternative: '*' is literal here and '^' anchors.
|
|
45
|
+
at_start = True
|
|
46
|
+
while i < n:
|
|
47
|
+
ch = pattern[i]
|
|
48
|
+
if ch == "[":
|
|
49
|
+
i = _bracket(pattern, i, out)
|
|
50
|
+
at_start = False
|
|
51
|
+
continue
|
|
52
|
+
if ch == "\\":
|
|
53
|
+
if i + 1 >= n:
|
|
54
|
+
out.append("\\\\") # a trailing backslash is literal
|
|
55
|
+
i += 1
|
|
56
|
+
continue
|
|
57
|
+
nxt = pattern[i + 1]
|
|
58
|
+
i += 2
|
|
59
|
+
if nxt in _SWAPPED:
|
|
60
|
+
out.append(nxt)
|
|
61
|
+
at_start = nxt in "(|"
|
|
62
|
+
elif nxt in "<>":
|
|
63
|
+
out.append(r"\b")
|
|
64
|
+
at_start = False
|
|
65
|
+
else:
|
|
66
|
+
out.append("\\" + nxt)
|
|
67
|
+
at_start = False
|
|
68
|
+
continue
|
|
69
|
+
i += 1
|
|
70
|
+
if ch in _SWAPPED:
|
|
71
|
+
out.append("\\" + ch)
|
|
72
|
+
at_start = False
|
|
73
|
+
elif ch == "*" and at_start:
|
|
74
|
+
out.append(r"\*")
|
|
75
|
+
at_start = False
|
|
76
|
+
elif ch == "^":
|
|
77
|
+
# An anchor leaves the position a start: '^*' is a literal '*'.
|
|
78
|
+
out.append("^" if at_start else r"\^")
|
|
79
|
+
elif ch == "$":
|
|
80
|
+
rest = pattern[i:]
|
|
81
|
+
ends = not rest or rest.startswith(("\\)", "\\|"))
|
|
82
|
+
out.append("$" if ends else r"\$")
|
|
83
|
+
at_start = False
|
|
84
|
+
else:
|
|
85
|
+
out.append(ch)
|
|
86
|
+
at_start = False
|
|
87
|
+
return "".join(out)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _bracket(pattern: str, start: int, out: list[str]) -> int:
|
|
91
|
+
"""Copy the bracket expression at ``start`` into ``out``, with its
|
|
92
|
+
backslashes made literal; return the position after it.
|
|
93
|
+
|
|
94
|
+
An unterminated bracket is copied as far as it goes, for ``re`` to
|
|
95
|
+
refuse in its own words.
|
|
96
|
+
"""
|
|
97
|
+
n = len(pattern)
|
|
98
|
+
out.append("[")
|
|
99
|
+
i = start + 1
|
|
100
|
+
if i < n and pattern[i] == "^":
|
|
101
|
+
out.append("^")
|
|
102
|
+
i += 1
|
|
103
|
+
if i < n and pattern[i] == "]":
|
|
104
|
+
out.append(r"\]")
|
|
105
|
+
i += 1
|
|
106
|
+
while i < n:
|
|
107
|
+
ch = pattern[i]
|
|
108
|
+
i += 1
|
|
109
|
+
if ch == "]":
|
|
110
|
+
out.append("]")
|
|
111
|
+
return i
|
|
112
|
+
out.append("\\\\" if ch == "\\" else ch)
|
|
113
|
+
return i
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""``test`` and ``[``: the conditionals agents write before ``&&`` and ``||``.
|
|
2
|
+
|
|
3
|
+
``[ -f x ] && ...``, ``test -d out || mkdir out`` and ``[ -z "$VAR" ]``
|
|
4
|
+
are how a script asks a question, and without them each is
|
|
5
|
+
``command not found``. The subset is the POSIX one agents use:
|
|
6
|
+
|
|
7
|
+
- file tests: ``-e`` ``-f`` ``-d`` ``-s`` ``-r`` ``-w`` ``-x``
|
|
8
|
+
- string tests: ``-z`` ``-n``, ``=`` (and bash's ``==``) ``!=``, and a bare
|
|
9
|
+
string, true when it is not empty
|
|
10
|
+
- integer tests: ``-eq`` ``-ne`` ``-lt`` ``-le`` ``-gt`` ``-ge``
|
|
11
|
+
- ``!``, ``-a``, ``-o`` and parentheses
|
|
12
|
+
|
|
13
|
+
True exits 0, false exits 1 with nothing written, and a malformed
|
|
14
|
+
expression or a non-integer operand exits 2 with a diagnostic, as in
|
|
15
|
+
bash. Nothing is ever written to stdout.
|
|
16
|
+
|
|
17
|
+
The filesystem has no permission bits, so ``-r`` and ``-w`` are true
|
|
18
|
+
for any path that exists (a read-only mount still answers true: the
|
|
19
|
+
protocol cannot say otherwise without writing), and ``-x`` is true for
|
|
20
|
+
a directory, which can be entered, and false for a file, which nothing
|
|
21
|
+
here can run. Out of scope: ``[[ ]]``, ``-nt``/``-ot``, ``-L``, pattern
|
|
22
|
+
matching.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
|
|
29
|
+
from termish.context import CommandContext, CommandResult
|
|
30
|
+
from termish.fs import FileSystem
|
|
31
|
+
|
|
32
|
+
from ._util import resolve_path
|
|
33
|
+
|
|
34
|
+
_UNARY = {"-e", "-f", "-d", "-s", "-r", "-w", "-x", "-z", "-n"}
|
|
35
|
+
_BINARY = {"=", "==", "!=", "-eq", "-ne", "-lt", "-le", "-gt", "-ge"}
|
|
36
|
+
_INTEGER = re.compile(r"^\s*[+-]?\d+\s*$")
|
|
37
|
+
#: bash's integers are signed 64-bit; past either end is not an integer.
|
|
38
|
+
_INT_MIN, _INT_MAX = -(2**63), 2**63 - 1
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _Usage(Exception):
|
|
42
|
+
"""A malformed expression or a bad operand: exit 2, with this text."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_cmd(ctx: CommandContext) -> CommandResult | None:
|
|
46
|
+
"""``test EXPRESSION`` — exit 0 when it holds, 1 when it does not."""
|
|
47
|
+
return _run("test", list(ctx.args), ctx.fs)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def bracket_cmd(ctx: CommandContext) -> CommandResult | None:
|
|
51
|
+
"""``[ EXPRESSION ]`` — ``test``, with a closing ``]`` required."""
|
|
52
|
+
args = list(ctx.args)
|
|
53
|
+
if not args or args[-1] != "]":
|
|
54
|
+
return CommandResult(exit_code=2, stderr="[: missing `]'")
|
|
55
|
+
return _run("[", args[:-1], ctx.fs)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _run(name: str, args: list[str], fs: FileSystem) -> CommandResult | None:
|
|
59
|
+
try:
|
|
60
|
+
held = _evaluate(args, fs)
|
|
61
|
+
except _Usage as e:
|
|
62
|
+
return CommandResult(exit_code=2, stderr=f"{name}: {e}")
|
|
63
|
+
return None if held else CommandResult(exit_code=1, stderr="")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _evaluate(args: list[str], fs: FileSystem) -> bool:
|
|
67
|
+
"""POSIX reads one to four arguments by their count, which is what
|
|
68
|
+
makes ``[ -n ]`` (a bare string) and ``[ ! = x ]`` mean what they do;
|
|
69
|
+
longer expressions go to the general parser."""
|
|
70
|
+
n = len(args)
|
|
71
|
+
if n == 0:
|
|
72
|
+
return False
|
|
73
|
+
if n == 1:
|
|
74
|
+
return args[0] != ""
|
|
75
|
+
if n == 2:
|
|
76
|
+
if args[0] == "!":
|
|
77
|
+
return not _evaluate(args[1:], fs)
|
|
78
|
+
if args[0] in _UNARY:
|
|
79
|
+
return _unary(args[0], args[1], fs)
|
|
80
|
+
raise _Usage(f"{args[0]}: unary operator expected")
|
|
81
|
+
if n == 3:
|
|
82
|
+
if args[1] in _BINARY:
|
|
83
|
+
return _binary(args[0], args[1], args[2])
|
|
84
|
+
if args[0] == "!":
|
|
85
|
+
return not _evaluate(args[1:], fs)
|
|
86
|
+
if args[0] == "(" and args[2] == ")":
|
|
87
|
+
return _evaluate(args[1:2], fs)
|
|
88
|
+
if args[1] in ("-a", "-o"):
|
|
89
|
+
return _Parser(args, fs).parse()
|
|
90
|
+
raise _Usage(f"{args[1]}: binary operator expected")
|
|
91
|
+
if n == 4:
|
|
92
|
+
if args[0] == "!":
|
|
93
|
+
return not _evaluate(args[1:], fs)
|
|
94
|
+
if args[0] == "(" and args[3] == ")":
|
|
95
|
+
return _evaluate(args[1:3], fs)
|
|
96
|
+
return _Parser(args, fs).parse()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _Parser:
|
|
100
|
+
"""``-o`` binds loosest, then ``-a``, then ``!``; a primary is a
|
|
101
|
+
parenthesised expression, a unary test, a binary test or a bare
|
|
102
|
+
string."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, args: list[str], fs: FileSystem) -> None:
|
|
105
|
+
self.args = args
|
|
106
|
+
self.fs = fs
|
|
107
|
+
self.i = 0
|
|
108
|
+
|
|
109
|
+
def parse(self) -> bool:
|
|
110
|
+
value = self._or()
|
|
111
|
+
if self.i < len(self.args):
|
|
112
|
+
raise _Usage(f"{self.args[self.i]}: unexpected argument")
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
def _peek(self, ahead: int = 0) -> str | None:
|
|
116
|
+
j = self.i + ahead
|
|
117
|
+
return self.args[j] if j < len(self.args) else None
|
|
118
|
+
|
|
119
|
+
def _or(self) -> bool:
|
|
120
|
+
value = self._and()
|
|
121
|
+
while self._peek() == "-o":
|
|
122
|
+
self.i += 1
|
|
123
|
+
right = self._and()
|
|
124
|
+
value = value or right
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
def _and(self) -> bool:
|
|
128
|
+
value = self._not()
|
|
129
|
+
while self._peek() == "-a":
|
|
130
|
+
self.i += 1
|
|
131
|
+
right = self._not()
|
|
132
|
+
value = value and right
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
def _not(self) -> bool:
|
|
136
|
+
if self._peek() == "!":
|
|
137
|
+
self.i += 1
|
|
138
|
+
return not self._not()
|
|
139
|
+
return self._primary()
|
|
140
|
+
|
|
141
|
+
def _primary(self) -> bool:
|
|
142
|
+
tok = self._peek()
|
|
143
|
+
if tok is None:
|
|
144
|
+
raise _Usage("argument expected")
|
|
145
|
+
if tok == "(":
|
|
146
|
+
self.i += 1
|
|
147
|
+
value = self._or()
|
|
148
|
+
if self._peek() != ")":
|
|
149
|
+
raise _Usage("')' expected")
|
|
150
|
+
self.i += 1
|
|
151
|
+
return value
|
|
152
|
+
if tok in _UNARY and self._peek(1) is not None:
|
|
153
|
+
self.i += 2
|
|
154
|
+
return _unary(tok, self.args[self.i - 1], self.fs)
|
|
155
|
+
if self._peek(1) in _BINARY and self._peek(2) is not None:
|
|
156
|
+
left, op, right = self.args[self.i : self.i + 3]
|
|
157
|
+
self.i += 3
|
|
158
|
+
return _binary(left, op, right)
|
|
159
|
+
self.i += 1
|
|
160
|
+
return tok != ""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _unary(op: str, operand: str, fs: FileSystem) -> bool:
|
|
164
|
+
if op == "-z":
|
|
165
|
+
return operand == ""
|
|
166
|
+
if op == "-n":
|
|
167
|
+
return operand != ""
|
|
168
|
+
if operand == "":
|
|
169
|
+
# No path at all, not the current directory: an unset variable
|
|
170
|
+
# in `[ -d "$OUT" ]` must not test whatever the shell stands in.
|
|
171
|
+
return False
|
|
172
|
+
path = resolve_path(operand, fs)
|
|
173
|
+
if not fs.exists(path):
|
|
174
|
+
return False
|
|
175
|
+
if op in ("-e", "-r", "-w"):
|
|
176
|
+
return True
|
|
177
|
+
if op == "-f":
|
|
178
|
+
return fs.isfile(path)
|
|
179
|
+
if op == "-d":
|
|
180
|
+
return fs.isdir(path)
|
|
181
|
+
if op == "-x":
|
|
182
|
+
return fs.isdir(path)
|
|
183
|
+
# -s: exists and is not empty; a directory counts as not empty, as a
|
|
184
|
+
# real directory's own size does
|
|
185
|
+
if fs.isdir(path):
|
|
186
|
+
return True
|
|
187
|
+
return fs.stat(path).size > 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _binary(left: str, op: str, right: str) -> bool:
|
|
191
|
+
if op in ("=", "=="):
|
|
192
|
+
return left == right
|
|
193
|
+
if op == "!=":
|
|
194
|
+
return left != right
|
|
195
|
+
a, b = _integer(left), _integer(right)
|
|
196
|
+
return {
|
|
197
|
+
"-eq": a == b,
|
|
198
|
+
"-ne": a != b,
|
|
199
|
+
"-lt": a < b,
|
|
200
|
+
"-le": a <= b,
|
|
201
|
+
"-gt": a > b,
|
|
202
|
+
"-ge": a >= b,
|
|
203
|
+
}[op]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _integer(text: str) -> int:
|
|
207
|
+
value = int(text.strip()) if _INTEGER.match(text) else None
|
|
208
|
+
if value is None or not _INT_MIN <= value <= _INT_MAX:
|
|
209
|
+
raise _Usage(f"{text}: integer expression expected")
|
|
210
|
+
return value
|
|
@@ -3,6 +3,7 @@ Filesystem commands for the terminal interpreter.
|
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
5
|
import posixpath
|
|
6
|
+
from typing import Any
|
|
6
7
|
|
|
7
8
|
from termish.context import CommandContext, CommandResult
|
|
8
9
|
from termish.errors import TerminalError
|
|
@@ -91,108 +92,131 @@ def ls(ctx: CommandContext) -> CommandResult | None:
|
|
|
91
92
|
if unknown:
|
|
92
93
|
raise TerminalError(f"ls: unknown option: {unknown[0]}")
|
|
93
94
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if parsed.l:
|
|
120
|
-
# List detailed for file
|
|
121
|
-
meta = fs.stat(path)
|
|
95
|
+
def long_line(path: str, type_char: str) -> str:
|
|
96
|
+
meta = fs.stat(path)
|
|
97
|
+
if parsed.human_readable:
|
|
98
|
+
size = _human_readable_size(meta.size).rjust(6)
|
|
99
|
+
else:
|
|
100
|
+
size = str(meta.size).rjust(8)
|
|
101
|
+
time = meta.modified_at[:16].replace("T", " ") if meta.modified_at else " " * 16
|
|
102
|
+
return f"{type_char}rw-r--r-- 1 agent agent {size} {time} {path}\n"
|
|
103
|
+
|
|
104
|
+
def list_dir(path: str) -> None:
|
|
105
|
+
needs_detailed = parsed.l or parsed.t or parsed.S or parsed.classify
|
|
106
|
+
if needs_detailed:
|
|
107
|
+
items = fs.list_detailed(path, recursive=parsed.R)
|
|
108
|
+
if not parsed.a:
|
|
109
|
+
items = [i for i in items if not i.name.startswith(".")]
|
|
110
|
+
if parsed.S:
|
|
111
|
+
items = sorted(items, key=lambda x: x.size, reverse=True)
|
|
112
|
+
elif parsed.t:
|
|
113
|
+
items = sorted(items, key=lambda x: x.modified_at or "", reverse=True)
|
|
114
|
+
if parsed.r:
|
|
115
|
+
items = list(reversed(items))
|
|
116
|
+
|
|
117
|
+
if parsed.l:
|
|
118
|
+
for item in items:
|
|
119
|
+
type_char = "d" if item.is_dir else "-"
|
|
122
120
|
if parsed.human_readable:
|
|
123
|
-
size = _human_readable_size(
|
|
121
|
+
size = _human_readable_size(item.size).rjust(6)
|
|
124
122
|
else:
|
|
125
|
-
size = str(
|
|
123
|
+
size = str(item.size).rjust(8)
|
|
126
124
|
time = (
|
|
127
|
-
|
|
128
|
-
if
|
|
125
|
+
item.modified_at[:16].replace("T", " ")
|
|
126
|
+
if item.modified_at
|
|
129
127
|
else " " * 16
|
|
130
128
|
)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
continue
|
|
135
|
-
|
|
136
|
-
needs_detailed = parsed.l or parsed.t or parsed.S or parsed.classify
|
|
137
|
-
if needs_detailed:
|
|
138
|
-
items = fs.list_detailed(path, recursive=parsed.R)
|
|
139
|
-
if not parsed.a:
|
|
140
|
-
items = [i for i in items if not i.name.startswith(".")]
|
|
141
|
-
if parsed.S:
|
|
142
|
-
items = sorted(items, key=lambda x: x.size, reverse=True)
|
|
143
|
-
elif parsed.t:
|
|
144
|
-
items = sorted(
|
|
145
|
-
items, key=lambda x: x.modified_at or "", reverse=True
|
|
129
|
+
suffix = "/" if parsed.classify and item.is_dir else ""
|
|
130
|
+
stdout.write(
|
|
131
|
+
f"{type_char}rw-r--r-- 1 agent agent {size} {time} {item.path}{suffix}\n"
|
|
146
132
|
)
|
|
147
|
-
if parsed.r:
|
|
148
|
-
items = list(reversed(items))
|
|
149
|
-
|
|
150
|
-
if parsed.l:
|
|
151
|
-
for item in items:
|
|
152
|
-
type_char = "d" if item.is_dir else "-"
|
|
153
|
-
if parsed.human_readable:
|
|
154
|
-
size = _human_readable_size(item.size).rjust(6)
|
|
155
|
-
else:
|
|
156
|
-
size = str(item.size).rjust(8)
|
|
157
|
-
time = (
|
|
158
|
-
item.modified_at[:16].replace("T", " ")
|
|
159
|
-
if item.modified_at
|
|
160
|
-
else " " * 16
|
|
161
|
-
)
|
|
162
|
-
suffix = "/" if parsed.classify and item.is_dir else ""
|
|
163
|
-
stdout.write(
|
|
164
|
-
f"{type_char}rw-r--r-- 1 agent agent {size} {time} {item.path}{suffix}\n"
|
|
165
|
-
)
|
|
166
|
-
else:
|
|
167
|
-
filtered = [
|
|
168
|
-
item.path + ("/" if parsed.classify and item.is_dir else "")
|
|
169
|
-
for item in items
|
|
170
|
-
]
|
|
171
|
-
if filtered:
|
|
172
|
-
stdout.write("\n".join(filtered) + "\n")
|
|
173
133
|
else:
|
|
174
|
-
items_str = fs.list(path, recursive=parsed.R)
|
|
175
134
|
filtered = [
|
|
176
|
-
|
|
177
|
-
for
|
|
178
|
-
if parsed.a or not p.split("/")[-1].startswith(".")
|
|
135
|
+
item.path + ("/" if parsed.classify and item.is_dir else "")
|
|
136
|
+
for item in items
|
|
179
137
|
]
|
|
180
|
-
if parsed.r:
|
|
181
|
-
filtered = list(reversed(filtered))
|
|
182
138
|
if filtered:
|
|
183
139
|
stdout.write("\n".join(filtered) + "\n")
|
|
140
|
+
else:
|
|
141
|
+
items_str = fs.list(path, recursive=parsed.R)
|
|
142
|
+
filtered = [
|
|
143
|
+
p for p in items_str if parsed.a or not p.split("/")[-1].startswith(".")
|
|
144
|
+
]
|
|
145
|
+
if parsed.r:
|
|
146
|
+
filtered = list(reversed(filtered))
|
|
147
|
+
if filtered:
|
|
148
|
+
stdout.write("\n".join(filtered) + "\n")
|
|
149
|
+
|
|
150
|
+
# GNU's order: an operand that is missing is reported and the rest
|
|
151
|
+
# are listed; file operands (and, with -d, directories as entries)
|
|
152
|
+
# come first, by name and with no header; then each directory, by
|
|
153
|
+
# name, under a "dir:" header whenever there is more than one
|
|
154
|
+
# operand, with a blank line before every group after the first.
|
|
155
|
+
missing: list[str] = []
|
|
156
|
+
files: list[str] = []
|
|
157
|
+
dirs: list[str] = []
|
|
158
|
+
for path in parsed.paths:
|
|
159
|
+
try:
|
|
160
|
+
if fs.isdir(path):
|
|
161
|
+
(files if parsed.directory else dirs).append(path)
|
|
162
|
+
elif fs.isfile(path) or fs.exists(path):
|
|
163
|
+
files.append(path)
|
|
164
|
+
else:
|
|
165
|
+
missing.append(path)
|
|
166
|
+
except Exception:
|
|
167
|
+
missing.append(path)
|
|
168
|
+
|
|
169
|
+
def operand_order(paths: list[str]) -> list[str]:
|
|
170
|
+
"""Operands in the order their entries would be: by name, or by
|
|
171
|
+
size or time (largest and newest first) under -S and -t, and
|
|
172
|
+
reversed under -r."""
|
|
173
|
+
if parsed.S or parsed.t:
|
|
174
|
+
|
|
175
|
+
def key(p: str) -> Any:
|
|
176
|
+
try:
|
|
177
|
+
meta = fs.stat(p)
|
|
178
|
+
except Exception:
|
|
179
|
+
return 0 if parsed.S else ""
|
|
180
|
+
return meta.size if parsed.S else (meta.modified_at or "")
|
|
181
|
+
|
|
182
|
+
paths = sorted(sorted(paths), key=key, reverse=True)
|
|
183
|
+
else:
|
|
184
|
+
paths = sorted(paths)
|
|
185
|
+
return list(reversed(paths)) if parsed.r else paths
|
|
184
186
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
187
|
+
files = operand_order(files)
|
|
188
|
+
dirs = operand_order(dirs)
|
|
189
|
+
errors = [f"ls: cannot access '{p}': No such file or directory" for p in missing]
|
|
190
|
+
if not files and not dirs:
|
|
191
|
+
raise TerminalError("\n".join(errors))
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
for path in files:
|
|
195
|
+
if parsed.l:
|
|
196
|
+
stdout.write(long_line(path, "d" if fs.isdir(path) else "-"))
|
|
197
|
+
else:
|
|
198
|
+
stdout.write(f"{path}\n")
|
|
199
|
+
headers = len(parsed.paths) > 1
|
|
200
|
+
for k, path in enumerate(dirs):
|
|
201
|
+
if files or k:
|
|
202
|
+
stdout.write("\n")
|
|
203
|
+
if headers:
|
|
204
|
+
stdout.write(f"{path}:\n")
|
|
205
|
+
list_dir(path)
|
|
206
|
+
except FileNotFoundError as e:
|
|
207
|
+
raise TerminalError(
|
|
208
|
+
f"ls: cannot access '{e.filename or e}': No such file or directory"
|
|
209
|
+
)
|
|
210
|
+
except NotADirectoryError as e:
|
|
211
|
+
raise TerminalError(f"ls: cannot access '{e.filename or e}': Not a directory")
|
|
212
|
+
except TerminalError:
|
|
213
|
+
raise
|
|
214
|
+
except Exception as e:
|
|
215
|
+
raise TerminalError(f"ls: {e}")
|
|
193
216
|
|
|
194
|
-
|
|
195
|
-
|
|
217
|
+
if errors:
|
|
218
|
+
return CommandResult(exit_code=2, stderr="\n".join(errors) + "\n")
|
|
219
|
+
return None
|
|
196
220
|
|
|
197
221
|
|
|
198
222
|
def touch(ctx: CommandContext) -> CommandResult | None:
|