transcript-viewer 0.7.2__tar.gz → 0.8.1__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.
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/PKG-INFO +1 -1
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/pyproject.toml +1 -1
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/src/transcript_viewer/cli.py +6 -1
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/src/transcript_viewer/corpus.py +30 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/src/transcript_viewer/page.html +94 -4
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/src/transcript_viewer/viewer.py +13 -2
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/page.test.js +100 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/test_cli.py +5 -1
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/test_corpus.py +33 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/uv.lock +1 -1
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/.coverage +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/.github/workflows/publish.yml +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/.github/workflows/test.yml +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/.gitignore +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/README.md +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/src/transcript_viewer/__init__.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/src/transcript_viewer/ai.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/src/transcript_viewer/config.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/src/transcript_viewer/fetch.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/src/transcript_viewer/library.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/src/transcript_viewer/store.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/fixtures/big-command.json +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/test_ai.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/test_config.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/test_fetch.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/test_library.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/test_page.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/test_readme.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/test_store.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/test_stream.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/test_style.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/test_tidiness.py +0 -0
- {transcript_viewer-0.7.2 → transcript_viewer-0.8.1}/tests/test_viewer.py +0 -0
|
@@ -49,7 +49,12 @@ def cmd_view(args: argparse.Namespace) -> int:
|
|
|
49
49
|
# An explicit path that holds nothing is a mistake worth reporting; an
|
|
50
50
|
# empty library is not, so this only guards the argument.
|
|
51
51
|
if not entries:
|
|
52
|
-
print(
|
|
52
|
+
print(
|
|
53
|
+
f"transcript-viewer: no transcript in {path}. "
|
|
54
|
+
f"{corpus.describe_contents(path)}, and none of it is a log "
|
|
55
|
+
f"this can read.",
|
|
56
|
+
file=sys.stderr,
|
|
57
|
+
)
|
|
53
58
|
return 1
|
|
54
59
|
else:
|
|
55
60
|
# Only what has been added deliberately. Scanning a machine because the
|
|
@@ -9,6 +9,7 @@ something actually asks for that trajectory.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import hashlib
|
|
12
|
+
from collections import Counter
|
|
12
13
|
import re
|
|
13
14
|
import json
|
|
14
15
|
from dataclasses import asdict, dataclass
|
|
@@ -367,3 +368,32 @@ def load(path: Path | None = None) -> list[Entry]:
|
|
|
367
368
|
except TypeError:
|
|
368
369
|
continue
|
|
369
370
|
return entries
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def describe_contents(root: Path, limit: int = 4000) -> str:
|
|
374
|
+
"""What is inside a path, said in a sentence.
|
|
375
|
+
|
|
376
|
+
Reached only when nothing in it converted. "Nothing convertible" on its own
|
|
377
|
+
invites the reader to doubt the tool rather than the file — one real archive
|
|
378
|
+
held 1,952 files and an audit that referenced a transcript which had not
|
|
379
|
+
been shipped with it, and the answer gave no way to tell that from a bug.
|
|
380
|
+
"""
|
|
381
|
+
if root.is_file():
|
|
382
|
+
return f"{root.name} ({root.stat().st_size:,} bytes)"
|
|
383
|
+
|
|
384
|
+
kinds: Counter[str] = Counter()
|
|
385
|
+
total = 0
|
|
386
|
+
for item in root.rglob("*"):
|
|
387
|
+
if not item.is_file():
|
|
388
|
+
continue
|
|
389
|
+
total += 1
|
|
390
|
+
if total > limit:
|
|
391
|
+
break
|
|
392
|
+
kinds[item.suffix.lower() or "no extension"] += 1
|
|
393
|
+
|
|
394
|
+
if not total:
|
|
395
|
+
return "it is empty"
|
|
396
|
+
|
|
397
|
+
common = ", ".join(f"{n:,} {ext}" for ext, n in kinds.most_common(4))
|
|
398
|
+
more = "+" if total > limit else ""
|
|
399
|
+
return f"{total:,}{more} files inside — {common}"
|
|
@@ -431,6 +431,26 @@ pre{background:var(--sunk);border-radius:7px;padding:10px;overflow:auto;font-siz
|
|
|
431
431
|
.sh-op{color:var(--sh-op);font-weight:500}
|
|
432
432
|
.sh-var{color:var(--sh-var)}
|
|
433
433
|
.sh-com{color:var(--sh-com);font-style:italic}
|
|
434
|
+
|
|
435
|
+
/* A file, shown as a file. The numbers sit in a gutter and are not selectable,
|
|
436
|
+
so copying the block gives back the file rather than the listing of it. */
|
|
437
|
+
.fpath{font:11.5px/1.5 var(--font-mono);color:var(--muted);margin-bottom:6px;
|
|
438
|
+
overflow-wrap:anywhere}
|
|
439
|
+
.fmeta{font:11px/1.5 var(--font-mono);color:var(--faint);margin-top:6px}
|
|
440
|
+
.filelines,.filebody{background:var(--bg);border-radius:7px;padding:10px 12px;
|
|
441
|
+
margin:0;max-height:340px;overflow:auto;font:12px/1.6 var(--font-mono);
|
|
442
|
+
color:var(--ink);white-space:pre-wrap;overflow-wrap:anywhere}
|
|
443
|
+
.ln{display:inline-block;width:3.2em;margin-right:.8em;text-align:right;
|
|
444
|
+
color:var(--faint);user-select:none;-webkit-user-select:none}
|
|
445
|
+
/* What a change replaced, and what it put there. */
|
|
446
|
+
.epair{display:grid;gap:8px}
|
|
447
|
+
.eside{border-radius:7px;padding:8px 10px;background:var(--bg);
|
|
448
|
+
font:12px/1.6 var(--font-mono);white-space:pre-wrap;overflow-wrap:anywhere;
|
|
449
|
+
max-height:260px;overflow:auto}
|
|
450
|
+
.eside b{display:block;font:11px/1.5 var(--font-mono);font-weight:500;
|
|
451
|
+
margin-bottom:4px;letter-spacing:.04em;text-transform:uppercase}
|
|
452
|
+
.eside.out b{color:var(--danger)}
|
|
453
|
+
.eside.in b{color:var(--ok)}
|
|
434
454
|
/* What the call was for, beside what it was called. */
|
|
435
455
|
.tdesc{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;
|
|
436
456
|
white-space:nowrap;flex:1;min-width:0}
|
|
@@ -889,6 +909,44 @@ function sh(src) {
|
|
|
889
909
|
|
|
890
910
|
const shBlock = (command) => `<pre class="sh">${autolink(sh(command))}</pre>`;
|
|
891
911
|
|
|
912
|
+
/* Where a file is, as something you can open.
|
|
913
|
+
|
|
914
|
+
Linked whole rather than through autolink: this value is a path in its
|
|
915
|
+
entirety, where autolink has to guess boundaries in prose and stops at the
|
|
916
|
+
first space. A real one here — "/Users/…/Redwood Research/…" — linked only
|
|
917
|
+
as far as "Redwood", which pointed the link at a directory that is not the
|
|
918
|
+
file. PATH_EXACT is the same test the JSON view uses for a quoted value. */
|
|
919
|
+
const fileRef = (path) => {
|
|
920
|
+
const text = String(path ?? "");
|
|
921
|
+
if (!text) return "";
|
|
922
|
+
const shown = PATH_EXACT.test(text) ? pathAnchor(text, esc(text)) : esc(text);
|
|
923
|
+
return `<div class="fpath">${shown}</div>`;
|
|
924
|
+
};
|
|
925
|
+
|
|
926
|
+
const countOf = (text) => {
|
|
927
|
+
const s = String(text ?? "");
|
|
928
|
+
const lines = s ? s.split("\n").length : 0;
|
|
929
|
+
return `${num(lines)} line${lines === 1 ? "" : "s"} · ${bytes(s.length)}`;
|
|
930
|
+
};
|
|
931
|
+
|
|
932
|
+
/* A file read back arrives numbered, one "12\tline" per line. Put the numbers
|
|
933
|
+
in a gutter so the file reads as a file, and leave them unselectable so that
|
|
934
|
+
copying gives the contents rather than the listing. */
|
|
935
|
+
function numbered(text) {
|
|
936
|
+
const rows = String(text ?? "")
|
|
937
|
+
.split("\n")
|
|
938
|
+
.map((line) => {
|
|
939
|
+
const m = /^(\s*\d+)\t([\s\S]*)$/.exec(line);
|
|
940
|
+
return m
|
|
941
|
+
? `<span class="ln">${esc(m[1].trim())}</span>${esc(m[2])}`
|
|
942
|
+
: `<span class="ln"></span>${esc(line)}`;
|
|
943
|
+
});
|
|
944
|
+
return `<pre class="filelines">${autolink(rows.join("\n"))}</pre>`;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
const fileBlock = (text) =>
|
|
948
|
+
`<pre class="filebody">${autolink(esc(String(text ?? "")))}</pre>`;
|
|
949
|
+
|
|
892
950
|
/* What a call is worth showing as, when it is not worth showing as JSON. Only
|
|
893
951
|
the shapes that carry a command or an address get a body of their own; the
|
|
894
952
|
rest gain a title, which is the part that was missing everywhere. */
|
|
@@ -910,9 +968,37 @@ const TOOLVIEW = {
|
|
|
910
968
|
},
|
|
911
969
|
WebSearch: { title: (a) => a.query || "" },
|
|
912
970
|
// The end of a path is the part worth reading; the full one is on hover.
|
|
913
|
-
Read: {
|
|
914
|
-
|
|
915
|
-
|
|
971
|
+
Read: {
|
|
972
|
+
title: (a) => short(a.file_path),
|
|
973
|
+
body: (a) =>
|
|
974
|
+
fileRef(a.file_path) +
|
|
975
|
+
(a.offset || a.limit || a.pages
|
|
976
|
+
? `<div class="fmeta">${
|
|
977
|
+
a.pages
|
|
978
|
+
? `pages ${esc(String(a.pages))}`
|
|
979
|
+
: `from line ${num(a.offset || 1)}${a.limit ? `, ${num(a.limit)} lines` : ""}`
|
|
980
|
+
}</div>`
|
|
981
|
+
: ""),
|
|
982
|
+
// What came back is the file, numbered as the reader saw it.
|
|
983
|
+
output: (content) => numbered(content),
|
|
984
|
+
},
|
|
985
|
+
Write: {
|
|
986
|
+
title: (a) => short(a.file_path),
|
|
987
|
+
body: (a) =>
|
|
988
|
+
fileRef(a.file_path) +
|
|
989
|
+
fileBlock(a.content) +
|
|
990
|
+
`<div class="fmeta">${countOf(a.content)}</div>`,
|
|
991
|
+
},
|
|
992
|
+
Edit: {
|
|
993
|
+
title: (a) => short(a.file_path),
|
|
994
|
+
body: (a) =>
|
|
995
|
+
fileRef(a.file_path) +
|
|
996
|
+
`<div class="epair">
|
|
997
|
+
<div class="eside out"><b>replaced</b>${autolink(esc(a.old_string ?? ""))}</div>
|
|
998
|
+
<div class="eside in"><b>with</b>${autolink(esc(a.new_string ?? ""))}</div>
|
|
999
|
+
</div>` +
|
|
1000
|
+
(a.replace_all ? `<div class="fmeta">every occurrence</div>` : ""),
|
|
1001
|
+
},
|
|
916
1002
|
NotebookEdit: { title: (a) => short(a.notebook_path) },
|
|
917
1003
|
Skill: { title: (a) => a.skill || "" },
|
|
918
1004
|
Task: { title: (a) => a.description || "" },
|
|
@@ -943,7 +1029,11 @@ function toolBody(id) {
|
|
|
943
1029
|
`<div class="io in"><span class="iol">call</span>${shown}</div>` +
|
|
944
1030
|
(r && r.content
|
|
945
1031
|
? `<div class="io out"><span class="iol">output</span>${
|
|
946
|
-
typeof r.content
|
|
1032
|
+
typeof r.content !== "string"
|
|
1033
|
+
? body(r.content)
|
|
1034
|
+
: !toolIsRaw(id) && view && view.output
|
|
1035
|
+
? view.output(r.content, c.arguments || {})
|
|
1036
|
+
: pre(r.content)
|
|
947
1037
|
}</div>`
|
|
948
1038
|
: "") +
|
|
949
1039
|
(aiOn() ? callSummary(c.tool_call_id) : "")
|
|
@@ -27,6 +27,7 @@ from urllib.parse import parse_qs, unquote, urlparse
|
|
|
27
27
|
from atif_make.atif import ContentPart, Trajectory
|
|
28
28
|
from . import corpus
|
|
29
29
|
from atif_make.container import is_container, open_container
|
|
30
|
+
from atif_make.detect import FORMATS
|
|
30
31
|
from atif_make.convert import convert
|
|
31
32
|
from transcript_viewer.corpus import Entry, scan
|
|
32
33
|
|
|
@@ -615,9 +616,19 @@ class _Handler(BaseHTTPRequestHandler):
|
|
|
615
616
|
self._json({"error": str(exc)}, 400)
|
|
616
617
|
return
|
|
617
618
|
if not found:
|
|
618
|
-
# Nothing usable in it — do not keep the copy around.
|
|
619
|
+
# Nothing usable in it — do not keep the copy around. Say what was
|
|
620
|
+
# in it on the way out: an answer of "nothing convertible" with
|
|
621
|
+
# nothing behind it reads as the tool failing rather than the file
|
|
622
|
+
# not holding what was hoped.
|
|
623
|
+
inside = corpus.describe_contents(target)
|
|
619
624
|
shutil.rmtree(home, ignore_errors=True)
|
|
620
|
-
self._json(
|
|
625
|
+
self._json(
|
|
626
|
+
{
|
|
627
|
+
"error": f"No transcript in {name}. {inside}, and none of it "
|
|
628
|
+
f"is a log this can read ({', '.join(FORMATS)})."
|
|
629
|
+
},
|
|
630
|
+
415,
|
|
631
|
+
)
|
|
621
632
|
return
|
|
622
633
|
|
|
623
634
|
with self.lock:
|
|
@@ -1673,3 +1673,103 @@ test("a real 20,000-character command draws, and quickly", () => {
|
|
|
1673
1673
|
assert.ok(html.includes("class=\"sh\""), "it should render");
|
|
1674
1674
|
assert.ok(took < 2000, `took ${took}ms, which is a page nobody waits for`);
|
|
1675
1675
|
});
|
|
1676
|
+
|
|
1677
|
+
// ---- a file read, written or changed -----------------------------------------
|
|
1678
|
+
|
|
1679
|
+
test("a file read back keeps its line numbers in a gutter", () => {
|
|
1680
|
+
const html = run(`return TOOLVIEW.Read.output('1\\tfirst\\n2\\tsecond')`);
|
|
1681
|
+
assert.match(html, /class="filelines"/);
|
|
1682
|
+
assert.match(html, /<span class="ln">1<\/span>first/);
|
|
1683
|
+
assert.match(html, /<span class="ln">2<\/span>second/);
|
|
1684
|
+
assert.ok(!html.includes("\t"), "the tab becomes the gutter, not whitespace");
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
test("a line that is not numbered still shows", () => {
|
|
1688
|
+
const html = run(`return TOOLVIEW.Read.output('no number here')`);
|
|
1689
|
+
assert.match(html, /no number here/);
|
|
1690
|
+
});
|
|
1691
|
+
|
|
1692
|
+
test("a read of part of a file says which part", () => {
|
|
1693
|
+
const html = run(`return TOOLVIEW.Read.body({file_path:"/a/b.py",offset:40,limit:120})`);
|
|
1694
|
+
assert.match(html, /from line 40/);
|
|
1695
|
+
assert.match(html, /120 lines/);
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
test("a whole-file read says nothing about ranges", () => {
|
|
1699
|
+
const html = run(`return TOOLVIEW.Read.body({file_path:"/a/b.py"})`);
|
|
1700
|
+
assert.ok(!/from line/.test(html));
|
|
1701
|
+
});
|
|
1702
|
+
|
|
1703
|
+
test("a path in a file view still opens in Finder", () => {
|
|
1704
|
+
const html = run(`return TOOLVIEW.Read.body({file_path:"/Users/someone/a.py"})`);
|
|
1705
|
+
assert.match(html, /api\/reveal\?path=/);
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
test("a write shows what it wrote, and how much", () => {
|
|
1709
|
+
const html = run(
|
|
1710
|
+
`return TOOLVIEW.Write.body({file_path:"/a/b.js",content:"one\\ntwo\\nthree"})`,
|
|
1711
|
+
);
|
|
1712
|
+
assert.match(html, /class="filebody"/);
|
|
1713
|
+
assert.match(html, /three/);
|
|
1714
|
+
assert.match(html, /3 lines/);
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
test("an edit shows what it replaced and what it put there", () => {
|
|
1718
|
+
const html = run(
|
|
1719
|
+
`return TOOLVIEW.Edit.body({file_path:"/a/b.js",old_string:"before",new_string:"after",replace_all:true})`,
|
|
1720
|
+
);
|
|
1721
|
+
assert.match(html, /class="eside out"[^>]*>.*replaced.*before/s);
|
|
1722
|
+
assert.match(html, /class="eside in"[^>]*>.*with.*after/s);
|
|
1723
|
+
assert.match(html, /every occurrence/);
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
test("an edit that replaces once does not claim otherwise", () => {
|
|
1727
|
+
const html = run(
|
|
1728
|
+
`return TOOLVIEW.Edit.body({file_path:"/a/b.js",old_string:"x",new_string:"y",replace_all:false})`,
|
|
1729
|
+
);
|
|
1730
|
+
assert.ok(!/every occurrence/.test(html));
|
|
1731
|
+
});
|
|
1732
|
+
|
|
1733
|
+
test("file contents cannot reach the page as markup", () => {
|
|
1734
|
+
for (const expr of [
|
|
1735
|
+
`TOOLVIEW.Write.body({file_path:"/a",content:"<img src=x onerror=alert(1)>"})`,
|
|
1736
|
+
`TOOLVIEW.Edit.body({file_path:"/a",old_string:"<script>",new_string:"<b>"})`,
|
|
1737
|
+
`TOOLVIEW.Read.output("1\\t<img src=x onerror=alert(1)>")`,
|
|
1738
|
+
]) {
|
|
1739
|
+
const html = run(`return ${expr}`);
|
|
1740
|
+
assert.ok(!/<img|<script>/.test(html), `escaped badly: ${expr}`);
|
|
1741
|
+
}
|
|
1742
|
+
});
|
|
1743
|
+
|
|
1744
|
+
test("asking for the recording gives the recording, not the file view", () => {
|
|
1745
|
+
const html = run(`
|
|
1746
|
+
raw = false;
|
|
1747
|
+
CALLS["r1"] = {
|
|
1748
|
+
c:{function_name:"Read",tool_call_id:"r1",arguments:{file_path:"/a/b.py"}},
|
|
1749
|
+
r:{source_call_id:"r1",content:"1\\thello"},
|
|
1750
|
+
};
|
|
1751
|
+
TOOLRAW["r1"] = true;
|
|
1752
|
+
return toolBody("r1");
|
|
1753
|
+
`);
|
|
1754
|
+
assert.match(html, /class="json"/, "the call shows as recorded");
|
|
1755
|
+
assert.ok(!/class="filelines"/.test(html), "and so does the output");
|
|
1756
|
+
});
|
|
1757
|
+
|
|
1758
|
+
test("a path with a space in it links to the whole path", () => {
|
|
1759
|
+
// A real one: "/Users/girish/Documents/Redwood Research/…" linked only as far
|
|
1760
|
+
// as "Redwood", so the link opened a directory that was not the file.
|
|
1761
|
+
const html = run(
|
|
1762
|
+
`return TOOLVIEW.Read.body({file_path:"/Users/girish/Documents/Redwood Research/app/mentions.py"})`,
|
|
1763
|
+
);
|
|
1764
|
+
assert.match(html, /Redwood%20Research/, "the space must be inside the link");
|
|
1765
|
+
assert.ok(
|
|
1766
|
+
!/<\/a>\s*Research/.test(html),
|
|
1767
|
+
"the link must not stop at the space",
|
|
1768
|
+
);
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
test("a relative path is shown but not offered as a link", () => {
|
|
1772
|
+
const html = run(`return TOOLVIEW.Read.body({file_path:"src/thing.py"})`);
|
|
1773
|
+
assert.match(html, /src\/thing\.py/);
|
|
1774
|
+
assert.ok(!/api\/reveal/.test(html), "revealing a relative path would guess");
|
|
1775
|
+
});
|
|
@@ -65,7 +65,11 @@ def test_a_path_with_nothing_convertible_reports_rather_than_serving(
|
|
|
65
65
|
):
|
|
66
66
|
(tmp_path / "notes.txt").write_text("nothing here")
|
|
67
67
|
assert cli.main([str(tmp_path)]) == 1
|
|
68
|
-
|
|
68
|
+
said = capsys.readouterr().err
|
|
69
|
+
assert "no transcript" in said
|
|
70
|
+
# It must also say what it looked at: a bare refusal reads as the tool
|
|
71
|
+
# failing rather than the path not holding what was hoped for.
|
|
72
|
+
assert "1 file" in said and ".txt" in said
|
|
69
73
|
assert not served
|
|
70
74
|
|
|
71
75
|
|
|
@@ -496,3 +496,36 @@ def test_pieces_unpacked_beside_a_download_are_used_as_they_are(tmp_path):
|
|
|
496
496
|
entries = corpus.scan([tmp_path])
|
|
497
497
|
assert len(entries) == 3
|
|
498
498
|
assert all(str(beside) in e.path for e in entries)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def test_a_path_that_converts_to_nothing_still_says_what_was_in_it(tmp_path):
|
|
502
|
+
""""Nothing convertible" alone reads as the tool failing, not the file."""
|
|
503
|
+
(tmp_path / "a").mkdir()
|
|
504
|
+
(tmp_path / "a" / "notes.md").write_text("# hello")
|
|
505
|
+
(tmp_path / "a" / "data.json").write_text('{"not": "a transcript"}')
|
|
506
|
+
(tmp_path / "a" / "run.py").write_text("print(1)")
|
|
507
|
+
|
|
508
|
+
said = corpus.describe_contents(tmp_path)
|
|
509
|
+
|
|
510
|
+
assert "3 files" in said
|
|
511
|
+
for ext in (".md", ".json", ".py"):
|
|
512
|
+
assert ext in said, f"{ext} should be named"
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def test_describing_a_single_file_names_it(tmp_path):
|
|
516
|
+
path = tmp_path / "one.jsonl"
|
|
517
|
+
path.write_text("{}\n")
|
|
518
|
+
said = corpus.describe_contents(path)
|
|
519
|
+
assert "one.jsonl" in said and "bytes" in said
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def test_describing_an_empty_directory_says_so(tmp_path):
|
|
523
|
+
assert corpus.describe_contents(tmp_path) == "it is empty"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def test_describing_a_huge_tree_does_not_read_all_of_it(tmp_path):
|
|
527
|
+
"""A 600 MB archive should not be walked twice to explain itself."""
|
|
528
|
+
for i in range(60):
|
|
529
|
+
(tmp_path / f"f{i}.bin").write_text("x")
|
|
530
|
+
said = corpus.describe_contents(tmp_path, limit=10)
|
|
531
|
+
assert "+" in said, "it should say the count is a floor, not a total"
|
|
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
|