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.
Files changed (68) hide show
  1. {termish-0.2.1/termish.egg-info → termish-0.2.2}/PKG-INFO +1 -1
  2. {termish-0.2.1 → termish-0.2.2}/pyproject.toml +1 -1
  3. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/filesystem.py +113 -89
  4. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/core.py +6 -2
  5. termish-0.2.2/termish/interpreter/pathglob.py +140 -0
  6. {termish-0.2.1 → termish-0.2.2}/termish/parser.py +3 -2
  7. {termish-0.2.1 → termish-0.2.2/termish.egg-info}/PKG-INFO +1 -1
  8. {termish-0.2.1 → termish-0.2.2}/termish.egg-info/SOURCES.txt +2 -0
  9. {termish-0.2.1 → termish-0.2.2}/termish.egg-info/top_level.txt +1 -0
  10. {termish-0.2.1 → termish-0.2.2}/tests/test_expansion.py +2 -1
  11. termish-0.2.2/tests/test_pathglob.py +152 -0
  12. {termish-0.2.1 → termish-0.2.2}/LICENSE +0 -0
  13. {termish-0.2.1 → termish-0.2.2}/README.md +0 -0
  14. {termish-0.2.1 → termish-0.2.2}/setup.cfg +0 -0
  15. {termish-0.2.1 → termish-0.2.2}/termish/__init__.py +0 -0
  16. {termish-0.2.1 → termish-0.2.2}/termish/ast.py +0 -0
  17. {termish-0.2.1 → termish-0.2.2}/termish/context.py +0 -0
  18. {termish-0.2.1 → termish-0.2.2}/termish/errors.py +0 -0
  19. {termish-0.2.1 → termish-0.2.2}/termish/fs/__init__.py +0 -0
  20. {termish-0.2.1 → termish-0.2.2}/termish/fs/conformance.py +0 -0
  21. {termish-0.2.1 → termish-0.2.2}/termish/fs/memory.py +0 -0
  22. {termish-0.2.1 → termish-0.2.2}/termish/fs/protocol.py +0 -0
  23. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/__init__.py +0 -0
  24. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/__init__.py +0 -0
  25. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/_argparse.py +0 -0
  26. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/_regex.py +0 -0
  27. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/_util.py +0 -0
  28. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/archive.py +0 -0
  29. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/conditional.py +0 -0
  30. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/control.py +0 -0
  31. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/diff.py +0 -0
  32. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/file.py +0 -0
  33. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/io.py +0 -0
  34. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/jq.py +0 -0
  35. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/meta.py +0 -0
  36. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/search.py +0 -0
  37. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/sed.py +0 -0
  38. {termish-0.2.1 → termish-0.2.2}/termish/interpreter/commands/text.py +0 -0
  39. {termish-0.2.1 → termish-0.2.2}/termish/jq/__init__.py +0 -0
  40. {termish-0.2.1 → termish-0.2.2}/termish/jq/ast.py +0 -0
  41. {termish-0.2.1 → termish-0.2.2}/termish/jq/eval.py +0 -0
  42. {termish-0.2.1 → termish-0.2.2}/termish/jq/parser.py +0 -0
  43. {termish-0.2.1 → termish-0.2.2}/termish/py.typed +0 -0
  44. {termish-0.2.1 → termish-0.2.2}/termish/quote_masker.py +0 -0
  45. {termish-0.2.1 → termish-0.2.2}/termish.egg-info/dependency_links.txt +0 -0
  46. {termish-0.2.1 → termish-0.2.2}/termish.egg-info/requires.txt +0 -0
  47. {termish-0.2.1 → termish-0.2.2}/tests/test_archive.py +0 -0
  48. {termish-0.2.1 → termish-0.2.2}/tests/test_binary.py +0 -0
  49. {termish-0.2.1 → termish-0.2.2}/tests/test_bre.py +0 -0
  50. {termish-0.2.1 → termish-0.2.2}/tests/test_command_injection.py +0 -0
  51. {termish-0.2.1 → termish-0.2.2}/tests/test_conditional.py +0 -0
  52. {termish-0.2.1 → termish-0.2.2}/tests/test_control.py +0 -0
  53. {termish-0.2.1 → termish-0.2.2}/tests/test_dev_null.py +0 -0
  54. {termish-0.2.1 → termish-0.2.2}/tests/test_file.py +0 -0
  55. {termish-0.2.1 → termish-0.2.2}/tests/test_find.py +0 -0
  56. {termish-0.2.1 → termish-0.2.2}/tests/test_fs_conformance.py +0 -0
  57. {termish-0.2.1 → termish-0.2.2}/tests/test_grep.py +0 -0
  58. {termish-0.2.1 → termish-0.2.2}/tests/test_heredoc.py +0 -0
  59. {termish-0.2.1 → termish-0.2.2}/tests/test_interpreter.py +0 -0
  60. {termish-0.2.1 → termish-0.2.2}/tests/test_jq.py +0 -0
  61. {termish-0.2.1 → termish-0.2.2}/tests/test_memory_fs.py +0 -0
  62. {termish-0.2.1 → termish-0.2.2}/tests/test_monkeyfs_compat.py +0 -0
  63. {termish-0.2.1 → termish-0.2.2}/tests/test_parser.py +0 -0
  64. {termish-0.2.1 → termish-0.2.2}/tests/test_printf.py +0 -0
  65. {termish-0.2.1 → termish-0.2.2}/tests/test_quote_masker.py +0 -0
  66. {termish-0.2.1 → termish-0.2.2}/tests/test_sed.py +0 -0
  67. {termish-0.2.1 → termish-0.2.2}/tests/test_stderr.py +0 -0
  68. {termish-0.2.1 → 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.1
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "termish"
3
- version = "0.2.1"
3
+ version = "0.2.2"
4
4
  description = "Virtual terminal with shell-like commands over a pluggable filesystem."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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
- for i, path in enumerate(parsed.paths):
95
- if len(parsed.paths) > 1:
96
- stdout.write(f"{path}:\n")
97
-
98
- try:
99
- # -d: list directories themselves, not their contents
100
- if parsed.directory and fs.isdir(path):
101
- if parsed.l:
102
- meta = fs.stat(path)
103
- if parsed.human_readable:
104
- size = _human_readable_size(meta.size).rjust(6)
105
- else:
106
- size = str(meta.size).rjust(8)
107
- time = (
108
- meta.modified_at[:16].replace("T", " ")
109
- if meta.modified_at
110
- else " " * 16
111
- )
112
- stdout.write(f"drw-r--r-- 1 agent agent {size} {time} {path}\n")
113
- else:
114
- stdout.write(f"{path}\n")
115
- continue
116
-
117
- # Check if it is a file first
118
- if fs.isfile(path):
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(meta.size).rjust(6)
121
+ size = _human_readable_size(item.size).rjust(6)
124
122
  else:
125
- size = str(meta.size).rjust(8)
123
+ size = str(item.size).rjust(8)
126
124
  time = (
127
- meta.modified_at[:16].replace("T", " ")
128
- if meta.modified_at
125
+ item.modified_at[:16].replace("T", " ")
126
+ if item.modified_at
129
127
  else " " * 16
130
128
  )
131
- stdout.write(f"-rw-r--r-- 1 agent agent {size} {time} {path}\n")
132
- else:
133
- stdout.write(f"{path}\n")
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
- p
177
- for p in items_str
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
- except FileNotFoundError:
186
- raise TerminalError(
187
- f"ls: cannot access '{path}': No such file or directory"
188
- )
189
- except NotADirectoryError:
190
- raise TerminalError(f"ls: cannot access '{path}': Not a directory")
191
- except Exception as e:
192
- raise TerminalError(f"ls: {e}")
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
- if i < len(parsed.paths) - 1:
195
- stdout.write("\n")
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 "*" in masked or "?" in masked:
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 = fs.glob(masked)
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
- lexer.wordchars += ":@,%+!^$?{}"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: termish
3
- Version: 0.2.1
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
@@ -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
@@ -1,2 +1,3 @@
1
1
  dist
2
+ scratch
2
3
  termish
@@ -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
- assert sorted(out.split()) == ["/a.txt", "/b.txt"]
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