mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/tools: show 1-based line range on read_file results
This commit is contained in:
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from plyngent.agent import ToolTag, tool
|
from plyngent.agent import ToolTag, tool
|
||||||
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
|
from plyngent.agent.budget import DEFAULT_TOOL_RESULT_MAX_CHARS
|
||||||
from plyngent.tools.file.read import read_file
|
from plyngent.tools.file.read import line_range_label, read_raw_text
|
||||||
from plyngent.tools.net.fetch import fetch
|
from plyngent.tools.net.fetch import fetch
|
||||||
from plyngent.tools.truncate_token import decode_truncate_token, truncate_with_token
|
from plyngent.tools.truncate_token import decode_truncate_token, truncate_with_token
|
||||||
|
|
||||||
@@ -16,16 +16,17 @@ async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_
|
|||||||
Pass the token shown at the end of a truncated ``read_file``, ``fetch``, or
|
Pass the token shown at the end of a truncated ``read_file``, ``fetch``, or
|
||||||
earlier ``get_truncated`` result to keep reading without re-requesting the
|
earlier ``get_truncated`` result to keep reading without re-requesting the
|
||||||
whole source or raising limits. Truncated output carries a fresh token, so
|
whole source or raising limits. Truncated output carries a fresh token, so
|
||||||
chunks chain indefinitely.
|
chunks chain indefinitely. File chunks start with a 1-based ``L{begin}-{end}``
|
||||||
|
line range like ``read_file``.
|
||||||
"""
|
"""
|
||||||
parsed = decode_truncate_token(token)
|
parsed = decode_truncate_token(token)
|
||||||
if parsed is None:
|
if parsed is None:
|
||||||
return "error: invalid truncate token"
|
return "error: invalid truncate token"
|
||||||
if parsed.kind == "http":
|
if parsed.kind == "http":
|
||||||
return await fetch.handler(parsed.location, offset=parsed.offset, max_chars=parsed.limit)
|
return await fetch.handler(parsed.location, offset=parsed.offset, max_chars=parsed.limit)
|
||||||
text = await read_file.handler(parsed.location)
|
text = read_raw_text(parsed.location)
|
||||||
if text.startswith("error:"):
|
if text is None:
|
||||||
return text
|
return f"error: not a file: {parsed.location}"
|
||||||
start = parsed.offset
|
start = parsed.offset
|
||||||
segment = text[start : start + max_chars]
|
segment = text[start : start + max_chars]
|
||||||
chunk, _ = truncate_with_token(
|
chunk, _ = truncate_with_token(
|
||||||
@@ -37,4 +38,6 @@ async def get_truncated(token: str, *, max_chars: int = DEFAULT_TOOL_RESULT_MAX_
|
|||||||
limit=max_chars,
|
limit=max_chars,
|
||||||
total_len=len(text),
|
total_len=len(text),
|
||||||
)
|
)
|
||||||
return chunk
|
content_len = len(chunk.split("\n...[", 1)[0]) if "\n...[" in chunk else len(chunk)
|
||||||
|
header = line_range_label(text, start, start + content_len)
|
||||||
|
return f"{header}\n{chunk}"
|
||||||
|
|||||||
@@ -18,6 +18,31 @@ def _format_with_lineno(lines: list[str], *, start_lineno: int) -> str:
|
|||||||
return "".join(out)
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def line_range_label(text: str, start_char: int, end_char: int) -> str:
|
||||||
|
"""1-based inclusive line range covering ``text[start_char:end_char]``.
|
||||||
|
|
||||||
|
Char offsets do not map 1:1 to lines; this converts a contiguous char range
|
||||||
|
into the 1-based file line numbers it spans (offset is 0-based, so line 1
|
||||||
|
starts at char 0).
|
||||||
|
"""
|
||||||
|
begin = text.count("\n", 0, start_char) + 1
|
||||||
|
last = max(start_char, end_char - 1)
|
||||||
|
end = text.count("\n", 0, last) + 1
|
||||||
|
return f"L{begin}-{end}"
|
||||||
|
|
||||||
|
|
||||||
|
def read_raw_text(path: str) -> str | None:
|
||||||
|
"""Full raw text of a workspace file; None when the path is not a regular file.
|
||||||
|
|
||||||
|
Shared by ``read_file`` and ``get_truncated`` so truncate-token char offsets
|
||||||
|
always refer to the raw file text (never the ``L{begin}-{end}`` header).
|
||||||
|
"""
|
||||||
|
target = resolve_path(path)
|
||||||
|
if not target.is_file():
|
||||||
|
return None
|
||||||
|
return target.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
@tool(tags=ToolTag.LOCAL | ToolTag.INSTANCE_STATE)
|
||||||
async def read_file(
|
async def read_file(
|
||||||
path: str,
|
path: str,
|
||||||
@@ -29,18 +54,21 @@ async def read_file(
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Read a text file under the workspace.
|
"""Read a text file under the workspace.
|
||||||
|
|
||||||
``offset`` is 0-based line start; ``limit`` is max lines (None = rest of file).
|
``offset`` is 0-based line start (0 = first line); ``limit`` is max lines
|
||||||
When ``with_lineno`` is true, each line is prefixed with its 1-based file line
|
(None = rest of file). When ``with_lineno`` is true, each line is prefixed
|
||||||
number (`` N|…``), matching ``edit_lineno`` numbering, and those lines are
|
with its 1-based file line number (`` N|…``), matching ``edit_lineno``
|
||||||
marked readable for ``edit_lineno`` this turn.
|
numbering, and those lines are marked readable for ``edit_lineno`` this turn.
|
||||||
|
|
||||||
``max_chars`` caps the returned slice; a ``TRUNCATE_TOKEN`` is appended so
|
``max_chars`` caps the returned slice; a ``TRUNCATE_TOKEN`` is appended so
|
||||||
``get_truncated`` can continue reading the rest without a new request.
|
``get_truncated`` can continue reading the rest without a new request.
|
||||||
|
|
||||||
|
The result starts with a 1-based inclusive line range ``L{begin}-{end}`` so
|
||||||
|
the caller knows exactly which file lines were read (offset is 0-based).
|
||||||
"""
|
"""
|
||||||
target = resolve_path(path)
|
target = resolve_path(path)
|
||||||
if not target.is_file():
|
text = read_raw_text(path)
|
||||||
|
if text is None:
|
||||||
return f"error: not a file: {path}"
|
return f"error: not a file: {path}"
|
||||||
text = target.read_text(encoding="utf-8", errors="replace")
|
|
||||||
lines = text.splitlines(keepends=True)
|
lines = text.splitlines(keepends=True)
|
||||||
if offset < 0:
|
if offset < 0:
|
||||||
return "error: offset must be >= 0"
|
return "error: offset must be >= 0"
|
||||||
@@ -51,13 +79,13 @@ async def read_file(
|
|||||||
slice_lines = lines[start:end]
|
slice_lines = lines[start:end]
|
||||||
if with_lineno:
|
if with_lineno:
|
||||||
mark_lineno_read(str(target), set(range(start + 1, end + 1)))
|
mark_lineno_read(str(target), set(range(start + 1, end + 1)))
|
||||||
out = _format_with_lineno(slice_lines, start_lineno=start + 1)
|
body = _format_with_lineno(slice_lines, start_lineno=start + 1)
|
||||||
else:
|
else:
|
||||||
out = "".join(slice_lines)
|
body = "".join(slice_lines)
|
||||||
if max_chars is not None and max_chars >= 1:
|
if max_chars is not None and max_chars >= 1:
|
||||||
char_start = len("".join(lines[:start]))
|
char_start = len("".join(lines[:start]))
|
||||||
out, _ = truncate_with_token(
|
body, _ = truncate_with_token(
|
||||||
out,
|
body,
|
||||||
max_chars,
|
max_chars,
|
||||||
kind="file",
|
kind="file",
|
||||||
location=path, # arg form: short token, resolves to the same file
|
location=path, # arg form: short token, resolves to the same file
|
||||||
@@ -65,4 +93,10 @@ async def read_file(
|
|||||||
limit=max_chars,
|
limit=max_chars,
|
||||||
total_len=len(text),
|
total_len=len(text),
|
||||||
)
|
)
|
||||||
return out
|
if not body:
|
||||||
|
return ""
|
||||||
|
if with_lineno:
|
||||||
|
return body # per-line numbers already show the range
|
||||||
|
begin = start + 1
|
||||||
|
end_line = start + len(slice_lines)
|
||||||
|
return f"L{begin}-{end_line}\n{body}"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ def test_edit_lineno_replace_middle(workspace: object) -> None:
|
|||||||
_ = _read_lineno("a.txt")
|
_ = _read_lineno("a.txt")
|
||||||
out = call_sync(edit_lineno, "a.txt", 2, 3, "TWO\nTHREE\n")
|
out = call_sync(edit_lineno, "a.txt", 2, 3, "TWO\nTHREE\n")
|
||||||
assert "replaced lines 2-3" in out
|
assert "replaced lines 2-3" in out
|
||||||
assert call_sync(read_file, "a.txt") == "one\nTWO\nTHREE\nfour\n"
|
assert call_sync(read_file, "a.txt") == "L1-4\none\nTWO\nTHREE\nfour\n"
|
||||||
|
|
||||||
|
|
||||||
def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
|
def test_edit_lineno_requires_all_lines_read(workspace: object) -> None:
|
||||||
@@ -49,7 +49,7 @@ def test_edit_lineno_append_requires_last_line_read(workspace: object) -> None:
|
|||||||
_ = call_sync(read_file, "ap.txt", offset=2, limit=1, with_lineno=True)
|
_ = call_sync(read_file, "ap.txt", offset=2, limit=1, with_lineno=True)
|
||||||
out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")
|
out = call_sync(edit_lineno, "ap.txt", 4, 4, "d\n")
|
||||||
assert "appended" in out
|
assert "appended" in out
|
||||||
assert call_sync(read_file, "ap.txt") == "a\nb\nc\nd\n"
|
assert call_sync(read_file, "ap.txt") == "L1-4\na\nb\nc\nd\n"
|
||||||
|
|
||||||
|
|
||||||
def test_edit_lineno_delete_range(workspace: object) -> None:
|
def test_edit_lineno_delete_range(workspace: object) -> None:
|
||||||
@@ -58,7 +58,7 @@ def test_edit_lineno_delete_range(workspace: object) -> None:
|
|||||||
_ = _read_lineno("b.txt")
|
_ = _read_lineno("b.txt")
|
||||||
out = call_sync(edit_lineno, "b.txt", 2, 2, "")
|
out = call_sync(edit_lineno, "b.txt", 2, 2, "")
|
||||||
assert "replaced" in out
|
assert "replaced" in out
|
||||||
assert call_sync(read_file, "b.txt") == "a\nc\n"
|
assert call_sync(read_file, "b.txt") == "L1-2\na\nc\n"
|
||||||
|
|
||||||
|
|
||||||
def test_edit_lineno_append(workspace: object) -> None:
|
def test_edit_lineno_append(workspace: object) -> None:
|
||||||
@@ -67,7 +67,7 @@ def test_edit_lineno_append(workspace: object) -> None:
|
|||||||
_ = _read_lineno("c.txt")
|
_ = _read_lineno("c.txt")
|
||||||
out = call_sync(edit_lineno, "c.txt", 2, 2, "more\n")
|
out = call_sync(edit_lineno, "c.txt", 2, 2, "more\n")
|
||||||
assert "appended" in out
|
assert "appended" in out
|
||||||
assert call_sync(read_file, "c.txt") == "only\nmore\n"
|
assert call_sync(read_file, "c.txt") == "L1-2\nonly\nmore\n"
|
||||||
|
|
||||||
|
|
||||||
def test_edit_lineno_invalid(workspace: object) -> None:
|
def test_edit_lineno_invalid(workspace: object) -> None:
|
||||||
|
|||||||
@@ -19,19 +19,21 @@ def _extract_token(out: str) -> str | None:
|
|||||||
def test_write_read_listdir_edit(workspace: object) -> None:
|
def test_write_read_listdir_edit(workspace: object) -> None:
|
||||||
del workspace
|
del workspace
|
||||||
assert "wrote" in call_sync(write_file, "notes/a.txt", "hello world")
|
assert "wrote" in call_sync(write_file, "notes/a.txt", "hello world")
|
||||||
assert call_sync(read_file, "notes/a.txt") == "hello world"
|
assert call_sync(read_file, "notes/a.txt") == "L1-1\nhello world"
|
||||||
listing = call_sync(listdir, "notes")
|
listing = call_sync(listdir, "notes")
|
||||||
assert "a.txt" in listing
|
assert "a.txt" in listing
|
||||||
assert "file" in listing
|
assert "file" in listing
|
||||||
result = call_sync(edit_replace, "notes/a.txt", "world", "there")
|
result = call_sync(edit_replace, "notes/a.txt", "world", "there")
|
||||||
assert "replaced" in result
|
assert "replaced" in result
|
||||||
assert call_sync(read_file, "notes/a.txt") == "hello there"
|
assert call_sync(read_file, "notes/a.txt") == "L1-1\nhello there"
|
||||||
|
|
||||||
|
|
||||||
def test_read_file_max_chars_embeds_token(workspace: Path) -> None:
|
def test_read_file_max_chars_embeds_token(workspace: Path) -> None:
|
||||||
(workspace / "big.txt").write_text("a" * 1000, encoding="utf-8")
|
(workspace / "big.txt").write_text("a" * 1000, encoding="utf-8")
|
||||||
out = call_sync(read_file, "big.txt", max_chars=200)
|
out = call_sync(read_file, "big.txt", max_chars=200)
|
||||||
content, _, marker = out.partition("\n...[")
|
header, _, rest = out.partition("\n")
|
||||||
|
assert header.startswith("L1-") # 1-based range of the raw slice read
|
||||||
|
content, _, marker = rest.partition("\n...[")
|
||||||
assert content == "a" * len(content)
|
assert content == "a" * len(content)
|
||||||
assert len(content) < 200
|
assert len(content) < 200
|
||||||
assert "TRUNCATE_TOKEN:" in marker
|
assert "TRUNCATE_TOKEN:" in marker
|
||||||
@@ -46,7 +48,7 @@ def test_read_file_max_chars_embeds_token(workspace: Path) -> None:
|
|||||||
def test_read_file_max_chars_small_file_untouched(workspace: Path) -> None:
|
def test_read_file_max_chars_small_file_untouched(workspace: Path) -> None:
|
||||||
(workspace / "small.txt").write_text("tiny", encoding="utf-8")
|
(workspace / "small.txt").write_text("tiny", encoding="utf-8")
|
||||||
out = call_sync(read_file, "small.txt", max_chars=200)
|
out = call_sync(read_file, "small.txt", max_chars=200)
|
||||||
assert out == "tiny"
|
assert out == "L1-1\ntiny"
|
||||||
assert "TRUNCATE_TOKEN:" not in out
|
assert "TRUNCATE_TOKEN:" not in out
|
||||||
|
|
||||||
|
|
||||||
@@ -54,7 +56,7 @@ def test_edit_replace_first_only(workspace: object) -> None:
|
|||||||
del workspace
|
del workspace
|
||||||
_ = call_sync(write_file, "t.txt", "aa aa")
|
_ = call_sync(write_file, "t.txt", "aa aa")
|
||||||
result = call_sync(edit_replace, "t.txt", "aa", "bb")
|
result = call_sync(edit_replace, "t.txt", "aa", "bb")
|
||||||
assert call_sync(read_file, "t.txt") == "bb aa"
|
assert call_sync(read_file, "t.txt") == "L1-1\nbb aa"
|
||||||
assert "1 of 2" in result or "1 of 2 matches" in result
|
assert "1 of 2" in result or "1 of 2 matches" in result
|
||||||
assert "remain" in result
|
assert "remain" in result
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@ def test_edit_replace_max_replaces(workspace: object) -> None:
|
|||||||
del workspace
|
del workspace
|
||||||
_ = call_sync(write_file, "t.txt", "aa aa aa")
|
_ = call_sync(write_file, "t.txt", "aa aa aa")
|
||||||
result = call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=2)
|
result = call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=2)
|
||||||
assert call_sync(read_file, "t.txt") == "bb bb aa"
|
assert call_sync(read_file, "t.txt") == "L1-1\nbb bb aa"
|
||||||
assert "2 of 3" in result
|
assert "2 of 3" in result
|
||||||
assert "1 remain" in result
|
assert "1 remain" in result
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ def test_edit_replace_all_matches(workspace: object) -> None:
|
|||||||
del workspace
|
del workspace
|
||||||
_ = call_sync(write_file, "t.txt", "aa aa")
|
_ = call_sync(write_file, "t.txt", "aa aa")
|
||||||
result = call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=10)
|
result = call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=10)
|
||||||
assert call_sync(read_file, "t.txt") == "bb bb"
|
assert call_sync(read_file, "t.txt") == "L1-1\nbb bb"
|
||||||
assert "all 2 matches" in result
|
assert "all 2 matches" in result
|
||||||
|
|
||||||
|
|
||||||
@@ -91,7 +93,8 @@ def test_edit_missing_old_string(workspace: object) -> None:
|
|||||||
def test_read_offset_limit(workspace: object) -> None:
|
def test_read_offset_limit(workspace: object) -> None:
|
||||||
del workspace
|
del workspace
|
||||||
_ = call_sync(write_file, "lines.txt", "a\nb\nc\nd\n")
|
_ = call_sync(write_file, "lines.txt", "a\nb\nc\nd\n")
|
||||||
assert call_sync(read_file, "lines.txt", offset=1, limit=2) == "b\nc\n"
|
# offset is 0-based: offset=1 skips line 1, so the read starts at 1-based line 2.
|
||||||
|
assert call_sync(read_file, "lines.txt", offset=1, limit=2) == "L2-3\nb\nc\n"
|
||||||
|
|
||||||
|
|
||||||
def test_read_with_lineno(workspace: object) -> None:
|
def test_read_with_lineno(workspace: object) -> None:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ def test_copy_file(workspace: object) -> None:
|
|||||||
_ = call_sync(write_file, "a.txt", "hello")
|
_ = call_sync(write_file, "a.txt", "hello")
|
||||||
out = call_sync(copy_path, "a.txt", "b.txt")
|
out = call_sync(copy_path, "a.txt", "b.txt")
|
||||||
assert "copied" in out
|
assert "copied" in out
|
||||||
assert call_sync(read_file, "b.txt") == "hello"
|
assert call_sync(read_file, "b.txt") == "L1-1\nhello"
|
||||||
assert (workspace / "a.txt").is_file()
|
assert (workspace / "a.txt").is_file()
|
||||||
|
|
||||||
|
|
||||||
@@ -21,8 +21,8 @@ def test_copy_directory(workspace: object) -> None:
|
|||||||
_ = call_sync(write_file, "src/nested/y.txt", "y")
|
_ = call_sync(write_file, "src/nested/y.txt", "y")
|
||||||
out = call_sync(copy_path, "src", "dst")
|
out = call_sync(copy_path, "src", "dst")
|
||||||
assert "directory" in out
|
assert "directory" in out
|
||||||
assert call_sync(read_file, "dst/x.txt") == "x"
|
assert call_sync(read_file, "dst/x.txt") == "L1-1\nx"
|
||||||
assert call_sync(read_file, "dst/nested/y.txt") == "y"
|
assert call_sync(read_file, "dst/nested/y.txt") == "L1-1\ny"
|
||||||
|
|
||||||
|
|
||||||
def test_copy_overwrite(workspace: object) -> None:
|
def test_copy_overwrite(workspace: object) -> None:
|
||||||
@@ -32,7 +32,7 @@ def test_copy_overwrite(workspace: object) -> None:
|
|||||||
assert "exists" in call_sync(copy_path, "b.txt", "a.txt")
|
assert "exists" in call_sync(copy_path, "b.txt", "a.txt")
|
||||||
out = call_sync(copy_path, "b.txt", "a.txt", overwrite=True)
|
out = call_sync(copy_path, "b.txt", "a.txt", overwrite=True)
|
||||||
assert "copied" in out
|
assert "copied" in out
|
||||||
assert call_sync(read_file, "a.txt") == "new"
|
assert call_sync(read_file, "a.txt") == "L1-1\nnew"
|
||||||
|
|
||||||
|
|
||||||
def test_move_file(workspace: object) -> None:
|
def test_move_file(workspace: object) -> None:
|
||||||
@@ -41,7 +41,7 @@ def test_move_file(workspace: object) -> None:
|
|||||||
out = call_sync(move_path, "old.txt", "renamed.txt")
|
out = call_sync(move_path, "old.txt", "renamed.txt")
|
||||||
assert "moved" in out
|
assert "moved" in out
|
||||||
assert not (workspace / "old.txt").exists()
|
assert not (workspace / "old.txt").exists()
|
||||||
assert call_sync(read_file, "renamed.txt") == "data"
|
assert call_sync(read_file, "renamed.txt") == "L1-1\ndata"
|
||||||
|
|
||||||
|
|
||||||
def test_move_directory(workspace: object) -> None:
|
def test_move_directory(workspace: object) -> None:
|
||||||
@@ -50,7 +50,7 @@ def test_move_directory(workspace: object) -> None:
|
|||||||
out = call_sync(move_path, "d", "e")
|
out = call_sync(move_path, "d", "e")
|
||||||
assert "moved" in out
|
assert "moved" in out
|
||||||
assert not (workspace / "d").exists()
|
assert not (workspace / "d").exists()
|
||||||
assert call_sync(read_file, "e/a.txt") == "a"
|
assert call_sync(read_file, "e/a.txt") == "L1-1\na"
|
||||||
|
|
||||||
|
|
||||||
def test_delete_file(workspace: object) -> None:
|
def test_delete_file(workspace: object) -> None:
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ async def test_get_truncated_file_chains_without_gap(workspace: Path) -> None:
|
|||||||
total_read = 0
|
total_read = 0
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
out = await call_async(get_truncated, token, max_chars=200)
|
out = await call_async(get_truncated, token, max_chars=200)
|
||||||
content, _, _ = out.partition("\n...[")
|
body, _, _ = out.partition("\n...[")
|
||||||
|
header, _, content = body.partition("\n")
|
||||||
|
assert header.startswith("L") and "-" in header
|
||||||
assert content == "a" * len(content)
|
assert content == "a" * len(content)
|
||||||
total_read += len(content)
|
total_read += len(content)
|
||||||
next_token = _extract_token(out)
|
next_token = _extract_token(out)
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ def test_new_temporary_workspace_allowlist(workspace: object) -> None:
|
|||||||
target = temp / "scratch.txt"
|
target = temp / "scratch.txt"
|
||||||
_ = target.write_text("hello-temp", encoding="utf-8")
|
_ = target.write_text("hello-temp", encoding="utf-8")
|
||||||
assert resolve_path(str(target)) == target.resolve()
|
assert resolve_path(str(target)) == target.resolve()
|
||||||
assert call_sync(read_file, str(target)) == "hello-temp"
|
assert call_sync(read_file, str(target)) == "L1-1\nhello-temp"
|
||||||
|
|
||||||
_ = call_sync(write_file, "project.txt", "proj")
|
_ = call_sync(write_file, "project.txt", "proj")
|
||||||
assert call_sync(read_file, "project.txt") == "proj"
|
assert call_sync(read_file, "project.txt") == "L1-1\nproj"
|
||||||
|
|
||||||
# Sibling under system temp that we did not allowlist still fails.
|
# Sibling under system temp that we did not allowlist still fails.
|
||||||
outside = temp.parent / "not-ours-should-fail"
|
outside = temp.parent / "not-ours-should-fail"
|
||||||
|
|||||||
Reference in New Issue
Block a user