codeatlas-bundle 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,17 @@
1
+ """CodeAtlas Bundle: version CodeAtlas architecture maps inside any repository."""
2
+
3
+ from codeatlas_bundle.bundle import BundleResult, bundle, check, detect_repo_root
4
+ from codeatlas_bundle.config import Config, init_config, load_config
5
+
6
+ __all__ = [
7
+ "BundleResult",
8
+ "Config",
9
+ "bundle",
10
+ "check",
11
+ "detect_repo_root",
12
+ "init_config",
13
+ "load_config",
14
+ "__version__",
15
+ ]
16
+
17
+ __version__ = "0.1.0"
@@ -0,0 +1,179 @@
1
+ """Bundle CodeAtlas architecture docs into a target repository.
2
+
3
+ `codeatlas-bundle` runs the deterministic analysis, renders the docs, writes
4
+ them under the configured output directory, and (optionally) commits the result
5
+ so the architecture map is versioned alongside the code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import subprocess
12
+ import tempfile
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+
16
+ import codeatlas_rs
17
+ from codeatlas_rs.render import render
18
+
19
+ from codeatlas_bundle.config import CONFIG_FILENAME, Config, load_config
20
+
21
+ ANALYSIS_FILENAME = "analysis.json"
22
+ FINGERPRINT_FILENAME = "fingerprint.json"
23
+ FILE_COVERAGE_FILENAME = "file_coverage.json"
24
+ DEFAULT_COMMIT_MESSAGE = "docs(codeatlas): refresh architecture map"
25
+
26
+
27
+ @dataclass
28
+ class BundleResult:
29
+ repo_root: Path
30
+ output_dir: Path
31
+ files: list[Path] = field(default_factory=list)
32
+ committed: bool = False
33
+
34
+
35
+ def detect_repo_root(start: Path | str | None = None) -> Path:
36
+ """Walk up from `start` to the nearest directory containing `.git` or
37
+ `codeatlas.json`; fall back to `start` itself."""
38
+ current = Path(start or Path.cwd()).resolve()
39
+ for candidate in [current, *current.parents]:
40
+ if (candidate / ".git").exists() or (candidate / CONFIG_FILENAME).exists():
41
+ return candidate
42
+ return current
43
+
44
+
45
+ def bundle(
46
+ repo_root: Path | str,
47
+ config: Config | None = None,
48
+ *,
49
+ commit: bool = False,
50
+ message: str | None = None,
51
+ ) -> BundleResult:
52
+ """Generate the architecture bundle for `repo_root` into its output dir."""
53
+ root = Path(repo_root).resolve()
54
+ cfg = config or load_config(root)
55
+ out_dir = root / cfg.output_dir
56
+
57
+ files = _generate(root, cfg, out_dir)
58
+ result = BundleResult(repo_root=root, output_dir=out_dir, files=files)
59
+
60
+ if commit:
61
+ result.committed = _commit(root, out_dir, message or DEFAULT_COMMIT_MESSAGE)
62
+ return result
63
+
64
+
65
+ def check(repo_root: Path | str, config: Config | None = None) -> tuple[bool, list[str]]:
66
+ """Return `(is_current, changed_files)` comparing the committed bundle with
67
+ a freshly generated one."""
68
+ root = Path(repo_root).resolve()
69
+ cfg = config or load_config(root)
70
+ committed_dir = root / cfg.output_dir
71
+
72
+ changed: list[str] = []
73
+ with tempfile.TemporaryDirectory(prefix="codeatlas-bundle-") as tmp:
74
+ generated_dir = Path(tmp) / "out"
75
+ _generate(root, cfg, generated_dir)
76
+
77
+ generated = _relative_files(generated_dir)
78
+ committed = _relative_files(committed_dir) if committed_dir.is_dir() else {}
79
+
80
+ for rel, path in generated.items():
81
+ committed_path = committed_dir / rel
82
+ if rel not in committed or _content(path) != _content(committed_path):
83
+ changed.append(rel)
84
+ for rel in committed:
85
+ if rel not in generated:
86
+ changed.append(rel)
87
+
88
+ return (not changed, sorted(changed))
89
+
90
+
91
+ def _generate(root: Path, cfg: Config, out_dir: Path) -> list[Path]:
92
+ out_dir.mkdir(parents=True, exist_ok=True)
93
+ # Always exclude the repository's configured output dir from analysis, even
94
+ # when writing to a temporary directory (as `check` does).
95
+ repo_output = root / cfg.output_dir
96
+ analysis_json, fingerprint_json, coverage_json = codeatlas_rs.analyze(
97
+ str(root),
98
+ project_name=cfg.project_name,
99
+ depth_cap=cfg.depth_cap,
100
+ ignore_paths=[str(repo_output)],
101
+ )
102
+
103
+ written: list[Path] = []
104
+ analysis_path = out_dir / ANALYSIS_FILENAME
105
+ analysis_path.write_text(_pretty(analysis_json), encoding="utf-8")
106
+
107
+ if cfg.include_analysis:
108
+ (out_dir / FINGERPRINT_FILENAME).write_text(fingerprint_json, encoding="utf-8")
109
+ (out_dir / FILE_COVERAGE_FILENAME).write_text(coverage_json, encoding="utf-8")
110
+ written += [
111
+ analysis_path,
112
+ out_dir / FINGERPRINT_FILENAME,
113
+ out_dir / FILE_COVERAGE_FILENAME,
114
+ ]
115
+
116
+ for code in cfg.format_codes:
117
+ written.extend(render(code, analysis_path, out_dir))
118
+
119
+ if not cfg.include_analysis:
120
+ analysis_path.unlink(missing_ok=True)
121
+
122
+ return sorted({p for p in written})
123
+
124
+
125
+ def _pretty(raw_json: str) -> str:
126
+ return json.dumps(json.loads(raw_json), indent=2) + "\n"
127
+
128
+
129
+ def _relative_files(directory: Path) -> dict[str, Path]:
130
+ files: dict[str, Path] = {}
131
+ for path in sorted(directory.rglob("*")):
132
+ if path.is_file():
133
+ files[str(path.relative_to(directory))] = path
134
+ return files
135
+
136
+
137
+ def _content(path: Path) -> str:
138
+ text = path.read_text(encoding="utf-8")
139
+ if path.name == ANALYSIS_FILENAME:
140
+ data = json.loads(text)
141
+ data.get("metadata", {}).pop("generated_at", None)
142
+ return json.dumps(data, sort_keys=True)
143
+ return text
144
+
145
+
146
+ def _commit(root: Path, out_dir: Path, message: str) -> bool:
147
+ config_path = root / CONFIG_FILENAME
148
+ targets = [str(out_dir)]
149
+ if config_path.exists():
150
+ targets.append(str(config_path))
151
+
152
+ _git(root, "add", "--", *targets)
153
+ if _git(root, "diff", "--cached", "--quiet").returncode == 0:
154
+ return False
155
+
156
+ identity = _git(root, "config", "user.email").stdout.strip()
157
+ prefix: list[str] = []
158
+ if not identity:
159
+ prefix = [
160
+ "-c",
161
+ "user.name=codeatlas-bundle",
162
+ "-c",
163
+ "user.email=codeatlas-bundle@users.noreply.github.com",
164
+ ]
165
+ _git(root, *prefix, "commit", "-m", message, check=True)
166
+ return True
167
+
168
+
169
+ def _git(root: Path, *args: str, check: bool = False) -> subprocess.CompletedProcess[str]:
170
+ result = subprocess.run(
171
+ ["git", "-C", str(root), *args],
172
+ capture_output=True,
173
+ text=True,
174
+ )
175
+ if check and result.returncode != 0:
176
+ raise RuntimeError(
177
+ f"git {' '.join(args)} failed: {result.stderr.strip() or result.stdout.strip()}"
178
+ )
179
+ return result
@@ -0,0 +1,93 @@
1
+ """`codeatlas-bundle` command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from codeatlas_bundle import __version__
9
+ from codeatlas_bundle.bundle import bundle, check, detect_repo_root
10
+ from codeatlas_bundle.config import init_config, load_config
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ parser = argparse.ArgumentParser(
15
+ prog="codeatlas-bundle",
16
+ description="Bundle CodeAtlas architecture docs into a repository",
17
+ )
18
+ parser.add_argument("--version", action="version", version=f"codeatlas-bundle {__version__}")
19
+ sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
20
+
21
+ init = sub.add_parser("init", help="Write a codeatlas.json opt-in file")
22
+ init.add_argument("--repo", default=".", help="Repository root")
23
+ init.add_argument("--project-name")
24
+ init.add_argument("--output-dir")
25
+ init.add_argument("--formats", help="Comma-separated: markdown,html,mdx,rst")
26
+ init.add_argument("--depth-cap", type=int)
27
+ init.add_argument("--force", action="store_true")
28
+
29
+ build = sub.add_parser("build", help="Generate the bundle (and optionally commit)")
30
+ build.add_argument("--repo", default=".", help="Repository root")
31
+ build.add_argument("--commit", action="store_true", help="Commit the generated docs")
32
+ build.add_argument("--message", help="Commit message")
33
+
34
+ chk = sub.add_parser("check", help="Fail if the committed bundle is stale")
35
+ chk.add_argument("--repo", default=".", help="Repository root")
36
+
37
+ return parser
38
+
39
+
40
+ def main(argv: list[str] | None = None) -> int:
41
+ args = build_parser().parse_args(argv)
42
+ if args.command == "init":
43
+ return _cmd_init(args)
44
+ if args.command == "check":
45
+ return _cmd_check(args)
46
+ return _cmd_build(args)
47
+
48
+
49
+ def _cmd_init(args: argparse.Namespace) -> int:
50
+ root = detect_repo_root(args.repo)
51
+ formats = args.formats.split(",") if args.formats else None
52
+ try:
53
+ path = init_config(
54
+ root,
55
+ force=args.force,
56
+ project_name=args.project_name,
57
+ output_dir=args.output_dir,
58
+ formats=formats,
59
+ depth_cap=args.depth_cap,
60
+ )
61
+ except (FileExistsError, ValueError) as exc:
62
+ print(f"codeatlas-bundle: {exc}", file=sys.stderr)
63
+ return 1
64
+ print(f"Wrote {path}")
65
+ print("Next: codeatlas-bundle build --commit")
66
+ return 0
67
+
68
+
69
+ def _cmd_build(args: argparse.Namespace) -> int:
70
+ root = detect_repo_root(args.repo)
71
+ result = bundle(root, commit=args.commit, message=args.message)
72
+ for path in result.files:
73
+ print(f"Wrote {path.relative_to(result.repo_root)}")
74
+ if args.commit:
75
+ state = "committed" if result.committed else "no changes to commit"
76
+ print(f"Bundle {state}")
77
+ return 0
78
+
79
+
80
+ def _cmd_check(args: argparse.Namespace) -> int:
81
+ root = detect_repo_root(args.repo)
82
+ current, changed = check(root)
83
+ if current:
84
+ print(f"Bundle is up to date ({load_config(root).output_dir})")
85
+ return 0
86
+ print("codeatlas-bundle: bundle is stale; run `codeatlas-bundle build`:")
87
+ for rel in changed:
88
+ print(f" - {rel}")
89
+ return 1
90
+
91
+
92
+ if __name__ == "__main__":
93
+ raise SystemExit(main())
@@ -0,0 +1,101 @@
1
+ """Configuration for `codeatlas-bundle`.
2
+
3
+ A target repository opts in by committing a `codeatlas.json` file at its root.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ CONFIG_FILENAME = "codeatlas.json"
14
+ DEFAULT_OUTPUT_DIR = "docs/codeatlas"
15
+ DEFAULT_FORMATS = ("markdown", "html")
16
+
17
+ # Friendly format names -> `codeatlas.render` format codes.
18
+ FORMAT_CODES = {
19
+ "markdown": "md",
20
+ "md": "md",
21
+ "html": "html",
22
+ "mdx": "mdx",
23
+ "rst": "rst",
24
+ }
25
+
26
+
27
+ @dataclass
28
+ class Config:
29
+ """Resolved bundle configuration."""
30
+
31
+ project_name: str
32
+ output_dir: str = DEFAULT_OUTPUT_DIR
33
+ formats: list[str] = field(default_factory=lambda: list(DEFAULT_FORMATS))
34
+ depth_cap: int = 3
35
+ include_analysis: bool = False
36
+
37
+ @property
38
+ def format_codes(self) -> list[str]:
39
+ codes: list[str] = []
40
+ for name in self.formats:
41
+ code = FORMAT_CODES.get(name)
42
+ if code is None:
43
+ raise ValueError(f"unsupported format {name!r}")
44
+ if code not in codes:
45
+ codes.append(code)
46
+ return codes
47
+
48
+ def to_dict(self) -> dict[str, Any]:
49
+ return {
50
+ "project_name": self.project_name,
51
+ "output_dir": self.output_dir,
52
+ "formats": list(self.formats),
53
+ "depth_cap": self.depth_cap,
54
+ "include_analysis": self.include_analysis,
55
+ }
56
+
57
+
58
+ def load_config(repo_root: Path | str) -> Config:
59
+ """Load `codeatlas.json` from `repo_root` (falling back to defaults)."""
60
+ root = Path(repo_root)
61
+ path = root / CONFIG_FILENAME
62
+ if not path.is_file():
63
+ return Config(project_name=root.resolve().name)
64
+ data = json.loads(path.read_text(encoding="utf-8"))
65
+ return _from_dict(data, default_name=root.resolve().name)
66
+
67
+
68
+ def _from_dict(data: dict[str, Any], *, default_name: str) -> Config:
69
+ return Config(
70
+ project_name=data.get("project_name") or default_name,
71
+ output_dir=data.get("output_dir", DEFAULT_OUTPUT_DIR),
72
+ formats=list(data.get("formats", DEFAULT_FORMATS)),
73
+ depth_cap=int(data.get("depth_cap", 3)),
74
+ include_analysis=bool(data.get("include_analysis", False)),
75
+ )
76
+
77
+
78
+ def init_config(
79
+ repo_root: Path | str,
80
+ *,
81
+ force: bool = False,
82
+ project_name: str | None = None,
83
+ output_dir: str | None = None,
84
+ formats: list[str] | None = None,
85
+ depth_cap: int | None = None,
86
+ ) -> Path:
87
+ """Write a `codeatlas.json` opt-in file, returning its path."""
88
+ root = Path(repo_root).resolve()
89
+ path = root / CONFIG_FILENAME
90
+ if path.exists() and not force:
91
+ raise FileExistsError(f"{path} already exists (use --force to overwrite)")
92
+ config = Config(
93
+ project_name=project_name or root.name,
94
+ output_dir=output_dir or DEFAULT_OUTPUT_DIR,
95
+ formats=list(formats) if formats else list(DEFAULT_FORMATS),
96
+ depth_cap=depth_cap if depth_cap is not None else 3,
97
+ )
98
+ # Validate format names early.
99
+ _ = config.format_codes
100
+ path.write_text(json.dumps(config.to_dict(), indent=2) + "\n", encoding="utf-8")
101
+ return path
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: codeatlas-bundle
3
+ Version: 0.1.0
4
+ Summary: Bundle CodeAtlas architecture maps into any repository
5
+ Author: Mullassery
6
+ License: MIT
7
+ Keywords: architecture,diagrams,documentation,static-analysis
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Programming Language :: Python
10
+ Classifier: Topic :: Software Development :: Documentation
11
+ Requires-Python: >=3.9
12
+ Requires-Dist: codeatlas-rs>=0.1.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # codeatlas-bundle
16
+
17
+ Bundle [CodeAtlas](https://github.com/Mullassery/CodeAtlas) architecture maps
18
+ into any repository so the map is versioned alongside the code.
19
+
20
+ `codeatlas-bundle` runs the deterministic CodeAtlas analysis, renders the docs
21
+ (Markdown + HTML by default), writes them under a configured directory, and can
22
+ commit the result. It is pure Python and works for repositories in any language
23
+ CodeAtlas supports (Python, Rust, Go, JavaScript/TypeScript).
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install codeatlas-rs codeatlas-bundle
29
+ ```
30
+
31
+ ## Connect a repository
32
+
33
+ ```bash
34
+ cd my-repo
35
+ codeatlas-bundle init # writes codeatlas.json
36
+ codeatlas-bundle build # generates docs/codeatlas/
37
+ codeatlas-bundle build --commit # generate and commit
38
+ ```
39
+
40
+ `codeatlas.json`:
41
+
42
+ ```json
43
+ {
44
+ "project_name": "my-repo",
45
+ "output_dir": "docs/codeatlas",
46
+ "formats": ["markdown", "html"],
47
+ "depth_cap": 3,
48
+ "include_analysis": false
49
+ }
50
+ ```
51
+
52
+ - `formats`: any of `markdown`, `html`, `mdx`, `rst`.
53
+ - `include_analysis`: when `true`, also commit the raw `analysis.json`,
54
+ `fingerprint.json`, and `file_coverage.json` sidecars (the raw analysis
55
+ contains a generation timestamp, so this produces a larger diff each run).
56
+
57
+ ## CI: keep the map fresh
58
+
59
+ ```yaml
60
+ # .github/workflows/codeatlas.yml
61
+ name: CodeAtlas
62
+ on:
63
+ push:
64
+ branches: [main]
65
+ permissions:
66
+ contents: write
67
+ jobs:
68
+ bundle:
69
+ runs-on: ubuntu-latest
70
+ steps:
71
+ - uses: actions/checkout@v4
72
+ - uses: actions/setup-python@v5
73
+ with:
74
+ python-version: "3.12"
75
+ - run: pip install codeatlas-rs codeatlas-bundle
76
+ - run: git config user.name "codeatlas" && git config user.email "codeatlas@users.noreply.github.com"
77
+ - run: codeatlas-bundle build --commit
78
+ - run: git push
79
+ ```
80
+
81
+ To gate a pull request instead of committing, use `codeatlas-bundle check`,
82
+ which exits non-zero when the committed bundle is stale.
83
+
84
+ ## Python API
85
+
86
+ ```python
87
+ from codeatlas_bundle import bundle, check
88
+
89
+ result = bundle("path/to/repo", commit=True)
90
+ print([str(p) for p in result.files])
91
+
92
+ is_current, changed = check("path/to/repo")
93
+ ```
@@ -0,0 +1,8 @@
1
+ codeatlas_bundle/__init__.py,sha256=B17M3k97prbxU9QfobgjU2PreBcvSwgEY-a1n5plOUs,415
2
+ codeatlas_bundle/bundle.py,sha256=1s7SuiXxQv0uMxycxHtVG857svwHq1-Dk03pewpjBKc,5878
3
+ codeatlas_bundle/cli.py,sha256=YTcPGUNS_GlhWGDyWyY-BeZCRCM28hZ93uueF-O0wRk,3234
4
+ codeatlas_bundle/config.py,sha256=ZPdoC63jmpZLYE5DJSxq8ycykFpAXOjOp-fBv822D1g,3166
5
+ codeatlas_bundle-0.1.0.dist-info/METADATA,sha256=ooSrs4_vH4S9-Pxq1va5Ut8sHkKCB3l98l3WzoGXpnE,2563
6
+ codeatlas_bundle-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
7
+ codeatlas_bundle-0.1.0.dist-info/entry_points.txt,sha256=72qlVPnYa3EApLlQuNVHCTIek-l0cRs-tSNuunP-ps8,63
8
+ codeatlas_bundle-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ codeatlas-bundle = codeatlas_bundle.cli:main