Skip to content

Commit fe21e39

Browse files
committed
Fully expand $include’d files before filtering through program
This is what the user is likely to expect, and means for example that a converter (say, from Markdown to HTML) is run after its input is expanded. The output is still expanded too, in case the program has put any commands in its output.
1 parent 37d0867 commit fe21e39

File tree

8 files changed

+131
-79
lines changed

8 files changed

+131
-79
lines changed

Cookbook.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ Page contents.
175175
176176
--
177177
178-
Last updated: 2016-10-12
178+
Last updated: $paste(python,-c,import datetime; print(datetime.datetime(2016\,10\,12).strftime('%Y-%m-%d')))
179179
```
180180

181181
## Dynamically naming output files and directories according

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ $paste(date,+%Y-%m-%d)
194194
See the [date example](Cookbook.md#date-example) in the Cookbook for more
195195
detail.
196196

197+
When `$include` runs a program, any input is fully expanded before being
198+
passed to the program.
199+
197200
When commands that run programs are nested inside each other, the order in
198201
which they are run may matter. Nancy only guarantees that if one command is
199202
nested inside another, the inner command will be processed first. This means that if, for example, `$realpath` is passed as an argument to a program, the program will be given the actual path, rather than the string `$realpath`.

README.nancy.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ For example, to insert the current date:
179179
See the [date example](Cookbook.md#date-example) in the Cookbook for more
180180
detail.
181181

182+
When `\$include` runs a program, any input is fully expanded before being
183+
passed to the program. The program’s output is again expanded, in case it has inserted any commands in its output.
184+
182185
When commands that run programs are nested inside each other, the order in
183186
which they are run may matter. Nancy only guarantees that if one command is
184187
nested inside another, the inner command will be processed first. This means that if, for example, `\$realpath` is passed as an argument to a program, the program will be given the actual path, rather than the string `\$realpath`.

nancy/__init__.py

Lines changed: 102 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -142,18 +142,6 @@ def find_on_path(start_path: Path, file: Path) -> Optional[Path]:
142142
start_path = next_path
143143
return None
144144

145-
def filter_bytes(
146-
program: Path, args: list[bytes], input: bytes
147-
) -> bytes:
148-
debug(f"Running {program} {b' '.join(args)}")
149-
res = subprocess.run(
150-
[program.resolve(strict=True)] + args,
151-
capture_output=True,
152-
check=True,
153-
input=input,
154-
)
155-
return res.stdout
156-
157145
# Set up macros
158146
macros: dict[bytes, Callable[..., bytes]] = {}
159147
macros[b"path"] = lambda _args, _external_args: bytes(base_file)
@@ -164,65 +152,117 @@ def filter_bytes(
164152
else b""
165153
)
166154

167-
# Find the given file and read it, optionally filtering it
168-
# through a command, and return the output, with the file
169-
# name actually read, so as to exclude it from recursive
170-
# expansion.
171-
def read_filtered_file(
172-
command_name: str,
173-
args: list[bytes],
174-
external_args: Optional[list[bytes]],
155+
# Try to find the given file. If it is found, return its
156+
# contents, with the file name actually read, so as to
157+
# exclude it from recursive expansion; otherwise, return
158+
# `None` and an empty bytes.
159+
def read_file(
160+
basename: Path,
175161
) -> tuple[Optional[Path], bytes]:
176-
debug(f"${command_name}{{{b','.join(args)}}}")
177-
if len(args) < 1 and (
178-
external_args is None or len(external_args) < 1
179-
):
162+
file = find_on_path(base_file.parent, basename)
163+
if file is None:
180164
raise ValueError(
181-
f"${command_name} expects at least one argument"
182-
)
183-
file = None
184-
output = b""
185-
if len(args) > 0:
186-
basename = os.fsdecode(args[0])
187-
file = find_on_path(base_file.parent, Path(basename))
188-
if file is None:
189-
raise ValueError(
190-
f"cannot find '{basename}' while expanding '{base_file.parent}'"
191-
)
192-
with open(file, "rb") as fh:
193-
output = fh.read()
194-
if external_args is not None:
195-
exe_name = Path(os.fsdecode(external_args[0]))
196-
exe_path = find_on_path(base_file.parent, exe_name)
197-
if exe_path is None:
198-
exe_path_str = shutil.which(exe_name)
199-
if exe_path_str is None:
200-
raise ValueError(f"cannot find program '{exe_name}'")
201-
exe_path = Path(exe_path_str)
202-
output = filter_bytes(
203-
exe_path.resolve(strict=True),
204-
external_args[1:],
205-
output,
165+
f"cannot find '{basename}' while expanding '{base_file.parent}'"
206166
)
167+
with open(file, "rb") as fh:
168+
output = fh.read()
207169
return (file, output)
208170

171+
def filter_bytes(
172+
input: bytes,
173+
external_args: list[bytes],
174+
):
175+
exe_name = Path(os.fsdecode(external_args[0]))
176+
exe_path = find_on_path(base_file.parent, exe_name)
177+
if exe_path is None:
178+
exe_path_str = shutil.which(exe_name)
179+
if exe_path_str is None:
180+
raise ValueError(f"cannot find program '{exe_name}'")
181+
exe_path = Path(exe_path_str)
182+
exe_args = external_args[1:]
183+
debug(f"Running {exe_path} {b' '.join(exe_args)}")
184+
res = subprocess.run(
185+
[exe_path.resolve(strict=True)] + exe_args,
186+
capture_output=True,
187+
check=True,
188+
input=input,
189+
)
190+
return res.stdout
191+
192+
def command_to_str(
193+
name: bytes,
194+
external_args: Optional[list[bytes]],
195+
args: Optional[list[bytes]],
196+
):
197+
external_args_string = (
198+
b"(" + b",".join(external_args) + b")"
199+
if external_args is not None
200+
else b""
201+
)
202+
args_string = (
203+
b"{" + b",".join(args) + b"}" if args is not None else b""
204+
)
205+
return b"$" + name + external_args_string + args_string
206+
207+
def check_file_command_args(
208+
command_name: bytes,
209+
args: Optional[list[bytes]],
210+
external_args: Optional[list[bytes]],
211+
):
212+
command_name_str = command_name.decode("iso-8859-1")
213+
if args is None and external_args is None:
214+
raise ValueError(
215+
f"${command_name_str} needs arguments or external arguments"
216+
)
217+
if args is not None and len(args) != 1:
218+
raise ValueError(
219+
f"${command_name_str} needs exactly one argument"
220+
)
221+
debug(command_to_str(command_name, external_args, args))
222+
223+
def maybe_file_arg(
224+
args: Optional[list[bytes]],
225+
) -> tuple[Optional[Path], bytes]:
226+
file = None
227+
contents = b""
228+
if args is not None:
229+
basename = Path(os.fsdecode(args[0]))
230+
file, contents = read_file(basename)
231+
return (file, contents)
232+
233+
def maybe_filter_bytes(
234+
input: bytes, external_args: Optional[list[bytes]]
235+
) -> bytes:
236+
return (
237+
filter_bytes(input, external_args)
238+
if external_args is not None
239+
else input
240+
)
241+
209242
def include(
210-
args: list[bytes], external_args: Optional[list[bytes]]
243+
args: Optional[list[bytes]], external_args: Optional[list[bytes]]
211244
) -> bytes:
212-
file, contents = read_filtered_file("include", args, external_args)
245+
check_file_command_args(b"include", args, external_args)
246+
file, contents = maybe_file_arg(args)
247+
expanded_contents = inner_expand(
248+
contents, expand_stack + [file] if file is not None else []
249+
)
250+
filtered_contents = maybe_filter_bytes(
251+
expanded_contents, external_args
252+
)
213253
return strip_final_newline(
214-
inner_expand(
215-
contents, expand_stack + [file] if file is not None else []
216-
)
254+
inner_expand(filtered_contents, expand_stack)
217255
)
218256

219257
macros[b"include"] = include
220258

221259
def paste(
222-
args: list[bytes], external_args: Optional[list[bytes]]
260+
args: Optional[list[bytes]], external_args: Optional[list[bytes]]
223261
) -> bytes:
224-
_file, contents = read_filtered_file("paste", args, external_args)
225-
return strip_final_newline(contents)
262+
check_file_command_args(b"paste", args, external_args)
263+
_file, contents = maybe_file_arg(args)
264+
filtered_contents = maybe_filter_bytes(contents, external_args)
265+
return strip_final_newline(filtered_contents)
226266

227267
macros[b"paste"] = paste
228268

@@ -238,11 +278,11 @@ def expand_args(args: list[bytes]) -> list[bytes]:
238278

239279
def do_macro(
240280
macro: bytes,
241-
args: list[bytes],
281+
args: Optional[list[bytes]],
242282
external_args: Optional[list[bytes]],
243283
) -> bytes:
244-
debug(f"do_macro {macro} {args} {external_args}")
245-
expanded_args = expand_args(args)
284+
debug(f"do_macro {command_to_str(macro, external_args, args)}")
285+
expanded_args = expand_args(args) if args is not None else None
246286
expanded_external_args = (
247287
expand_args(external_args)
248288
if external_args is not None
@@ -264,7 +304,7 @@ def do_macro(
264304
escaped = res[1]
265305
name = res[2]
266306
startpos = res.end()
267-
args = []
307+
args = None
268308
external_args = None
269309
# Parse external program arguments
270310
if startpos < len(expanded) and expanded[startpos] == ord(b"("):
@@ -275,18 +315,7 @@ def do_macro(
275315
args, startpos = parse_arguments(expanded, startpos, ord("}"))
276316
if escaped != b"":
277317
# Just remove the leading '\'
278-
external_args_string = (
279-
b"(" + b",".join(external_args) + b")"
280-
if external_args is not None
281-
else b""
282-
)
283-
args_string = b"{" + b",".join(args) + b"}"
284-
output = (
285-
b"$"
286-
+ name
287-
+ external_args_string
288-
+ (args_string if len(args) > 0 else b"")
289-
)
318+
output = command_to_str(name, external_args, args)
290319
else:
291320
output = do_macro(name, args, external_args)
292321
expanded = expanded[: res.start()] + output + expanded[startpos:]

tests/test-files/foo.nancy.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
$include(foo)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
$paste()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
$paste{a,b}

tests/test_nancy.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ def test_failing_executable_test() -> None:
7474
)
7575

7676

77+
def test_non_existent_executable_test() -> None:
78+
with chdir(tests_dir):
79+
failing_test(
80+
[os.getcwd()], "cannot find program 'foo'", "foo.nancy.txt"
81+
)
82+
83+
7784
def test_passing_executable_test() -> None:
7885
with chdir(tests_dir):
7986
passing_test([os.getcwd()], "true-expected.txt", "true.nancy.txt")
@@ -107,7 +114,7 @@ def test_include_with_no_arguments_gives_an_error() -> None:
107114
with chdir(tests_dir):
108115
failing_test(
109116
[os.getcwd()],
110-
"$include expects at least one argument",
117+
"$include needs arguments or external arguments",
111118
"include-no-arg.nancy.txt",
112119
)
113120

@@ -116,11 +123,20 @@ def test_paste_with_no_arguments_gives_an_error() -> None:
116123
with chdir(tests_dir):
117124
failing_test(
118125
[os.getcwd()],
119-
"$paste expects at least one argument",
126+
"$paste needs arguments or external arguments",
120127
"paste-no-arg.nancy.txt",
121128
)
122129

123130

131+
def test_paste_with_too_many_arguments_gives_an_error() -> None:
132+
with chdir(tests_dir):
133+
failing_test(
134+
[os.getcwd()],
135+
"$paste needs exactly one argument",
136+
"paste-too-many-args.nancy.txt",
137+
)
138+
139+
124140
def test_escaping_a_macro_without_arguments() -> None:
125141
with chdir(tests_dir):
126142
passing_test(["escaped-path-src"], "escaped-path-expected")
@@ -213,9 +229,7 @@ def test_a_macro_call_with_mismatched_heterogeneous_brackets_causes_correct_erro
213229
None
214230
):
215231
with chdir(tests_dir):
216-
failing_test(
217-
[os.getcwd()], "missing )", "missing-close-paren.nancy.txt"
218-
)
232+
failing_test([os.getcwd()], "missing )", "missing-close-paren.nancy.txt")
219233

220234

221235
def test_trying_to_output_multiple_files_to_stdout_causes_an_error() -> None:

0 commit comments

Comments
 (0)