termish 0.2.1__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.1/termish.egg-info → termish-0.2.2}/PKG-INFO +1 -1
- {termish-0.2.1 → termish-0.2.2}/pyproject.toml +1 -1
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/filesystem.py +113 -89
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/core.py +6 -2
- termish-0.2.2/termish/interpreter/pathglob.py +140 -0
- {termish-0.2.1 → termish-0.2.2}/termish/parser.py +3 -2
- {termish-0.2.1 → termish-0.2.2/termish.egg-info}/PKG-INFO +1 -1
- {termish-0.2.1 → termish-0.2.2}/termish.egg-info/SOURCES.txt +2 -0
- {termish-0.2.1 → termish-0.2.2}/termish.egg-info/top_level.txt +1 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_expansion.py +2 -1
- termish-0.2.2/tests/test_pathglob.py +152 -0
- {termish-0.2.1 → termish-0.2.2}/LICENSE +0 -0
- {termish-0.2.1 → termish-0.2.2}/README.md +0 -0
- {termish-0.2.1 → termish-0.2.2}/setup.cfg +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/__init__.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/ast.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/context.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/errors.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/fs/__init__.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/fs/conformance.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/fs/memory.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/fs/protocol.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/__init__.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/__init__.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/_argparse.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/_regex.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/_util.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/archive.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/conditional.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/control.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/diff.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/file.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/io.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/jq.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/meta.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/search.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/sed.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/text.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/jq/__init__.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/jq/ast.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/jq/eval.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/jq/parser.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/py.typed +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish/quote_masker.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish.egg-info/dependency_links.txt +0 -0
- {termish-0.2.1 → termish-0.2.2}/termish.egg-info/requires.txt +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_archive.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_binary.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_bre.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_command_injection.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_conditional.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_control.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_dev_null.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_file.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_find.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_fs_conformance.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_grep.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_heredoc.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_interpreter.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_jq.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_memory_fs.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_monkeyfs_compat.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_parser.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_printf.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_quote_masker.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_sed.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_stderr.py +0 -0
- {termish-0.2.1 → termish-0.2.2}/tests/test_text_commands.py +0 -0
|
@@ -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:
|
|
@@ -21,6 +21,8 @@ from .commands import io as io_cmds
|
|
|
21
21
|
from .commands import jq as jq_cmd
|
|
22
22
|
from .commands import sed as sed_cmd
|
|
23
23
|
from .commands._util import resolve_path
|
|
24
|
+
from .pathglob import expand as expand_pathname
|
|
25
|
+
from .pathglob import has_magic
|
|
24
26
|
|
|
25
27
|
#: The redirect target that discards: written to, nothing is kept;
|
|
26
28
|
#: read from, it is empty. Never a file in the filesystem.
|
|
@@ -535,9 +537,11 @@ def _expand_args(
|
|
|
535
537
|
# Fully unquoted: word removal, then globbing
|
|
536
538
|
if masked == "" and arg != "":
|
|
537
539
|
continue
|
|
538
|
-
if
|
|
540
|
+
if has_magic(masked):
|
|
541
|
+
# bash's pathname expansion, segment by segment (see
|
|
542
|
+
# pathglob); a word that matches nothing stays as typed
|
|
539
543
|
try:
|
|
540
|
-
matches =
|
|
544
|
+
matches = expand_pathname(masked, fs)
|
|
541
545
|
expanded.extend(matches if matches else [masked])
|
|
542
546
|
except Exception:
|
|
543
547
|
expanded.append(masked)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Pathname expansion, as bash does it.
|
|
2
|
+
|
|
3
|
+
An unquoted word holding ``*``, ``?`` or a ``[...]`` class is matched
|
|
4
|
+
against the filesystem one path segment at a time:
|
|
5
|
+
|
|
6
|
+
- ``*``, ``?`` and ``[...]`` match within a segment and never cross a
|
|
7
|
+
``/``: ``a/*`` names the entries directly under ``a``, directories
|
|
8
|
+
included, and nothing below them;
|
|
9
|
+
- a name starting with ``.`` is matched only by a segment that itself
|
|
10
|
+
starts with ``.``, as with bash's ``dotglob`` off;
|
|
11
|
+
- ``**`` matches zero or more directories (bash with ``globstar`` on),
|
|
12
|
+
and a final ``**`` everything below, files included;
|
|
13
|
+
- a trailing ``/`` matches directories only, and is kept;
|
|
14
|
+
- the matches keep the word's own form: a relative word expands to
|
|
15
|
+
relative paths and an absolute one to absolute paths, sorted.
|
|
16
|
+
|
|
17
|
+
A word that matches nothing is left as it is (``nullglob`` off), which
|
|
18
|
+
the caller does by getting an empty list back.
|
|
19
|
+
|
|
20
|
+
It is done here, through the filesystem protocol's ``list``, ``isdir``
|
|
21
|
+
and ``exists``, rather than by asking the filesystem to glob: every
|
|
22
|
+
filesystem then expands the same way, whether or not its own ``glob``
|
|
23
|
+
treats ``*`` as crossing ``/`` or knows about directories it holds
|
|
24
|
+
only implicitly.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import posixpath
|
|
30
|
+
from fnmatch import fnmatchcase
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def has_magic(word: str) -> bool:
|
|
35
|
+
"""Whether ``word`` is a pattern: it holds ``*`` or ``?``, or a
|
|
36
|
+
``[`` closed by a later ``]``."""
|
|
37
|
+
if "*" in word or "?" in word:
|
|
38
|
+
return True
|
|
39
|
+
start = word.find("[")
|
|
40
|
+
return start != -1 and word.find("]", start + 2) != -1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def expand(pattern: str, fs: Any) -> list[str]:
|
|
44
|
+
"""The paths ``pattern`` matches, sorted; empty when none do."""
|
|
45
|
+
absolute = pattern.startswith("/")
|
|
46
|
+
parts = pattern.split("/")
|
|
47
|
+
if absolute:
|
|
48
|
+
parts = parts[1:]
|
|
49
|
+
trailing = len(parts) > 1 and parts[-1] == ""
|
|
50
|
+
if trailing:
|
|
51
|
+
parts = parts[:-1]
|
|
52
|
+
parts = [p for p in parts if p != ""] if parts else []
|
|
53
|
+
found: set[str] = set()
|
|
54
|
+
_walk(fs, "/" if absolute else "", parts, 0, trailing, found)
|
|
55
|
+
return sorted(found)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _join(base: str, name: str) -> str:
|
|
59
|
+
if base == "":
|
|
60
|
+
return name
|
|
61
|
+
if base.endswith("/"):
|
|
62
|
+
return base + name
|
|
63
|
+
return base + "/" + name
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _names(fs: Any, base: str) -> list[str]:
|
|
67
|
+
try:
|
|
68
|
+
entries = fs.list(base or ".")
|
|
69
|
+
except Exception:
|
|
70
|
+
return []
|
|
71
|
+
return [posixpath.basename(str(e).rstrip("/")) for e in entries]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _isdir(fs: Any, path: str) -> bool:
|
|
75
|
+
try:
|
|
76
|
+
return bool(fs.isdir(path or "."))
|
|
77
|
+
except Exception:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _exists(fs: Any, path: str) -> bool:
|
|
82
|
+
try:
|
|
83
|
+
return bool(fs.exists(path))
|
|
84
|
+
except Exception:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _everything(fs: Any, base: str, found: set[str]) -> None:
|
|
89
|
+
"""Every entry below ``base`` that does not start with a dot."""
|
|
90
|
+
for name in _names(fs, base):
|
|
91
|
+
if name.startswith("."):
|
|
92
|
+
continue
|
|
93
|
+
path = _join(base, name)
|
|
94
|
+
found.add(path)
|
|
95
|
+
if _isdir(fs, path):
|
|
96
|
+
_everything(fs, path, found)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _walk(
|
|
100
|
+
fs: Any, base: str, parts: list[str], i: int, trailing: bool, found: set[str]
|
|
101
|
+
) -> None:
|
|
102
|
+
if i == len(parts):
|
|
103
|
+
if trailing:
|
|
104
|
+
if _isdir(fs, base):
|
|
105
|
+
found.add(base if base.endswith("/") else base + "/")
|
|
106
|
+
elif base:
|
|
107
|
+
found.add(base)
|
|
108
|
+
return
|
|
109
|
+
part = parts[i]
|
|
110
|
+
last = i == len(parts) - 1
|
|
111
|
+
if part == "**" and last and not trailing:
|
|
112
|
+
# a final ** names everything below: the base as a directory,
|
|
113
|
+
# then every entry at every depth, files as well as directories
|
|
114
|
+
if base:
|
|
115
|
+
found.add(base if base.endswith("/") else base + "/")
|
|
116
|
+
_everything(fs, base, found)
|
|
117
|
+
return
|
|
118
|
+
if part == "**":
|
|
119
|
+
# zero directories here, then one more level and ** again
|
|
120
|
+
_walk(fs, base, parts, i + 1, trailing, found)
|
|
121
|
+
for name in _names(fs, base):
|
|
122
|
+
if name.startswith("."):
|
|
123
|
+
continue
|
|
124
|
+
path = _join(base, name)
|
|
125
|
+
if _isdir(fs, path):
|
|
126
|
+
_walk(fs, path, parts, i, trailing, found)
|
|
127
|
+
return
|
|
128
|
+
if not has_magic(part):
|
|
129
|
+
path = _join(base, part)
|
|
130
|
+
if (last and _exists(fs, path)) or (not last and _isdir(fs, path)):
|
|
131
|
+
_walk(fs, path, parts, i + 1, trailing, found)
|
|
132
|
+
return
|
|
133
|
+
for name in _names(fs, base):
|
|
134
|
+
if name.startswith(".") and not part.startswith("."):
|
|
135
|
+
continue
|
|
136
|
+
if not fnmatchcase(name, part):
|
|
137
|
+
continue
|
|
138
|
+
path = _join(base, name)
|
|
139
|
+
if last or _isdir(fs, path):
|
|
140
|
+
_walk(fs, path, parts, i + 1, trailing, found)
|
|
@@ -237,8 +237,9 @@ def to_script(text: str) -> Script:
|
|
|
237
237
|
# be treated as part of words. Without this, "user@host" splits into
|
|
238
238
|
# ["user", "@", "host"], "100%" into ["100", "%"], etc.
|
|
239
239
|
# "$?{}" keeps variable forms ("$?", "$NAME", "${NAME}") intact as
|
|
240
|
-
# single words for execution-time expansion.
|
|
241
|
-
|
|
240
|
+
# single words for execution-time expansion. "[]" keeps a glob's
|
|
241
|
+
# class ("a/[bc]*") one word; `[ -f x ]` still splits on its spaces.
|
|
242
|
+
lexer.wordchars += ":@,%+!^$?{}[]"
|
|
242
243
|
|
|
243
244
|
# Treat newlines as tokens, not whitespace, so we can use them as separators
|
|
244
245
|
lexer.whitespace = " \t\r"
|
|
@@ -19,6 +19,7 @@ termish/fs/memory.py
|
|
|
19
19
|
termish/fs/protocol.py
|
|
20
20
|
termish/interpreter/__init__.py
|
|
21
21
|
termish/interpreter/core.py
|
|
22
|
+
termish/interpreter/pathglob.py
|
|
22
23
|
termish/interpreter/commands/__init__.py
|
|
23
24
|
termish/interpreter/commands/_argparse.py
|
|
24
25
|
termish/interpreter/commands/_regex.py
|
|
@@ -57,6 +58,7 @@ tests/test_jq.py
|
|
|
57
58
|
tests/test_memory_fs.py
|
|
58
59
|
tests/test_monkeyfs_compat.py
|
|
59
60
|
tests/test_parser.py
|
|
61
|
+
tests/test_pathglob.py
|
|
60
62
|
tests/test_printf.py
|
|
61
63
|
tests/test_quote_masker.py
|
|
62
64
|
tests/test_sed.py
|
|
@@ -141,7 +141,8 @@ class TestVarExpansion:
|
|
|
141
141
|
fs.write("/a.txt", b"")
|
|
142
142
|
fs.write("/b.txt", b"")
|
|
143
143
|
out = execute("echo $PAT", fs, env={"PAT": "*.txt"})
|
|
144
|
-
|
|
144
|
+
# a relative pattern expands to relative paths, as in bash
|
|
145
|
+
assert sorted(out.split()) == ["a.txt", "b.txt"]
|
|
145
146
|
|
|
146
147
|
def test_env_mutation_visible_to_later_commands(self, fs):
|
|
147
148
|
def setvar(ctx: CommandContext) -> CommandResult | None:
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Pathname expansion and ``ls`` operands, as bash and GNU do them (#28).
|
|
2
|
+
|
|
3
|
+
``ls app/assets/*`` in a session listed every file in the tree twice:
|
|
4
|
+
``*`` crossed ``/`` and expanded to absolute paths, and ``ls`` gave each
|
|
5
|
+
file operand a ``path:`` header of its own. Every expected value here
|
|
6
|
+
was checked against bash and GNU coreutils.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from termish import MemoryFS, execute
|
|
12
|
+
from termish.errors import TerminalError
|
|
13
|
+
from termish.interpreter.pathglob import has_magic
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def fs():
|
|
18
|
+
fs = MemoryFS()
|
|
19
|
+
execute(
|
|
20
|
+
"mkdir -p a/bg a/cards a/.git; touch a/bg/x.png a/bg/y.png a/cards/z.png "
|
|
21
|
+
"a/top.txt a/.hid",
|
|
22
|
+
fs,
|
|
23
|
+
)
|
|
24
|
+
return fs
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.parametrize(
|
|
28
|
+
"command, expected",
|
|
29
|
+
[
|
|
30
|
+
# * stays within a segment, directories included
|
|
31
|
+
("echo a/*", "a/bg a/cards a/top.txt"),
|
|
32
|
+
("echo /a/*", "/a/bg /a/cards /a/top.txt"),
|
|
33
|
+
("echo a/*/*.png", "a/bg/x.png a/bg/y.png a/cards/z.png"),
|
|
34
|
+
# a trailing / matches directories only, and is kept
|
|
35
|
+
("echo a/*/", "a/bg/ a/cards/"),
|
|
36
|
+
# ** crosses directories, zero or more of them
|
|
37
|
+
("echo a/**/*.png", "a/bg/x.png a/bg/y.png a/cards/z.png"),
|
|
38
|
+
("echo a/**/top.txt", "a/top.txt"),
|
|
39
|
+
# a final ** names everything below, files included
|
|
40
|
+
("echo a/**", "a/ a/bg a/bg/x.png a/bg/y.png a/cards a/cards/z.png a/top.txt"),
|
|
41
|
+
("echo a/**/", "a/ a/bg/ a/cards/"),
|
|
42
|
+
# dotfiles only for a pattern that starts with a dot
|
|
43
|
+
("echo a/.*", "a/.git a/.hid"),
|
|
44
|
+
("echo a/.h*", "a/.hid"),
|
|
45
|
+
# ? and classes, negated classes too, all one word
|
|
46
|
+
("echo a/?g", "a/bg"),
|
|
47
|
+
("echo a/[bc]*", "a/bg a/cards"),
|
|
48
|
+
("echo a/[!b]*", "a/cards a/top.txt"),
|
|
49
|
+
# relative to the working directory, and kept relative
|
|
50
|
+
("cd a; echo *", "bg cards top.txt"),
|
|
51
|
+
("cd a; echo bg/*", "bg/x.png bg/y.png"),
|
|
52
|
+
("cd a; echo ../a/top*", "../a/top.txt"),
|
|
53
|
+
# no match: the word stays as typed
|
|
54
|
+
("echo nope/*", "nope/*"),
|
|
55
|
+
("echo a/*.zip", "a/*.zip"),
|
|
56
|
+
# quoted: never expanded
|
|
57
|
+
("echo 'a/*'", "a/*"),
|
|
58
|
+
("echo x[0]", "x[0]"),
|
|
59
|
+
],
|
|
60
|
+
)
|
|
61
|
+
def test_expansion_matches_bash(fs, command, expected):
|
|
62
|
+
assert execute(command, fs).strip() == expected
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_a_bracket_is_part_of_the_word_but_test_still_parses(fs):
|
|
66
|
+
assert execute("[ -d a ] && echo dir", fs).strip() == "dir"
|
|
67
|
+
assert execute("[ -f a/top.txt ]; echo $?", fs).strip() == "0"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_what_counts_as_a_pattern():
|
|
71
|
+
assert has_magic("*.py") and has_magic("a?") and has_magic("[ab]")
|
|
72
|
+
assert not has_magic("plain") and not has_magic("[") and not has_magic("x[]")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.parametrize(
|
|
76
|
+
"command, expected",
|
|
77
|
+
[
|
|
78
|
+
# files first, by name, no headers; then each directory under one
|
|
79
|
+
(
|
|
80
|
+
"ls a/*",
|
|
81
|
+
"a/top.txt\n\na/bg:\nx.png\ny.png\n\na/cards:\nz.png\n",
|
|
82
|
+
),
|
|
83
|
+
("ls a/top.txt a/bg/x.png", "a/bg/x.png\na/top.txt\n"),
|
|
84
|
+
("ls a/bg a/cards", "a/bg:\nx.png\ny.png\n\na/cards:\nz.png\n"),
|
|
85
|
+
# one directory operand: its contents, no header
|
|
86
|
+
("ls a/bg", "x.png\ny.png\n"),
|
|
87
|
+
# -d lists the directories themselves
|
|
88
|
+
("ls -d a/*", "a/bg\na/cards\na/top.txt\n"),
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
def test_ls_operands_match_gnu(fs, command, expected):
|
|
92
|
+
assert execute(command, fs) == expected
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_ls_reports_a_missing_operand_and_lists_the_rest(fs):
|
|
96
|
+
out = execute("ls a/top.txt nope; echo st=$?", fs)
|
|
97
|
+
assert "a/top.txt\n" in out
|
|
98
|
+
assert "ls: cannot access 'nope': No such file or directory" in out
|
|
99
|
+
assert out.endswith("st=2\n")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_ls_with_nothing_to_list_is_an_error(fs):
|
|
103
|
+
with pytest.raises(TerminalError, match="cannot access 'nope'"):
|
|
104
|
+
execute("ls nope", fs)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
PATTERNS = [
|
|
108
|
+
"a/*",
|
|
109
|
+
"a/*/",
|
|
110
|
+
"a/*/*.png",
|
|
111
|
+
"a/**",
|
|
112
|
+
"a/**/",
|
|
113
|
+
"a/**/*.png",
|
|
114
|
+
"a/**/top.txt",
|
|
115
|
+
"a/.*",
|
|
116
|
+
"a/?g",
|
|
117
|
+
"a/[bc]*",
|
|
118
|
+
"a/[!b]*",
|
|
119
|
+
"*",
|
|
120
|
+
"**",
|
|
121
|
+
"a/nope*",
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.parametrize("pattern", PATTERNS)
|
|
126
|
+
def test_expansion_agrees_with_pythons_glob(tmp_path, monkeypatch, pattern):
|
|
127
|
+
"""Python's glob(recursive=True) follows bash's globstar, dotfiles
|
|
128
|
+
and trailing-slash rules, so a real directory tree is the reference."""
|
|
129
|
+
import glob as pyglob
|
|
130
|
+
|
|
131
|
+
for d in ("a/bg", "a/cards", "a/.git"):
|
|
132
|
+
(tmp_path / d).mkdir(parents=True)
|
|
133
|
+
for f in ("a/bg/x.png", "a/bg/y.png", "a/cards/z.png", "a/top.txt", "a/.hid"):
|
|
134
|
+
(tmp_path / f).write_text("")
|
|
135
|
+
monkeypatch.chdir(tmp_path)
|
|
136
|
+
expected = sorted(pyglob.glob(pattern, recursive=True)) or [pattern]
|
|
137
|
+
|
|
138
|
+
fs = MemoryFS()
|
|
139
|
+
execute(
|
|
140
|
+
"mkdir -p a/bg a/cards a/.git; touch a/bg/x.png a/bg/y.png a/cards/z.png "
|
|
141
|
+
"a/top.txt a/.hid",
|
|
142
|
+
fs,
|
|
143
|
+
)
|
|
144
|
+
assert execute(f"printf '%s\\n' {pattern}", fs).split() == expected
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_ls_orders_operands_by_size_and_time_under_s_and_t(fs):
|
|
148
|
+
fs.write("/small.txt", b"x")
|
|
149
|
+
fs.write("/big.txt", b"x" * 100)
|
|
150
|
+
assert execute("ls -S small.txt big.txt", fs) == "big.txt\nsmall.txt\n"
|
|
151
|
+
assert execute("ls -Sr small.txt big.txt", fs) == "small.txt\nbig.txt\n"
|
|
152
|
+
assert execute("ls small.txt big.txt", fs) == "big.txt\nsmall.txt\n" # by name
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|