explain-repo 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.
- explain_repo-0.1.0/.gitignore +51 -0
- explain_repo-0.1.0/LICENSE +21 -0
- explain_repo-0.1.0/PKG-INFO +157 -0
- explain_repo-0.1.0/README.md +111 -0
- explain_repo-0.1.0/pyproject.toml +47 -0
- explain_repo-0.1.0/src/explain_repo/__init__.py +3 -0
- explain_repo-0.1.0/src/explain_repo/cli.py +143 -0
- explain_repo-0.1.0/src/explain_repo/graph.py +124 -0
- explain_repo-0.1.0/src/explain_repo/llm.py +48 -0
- explain_repo-0.1.0/src/explain_repo/parser.py +107 -0
- explain_repo-0.1.0/tests/test_cli.py +67 -0
- explain_repo-0.1.0/tests/test_graph.py +40 -0
- explain_repo-0.1.0/tests/test_parser.py +46 -0
- explain_repo-0.1.0/uv.lock +504 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Byte-compiled files and Python caches
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# Virtual environments
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
env/
|
|
10
|
+
ENV/
|
|
11
|
+
|
|
12
|
+
# Packaging and build output
|
|
13
|
+
build/
|
|
14
|
+
dist/
|
|
15
|
+
*.egg-info/
|
|
16
|
+
.eggs/
|
|
17
|
+
eggs/
|
|
18
|
+
sdist/
|
|
19
|
+
wheels/
|
|
20
|
+
pip-wheel-metadata/
|
|
21
|
+
|
|
22
|
+
# Test, coverage, and analysis caches
|
|
23
|
+
.pytest_cache/
|
|
24
|
+
.coverage
|
|
25
|
+
.coverage.*
|
|
26
|
+
coverage.xml
|
|
27
|
+
htmlcov/
|
|
28
|
+
.tox/
|
|
29
|
+
.nox/
|
|
30
|
+
.mypy_cache/
|
|
31
|
+
.pyright/
|
|
32
|
+
.ruff_cache/
|
|
33
|
+
.hypothesis/
|
|
34
|
+
|
|
35
|
+
# Local environment and secrets
|
|
36
|
+
.env
|
|
37
|
+
.env.*
|
|
38
|
+
!.env.example
|
|
39
|
+
|
|
40
|
+
# Editor and operating-system files
|
|
41
|
+
.vscode/
|
|
42
|
+
.idea/
|
|
43
|
+
*.swp
|
|
44
|
+
*.swo
|
|
45
|
+
*~
|
|
46
|
+
.DS_Store
|
|
47
|
+
Thumbs.db
|
|
48
|
+
|
|
49
|
+
# Logs and temporary files
|
|
50
|
+
*.log
|
|
51
|
+
*.tmp
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 alintm4
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: explain-repo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Static-analysis guided onboarding reports for Python repositories
|
|
5
|
+
Project-URL: Homepage, https://github.com/alintm4/explain-repo
|
|
6
|
+
Project-URL: Repository, https://github.com/alintm4/explain-repo.git
|
|
7
|
+
Project-URL: Issues, https://github.com/alintm4/explain-repo/issues
|
|
8
|
+
Author-email: alintm4 <alintimilsana@gmail.com>
|
|
9
|
+
License: MIT License
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2026 alintm4
|
|
12
|
+
|
|
13
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
14
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
15
|
+
in the Software without restriction, including without limitation the rights
|
|
16
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
17
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
18
|
+
furnished to do so, subject to the following conditions:
|
|
19
|
+
|
|
20
|
+
The above copyright notice and this permission notice shall be included in all
|
|
21
|
+
copies or substantial portions of the Software.
|
|
22
|
+
|
|
23
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
24
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
25
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
26
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
27
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
28
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
29
|
+
SOFTWARE.
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
37
|
+
Requires-Python: >=3.11
|
|
38
|
+
Requires-Dist: click>=8.1
|
|
39
|
+
Requires-Dist: networkx>=3.2
|
|
40
|
+
Requires-Dist: rich>=13.7
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
43
|
+
Provides-Extra: llm
|
|
44
|
+
Requires-Dist: anthropic>=0.40; extra == 'llm'
|
|
45
|
+
Description-Content-Type: text/markdown
|
|
46
|
+
|
|
47
|
+
# explain-repo
|
|
48
|
+
|
|
49
|
+
`explain-repo` statically analyzes a local Python repository and produces a
|
|
50
|
+
guided onboarding report. It parses Python with the standard-library `ast`
|
|
51
|
+
module, resolves internal imports, builds a NetworkX dependency graph, and ranks
|
|
52
|
+
files without reading meaning into source text.
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
After the package is published to PyPI, run it without installing it globally:
|
|
57
|
+
|
|
58
|
+
```console
|
|
59
|
+
uvx explain-repo ./path/to/repository
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For local development:
|
|
63
|
+
|
|
64
|
+
```console
|
|
65
|
+
git clone <repository-url>
|
|
66
|
+
cd explain-repo
|
|
67
|
+
uv sync
|
|
68
|
+
uv run pytest
|
|
69
|
+
uvx --from . explain-repo ./path/to/repository
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Python 3.11 or newer is required.
|
|
73
|
+
|
|
74
|
+
## Usage
|
|
75
|
+
|
|
76
|
+
```console
|
|
77
|
+
explain-repo [OPTIONS] PATH
|
|
78
|
+
|
|
79
|
+
Options:
|
|
80
|
+
--top INTEGER RANGE Number of files to show. [default: 10]
|
|
81
|
+
--json Output structured JSON.
|
|
82
|
+
--rank-method [indegree|pagerank]
|
|
83
|
+
Ranking algorithm. [default: pagerank]
|
|
84
|
+
--llm Add structure-only Anthropic descriptions.
|
|
85
|
+
--help Show help and exit.
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Examples:
|
|
89
|
+
|
|
90
|
+
```console
|
|
91
|
+
uvx explain-repo . --top 5
|
|
92
|
+
uvx explain-repo . --rank-method indegree
|
|
93
|
+
uvx explain-repo . --json > report.json
|
|
94
|
+
uvx --from 'explain-repo[llm]' explain-repo . --llm
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Sample terminal output:
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
Suggested Reading Order
|
|
101
|
+
┏━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
|
|
102
|
+
┃ # ┃ File ┃ Why central ┃ Dependencies ┃
|
|
103
|
+
┡━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
|
|
104
|
+
│ 1 │ src/app/core.py │ imported by 12 other files │ src/app/types.py │
|
|
105
|
+
│ 2 │ src/app/service.py │ imported by 4 other files │ src/app/core.py │
|
|
106
|
+
└───┴────────────────────┴───────────────────────────┴──────────────────┘
|
|
107
|
+
|
|
108
|
+
Core Abstractions
|
|
109
|
+
┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
|
|
110
|
+
┃ File ┃ Classes ┃ Functions ┃
|
|
111
|
+
┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
|
|
112
|
+
│ src/app/core.py │ Repository (load) │ create_app │
|
|
113
|
+
│ src/app/service.py │ AnalysisService (run)│ analyze │
|
|
114
|
+
└────────────────────┴──────────────────────┴──────────────────┘
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Syntax-invalid files are skipped with a warning. Common generated directories,
|
|
118
|
+
including `.git`, `.venv`, `venv`, `node_modules`, `__pycache__`, `build`, and
|
|
119
|
+
`dist`, are excluded from scanning. Circular imports are represented as ordinary
|
|
120
|
+
cycles in the graph and require no recursive traversal.
|
|
121
|
+
|
|
122
|
+
## Optional Anthropic descriptions
|
|
123
|
+
|
|
124
|
+
Install the `llm` extra and provide Anthropic credentials in the environment:
|
|
125
|
+
|
|
126
|
+
```console
|
|
127
|
+
export ANTHROPIC_API_KEY="..."
|
|
128
|
+
uv sync --extra llm
|
|
129
|
+
uv run explain-repo . --llm
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The model receives only the file path and extracted imports, function names,
|
|
133
|
+
class names, and method names. Full source content is never sent. Override the
|
|
134
|
+
default model with `EXPLAIN_REPO_ANTHROPIC_MODEL`.
|
|
135
|
+
|
|
136
|
+
## Publishing to PyPI
|
|
137
|
+
|
|
138
|
+
The distribution name, Python requirement, runtime dependencies, build backend,
|
|
139
|
+
and `[project.scripts]` entry point are defined in `pyproject.toml`. The script
|
|
140
|
+
entry is what lets `uvx` install the distribution and invoke `explain-repo`.
|
|
141
|
+
|
|
142
|
+
1. Choose the next semantic version and update both `project.version` in
|
|
143
|
+
`pyproject.toml` and `__version__` in `src/explain_repo/__init__.py`.
|
|
144
|
+
2. Run `uv lock`, `uv sync`, `uv run pytest`, and
|
|
145
|
+
`uvx --from . explain-repo .`.
|
|
146
|
+
3. Build clean wheel and source distributions with `uv build`.
|
|
147
|
+
4. Check the release files with `uvx twine check dist/*`.
|
|
148
|
+
5. Create a PyPI trusted publisher for the repository's release workflow, or
|
|
149
|
+
create a scoped PyPI API token.
|
|
150
|
+
6. Publish interactively with `uv publish`; when prompted for token credentials,
|
|
151
|
+
use `__token__` as the username and the PyPI token as the password. In CI,
|
|
152
|
+
prefer PyPI trusted publishing instead of storing a long-lived token.
|
|
153
|
+
7. Verify the published release with
|
|
154
|
+
`uvx --refresh --from explain-repo==<version> explain-repo --help`.
|
|
155
|
+
|
|
156
|
+
PyPI makes the distribution globally discoverable. Before publication,
|
|
157
|
+
`uvx --from . explain-repo PATH` is the correct local equivalent.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# explain-repo
|
|
2
|
+
|
|
3
|
+
`explain-repo` statically analyzes a local Python repository and produces a
|
|
4
|
+
guided onboarding report. It parses Python with the standard-library `ast`
|
|
5
|
+
module, resolves internal imports, builds a NetworkX dependency graph, and ranks
|
|
6
|
+
files without reading meaning into source text.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
After the package is published to PyPI, run it without installing it globally:
|
|
11
|
+
|
|
12
|
+
```console
|
|
13
|
+
uvx explain-repo ./path/to/repository
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
For local development:
|
|
17
|
+
|
|
18
|
+
```console
|
|
19
|
+
git clone <repository-url>
|
|
20
|
+
cd explain-repo
|
|
21
|
+
uv sync
|
|
22
|
+
uv run pytest
|
|
23
|
+
uvx --from . explain-repo ./path/to/repository
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Python 3.11 or newer is required.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```console
|
|
31
|
+
explain-repo [OPTIONS] PATH
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
--top INTEGER RANGE Number of files to show. [default: 10]
|
|
35
|
+
--json Output structured JSON.
|
|
36
|
+
--rank-method [indegree|pagerank]
|
|
37
|
+
Ranking algorithm. [default: pagerank]
|
|
38
|
+
--llm Add structure-only Anthropic descriptions.
|
|
39
|
+
--help Show help and exit.
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Examples:
|
|
43
|
+
|
|
44
|
+
```console
|
|
45
|
+
uvx explain-repo . --top 5
|
|
46
|
+
uvx explain-repo . --rank-method indegree
|
|
47
|
+
uvx explain-repo . --json > report.json
|
|
48
|
+
uvx --from 'explain-repo[llm]' explain-repo . --llm
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Sample terminal output:
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
Suggested Reading Order
|
|
55
|
+
┏━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
|
|
56
|
+
┃ # ┃ File ┃ Why central ┃ Dependencies ┃
|
|
57
|
+
┡━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
|
|
58
|
+
│ 1 │ src/app/core.py │ imported by 12 other files │ src/app/types.py │
|
|
59
|
+
│ 2 │ src/app/service.py │ imported by 4 other files │ src/app/core.py │
|
|
60
|
+
└───┴────────────────────┴───────────────────────────┴──────────────────┘
|
|
61
|
+
|
|
62
|
+
Core Abstractions
|
|
63
|
+
┏━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
|
|
64
|
+
┃ File ┃ Classes ┃ Functions ┃
|
|
65
|
+
┡━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
|
|
66
|
+
│ src/app/core.py │ Repository (load) │ create_app │
|
|
67
|
+
│ src/app/service.py │ AnalysisService (run)│ analyze │
|
|
68
|
+
└────────────────────┴──────────────────────┴──────────────────┘
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Syntax-invalid files are skipped with a warning. Common generated directories,
|
|
72
|
+
including `.git`, `.venv`, `venv`, `node_modules`, `__pycache__`, `build`, and
|
|
73
|
+
`dist`, are excluded from scanning. Circular imports are represented as ordinary
|
|
74
|
+
cycles in the graph and require no recursive traversal.
|
|
75
|
+
|
|
76
|
+
## Optional Anthropic descriptions
|
|
77
|
+
|
|
78
|
+
Install the `llm` extra and provide Anthropic credentials in the environment:
|
|
79
|
+
|
|
80
|
+
```console
|
|
81
|
+
export ANTHROPIC_API_KEY="..."
|
|
82
|
+
uv sync --extra llm
|
|
83
|
+
uv run explain-repo . --llm
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The model receives only the file path and extracted imports, function names,
|
|
87
|
+
class names, and method names. Full source content is never sent. Override the
|
|
88
|
+
default model with `EXPLAIN_REPO_ANTHROPIC_MODEL`.
|
|
89
|
+
|
|
90
|
+
## Publishing to PyPI
|
|
91
|
+
|
|
92
|
+
The distribution name, Python requirement, runtime dependencies, build backend,
|
|
93
|
+
and `[project.scripts]` entry point are defined in `pyproject.toml`. The script
|
|
94
|
+
entry is what lets `uvx` install the distribution and invoke `explain-repo`.
|
|
95
|
+
|
|
96
|
+
1. Choose the next semantic version and update both `project.version` in
|
|
97
|
+
`pyproject.toml` and `__version__` in `src/explain_repo/__init__.py`.
|
|
98
|
+
2. Run `uv lock`, `uv sync`, `uv run pytest`, and
|
|
99
|
+
`uvx --from . explain-repo .`.
|
|
100
|
+
3. Build clean wheel and source distributions with `uv build`.
|
|
101
|
+
4. Check the release files with `uvx twine check dist/*`.
|
|
102
|
+
5. Create a PyPI trusted publisher for the repository's release workflow, or
|
|
103
|
+
create a scoped PyPI API token.
|
|
104
|
+
6. Publish interactively with `uv publish`; when prompted for token credentials,
|
|
105
|
+
use `__token__` as the username and the PyPI token as the password. In CI,
|
|
106
|
+
prefer PyPI trusted publishing instead of storing a long-lived token.
|
|
107
|
+
7. Verify the published release with
|
|
108
|
+
`uvx --refresh --from explain-repo==<version> explain-repo --help`.
|
|
109
|
+
|
|
110
|
+
PyPI makes the distribution globally discoverable. Before publication,
|
|
111
|
+
`uvx --from . explain-repo PATH` is the correct local equivalent.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "explain-repo"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Static-analysis guided onboarding reports for Python repositories"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { file = "LICENSE" }
|
|
12
|
+
authors = [{ name = "alintm4", email = "alintimilsana@gmail.com" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"License :: OSI Approved :: MIT License",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"click>=8.1",
|
|
23
|
+
"networkx>=3.2",
|
|
24
|
+
"rich>=13.7",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/alintm4/explain-repo"
|
|
29
|
+
Repository = "https://github.com/alintm4/explain-repo.git"
|
|
30
|
+
Issues = "https://github.com/alintm4/explain-repo/issues"
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
llm = ["anthropic>=0.40"]
|
|
34
|
+
dev = ["pytest>=8.0"]
|
|
35
|
+
|
|
36
|
+
[project.scripts]
|
|
37
|
+
explain-repo = "explain_repo.cli:main"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["src/explain_repo"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
addopts = "-q"
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
|
|
46
|
+
[dependency-groups]
|
|
47
|
+
dev = ["pytest>=8.0"]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Command-line interface for explain-repo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .graph import build_dependency_graph, rank_files
|
|
15
|
+
from .parser import FileInfo, parse_repository
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _import_label(info: FileInfo) -> list[str]:
|
|
19
|
+
labels = []
|
|
20
|
+
for imported in info.imports:
|
|
21
|
+
prefix = "." * imported.level + (imported.module or "")
|
|
22
|
+
labels.append(prefix or ", ".join(imported.names))
|
|
23
|
+
return labels
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _build_report(
|
|
27
|
+
root: Path, top: int, rank_method: str, include_llm: bool
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
files = parse_repository(root)
|
|
30
|
+
graph = build_dependency_graph(files)
|
|
31
|
+
ranked = rank_files(graph, rank_method)[:top]
|
|
32
|
+
entries = []
|
|
33
|
+
for path, score in ranked:
|
|
34
|
+
info = files[path]
|
|
35
|
+
imported_by = sorted(source.as_posix() for source in graph.predecessors(path))
|
|
36
|
+
dependencies = sorted(target.as_posix() for target in graph.successors(path))
|
|
37
|
+
entry: dict[str, Any] = {
|
|
38
|
+
"path": path.as_posix(),
|
|
39
|
+
"score": score,
|
|
40
|
+
"why_central": f"imported by {len(imported_by)} other file{'s' if len(imported_by) != 1 else ''}",
|
|
41
|
+
"imported_by": imported_by,
|
|
42
|
+
"dependencies": dependencies,
|
|
43
|
+
"imports": _import_label(info),
|
|
44
|
+
"functions": info.functions,
|
|
45
|
+
"classes": [
|
|
46
|
+
{"name": name, "methods": info.class_methods.get(name, [])}
|
|
47
|
+
for name in info.classes
|
|
48
|
+
],
|
|
49
|
+
}
|
|
50
|
+
if include_llm:
|
|
51
|
+
from .llm import describe_file
|
|
52
|
+
|
|
53
|
+
entry["description"] = describe_file(info)
|
|
54
|
+
entries.append(entry)
|
|
55
|
+
return {
|
|
56
|
+
"repository": str(root),
|
|
57
|
+
"rank_method": rank_method,
|
|
58
|
+
"python_file_count": len(files),
|
|
59
|
+
"syntax_errors": [
|
|
60
|
+
{"path": path.as_posix(), "error": info.syntax_error}
|
|
61
|
+
for path, info in files.items()
|
|
62
|
+
if info.syntax_error
|
|
63
|
+
],
|
|
64
|
+
"reading_order": entries,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _render_text(report: dict[str, Any], console: Console) -> None:
|
|
69
|
+
console.print("[bold]Suggested Reading Order[/bold]")
|
|
70
|
+
reading_table = Table(show_header=True, header_style="bold cyan")
|
|
71
|
+
reading_table.add_column("#", justify="right")
|
|
72
|
+
reading_table.add_column("File")
|
|
73
|
+
reading_table.add_column("Why central")
|
|
74
|
+
reading_table.add_column("Dependencies")
|
|
75
|
+
for index, entry in enumerate(report["reading_order"], start=1):
|
|
76
|
+
reading_table.add_row(
|
|
77
|
+
str(index),
|
|
78
|
+
entry["path"],
|
|
79
|
+
entry["why_central"],
|
|
80
|
+
", ".join(entry["dependencies"]) or "None",
|
|
81
|
+
)
|
|
82
|
+
if entry.get("description"):
|
|
83
|
+
reading_table.add_row("", "[dim]Description[/dim]", entry["description"], "")
|
|
84
|
+
console.print(reading_table)
|
|
85
|
+
|
|
86
|
+
console.print("\n[bold]Core Abstractions[/bold]")
|
|
87
|
+
abstraction_table = Table(show_header=True, header_style="bold cyan")
|
|
88
|
+
abstraction_table.add_column("File")
|
|
89
|
+
abstraction_table.add_column("Classes")
|
|
90
|
+
abstraction_table.add_column("Functions")
|
|
91
|
+
for entry in report["reading_order"]:
|
|
92
|
+
classes = []
|
|
93
|
+
for class_info in entry["classes"]:
|
|
94
|
+
methods = ", ".join(class_info["methods"])
|
|
95
|
+
classes.append(f"{class_info['name']} ({methods})" if methods else class_info["name"])
|
|
96
|
+
abstraction_table.add_row(
|
|
97
|
+
entry["path"],
|
|
98
|
+
"\n".join(classes) or "None",
|
|
99
|
+
", ".join(entry["functions"]) or "None",
|
|
100
|
+
)
|
|
101
|
+
console.print(abstraction_table)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@click.command()
|
|
105
|
+
@click.version_option(version=__version__, prog_name="explain-repo")
|
|
106
|
+
@click.argument(
|
|
107
|
+
"path",
|
|
108
|
+
type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
|
|
109
|
+
)
|
|
110
|
+
@click.option("--top", type=click.IntRange(min=1), default=10, show_default=True)
|
|
111
|
+
@click.option("--json", "as_json", is_flag=True, help="Output structured JSON.")
|
|
112
|
+
@click.option(
|
|
113
|
+
"--rank-method",
|
|
114
|
+
type=click.Choice(["indegree", "pagerank"]),
|
|
115
|
+
default="pagerank",
|
|
116
|
+
show_default=True,
|
|
117
|
+
)
|
|
118
|
+
@click.option("--llm", is_flag=True, help="Add Anthropic descriptions from extracted structure.")
|
|
119
|
+
def main(path: Path, top: int, as_json: bool, rank_method: str, llm: bool) -> None:
|
|
120
|
+
"""Analyze the Python repository at PATH and suggest a reading order."""
|
|
121
|
+
root = path.resolve()
|
|
122
|
+
try:
|
|
123
|
+
report = _build_report(root, top, rank_method, llm)
|
|
124
|
+
except RuntimeError as error:
|
|
125
|
+
raise click.ClickException(str(error)) from error
|
|
126
|
+
except Exception as error:
|
|
127
|
+
if llm:
|
|
128
|
+
raise click.ClickException(f"LLM request failed: {error}") from error
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
for syntax_error in report["syntax_errors"]:
|
|
132
|
+
click.echo(
|
|
133
|
+
f"Warning: skipped {syntax_error['path']}: {syntax_error['error']}",
|
|
134
|
+
err=True,
|
|
135
|
+
)
|
|
136
|
+
if as_json:
|
|
137
|
+
click.echo(json.dumps(report, indent=2))
|
|
138
|
+
else:
|
|
139
|
+
_render_text(report, Console())
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
main()
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Build and rank a file-level Python dependency graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from pathlib import Path, PurePosixPath
|
|
7
|
+
|
|
8
|
+
import networkx as nx
|
|
9
|
+
|
|
10
|
+
from .parser import FileInfo, ImportInfo
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _module_name(path: Path) -> str:
|
|
14
|
+
pure_path = PurePosixPath(path.as_posix())
|
|
15
|
+
if pure_path.name == "__init__.py":
|
|
16
|
+
return ".".join(pure_path.parent.parts)
|
|
17
|
+
return ".".join(pure_path.with_suffix("").parts)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _module_index(paths: Iterable[Path]) -> dict[str, Path]:
|
|
21
|
+
index: dict[str, Path] = {}
|
|
22
|
+
for path in paths:
|
|
23
|
+
module = _module_name(path)
|
|
24
|
+
if module:
|
|
25
|
+
index[module] = path
|
|
26
|
+
if module.startswith("src."):
|
|
27
|
+
index[module.removeprefix("src.")] = path
|
|
28
|
+
elif path.name == "__init__.py":
|
|
29
|
+
index["__root__"] = path
|
|
30
|
+
return index
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _package_parts(importer: Path) -> list[str]:
|
|
34
|
+
module_parts = _module_name(importer).split(".") if _module_name(importer) else []
|
|
35
|
+
if importer.name != "__init__.py":
|
|
36
|
+
module_parts = module_parts[:-1]
|
|
37
|
+
return module_parts
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _import_candidates(importer: Path, imported: ImportInfo) -> list[str]:
|
|
41
|
+
if imported.level:
|
|
42
|
+
package = _package_parts(importer)
|
|
43
|
+
keep = max(0, len(package) - imported.level + 1)
|
|
44
|
+
base_parts = package[:keep]
|
|
45
|
+
if imported.module:
|
|
46
|
+
base_parts.extend(imported.module.split("."))
|
|
47
|
+
base = ".".join(base_parts)
|
|
48
|
+
if imported.module:
|
|
49
|
+
return [*(f"{base}.{name}" for name in imported.names), base]
|
|
50
|
+
return [".".join([*base_parts, name]) for name in imported.names]
|
|
51
|
+
|
|
52
|
+
if imported.module:
|
|
53
|
+
return [
|
|
54
|
+
*(f"{imported.module}.{name}" for name in imported.names),
|
|
55
|
+
imported.module,
|
|
56
|
+
]
|
|
57
|
+
return list(imported.names)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_import(
|
|
61
|
+
importer: Path, imported: ImportInfo, module_index: dict[str, Path]
|
|
62
|
+
) -> set[Path]:
|
|
63
|
+
"""Resolve an import to files indexed inside the analyzed repository."""
|
|
64
|
+
resolved: set[Path] = set()
|
|
65
|
+
for candidate in _import_candidates(importer, imported):
|
|
66
|
+
if candidate in module_index:
|
|
67
|
+
resolved.add(module_index[candidate])
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
parts = candidate.split(".")
|
|
71
|
+
while len(parts) > 1:
|
|
72
|
+
parts.pop()
|
|
73
|
+
parent = ".".join(parts)
|
|
74
|
+
if parent in module_index:
|
|
75
|
+
resolved.add(module_index[parent])
|
|
76
|
+
break
|
|
77
|
+
return resolved
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def build_dependency_graph(files: dict[Path, FileInfo]) -> nx.DiGraph:
|
|
81
|
+
"""Build a graph whose edges point from importers to imported files."""
|
|
82
|
+
graph = nx.DiGraph()
|
|
83
|
+
graph.add_nodes_from(files)
|
|
84
|
+
index = _module_index(files)
|
|
85
|
+
for path, info in files.items():
|
|
86
|
+
for imported in info.imports:
|
|
87
|
+
for dependency in resolve_import(path, imported, index):
|
|
88
|
+
if dependency != path:
|
|
89
|
+
graph.add_edge(path, dependency)
|
|
90
|
+
return graph
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _pagerank(graph: nx.DiGraph, damping: float = 0.85) -> dict[Path, float]:
|
|
94
|
+
if not graph:
|
|
95
|
+
return {}
|
|
96
|
+
node_count = len(graph)
|
|
97
|
+
scores = {node: 1.0 / node_count for node in graph}
|
|
98
|
+
base_score = (1.0 - damping) / node_count
|
|
99
|
+
for _ in range(100):
|
|
100
|
+
dangling_score = sum(scores[node] for node in graph if graph.out_degree(node) == 0)
|
|
101
|
+
updated = {}
|
|
102
|
+
for node in graph:
|
|
103
|
+
inbound_score = sum(
|
|
104
|
+
scores[source] / graph.out_degree(source)
|
|
105
|
+
for source in graph.predecessors(node)
|
|
106
|
+
)
|
|
107
|
+
updated[node] = base_score + damping * (
|
|
108
|
+
inbound_score + dangling_score / node_count
|
|
109
|
+
)
|
|
110
|
+
if sum(abs(updated[node] - scores[node]) for node in graph) < node_count * 1e-6:
|
|
111
|
+
return updated
|
|
112
|
+
scores = updated
|
|
113
|
+
return scores
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def rank_files(graph: nx.DiGraph, method: str = "pagerank") -> list[tuple[Path, float]]:
|
|
117
|
+
"""Rank files by PageRank or in-degree centrality."""
|
|
118
|
+
if method == "pagerank":
|
|
119
|
+
scores = _pagerank(graph)
|
|
120
|
+
elif method == "indegree":
|
|
121
|
+
scores = nx.in_degree_centrality(graph) if len(graph) > 1 else {node: 0.0 for node in graph}
|
|
122
|
+
else:
|
|
123
|
+
raise ValueError(f"Unsupported rank method: {method}")
|
|
124
|
+
return sorted(scores.items(), key=lambda item: (-item[1], item[0].as_posix()))
|