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.
@@ -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