codeatlas-bundle 0.1.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.
- codeatlas_bundle-0.1.0/.gitignore +15 -0
- codeatlas_bundle-0.1.0/PKG-INFO +93 -0
- codeatlas_bundle-0.1.0/README.md +79 -0
- codeatlas_bundle-0.1.0/pyproject.toml +28 -0
- codeatlas_bundle-0.1.0/src/codeatlas_bundle/__init__.py +17 -0
- codeatlas_bundle-0.1.0/src/codeatlas_bundle/bundle.py +179 -0
- codeatlas_bundle-0.1.0/src/codeatlas_bundle/cli.py +93 -0
- codeatlas_bundle-0.1.0/src/codeatlas_bundle/config.py +101 -0
- codeatlas_bundle-0.1.0/tests/test_bundle.py +104 -0
|
@@ -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,79 @@
|
|
|
1
|
+
# codeatlas-bundle
|
|
2
|
+
|
|
3
|
+
Bundle [CodeAtlas](https://github.com/Mullassery/CodeAtlas) architecture maps
|
|
4
|
+
into any repository so the map is versioned alongside the code.
|
|
5
|
+
|
|
6
|
+
`codeatlas-bundle` runs the deterministic CodeAtlas analysis, renders the docs
|
|
7
|
+
(Markdown + HTML by default), writes them under a configured directory, and can
|
|
8
|
+
commit the result. It is pure Python and works for repositories in any language
|
|
9
|
+
CodeAtlas supports (Python, Rust, Go, JavaScript/TypeScript).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install codeatlas-rs codeatlas-bundle
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Connect a repository
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd my-repo
|
|
21
|
+
codeatlas-bundle init # writes codeatlas.json
|
|
22
|
+
codeatlas-bundle build # generates docs/codeatlas/
|
|
23
|
+
codeatlas-bundle build --commit # generate and commit
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`codeatlas.json`:
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"project_name": "my-repo",
|
|
31
|
+
"output_dir": "docs/codeatlas",
|
|
32
|
+
"formats": ["markdown", "html"],
|
|
33
|
+
"depth_cap": 3,
|
|
34
|
+
"include_analysis": false
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- `formats`: any of `markdown`, `html`, `mdx`, `rst`.
|
|
39
|
+
- `include_analysis`: when `true`, also commit the raw `analysis.json`,
|
|
40
|
+
`fingerprint.json`, and `file_coverage.json` sidecars (the raw analysis
|
|
41
|
+
contains a generation timestamp, so this produces a larger diff each run).
|
|
42
|
+
|
|
43
|
+
## CI: keep the map fresh
|
|
44
|
+
|
|
45
|
+
```yaml
|
|
46
|
+
# .github/workflows/codeatlas.yml
|
|
47
|
+
name: CodeAtlas
|
|
48
|
+
on:
|
|
49
|
+
push:
|
|
50
|
+
branches: [main]
|
|
51
|
+
permissions:
|
|
52
|
+
contents: write
|
|
53
|
+
jobs:
|
|
54
|
+
bundle:
|
|
55
|
+
runs-on: ubuntu-latest
|
|
56
|
+
steps:
|
|
57
|
+
- uses: actions/checkout@v4
|
|
58
|
+
- uses: actions/setup-python@v5
|
|
59
|
+
with:
|
|
60
|
+
python-version: "3.12"
|
|
61
|
+
- run: pip install codeatlas-rs codeatlas-bundle
|
|
62
|
+
- run: git config user.name "codeatlas" && git config user.email "codeatlas@users.noreply.github.com"
|
|
63
|
+
- run: codeatlas-bundle build --commit
|
|
64
|
+
- run: git push
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
To gate a pull request instead of committing, use `codeatlas-bundle check`,
|
|
68
|
+
which exits non-zero when the committed bundle is stale.
|
|
69
|
+
|
|
70
|
+
## Python API
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from codeatlas_bundle import bundle, check
|
|
74
|
+
|
|
75
|
+
result = bundle("path/to/repo", commit=True)
|
|
76
|
+
print([str(p) for p in result.files])
|
|
77
|
+
|
|
78
|
+
is_current, changed = check("path/to/repo")
|
|
79
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "codeatlas-bundle"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Bundle CodeAtlas architecture maps into any repository"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Mullassery" }]
|
|
13
|
+
keywords = ["architecture", "diagrams", "static-analysis", "documentation"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Programming Language :: Python",
|
|
17
|
+
"Topic :: Software Development :: Documentation",
|
|
18
|
+
]
|
|
19
|
+
dependencies = ["codeatlas-rs>=0.1.0"]
|
|
20
|
+
|
|
21
|
+
[project.scripts]
|
|
22
|
+
codeatlas-bundle = "codeatlas_bundle.cli:main"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/codeatlas_bundle"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
@@ -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,104 @@
|
|
|
1
|
+
"""Tests for codeatlas-bundle: generation, staleness checks, and commits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from codeatlas_bundle import bundle, check, detect_repo_root, init_config, load_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _make_repo(tmp_path: Path) -> Path:
|
|
15
|
+
(tmp_path / "app").mkdir()
|
|
16
|
+
(tmp_path / "core").mkdir()
|
|
17
|
+
(tmp_path / "app" / "main.py").write_text("def run():\n return helper()\n")
|
|
18
|
+
(tmp_path / "app" / "helper.py").write_text("def helper():\n return 1\n")
|
|
19
|
+
(tmp_path / "core" / "engine.py").write_text("def start():\n return 1\n")
|
|
20
|
+
return tmp_path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _git(repo: Path, *args: str) -> None:
|
|
24
|
+
subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_init_writes_config(tmp_path):
|
|
28
|
+
repo = _make_repo(tmp_path)
|
|
29
|
+
path = init_config(repo)
|
|
30
|
+
assert path == repo / "codeatlas.json"
|
|
31
|
+
data = json.loads(path.read_text())
|
|
32
|
+
assert data["output_dir"] == "docs/codeatlas"
|
|
33
|
+
assert data["formats"] == ["markdown", "html"]
|
|
34
|
+
|
|
35
|
+
with pytest.raises(FileExistsError):
|
|
36
|
+
init_config(repo)
|
|
37
|
+
assert init_config(repo, force=True).exists()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_bundle_generates_docs_and_is_current(tmp_path):
|
|
41
|
+
repo = _make_repo(tmp_path)
|
|
42
|
+
init_config(repo)
|
|
43
|
+
|
|
44
|
+
result = bundle(repo)
|
|
45
|
+
rels = {p.relative_to(repo).as_posix() for p in result.files}
|
|
46
|
+
assert "docs/codeatlas/overview.md" in rels
|
|
47
|
+
assert "docs/codeatlas/overview.html" in rels
|
|
48
|
+
|
|
49
|
+
current, changed = check(repo)
|
|
50
|
+
assert current, changed
|
|
51
|
+
assert changed == []
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_check_detects_source_change(tmp_path):
|
|
55
|
+
repo = _make_repo(tmp_path)
|
|
56
|
+
init_config(repo)
|
|
57
|
+
bundle(repo)
|
|
58
|
+
|
|
59
|
+
# Change the code without regenerating the bundle.
|
|
60
|
+
(repo / "app" / "helper.py").write_text("def helper():\n return 2\n")
|
|
61
|
+
(repo / "app" / "new.py").write_text("def added():\n return 3\n")
|
|
62
|
+
|
|
63
|
+
current, changed = check(repo)
|
|
64
|
+
assert not current
|
|
65
|
+
assert changed
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_bundle_commits_into_repo(tmp_path):
|
|
69
|
+
repo = _make_repo(tmp_path)
|
|
70
|
+
_git(repo, "init")
|
|
71
|
+
_git(repo, "config", "user.email", "test@example.com")
|
|
72
|
+
_git(repo, "config", "user.name", "Test")
|
|
73
|
+
_git(repo, "add", "-A")
|
|
74
|
+
_git(repo, "commit", "-m", "initial")
|
|
75
|
+
|
|
76
|
+
init_config(repo)
|
|
77
|
+
result = bundle(repo, commit=True)
|
|
78
|
+
assert result.committed is True
|
|
79
|
+
|
|
80
|
+
log = subprocess.run(
|
|
81
|
+
["git", "-C", str(repo), "log", "--oneline"],
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
check=True,
|
|
85
|
+
).stdout
|
|
86
|
+
assert "codeatlas" in log
|
|
87
|
+
|
|
88
|
+
# No changes on a second run -> nothing to commit.
|
|
89
|
+
result2 = bundle(repo, commit=True)
|
|
90
|
+
assert result2.committed is False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_detect_repo_root_finds_config(tmp_path):
|
|
94
|
+
repo = _make_repo(tmp_path)
|
|
95
|
+
init_config(repo)
|
|
96
|
+
nested = repo / "app"
|
|
97
|
+
assert detect_repo_root(nested) == repo
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_load_config_defaults_without_file(tmp_path):
|
|
101
|
+
repo = _make_repo(tmp_path)
|
|
102
|
+
config = load_config(repo)
|
|
103
|
+
assert config.project_name == repo.name
|
|
104
|
+
assert config.output_dir == "docs/codeatlas"
|