readme-drift 1.0.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.
- readme_drift-1.0.0/LICENSE +21 -0
- readme_drift-1.0.0/PKG-INFO +209 -0
- readme_drift-1.0.0/README.md +187 -0
- readme_drift-1.0.0/pyproject.toml +55 -0
- readme_drift-1.0.0/readme_drift/ast_diff.py +224 -0
- readme_drift-1.0.0/readme_drift/cli.py +118 -0
- readme_drift-1.0.0/readme_drift/config_diff.py +172 -0
- readme_drift-1.0.0/readme_drift/drift_checker.py +134 -0
- readme_drift-1.0.0/readme_drift/git.py +148 -0
- readme_drift-1.0.0/readme_drift/models.py +99 -0
- readme_drift-1.0.0/readme_drift/report.py +33 -0
- readme_drift-1.0.0/readme_drift/scanner.py +82 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, readme-check contributors <Sachin Nandakumar>
|
|
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,209 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: readme-drift
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Detect stale README references after code changes — for pre-commit and CI.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: readme,documentation,pre-commit,ci,linting
|
|
7
|
+
Author: Sachin Nandakumar
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
17
|
+
Requires-Dist: pyyaml (>=6.0)
|
|
18
|
+
Project-URL: Homepage, https://github.com/sachn1/readme-drift
|
|
19
|
+
Project-URL: Issues, https://github.com/sachn1/readme-drift/issues
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# readme-drift
|
|
23
|
+
|
|
24
|
+
> Detect stale README references after code changes — for pre-commit and CI.
|
|
25
|
+
|
|
26
|
+
When you rename a function, change a method signature, remove a class, or rename a key in a config file, `readme-drift` warns you if those names are still referenced in your README — before the commit lands.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## How it works
|
|
31
|
+
|
|
32
|
+
```mermaid
|
|
33
|
+
flowchart LR
|
|
34
|
+
A["git diff"] --> B["Changed .py files\nAST diff"]
|
|
35
|
+
A --> C["Changed config files\nKey-path diff"]
|
|
36
|
+
B --> D["Scan README\nbacktick + word-boundary"]
|
|
37
|
+
C --> D
|
|
38
|
+
D --> E{"Match?"}
|
|
39
|
+
E -->|Yes| F["❌ Fail"]
|
|
40
|
+
E -->|No| G["✅ Pass"]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Installation
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install readme-drift
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
### As a pre-commit hook (recommended)
|
|
56
|
+
|
|
57
|
+
Add to your `.pre-commit-config.yaml`:
|
|
58
|
+
|
|
59
|
+
```yaml
|
|
60
|
+
repos:
|
|
61
|
+
- repo: https://github.com/sachn1/readme-drift
|
|
62
|
+
rev: v0.2.0
|
|
63
|
+
hooks:
|
|
64
|
+
- id: readme-drift
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Then install the hook:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
pre-commit install
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Every `git commit` that changes `.py`, `.yml`, `.yaml`, `.json`, or `.toml` files will now check if the README needs updating.
|
|
74
|
+
|
|
75
|
+
### As a CLI tool
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Check staged changes (same as pre-commit)
|
|
79
|
+
readme-drift --staged
|
|
80
|
+
|
|
81
|
+
# Check against a specific branch (for CI / PRs)
|
|
82
|
+
readme-drift --base-ref origin/main
|
|
83
|
+
|
|
84
|
+
# Warn only — don't fail the build
|
|
85
|
+
readme-drift --base-ref origin/main --warn-only
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### In CI (GitHub Actions)
|
|
89
|
+
|
|
90
|
+
```yaml
|
|
91
|
+
- name: Check README staleness
|
|
92
|
+
run: readme-drift --base-ref origin/${{ github.base_ref }}
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
All CLI flags can be set permanently in `pyproject.toml` under `[tool.readme-drift]`.
|
|
100
|
+
CLI flags always take precedence over the file.
|
|
101
|
+
|
|
102
|
+
```toml
|
|
103
|
+
[tool.readme-drift]
|
|
104
|
+
# Git ref to diff against. Default: "HEAD"
|
|
105
|
+
base-ref = "origin/main"
|
|
106
|
+
|
|
107
|
+
# Print warnings but never fail the build. Default: false
|
|
108
|
+
warn-only = false
|
|
109
|
+
|
|
110
|
+
# Track private (underscore-prefixed) symbols. Default: false
|
|
111
|
+
include-private = false
|
|
112
|
+
|
|
113
|
+
# Skip files or directories matching these glob patterns.
|
|
114
|
+
# Each entry is equivalent to one --exclude flag on the CLI.
|
|
115
|
+
# Default: []
|
|
116
|
+
exclude = [
|
|
117
|
+
"generated/",
|
|
118
|
+
"tests/",
|
|
119
|
+
"docs/",
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
# Match symbols as plain text (word-boundary) in addition to backtick spans.
|
|
123
|
+
# Default: true. Set to false to restrict matching to backtick spans only.
|
|
124
|
+
plain-text-search = true
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Supported keys
|
|
128
|
+
|
|
129
|
+
| Key | Type | CLI equivalent | Default |
|
|
130
|
+
|---|---|---|---|
|
|
131
|
+
| `base-ref` | string | `--base-ref` | `"HEAD"` |
|
|
132
|
+
| `warn-only` | bool | `--warn-only=true/false` | `false` |
|
|
133
|
+
| `include-private` | bool | `--include-private=true/false` | `false` |
|
|
134
|
+
| `exclude` | list of strings | `--exclude` (repeatable) | `[]` |
|
|
135
|
+
| `plain-text-search` | bool | `--plain-text-search=true/false` | `true` |
|
|
136
|
+
|
|
137
|
+
`pyproject.toml` is discovered by walking up from the current directory (or `--repo-root` if set). If no `[tool.readme-drift]` section is present, all defaults apply.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Developer reference
|
|
142
|
+
|
|
143
|
+
A fully annotated [Jupyter notebook](notebooks/demo.ipynb) walks through each module in depth — AST parsing, signature extraction, config diffing, the README scanner, and the complete end-to-end pipeline without git. Useful for understanding the internals or experimenting with edge cases.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## Example output
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
readme-drift: ❌ README.md may be stale:
|
|
151
|
+
|
|
152
|
+
• `Client.connect` signature changed: connect(host, port) → connect(url)
|
|
153
|
+
in src/client.py
|
|
154
|
+
referenced in README.md line 42: …call `Client.connect(host, port)` to connect…
|
|
155
|
+
|
|
156
|
+
• `build` was removed
|
|
157
|
+
in package.json
|
|
158
|
+
referenced in README.md line 18: …run `npm run build` to compile…
|
|
159
|
+
|
|
160
|
+
→ Please update the README or run with --no-verify to skip.
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
---
|
|
164
|
+
|
|
165
|
+
## What it catches
|
|
166
|
+
|
|
167
|
+
### Python files (`.py`)
|
|
168
|
+
|
|
169
|
+
| Change | Detected? |
|
|
170
|
+
|---|---|
|
|
171
|
+
| Function renamed | ✅ old name flagged as removed |
|
|
172
|
+
| Function removed | ✅ |
|
|
173
|
+
| Method signature changed | ✅ |
|
|
174
|
+
| Class removed | ✅ |
|
|
175
|
+
| Private symbol changed (`_name`) | ➖ ignored by design |
|
|
176
|
+
| README updated alongside code | ✅ passes silently |
|
|
177
|
+
| No Python files changed | ✅ skipped |
|
|
178
|
+
|
|
179
|
+
### Config files (`.yml`, `.yaml`, `.json`, `.toml`)
|
|
180
|
+
|
|
181
|
+
| Change | Detected? |
|
|
182
|
+
|---|---|
|
|
183
|
+
| Script key removed (`"build"` → gone) | ✅ |
|
|
184
|
+
| Job name removed (`build:` → gone) | ✅ |
|
|
185
|
+
| Tool section removed (`[tool.black]` → gone) | ✅ |
|
|
186
|
+
| Key renamed at same level | ✅ (reported as remove + add) |
|
|
187
|
+
| Value changed, key unchanged | ➖ not tracked |
|
|
188
|
+
|
|
189
|
+
## What it doesn't catch
|
|
190
|
+
|
|
191
|
+
- Behavioral changes that don't affect the public API or config surface
|
|
192
|
+
- Symbols not mentioned in the README
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Supported README formats
|
|
197
|
+
|
|
198
|
+
Any file named `readme` (case-insensitive) with the extension `.md`, `.markdown`, `.rst`, `.txt`, or no extension is scanned. All README files in the repository are discovered recursively, including per-package READMEs in monorepos.
|
|
199
|
+
|
|
200
|
+
The following directories are never searched:
|
|
201
|
+
|
|
202
|
+
`.git` · `node_modules` · `venv` · `.venv` · `.tox` · `__pycache__` · `.pytest_cache` · `dist` · `build` · `.mypy_cache`
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
MIT
|
|
209
|
+
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# readme-drift
|
|
2
|
+
|
|
3
|
+
> Detect stale README references after code changes — for pre-commit and CI.
|
|
4
|
+
|
|
5
|
+
When you rename a function, change a method signature, remove a class, or rename a key in a config file, `readme-drift` warns you if those names are still referenced in your README — before the commit lands.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
```mermaid
|
|
12
|
+
flowchart LR
|
|
13
|
+
A["git diff"] --> B["Changed .py files\nAST diff"]
|
|
14
|
+
A --> C["Changed config files\nKey-path diff"]
|
|
15
|
+
B --> D["Scan README\nbacktick + word-boundary"]
|
|
16
|
+
C --> D
|
|
17
|
+
D --> E{"Match?"}
|
|
18
|
+
E -->|Yes| F["❌ Fail"]
|
|
19
|
+
E -->|No| G["✅ Pass"]
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install readme-drift
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
### As a pre-commit hook (recommended)
|
|
35
|
+
|
|
36
|
+
Add to your `.pre-commit-config.yaml`:
|
|
37
|
+
|
|
38
|
+
```yaml
|
|
39
|
+
repos:
|
|
40
|
+
- repo: https://github.com/sachn1/readme-drift
|
|
41
|
+
rev: v0.2.0
|
|
42
|
+
hooks:
|
|
43
|
+
- id: readme-drift
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Then install the hook:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pre-commit install
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Every `git commit` that changes `.py`, `.yml`, `.yaml`, `.json`, or `.toml` files will now check if the README needs updating.
|
|
53
|
+
|
|
54
|
+
### As a CLI tool
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
# Check staged changes (same as pre-commit)
|
|
58
|
+
readme-drift --staged
|
|
59
|
+
|
|
60
|
+
# Check against a specific branch (for CI / PRs)
|
|
61
|
+
readme-drift --base-ref origin/main
|
|
62
|
+
|
|
63
|
+
# Warn only — don't fail the build
|
|
64
|
+
readme-drift --base-ref origin/main --warn-only
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### In CI (GitHub Actions)
|
|
68
|
+
|
|
69
|
+
```yaml
|
|
70
|
+
- name: Check README staleness
|
|
71
|
+
run: readme-drift --base-ref origin/${{ github.base_ref }}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Configuration
|
|
77
|
+
|
|
78
|
+
All CLI flags can be set permanently in `pyproject.toml` under `[tool.readme-drift]`.
|
|
79
|
+
CLI flags always take precedence over the file.
|
|
80
|
+
|
|
81
|
+
```toml
|
|
82
|
+
[tool.readme-drift]
|
|
83
|
+
# Git ref to diff against. Default: "HEAD"
|
|
84
|
+
base-ref = "origin/main"
|
|
85
|
+
|
|
86
|
+
# Print warnings but never fail the build. Default: false
|
|
87
|
+
warn-only = false
|
|
88
|
+
|
|
89
|
+
# Track private (underscore-prefixed) symbols. Default: false
|
|
90
|
+
include-private = false
|
|
91
|
+
|
|
92
|
+
# Skip files or directories matching these glob patterns.
|
|
93
|
+
# Each entry is equivalent to one --exclude flag on the CLI.
|
|
94
|
+
# Default: []
|
|
95
|
+
exclude = [
|
|
96
|
+
"generated/",
|
|
97
|
+
"tests/",
|
|
98
|
+
"docs/",
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
# Match symbols as plain text (word-boundary) in addition to backtick spans.
|
|
102
|
+
# Default: true. Set to false to restrict matching to backtick spans only.
|
|
103
|
+
plain-text-search = true
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Supported keys
|
|
107
|
+
|
|
108
|
+
| Key | Type | CLI equivalent | Default |
|
|
109
|
+
|---|---|---|---|
|
|
110
|
+
| `base-ref` | string | `--base-ref` | `"HEAD"` |
|
|
111
|
+
| `warn-only` | bool | `--warn-only=true/false` | `false` |
|
|
112
|
+
| `include-private` | bool | `--include-private=true/false` | `false` |
|
|
113
|
+
| `exclude` | list of strings | `--exclude` (repeatable) | `[]` |
|
|
114
|
+
| `plain-text-search` | bool | `--plain-text-search=true/false` | `true` |
|
|
115
|
+
|
|
116
|
+
`pyproject.toml` is discovered by walking up from the current directory (or `--repo-root` if set). If no `[tool.readme-drift]` section is present, all defaults apply.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Developer reference
|
|
121
|
+
|
|
122
|
+
A fully annotated [Jupyter notebook](notebooks/demo.ipynb) walks through each module in depth — AST parsing, signature extraction, config diffing, the README scanner, and the complete end-to-end pipeline without git. Useful for understanding the internals or experimenting with edge cases.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## Example output
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
readme-drift: ❌ README.md may be stale:
|
|
130
|
+
|
|
131
|
+
• `Client.connect` signature changed: connect(host, port) → connect(url)
|
|
132
|
+
in src/client.py
|
|
133
|
+
referenced in README.md line 42: …call `Client.connect(host, port)` to connect…
|
|
134
|
+
|
|
135
|
+
• `build` was removed
|
|
136
|
+
in package.json
|
|
137
|
+
referenced in README.md line 18: …run `npm run build` to compile…
|
|
138
|
+
|
|
139
|
+
→ Please update the README or run with --no-verify to skip.
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## What it catches
|
|
145
|
+
|
|
146
|
+
### Python files (`.py`)
|
|
147
|
+
|
|
148
|
+
| Change | Detected? |
|
|
149
|
+
|---|---|
|
|
150
|
+
| Function renamed | ✅ old name flagged as removed |
|
|
151
|
+
| Function removed | ✅ |
|
|
152
|
+
| Method signature changed | ✅ |
|
|
153
|
+
| Class removed | ✅ |
|
|
154
|
+
| Private symbol changed (`_name`) | ➖ ignored by design |
|
|
155
|
+
| README updated alongside code | ✅ passes silently |
|
|
156
|
+
| No Python files changed | ✅ skipped |
|
|
157
|
+
|
|
158
|
+
### Config files (`.yml`, `.yaml`, `.json`, `.toml`)
|
|
159
|
+
|
|
160
|
+
| Change | Detected? |
|
|
161
|
+
|---|---|
|
|
162
|
+
| Script key removed (`"build"` → gone) | ✅ |
|
|
163
|
+
| Job name removed (`build:` → gone) | ✅ |
|
|
164
|
+
| Tool section removed (`[tool.black]` → gone) | ✅ |
|
|
165
|
+
| Key renamed at same level | ✅ (reported as remove + add) |
|
|
166
|
+
| Value changed, key unchanged | ➖ not tracked |
|
|
167
|
+
|
|
168
|
+
## What it doesn't catch
|
|
169
|
+
|
|
170
|
+
- Behavioral changes that don't affect the public API or config surface
|
|
171
|
+
- Symbols not mentioned in the README
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## Supported README formats
|
|
176
|
+
|
|
177
|
+
Any file named `readme` (case-insensitive) with the extension `.md`, `.markdown`, `.rst`, `.txt`, or no extension is scanned. All README files in the repository are discovered recursively, including per-package READMEs in monorepos.
|
|
178
|
+
|
|
179
|
+
The following directories are never searched:
|
|
180
|
+
|
|
181
|
+
`.git` · `node_modules` · `venv` · `.venv` · `.tox` · `__pycache__` · `.pytest_cache` · `dist` · `build` · `.mypy_cache`
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["poetry-core>=1.0.0"]
|
|
3
|
+
build-backend = "poetry.core.masonry.api"
|
|
4
|
+
|
|
5
|
+
[tool.poetry]
|
|
6
|
+
name = "readme-drift"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Detect stale README references after code changes — for pre-commit and CI."
|
|
9
|
+
authors = ["Sachin Nandakumar"]
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = ["readme", "documentation", "pre-commit", "ci", "linting"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
21
|
+
]
|
|
22
|
+
packages = [{ include = "readme_drift" }]
|
|
23
|
+
|
|
24
|
+
[tool.poetry.dependencies]
|
|
25
|
+
python = ">=3.11"
|
|
26
|
+
pyyaml = ">=6.0"
|
|
27
|
+
|
|
28
|
+
[tool.poetry.group.dev.dependencies]
|
|
29
|
+
pytest = ">=7.0"
|
|
30
|
+
pytest-cov = ">=4.0"
|
|
31
|
+
ruff = ">=0.4"
|
|
32
|
+
commitizen = ">=4.1.1,<5"
|
|
33
|
+
types-PyYAML = ">=6.0"
|
|
34
|
+
|
|
35
|
+
[tool.poetry.scripts]
|
|
36
|
+
readme-drift = "readme_drift.cli:main"
|
|
37
|
+
|
|
38
|
+
[tool.poetry.urls]
|
|
39
|
+
Homepage = "https://github.com/sachn1/readme-drift"
|
|
40
|
+
Issues = "https://github.com/sachn1/readme-drift/issues"
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
44
|
+
|
|
45
|
+
[tool.commitizen]
|
|
46
|
+
name = "cz_conventional_commits"
|
|
47
|
+
version_provider = "poetry"
|
|
48
|
+
version_scheme = "semver2"
|
|
49
|
+
tag_format = "v$version"
|
|
50
|
+
update_changelog_on_bump = false
|
|
51
|
+
|
|
52
|
+
[tool.ruff]
|
|
53
|
+
line-length = 88
|
|
54
|
+
target-version = "py311"
|
|
55
|
+
exclude = ["docs", "*.md", "*.ipynb"]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""AST-based diffing to extract changed public symbols between two code versions."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
|
|
5
|
+
from .models import ChangeType, PublicAPI, SymbolChange
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _is_public(name: str) -> bool:
|
|
9
|
+
"""Determine if a name is public (does not start with an underscore)."""
|
|
10
|
+
return not name.startswith("_")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _positional_args_with_defaults(
|
|
14
|
+
arg_list: list[ast.arg],
|
|
15
|
+
offset: int,
|
|
16
|
+
num_all: int,
|
|
17
|
+
defaults: list[ast.expr],
|
|
18
|
+
) -> list[str]:
|
|
19
|
+
"""Format a slice of positional args, resolving the shared defaults array."""
|
|
20
|
+
num_defaults = len(defaults)
|
|
21
|
+
result = []
|
|
22
|
+
for i, arg in enumerate(arg_list):
|
|
23
|
+
if arg.arg in ("self", "cls"):
|
|
24
|
+
continue
|
|
25
|
+
default_index = (offset + i) - (num_all - num_defaults)
|
|
26
|
+
|
|
27
|
+
# default_index >= 0 means this arg has a default value in the defaults array
|
|
28
|
+
if default_index >= 0:
|
|
29
|
+
result.append(f"{arg.arg}={ast.unparse(defaults[default_index])}")
|
|
30
|
+
else:
|
|
31
|
+
result.append(arg.arg)
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _kwonly_args(
|
|
36
|
+
kwonlyargs: list[ast.arg],
|
|
37
|
+
kw_defaults: list[ast.expr | None],
|
|
38
|
+
) -> list[str]:
|
|
39
|
+
"""Format keyword-only args with their optional defaults."""
|
|
40
|
+
result = []
|
|
41
|
+
for arg, default in zip(kwonlyargs, kw_defaults):
|
|
42
|
+
if default is not None:
|
|
43
|
+
result.append(f"{arg.arg}={ast.unparse(default)}")
|
|
44
|
+
else:
|
|
45
|
+
result.append(arg.arg)
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _format_signature(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
50
|
+
"""Format a function/method signature as a readable string.
|
|
51
|
+
|
|
52
|
+
ast.unparse is intentionally avoided: it retains self/cls and type
|
|
53
|
+
annotations, which only affects report readability — the signature
|
|
54
|
+
string is never used for detection, only for display in the output.
|
|
55
|
+
"""
|
|
56
|
+
func_args = node.args
|
|
57
|
+
num_all = len(func_args.posonlyargs) + len(func_args.args)
|
|
58
|
+
|
|
59
|
+
posonly = _positional_args_with_defaults(
|
|
60
|
+
func_args.posonlyargs, 0, num_all, func_args.defaults
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
regular = _positional_args_with_defaults(
|
|
64
|
+
func_args.args, len(func_args.posonlyargs), num_all, func_args.defaults
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
parts = [*posonly]
|
|
68
|
+
if posonly:
|
|
69
|
+
parts.append("/")
|
|
70
|
+
parts.extend(regular)
|
|
71
|
+
|
|
72
|
+
if func_args.vararg:
|
|
73
|
+
parts.append(f"*{func_args.vararg.arg}")
|
|
74
|
+
elif func_args.kwonlyargs:
|
|
75
|
+
parts.append("*")
|
|
76
|
+
|
|
77
|
+
parts.extend(_kwonly_args(func_args.kwonlyargs, func_args.kw_defaults))
|
|
78
|
+
|
|
79
|
+
if func_args.kwarg:
|
|
80
|
+
parts.append(f"**{func_args.kwarg.arg}")
|
|
81
|
+
|
|
82
|
+
return f"{node.name}({', '.join(parts)})"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def extract_public_api(source: str, *, include_private: bool = False) -> PublicAPI:
|
|
86
|
+
"""Parse Python source and extract the public API surface.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
source : str
|
|
91
|
+
The source code of a Python module.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
PublicAPI
|
|
96
|
+
An object containing the public functions, classes, and methods defined in the source.
|
|
97
|
+
"""
|
|
98
|
+
if not source.strip():
|
|
99
|
+
return PublicAPI()
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
tree = ast.parse(source)
|
|
103
|
+
except SyntaxError:
|
|
104
|
+
return PublicAPI()
|
|
105
|
+
|
|
106
|
+
api = PublicAPI()
|
|
107
|
+
|
|
108
|
+
is_visible = (lambda _: True) if include_private else _is_public
|
|
109
|
+
|
|
110
|
+
for node in ast.iter_child_nodes(tree):
|
|
111
|
+
if isinstance(node, ast.ClassDef) and is_visible(node.name):
|
|
112
|
+
method_sigs: set[str] = set()
|
|
113
|
+
for item in node.body:
|
|
114
|
+
if isinstance(
|
|
115
|
+
item, (ast.FunctionDef, ast.AsyncFunctionDef)
|
|
116
|
+
) and is_visible(item.name):
|
|
117
|
+
sig = _format_signature(item)
|
|
118
|
+
method_sigs.add(sig)
|
|
119
|
+
api.methods[f"{node.name}.{item.name}"] = sig
|
|
120
|
+
api.classes[node.name] = method_sigs
|
|
121
|
+
|
|
122
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and is_visible(
|
|
123
|
+
node.name
|
|
124
|
+
):
|
|
125
|
+
# Only top-level functions
|
|
126
|
+
api.functions[node.name] = _format_signature(node)
|
|
127
|
+
|
|
128
|
+
return api
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def diff_apis(
|
|
132
|
+
old_source: str,
|
|
133
|
+
new_source: str,
|
|
134
|
+
file: str = "",
|
|
135
|
+
*,
|
|
136
|
+
include_private: bool = False,
|
|
137
|
+
) -> list[SymbolChange]:
|
|
138
|
+
"""Compare two versions of a Python file and return what changed.
|
|
139
|
+
|
|
140
|
+
Parameters
|
|
141
|
+
----------
|
|
142
|
+
old_source : str
|
|
143
|
+
The source code of the old version of the Python module.
|
|
144
|
+
new_source : str
|
|
145
|
+
The source code of the new version of the Python module.
|
|
146
|
+
file : str, optional
|
|
147
|
+
The name of the file being compared, by default "".
|
|
148
|
+
include_private : bool, optional
|
|
149
|
+
If True, include private (underscore-prefixed) symbols, by default False.
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
list[SymbolChange]
|
|
154
|
+
A list of changes detected between the old and new versions.
|
|
155
|
+
"""
|
|
156
|
+
old_api = extract_public_api(old_source, include_private=include_private)
|
|
157
|
+
new_api = extract_public_api(new_source, include_private=include_private)
|
|
158
|
+
|
|
159
|
+
changes: list[SymbolChange] = []
|
|
160
|
+
|
|
161
|
+
# Diff top-level functions
|
|
162
|
+
changes.extend(_diff_signatures(old_api.functions, new_api.functions, file))
|
|
163
|
+
|
|
164
|
+
# Diff classes
|
|
165
|
+
old_classes = set(old_api.classes)
|
|
166
|
+
new_classes = set(new_api.classes)
|
|
167
|
+
|
|
168
|
+
for cls in old_classes - new_classes:
|
|
169
|
+
changes.append(SymbolChange(cls, ChangeType.REMOVED, file=file))
|
|
170
|
+
|
|
171
|
+
for cls in new_classes - old_classes:
|
|
172
|
+
changes.append(SymbolChange(cls, ChangeType.ADDED, file=file))
|
|
173
|
+
|
|
174
|
+
# Diff methods within classes that exist in both
|
|
175
|
+
for cls in old_classes & new_classes:
|
|
176
|
+
old_methods = {
|
|
177
|
+
k.split(".")[1]: v
|
|
178
|
+
for k, v in old_api.methods.items()
|
|
179
|
+
if k.startswith(f"{cls}.")
|
|
180
|
+
}
|
|
181
|
+
new_methods = {
|
|
182
|
+
k.split(".")[1]: v
|
|
183
|
+
for k, v in new_api.methods.items()
|
|
184
|
+
if k.startswith(f"{cls}.")
|
|
185
|
+
}
|
|
186
|
+
for change in _diff_signatures(old_methods, new_methods, file):
|
|
187
|
+
# Prefix with class name
|
|
188
|
+
change.name = f"{cls}.{change.name}"
|
|
189
|
+
if change.old_signature:
|
|
190
|
+
change.old_signature = f"{cls}.{change.old_signature}"
|
|
191
|
+
if change.new_signature:
|
|
192
|
+
change.new_signature = f"{cls}.{change.new_signature}"
|
|
193
|
+
changes.append(change)
|
|
194
|
+
|
|
195
|
+
return changes
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _diff_signatures(
|
|
199
|
+
old: dict[str, str],
|
|
200
|
+
new: dict[str, str],
|
|
201
|
+
file: str,
|
|
202
|
+
) -> list[SymbolChange]:
|
|
203
|
+
"""Helper to diff two sets of signatures (functions or methods)."""
|
|
204
|
+
changes: list[SymbolChange] = []
|
|
205
|
+
|
|
206
|
+
for name in set(old) - set(new):
|
|
207
|
+
changes.append(SymbolChange(name, ChangeType.REMOVED, file=file))
|
|
208
|
+
|
|
209
|
+
for name in set(new) - set(old):
|
|
210
|
+
changes.append(SymbolChange(name, ChangeType.ADDED, file=file))
|
|
211
|
+
|
|
212
|
+
for name in set(old) & set(new):
|
|
213
|
+
if old[name] != new[name]:
|
|
214
|
+
changes.append(
|
|
215
|
+
SymbolChange(
|
|
216
|
+
name=name,
|
|
217
|
+
change_type=ChangeType.SIGNATURE_CHANGED,
|
|
218
|
+
old_signature=old[name],
|
|
219
|
+
new_signature=new[name],
|
|
220
|
+
file=file,
|
|
221
|
+
)
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return changes
|