cartograph-cli 0.2.2__tar.gz → 0.3.0__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.
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/CLAUDE.md +10 -1
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/PKG-INFO +1 -1
- {cartograph_cli-0.2.2/src/cartograph/_vendor → cartograph_cli-0.3.0/cg/infra_agent_cli_python/src}/agent_cli.py +20 -10
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/cg/infra_agent_cli_python/tests/test_agent_cli.py +68 -0
- cartograph_cli-0.3.0/cg/infra_file_stamp_python/.validation_stamp.json +1 -0
- cartograph_cli-0.3.0/cg/infra_file_stamp_python/examples/example_usage.py +56 -0
- cartograph_cli-0.3.0/cg/infra_file_stamp_python/src/__init__.py +1 -0
- cartograph_cli-0.3.0/cg/infra_file_stamp_python/src/file_stamp.py +156 -0
- cartograph_cli-0.3.0/cg/infra_file_stamp_python/tests/test_file_stamp.py +144 -0
- cartograph_cli-0.3.0/cg/infra_file_stamp_python/widget.json +24 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/pyproject.toml +2 -2
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/cli.py +245 -12
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/cloud.py +146 -29
- cartograph_cli-0.3.0/src/cartograph/config.py +52 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/installer.py +7 -1
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/scaffolding/__init__.py +2 -2
- cartograph_cli-0.3.0/src/cartograph/validation_stamp.py +79 -0
- cartograph_cli-0.2.2/cg/infra_agent_cli_python/src/agent_cli.py +0 -195
- cartograph_cli-0.2.2/src/cartograph/validation_stamp.py +0 -115
- cartograph_cli-0.2.2/tests/__init__.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/.github/workflows/fresh-install.yml +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/.github/workflows/publish.yml +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/.github/workflows/test.yml +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/.gitignore +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/CONTRIBUTING.md +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/LICENSE +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/README.md +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/cg/infra_agent_cli_python/.validation_stamp.json +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/cg/infra_agent_cli_python/examples/example_usage.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/cg/infra_agent_cli_python/src/__init__.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/cg/infra_agent_cli_python/widget.json +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/logo/logo-cartograph-black.svg +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/logo/logo-cartograph-white.svg +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/logo/logo-cartograph.svg +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/pytest.ini +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/__init__.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/__main__.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/auth.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/checkin.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/dashboard.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/dashboard_ui.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/engine.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/inspector.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/languages/__init__.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/languages/base.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/languages/javascript.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/languages/nim.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/languages/python.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/languages/registry.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/languages/scanners/js_scanner.js +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/languages/scanners/nim_scanner.nim +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/library_config.json +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/search/__init__.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/search/base.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/search/bm25.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/search/filters.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/search/hybrid.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/search/ngram.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/search/synonyms.json +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/trust.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/src/cartograph/validator.py +0 -0
- {cartograph_cli-0.2.2/src/cartograph/_vendor → cartograph_cli-0.3.0/tests}/__init__.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/tests/cloud_health.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/tests/conftest.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/tests/fresh_install/Dockerfile +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/tests/fresh_install/test_fresh_install.sh +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/tests/test_checkin.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/tests/test_create.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/tests/test_inspect.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/tests/test_install.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/tests/test_language_engines.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/tests/test_loading.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/tests/test_search.py +0 -0
- {cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/tests/test_validate.py +0 -0
|
@@ -63,10 +63,19 @@ update. Wrap or extend in your own code instead.
|
|
|
63
63
|
|
|
64
64
|
cartograph cloud publish [widget_id] [path]
|
|
65
65
|
[--lib] publish from library by ID
|
|
66
|
-
[--visibility public|private] defaults to public
|
|
66
|
+
[--visibility public|private] defaults to public (or cartograph.toml)
|
|
67
|
+
[--governance open|protected] contribution governance model
|
|
68
|
+
cartograph cloud update <@handle/widget_id>
|
|
69
|
+
[--governance open|protected] update governance model
|
|
67
70
|
cartograph cloud unpublish <widget_id> [--confirm]
|
|
68
71
|
cartograph cloud sync reconcile local with cloud
|
|
69
72
|
cartograph cloud rate <widget_id> <score 1-5> [--comment "..."]
|
|
73
|
+
cartograph cloud propose <@owner/widget_id> [path]
|
|
74
|
+
--reason "what changed and why" REQUIRED
|
|
75
|
+
cartograph cloud proposals list list my proposals
|
|
76
|
+
cartograph cloud proposals view <@owner/widget_id> [proposal_id]
|
|
77
|
+
cartograph cloud proposals accept <@owner/widget_id> <proposal_id>
|
|
78
|
+
cartograph cloud proposals reject <@owner/widget_id> <proposal_id> [--reason "..."]
|
|
70
79
|
|
|
71
80
|
**Library and account**
|
|
72
81
|
|
|
@@ -95,6 +95,9 @@ class AgentCLI:
|
|
|
95
95
|
)
|
|
96
96
|
|
|
97
97
|
sub = parser.add_subparsers(dest="command", metavar="<command>")
|
|
98
|
+
# Maps a tuple of parent parts to (parser, subparsers) for nested commands.
|
|
99
|
+
# e.g. ("cloud",) -> (cloud_parser, cloud_subparsers)
|
|
100
|
+
# ("cloud", "proposals") -> (proposals_parser, proposals_subparsers)
|
|
98
101
|
self._nested_parsers = {}
|
|
99
102
|
|
|
100
103
|
for _group_name, commands in self._groups:
|
|
@@ -103,16 +106,23 @@ class AgentCLI:
|
|
|
103
106
|
parts = name.split()
|
|
104
107
|
handler = cmd.get("handler")
|
|
105
108
|
|
|
106
|
-
if len(parts) ==
|
|
107
|
-
parent, child = parts
|
|
108
|
-
if parent not in self._nested_parsers:
|
|
109
|
-
parent_parser = sub.add_parser(parent, help=f"{parent} operations")
|
|
110
|
-
parent_sub = parent_parser.add_subparsers(dest=f"{parent}_command")
|
|
111
|
-
self._nested_parsers[parent] = (parent_parser, parent_sub)
|
|
112
|
-
_, parent_sub = self._nested_parsers[parent]
|
|
113
|
-
p = parent_sub.add_parser(child, help=cmd.get("help", ""))
|
|
114
|
-
else:
|
|
109
|
+
if len(parts) == 1:
|
|
115
110
|
p = sub.add_parser(name, help=cmd.get("help", ""))
|
|
111
|
+
else:
|
|
112
|
+
# Walk the parent chain, creating subparsers at each level
|
|
113
|
+
current_sub = sub
|
|
114
|
+
for depth in range(len(parts) - 1):
|
|
115
|
+
key = tuple(parts[: depth + 1])
|
|
116
|
+
if key not in self._nested_parsers:
|
|
117
|
+
parent_name = parts[depth]
|
|
118
|
+
dest = "_".join(key) + "_command"
|
|
119
|
+
parent_parser = current_sub.add_parser(
|
|
120
|
+
parent_name, help=f"{parent_name} operations",
|
|
121
|
+
)
|
|
122
|
+
parent_sub = parent_parser.add_subparsers(dest=dest)
|
|
123
|
+
self._nested_parsers[key] = (parent_parser, parent_sub)
|
|
124
|
+
_, current_sub = self._nested_parsers[key]
|
|
125
|
+
p = current_sub.add_parser(parts[-1], help=cmd.get("help", ""))
|
|
116
126
|
|
|
117
127
|
for arg_spec in cmd.get("args", []):
|
|
118
128
|
self._add_arg(p, arg_spec)
|
|
@@ -120,7 +130,7 @@ class AgentCLI:
|
|
|
120
130
|
if handler:
|
|
121
131
|
p.set_defaults(func=handler)
|
|
122
132
|
|
|
123
|
-
for
|
|
133
|
+
for key, (parent_parser, _) in self._nested_parsers.items():
|
|
124
134
|
parent_parser.set_defaults(func=lambda args, pp=parent_parser: pp.print_help())
|
|
125
135
|
|
|
126
136
|
return parser
|
{cartograph_cli-0.2.2 → cartograph_cli-0.3.0}/cg/infra_agent_cli_python/tests/test_agent_cli.py
RENAMED
|
@@ -248,6 +248,74 @@ class TestRun:
|
|
|
248
248
|
assert "Basic:" in captured.out
|
|
249
249
|
|
|
250
250
|
|
|
251
|
+
class TestDeepNesting:
|
|
252
|
+
def test_three_level_nesting(self):
|
|
253
|
+
def cmd_leaf(args):
|
|
254
|
+
return {"id": args.id}
|
|
255
|
+
|
|
256
|
+
cli = AgentCLI(prog="t", version="0.1.0")
|
|
257
|
+
cli.add_commands("Cloud", [
|
|
258
|
+
{
|
|
259
|
+
"name": "cloud proposals list",
|
|
260
|
+
"help": "List proposals",
|
|
261
|
+
"handler": lambda a: {"items": []},
|
|
262
|
+
"args": [],
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
"name": "cloud proposals view",
|
|
266
|
+
"help": "View a proposal",
|
|
267
|
+
"handler": cmd_leaf,
|
|
268
|
+
"args": [{"name": "id"}],
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"name": "cloud push",
|
|
272
|
+
"help": "Push to cloud",
|
|
273
|
+
"handler": lambda a: {"pushed": True},
|
|
274
|
+
"args": [],
|
|
275
|
+
},
|
|
276
|
+
])
|
|
277
|
+
parser = cli.build_parser()
|
|
278
|
+
|
|
279
|
+
# Three-level command works
|
|
280
|
+
args = parser.parse_args(["cloud", "proposals", "view", "42"])
|
|
281
|
+
assert args.id == "42"
|
|
282
|
+
assert hasattr(args, "func")
|
|
283
|
+
|
|
284
|
+
# Two-level sibling still works
|
|
285
|
+
args2 = parser.parse_args(["cloud", "push"])
|
|
286
|
+
assert hasattr(args2, "func")
|
|
287
|
+
|
|
288
|
+
def test_four_level_nesting(self):
|
|
289
|
+
cli = AgentCLI(prog="t", version="0.1.0")
|
|
290
|
+
cli.add_commands("Deep", [
|
|
291
|
+
{
|
|
292
|
+
"name": "a b c d",
|
|
293
|
+
"help": "Four levels deep",
|
|
294
|
+
"handler": lambda a: {"deep": True},
|
|
295
|
+
"args": [{"name": "val"}],
|
|
296
|
+
},
|
|
297
|
+
])
|
|
298
|
+
parser = cli.build_parser()
|
|
299
|
+
args = parser.parse_args(["a", "b", "c", "d", "hello"])
|
|
300
|
+
assert args.val == "hello"
|
|
301
|
+
|
|
302
|
+
def test_parent_shows_help(self, capsys):
|
|
303
|
+
cli = AgentCLI(prog="t", version="0.1.0")
|
|
304
|
+
cli.add_commands("Cloud", [
|
|
305
|
+
{
|
|
306
|
+
"name": "cloud proposals list",
|
|
307
|
+
"help": "List proposals",
|
|
308
|
+
"handler": lambda a: {},
|
|
309
|
+
"args": [],
|
|
310
|
+
},
|
|
311
|
+
])
|
|
312
|
+
parser = cli.build_parser()
|
|
313
|
+
# "cloud proposals" with no subcommand should print help
|
|
314
|
+
args = parser.parse_args(["cloud", "proposals"])
|
|
315
|
+
func = getattr(args, "func", None)
|
|
316
|
+
assert func is not None # should have the help-printing default
|
|
317
|
+
|
|
318
|
+
|
|
251
319
|
class TestMultipleGroups:
|
|
252
320
|
def test_three_groups(self):
|
|
253
321
|
cli = AgentCLI(prog="t", version="0.1.0")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"language": "python", "fingerprint": "ff91956c7b94d2841373d49cd69337069be2201e580839049311d6cc52d28a8c", "validated_at": "2026-03-30T01:26:48.772591+00:00", "cartograph_version": "0.2.0", "test_results": {}}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Example usage of File Stamp.
|
|
3
|
+
|
|
4
|
+
Demonstrates fingerprinting a directory and using stamps to detect changes.
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
11
|
+
from src.file_stamp import (
|
|
12
|
+
collect_files,
|
|
13
|
+
fingerprint,
|
|
14
|
+
write_stamp,
|
|
15
|
+
read_stamp,
|
|
16
|
+
is_stamp_valid,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Create a temporary project to stamp
|
|
20
|
+
with tempfile.TemporaryDirectory() as project:
|
|
21
|
+
src = os.path.join(project, "src")
|
|
22
|
+
os.makedirs(src)
|
|
23
|
+
|
|
24
|
+
with open(os.path.join(src, "main.py"), "w") as f:
|
|
25
|
+
f.write("def run(): pass\n")
|
|
26
|
+
with open(os.path.join(src, "util.py"), "w") as f:
|
|
27
|
+
f.write("helpers = True\n")
|
|
28
|
+
|
|
29
|
+
# 1. Collect files
|
|
30
|
+
files = collect_files(project)
|
|
31
|
+
sys.stdout.write(f"Found {len(files)} files\n")
|
|
32
|
+
|
|
33
|
+
# 2. Compute fingerprint
|
|
34
|
+
fp = fingerprint(project)
|
|
35
|
+
sys.stdout.write(f"Fingerprint: {fp[:16]}...\n")
|
|
36
|
+
|
|
37
|
+
# 3. Write a stamp with metadata
|
|
38
|
+
stamp = write_stamp(project, metadata={"language": "python", "tool": "example"})
|
|
39
|
+
sys.stdout.write(f"Stamp written at {stamp['stamped_at']}\n")
|
|
40
|
+
|
|
41
|
+
# 4. Check validity - should be valid (nothing changed)
|
|
42
|
+
valid = is_stamp_valid(project, metadata_match={"language": "python"})
|
|
43
|
+
sys.stdout.write(f"Valid (no changes): {valid}\n")
|
|
44
|
+
|
|
45
|
+
# 5. Modify a file
|
|
46
|
+
with open(os.path.join(src, "main.py"), "w") as f:
|
|
47
|
+
f.write("def run(): return 42\n")
|
|
48
|
+
|
|
49
|
+
# 6. Check again - should be invalid (file changed)
|
|
50
|
+
valid = is_stamp_valid(project, metadata_match={"language": "python"})
|
|
51
|
+
sys.stdout.write(f"Valid (after edit): {valid}\n")
|
|
52
|
+
|
|
53
|
+
# 7. Re-stamp
|
|
54
|
+
write_stamp(project, metadata={"language": "python", "tool": "example"})
|
|
55
|
+
valid = is_stamp_valid(project, metadata_match={"language": "python"})
|
|
56
|
+
sys.stdout.write(f"Valid (after re-stamp): {valid}\n")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Package marker - add explicit exports here once the public API is stable.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File Stamp - fingerprint-based change detection for file sets.
|
|
3
|
+
|
|
4
|
+
Computes a SHA-256 digest over a set of files (paths + contents) and
|
|
5
|
+
stores it as a JSON stamp. On subsequent runs, recomputes and compares
|
|
6
|
+
to detect whether any watched file has changed.
|
|
7
|
+
|
|
8
|
+
Use cases: build cache invalidation, validation short-circuiting,
|
|
9
|
+
incremental processing pipelines.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import glob as _glob
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
STAMP_FILE = ".file_stamp.json"
|
|
20
|
+
|
|
21
|
+
_SKIP_EXTENSIONS = frozenset({".pyc", ".pyo", ".o", ".so", ".dylib"})
|
|
22
|
+
_SKIP_DIRS = frozenset({"__pycache__", ".pytest_cache", ".git", "node_modules"})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def collect_files(root, patterns=None, skip_dirs=None, skip_extensions=None):
|
|
26
|
+
"""Return sorted absolute paths matching glob patterns under root.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
root: Base directory to search from.
|
|
30
|
+
patterns: Glob patterns relative to root. Defaults to common source
|
|
31
|
+
patterns (src/, tests/, examples/, plus root config files).
|
|
32
|
+
skip_dirs: Directory names to exclude. Defaults to __pycache__,
|
|
33
|
+
.pytest_cache, .git, node_modules.
|
|
34
|
+
skip_extensions: File extensions to exclude. Defaults to .pyc, .pyo,
|
|
35
|
+
.o, .so, .dylib.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Sorted list of absolute file paths.
|
|
39
|
+
"""
|
|
40
|
+
if skip_dirs is None:
|
|
41
|
+
skip_dirs = _SKIP_DIRS
|
|
42
|
+
if skip_extensions is None:
|
|
43
|
+
skip_extensions = _SKIP_EXTENSIONS
|
|
44
|
+
if patterns is None:
|
|
45
|
+
patterns = [
|
|
46
|
+
os.path.join(root, "src", "**", "*"),
|
|
47
|
+
os.path.join(root, "tests", "**", "*"),
|
|
48
|
+
os.path.join(root, "examples", "**", "*"),
|
|
49
|
+
os.path.join(root, "*.json"),
|
|
50
|
+
os.path.join(root, "*.toml"),
|
|
51
|
+
os.path.join(root, "*.yaml"),
|
|
52
|
+
os.path.join(root, "*.yml"),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
files = set()
|
|
56
|
+
for pattern in patterns:
|
|
57
|
+
for f in _glob.glob(pattern, recursive=True):
|
|
58
|
+
if not os.path.isfile(f):
|
|
59
|
+
continue
|
|
60
|
+
rel_parts = os.path.relpath(f, root).split(os.sep)
|
|
61
|
+
if any(part in skip_dirs for part in rel_parts):
|
|
62
|
+
continue
|
|
63
|
+
if os.path.splitext(f)[1] in skip_extensions:
|
|
64
|
+
continue
|
|
65
|
+
files.add(os.path.abspath(f))
|
|
66
|
+
return sorted(files)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def fingerprint(root, files=None, **collect_kwargs):
|
|
70
|
+
"""Compute a SHA-256 hex digest over (relative-path, content) pairs.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
root: Base directory (used for relative path computation).
|
|
74
|
+
files: Explicit list of absolute paths. If None, calls
|
|
75
|
+
collect_files(root, **collect_kwargs).
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Hex digest string.
|
|
79
|
+
"""
|
|
80
|
+
if files is None:
|
|
81
|
+
files = collect_files(root, **collect_kwargs)
|
|
82
|
+
|
|
83
|
+
h = hashlib.sha256()
|
|
84
|
+
for fpath in files:
|
|
85
|
+
rel = os.path.relpath(fpath, root)
|
|
86
|
+
h.update(rel.encode())
|
|
87
|
+
try:
|
|
88
|
+
with open(fpath, "rb") as f:
|
|
89
|
+
h.update(f.read())
|
|
90
|
+
except OSError:
|
|
91
|
+
pass
|
|
92
|
+
return h.hexdigest()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def write_stamp(root, metadata=None, stamp_name=STAMP_FILE, **collect_kwargs):
|
|
96
|
+
"""Write a stamp file after computing the current fingerprint.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
root: Directory to fingerprint and write the stamp into.
|
|
100
|
+
metadata: Optional dict of extra fields to include in the stamp
|
|
101
|
+
(e.g. {"language": "python", "tool_version": "1.0"}).
|
|
102
|
+
stamp_name: Filename for the stamp. Defaults to .file_stamp.json.
|
|
103
|
+
**collect_kwargs: Passed to collect_files (patterns, skip_dirs, etc).
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
The stamp dict that was written.
|
|
107
|
+
"""
|
|
108
|
+
stamp = {
|
|
109
|
+
"fingerprint": fingerprint(root, **collect_kwargs),
|
|
110
|
+
"stamped_at": datetime.now(timezone.utc).isoformat(),
|
|
111
|
+
}
|
|
112
|
+
if metadata:
|
|
113
|
+
stamp.update(metadata)
|
|
114
|
+
|
|
115
|
+
stamp_path = os.path.join(root, stamp_name)
|
|
116
|
+
with open(stamp_path, "w") as f:
|
|
117
|
+
json.dump(stamp, f)
|
|
118
|
+
return stamp
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def read_stamp(root, stamp_name=STAMP_FILE):
|
|
122
|
+
"""Read an existing stamp file. Returns the stamp dict or None."""
|
|
123
|
+
stamp_path = os.path.join(root, stamp_name)
|
|
124
|
+
try:
|
|
125
|
+
with open(stamp_path) as f:
|
|
126
|
+
return json.load(f)
|
|
127
|
+
except Exception:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def is_stamp_valid(root, metadata_match=None, stamp_name=STAMP_FILE,
|
|
132
|
+
**collect_kwargs):
|
|
133
|
+
"""Check if the stamp is still valid (no files changed).
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
root: Directory to check.
|
|
137
|
+
metadata_match: Optional dict of metadata keys that must match.
|
|
138
|
+
e.g. {"language": "python"} ensures the stamp was
|
|
139
|
+
written for the same language.
|
|
140
|
+
stamp_name: Filename for the stamp.
|
|
141
|
+
**collect_kwargs: Passed to collect_files for recomputing fingerprint.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
True if stamp exists, metadata matches, and fingerprint is current.
|
|
145
|
+
"""
|
|
146
|
+
stamp = read_stamp(root, stamp_name)
|
|
147
|
+
if stamp is None:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
if metadata_match:
|
|
151
|
+
for key, value in metadata_match.items():
|
|
152
|
+
if stamp.get(key) != value:
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
current = fingerprint(root, **collect_kwargs)
|
|
156
|
+
return stamp.get("fingerprint") == current
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
6
|
+
from src.file_stamp import (
|
|
7
|
+
collect_files,
|
|
8
|
+
fingerprint,
|
|
9
|
+
write_stamp,
|
|
10
|
+
read_stamp,
|
|
11
|
+
is_stamp_valid,
|
|
12
|
+
STAMP_FILE,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _make_tree(tmp_path):
|
|
17
|
+
"""Create a small file tree for testing."""
|
|
18
|
+
src = tmp_path / "src"
|
|
19
|
+
src.mkdir()
|
|
20
|
+
(src / "main.py").write_text("print('hello')")
|
|
21
|
+
(src / "util.py").write_text("x = 1")
|
|
22
|
+
tests = tmp_path / "tests"
|
|
23
|
+
tests.mkdir()
|
|
24
|
+
(tests / "test_main.py").write_text("assert True")
|
|
25
|
+
(tmp_path / "widget.json").write_text('{"name": "test"}')
|
|
26
|
+
return tmp_path
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_collect_files_defaults(tmp_path):
|
|
30
|
+
root = _make_tree(tmp_path)
|
|
31
|
+
files = collect_files(str(root))
|
|
32
|
+
names = [os.path.basename(f) for f in files]
|
|
33
|
+
assert "main.py" in names
|
|
34
|
+
assert "util.py" in names
|
|
35
|
+
assert "test_main.py" in names
|
|
36
|
+
assert "widget.json" in names
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_collect_files_skips_pycache(tmp_path):
|
|
40
|
+
root = _make_tree(tmp_path)
|
|
41
|
+
cache = tmp_path / "src" / "__pycache__"
|
|
42
|
+
cache.mkdir()
|
|
43
|
+
(cache / "main.cpython-312.pyc").write_bytes(b"\x00")
|
|
44
|
+
files = collect_files(str(root))
|
|
45
|
+
for f in files:
|
|
46
|
+
assert "__pycache__" not in f
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_collect_files_custom_patterns(tmp_path):
|
|
50
|
+
root = _make_tree(tmp_path)
|
|
51
|
+
patterns = [os.path.join(str(root), "src", "*.py")]
|
|
52
|
+
files = collect_files(str(root), patterns=patterns)
|
|
53
|
+
names = [os.path.basename(f) for f in files]
|
|
54
|
+
assert "main.py" in names
|
|
55
|
+
assert "test_main.py" not in names
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_fingerprint_deterministic(tmp_path):
|
|
59
|
+
root = _make_tree(tmp_path)
|
|
60
|
+
fp1 = fingerprint(str(root))
|
|
61
|
+
fp2 = fingerprint(str(root))
|
|
62
|
+
assert fp1 == fp2
|
|
63
|
+
assert len(fp1) == 64 # SHA-256 hex
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_fingerprint_changes_on_edit(tmp_path):
|
|
67
|
+
root = _make_tree(tmp_path)
|
|
68
|
+
fp1 = fingerprint(str(root))
|
|
69
|
+
(tmp_path / "src" / "main.py").write_text("print('changed')")
|
|
70
|
+
fp2 = fingerprint(str(root))
|
|
71
|
+
assert fp1 != fp2
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_fingerprint_changes_on_new_file(tmp_path):
|
|
75
|
+
root = _make_tree(tmp_path)
|
|
76
|
+
fp1 = fingerprint(str(root))
|
|
77
|
+
(tmp_path / "src" / "new.py").write_text("y = 2")
|
|
78
|
+
fp2 = fingerprint(str(root))
|
|
79
|
+
assert fp1 != fp2
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_fingerprint_explicit_files(tmp_path):
|
|
83
|
+
root = _make_tree(tmp_path)
|
|
84
|
+
only = [str(tmp_path / "src" / "main.py")]
|
|
85
|
+
fp = fingerprint(str(root), files=only)
|
|
86
|
+
assert len(fp) == 64
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_write_and_read_stamp(tmp_path):
|
|
90
|
+
root = _make_tree(tmp_path)
|
|
91
|
+
stamp = write_stamp(str(root))
|
|
92
|
+
assert "fingerprint" in stamp
|
|
93
|
+
assert "stamped_at" in stamp
|
|
94
|
+
|
|
95
|
+
loaded = read_stamp(str(root))
|
|
96
|
+
assert loaded is not None
|
|
97
|
+
assert loaded["fingerprint"] == stamp["fingerprint"]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_write_stamp_with_metadata(tmp_path):
|
|
101
|
+
root = _make_tree(tmp_path)
|
|
102
|
+
stamp = write_stamp(str(root), metadata={"language": "python", "version": "1.0"})
|
|
103
|
+
assert stamp["language"] == "python"
|
|
104
|
+
assert stamp["version"] == "1.0"
|
|
105
|
+
|
|
106
|
+
loaded = read_stamp(str(root))
|
|
107
|
+
assert loaded["language"] == "python"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_read_stamp_missing(tmp_path):
|
|
111
|
+
assert read_stamp(str(tmp_path)) is None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_is_stamp_valid_fresh(tmp_path):
|
|
115
|
+
root = _make_tree(tmp_path)
|
|
116
|
+
write_stamp(str(root))
|
|
117
|
+
assert is_stamp_valid(str(root)) is True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_is_stamp_valid_after_change(tmp_path):
|
|
121
|
+
root = _make_tree(tmp_path)
|
|
122
|
+
write_stamp(str(root))
|
|
123
|
+
(tmp_path / "src" / "main.py").write_text("print('changed')")
|
|
124
|
+
assert is_stamp_valid(str(root)) is False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_is_stamp_valid_metadata_match(tmp_path):
|
|
128
|
+
root = _make_tree(tmp_path)
|
|
129
|
+
write_stamp(str(root), metadata={"language": "python"})
|
|
130
|
+
assert is_stamp_valid(str(root), metadata_match={"language": "python"}) is True
|
|
131
|
+
assert is_stamp_valid(str(root), metadata_match={"language": "nim"}) is False
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_is_stamp_valid_no_stamp(tmp_path):
|
|
135
|
+
root = _make_tree(tmp_path)
|
|
136
|
+
assert is_stamp_valid(str(root)) is False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_custom_stamp_name(tmp_path):
|
|
140
|
+
root = _make_tree(tmp_path)
|
|
141
|
+
write_stamp(str(root), stamp_name=".my_stamp.json")
|
|
142
|
+
assert os.path.exists(str(tmp_path / ".my_stamp.json"))
|
|
143
|
+
assert is_stamp_valid(str(root), stamp_name=".my_stamp.json") is True
|
|
144
|
+
assert is_stamp_valid(str(root)) is False # default name doesn't exist
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"id": "infra-file-stamp-python",
|
|
4
|
+
"name": "File Stamp",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"domain": "infra",
|
|
7
|
+
"tags": [
|
|
8
|
+
"fingerprint",
|
|
9
|
+
"cache-invalidation",
|
|
10
|
+
"file-hash",
|
|
11
|
+
"change-detection",
|
|
12
|
+
"build-tools"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"description": "Fingerprint-based change detection for file sets. Computes SHA-256 over watched files, writes a JSON stamp, and checks validity on subsequent runs. Useful for build cache invalidation, validation short-circuiting, and incremental processing pipelines.",
|
|
16
|
+
"tech_stack": {
|
|
17
|
+
"language": "python",
|
|
18
|
+
"dependencies": []
|
|
19
|
+
},
|
|
20
|
+
"library_notes": {
|
|
21
|
+
"general": "Single responsibility - one widget does one thing. No global state. No project-specific code, hardcoded paths, credentials, or environment-specific values. Expose variation points as constructor parameters or function arguments - no hardcoded thresholds, colors, sizes, or behavior flags. All dependencies must be declared in widget.json tech_stack.dependencies. Widgets cannot depend on other widgets - they must be fully self-contained. If you need logic from another widget, copy it into src/ directly. Examples must run and exit cleanly with no user input, no network calls, and no external services or API keys - use fake/hardcoded data. The widget's own declared dependencies are fine and will be installed by the validator before the example runs. Do not modify library_notes - it is managed by Cartograph and will be restored on checkin.",
|
|
22
|
+
"language": "Use pytest for all tests - no unittest. Type hints required on all public functions. No __init__.py in tests/. No if __name__ == '__main__' blocks in src/ files. Imports ordered: stdlib, third-party, local."
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "cartograph-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "Widget library manager for AI agents — CLI"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -18,7 +18,7 @@ dependencies = [
|
|
|
18
18
|
cartograph = "cartograph.cli:main"
|
|
19
19
|
|
|
20
20
|
[tool.hatch.build.targets.wheel]
|
|
21
|
-
packages = ["src/cartograph"]
|
|
21
|
+
packages = ["src/cartograph", "cg"]
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
[tool.pytest.ini_options]
|