Skip to content

Commit 827a640

Browse files
committed
🔧 Add public API diff utility
Add a utility to extract and diff the public API from the source tree without importing AiiDA. Also add a pull request workflow that compares the current checkout against the base branch snapshot and fails when public API changes are detected via the utility.
1 parent 27d71c1 commit 827a640

3 files changed

Lines changed: 1148 additions & 0 deletions

File tree

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
name: public-api
2+
3+
on:
4+
pull_request:
5+
branches-ignore: [gh-pages]
6+
paths:
7+
- src/aiida/**
8+
- utils/public_api.py
9+
- .github/workflows/public-api.yml
10+
11+
# https://docs.github.com/en/actions/using-jobs/using-concurrency
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
env:
17+
FORCE_COLOR: 1
18+
19+
jobs:
20+
21+
diff:
22+
23+
runs-on: ubuntu-24.04
24+
timeout-minutes: 10
25+
26+
steps:
27+
- uses: actions/checkout@v6
28+
with:
29+
fetch-depth: 0
30+
31+
- name: Install aiida-core
32+
uses: ./.github/actions/install-aiida-core
33+
with:
34+
python-version: '3.11'
35+
from-lock: 'true'
36+
extras: ''
37+
38+
- name: Check public API diff against base branch
39+
env:
40+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
41+
run: |
42+
set -euo pipefail
43+
44+
worktree_dir="$(mktemp -d)"
45+
trap 'git worktree remove --force "$worktree_dir"' EXIT
46+
47+
git worktree add --detach "$worktree_dir" "$BASE_SHA"
48+
49+
uv run python utils/public_api.py extract \
50+
--src-root "$worktree_dir/src/aiida" \
51+
--output /tmp/public_api_base.json
52+
53+
uv run python utils/public_api.py diff --exit-code /tmp/public_api_base.json
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
###########################################################################
2+
# Copyright (c), The AiiDA team. All rights reserved. #
3+
# This file is part of the AiiDA code. #
4+
# #
5+
# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core #
6+
# For further information on the license, see the LICENSE.txt file #
7+
# For further information please visit http://www.aiida.net #
8+
###########################################################################
9+
"""Tests for ``utils/public_api.py``."""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import os
15+
import runpy
16+
import sys
17+
import tempfile
18+
from pathlib import Path
19+
20+
os.environ.setdefault('AIIDA_PATH', tempfile.mkdtemp())
21+
22+
23+
def load_public_api_module() -> dict[str, object]:
24+
"""Load the public_api utility module."""
25+
module_path = Path(__file__).resolve().parents[2] / 'utils' / 'public_api.py'
26+
return runpy.run_path(str(module_path))
27+
28+
29+
def test_extract_module_api_recurses_into_public_classes(tmp_path):
30+
"""Public class members and nested classes should be included recursively."""
31+
src_root = tmp_path / 'src' / 'aiida'
32+
src_root.mkdir(parents=True)
33+
34+
(src_root / '__init__.py').write_text(
35+
"from .submodule import PublicClass, public_function\n__all__ = ('PublicClass', 'public_function')\n",
36+
encoding='utf8',
37+
)
38+
(src_root / 'submodule.py').write_text(
39+
'class PublicClass:\n'
40+
" CLASS_ATTRIBUTE = 'value'\n"
41+
'\n'
42+
' class Nested:\n'
43+
" NESTED_ATTRIBUTE = 'value'\n"
44+
'\n'
45+
' def nested_method(self):\n'
46+
' return None\n'
47+
'\n'
48+
' @property\n'
49+
' def label(self):\n'
50+
' return None\n'
51+
'\n'
52+
' @label.setter\n'
53+
' def label(self, value):\n'
54+
' return None\n'
55+
'\n'
56+
' async def async_method(self, value: int | None = None):\n'
57+
' return None\n'
58+
'\n'
59+
' def public_method(self, value: str, /, flag: bool = False):\n'
60+
' return None\n'
61+
'\n'
62+
' def _private_method(self):\n'
63+
' return None\n'
64+
'\n'
65+
'def public_function(value: int, label: str | None = None):\n'
66+
' return None\n',
67+
encoding='utf8',
68+
)
69+
70+
module = load_public_api_module()
71+
extractor = module['ApiExtractor'](src_root)
72+
api = extractor.extract_module_api(src_root / '__init__.py', 'aiida')
73+
74+
resources = {resource.path: resource for resource in api.resources}
75+
76+
assert resources['aiida.PublicClass'].kind == 'class'
77+
assert resources['aiida.PublicClass.CLASS_ATTRIBUTE'].kind == 'attribute'
78+
assert resources['aiida.PublicClass.Nested'].kind == 'class'
79+
assert resources['aiida.PublicClass.Nested.NESTED_ATTRIBUTE'].kind == 'attribute'
80+
assert resources['aiida.PublicClass.Nested.nested_method'].kind == 'method'
81+
assert resources['aiida.PublicClass.Nested.nested_method'].signature == 'self'
82+
assert resources['aiida.PublicClass.async_method'].kind == 'method'
83+
assert resources['aiida.PublicClass.async_method'].signature == 'self, value: int | None=None'
84+
assert resources['aiida.PublicClass.label'].kind == 'property'
85+
assert resources['aiida.PublicClass.label'].signature is None
86+
assert resources['aiida.PublicClass.public_method'].kind == 'method'
87+
assert resources['aiida.PublicClass.public_method'].signature == 'self, value: str, /, flag: bool=False'
88+
assert resources['aiida.public_function'].kind == 'function'
89+
assert resources['aiida.public_function'].signature == 'value: int, label: str | None=None'
90+
assert 'aiida.PublicClass._private_method' not in resources
91+
assert list(resources).count('aiida.PublicClass.label') == 1
92+
93+
94+
def test_build_payload_resolves_reexported_classes_from_star_imports(tmp_path):
95+
"""Re-exported classes should include their nested public API."""
96+
src_root = tmp_path / 'src' / 'aiida'
97+
orm_root = src_root / 'orm'
98+
orm_root.mkdir(parents=True)
99+
100+
(src_root / '__init__.py').write_text('__all__ = ()\n', encoding='utf8')
101+
(orm_root / '__init__.py').write_text("from .nodes import *\n__all__ = ('Node',)\n", encoding='utf8')
102+
(orm_root / 'nodes.py').write_text(
103+
'class Node:\n'
104+
' class Manager:\n'
105+
' def all(self):\n'
106+
' return []\n'
107+
'\n'
108+
' def store(self):\n'
109+
' return self\n',
110+
encoding='utf8',
111+
)
112+
113+
module = load_public_api_module()
114+
payload = module['ApiExtractor'](src_root).build_payload()
115+
116+
resources = payload['resources']
117+
118+
assert len(resources) == 4
119+
assert resources['aiida.orm.Node']['kind'] == 'class'
120+
assert resources['aiida.orm.Node.Manager']['kind'] == 'class'
121+
assert resources['aiida.orm.Node.Manager.all']['kind'] == 'method'
122+
assert resources['aiida.orm.Node.Manager.all']['signature'] == 'self'
123+
assert resources['aiida.orm.Node.store']['kind'] == 'method'
124+
assert resources['aiida.orm.Node.store']['signature'] == 'self'
125+
126+
127+
def test_diff_payloads_classifies_extensions_and_breaking_changes(tmp_path):
128+
"""The diff should distinguish additions from breaking changes."""
129+
src_root = tmp_path / 'src' / 'aiida'
130+
src_root.mkdir(parents=True)
131+
132+
(src_root / '__init__.py').write_text(
133+
"from .submodule import PublicClass, public_function\n__all__ = ('PublicClass', 'public_function')\n",
134+
encoding='utf8',
135+
)
136+
(src_root / 'submodule.py').write_text(
137+
'class PublicClass:\n'
138+
' def method(self, value):\n'
139+
' return value\n'
140+
'\n'
141+
'def public_function(value):\n'
142+
' return value\n',
143+
encoding='utf8',
144+
)
145+
146+
module = load_public_api_module()
147+
baseline = module['ApiExtractor'](src_root).build_payload()
148+
149+
changed_payload = json.loads(json.dumps(baseline))
150+
changed_payload['resources']['aiida.PublicClass.method']['signature'] = 'self, value, extra=None'
151+
changed_payload['resources']['aiida.public_function'] = {'kind': 'function', 'signature': 'value'}
152+
changed_payload['resources']['aiida.PublicClass.new_method'] = {'kind': 'method', 'signature': 'self'}
153+
154+
diff = module['SnapshotDiffer'].diff_payloads(baseline, changed_payload)
155+
156+
assert [resource.path for resource in diff.added] == ['aiida.PublicClass.new_method']
157+
assert [resource.path for resource in diff.removed] == []
158+
assert len(diff.changed) == 1
159+
assert diff.changed[0]['old'].path == 'aiida.PublicClass.method'
160+
assert diff.changed[0]['new'].signature == 'self, value, extra=None'
161+
162+
163+
def test_diff_payloads_accepts_older_export_only_snapshots():
164+
"""Older snapshots without resource metadata should still be comparable."""
165+
module = load_public_api_module()
166+
old_payload = {
167+
'modules': {
168+
'aiida': {
169+
'exports': ['PublicClass'],
170+
}
171+
}
172+
}
173+
new_payload = {
174+
'modules': {
175+
'aiida': {
176+
'resources': [
177+
{'path': 'aiida.PublicClass', 'kind': 'class', 'signature': None},
178+
{'path': 'aiida.PublicClass.method', 'kind': 'method', 'signature': 'self'},
179+
]
180+
}
181+
}
182+
}
183+
184+
diff = module['SnapshotDiffer'].diff_payloads(old_payload, new_payload)
185+
186+
assert [resource.path for resource in diff.added] == ['aiida.PublicClass.method']
187+
assert diff.removed == []
188+
assert diff.changed == []
189+
190+
191+
def test_parse_arguments_extract_and_diff(monkeypatch, tmp_path):
192+
"""The CLI should expose ``extract`` and ``diff`` subcommands."""
193+
module = load_public_api_module()
194+
output = tmp_path / 'public-api.json'
195+
baseline = tmp_path / 'baseline.json'
196+
comparison = tmp_path / 'comparison.json'
197+
198+
monkeypatch.setattr(sys, 'argv', ['public_api.py', 'extract', '--src-root', 'src/aiida', '--output', str(output)])
199+
arguments = module['parse_arguments']()
200+
201+
assert arguments.command == 'extract'
202+
assert arguments.src_root == Path('src/aiida')
203+
assert arguments.output == output
204+
205+
monkeypatch.setattr(sys, 'argv', ['public_api.py', 'diff', str(baseline)])
206+
arguments = module['parse_arguments']()
207+
208+
assert arguments.command == 'diff'
209+
assert arguments.file1 == baseline
210+
assert arguments.file2 is None
211+
assert arguments.exit_code is False
212+
213+
monkeypatch.setattr(sys, 'argv', ['public_api.py', 'diff', '--exit-code', str(baseline)])
214+
arguments = module['parse_arguments']()
215+
216+
assert arguments.command == 'diff'
217+
assert arguments.file1 == baseline
218+
assert arguments.file2 is None
219+
assert arguments.exit_code is True
220+
221+
monkeypatch.setattr(sys, 'argv', ['public_api.py', 'diff', str(baseline), str(comparison)])
222+
arguments = module['parse_arguments']()
223+
224+
assert arguments.command == 'diff'
225+
assert arguments.file1 == baseline
226+
assert arguments.file2 == comparison
227+
228+
229+
def test_diff_snapshots_compares_baseline_with_current_checkout(tmp_path):
230+
"""If the second file is omitted, diff against the current source tree."""
231+
src_root = tmp_path / 'src' / 'aiida'
232+
src_root.mkdir(parents=True)
233+
234+
(src_root / '__init__.py').write_text(
235+
"from .submodule import PublicClass\n__all__ = ('PublicClass',)\n",
236+
encoding='utf8',
237+
)
238+
(src_root / 'submodule.py').write_text(
239+
'class PublicClass:\n def method(self):\n return None\n',
240+
encoding='utf8',
241+
)
242+
243+
module = load_public_api_module()
244+
baseline_path = tmp_path / 'baseline.json'
245+
baseline_path.write_text(json.dumps(module['ApiExtractor'](src_root).build_payload()), encoding='utf8')
246+
247+
(src_root / 'submodule.py').write_text(
248+
'class PublicClass:\n'
249+
' def method(self):\n'
250+
' return None\n'
251+
'\n'
252+
' def new_method(self):\n'
253+
' return None\n',
254+
encoding='utf8',
255+
)
256+
257+
diff = module['_diff_snapshots'](baseline_path, None, src_root)
258+
259+
assert [resource.path for resource in diff.added] == ['aiida.PublicClass.new_method']
260+
assert diff.removed == []
261+
assert diff.changed == []
262+
263+
264+
def test_has_differences():
265+
"""Return whether a diff contains any changes."""
266+
module = load_public_api_module()
267+
268+
assert module['_has_differences'](module['ApiDiff'](added=[], removed=[], changed=[])) is False
269+
assert (
270+
module['_has_differences'](
271+
module['ApiDiff'](added=[module['ApiResource']('a', 'class')], removed=[], changed=[])
272+
)
273+
is True
274+
)
275+
276+
277+
def test_print_diff_formats_changed_resources(capsys, tmp_path):
278+
"""Changed resources should be printed on separate removed/added lines."""
279+
module = load_public_api_module()
280+
diff = module['ApiDiff'](
281+
added=[],
282+
removed=[],
283+
changed=[
284+
{
285+
'old': module['ApiResource'](
286+
path='aiida.orm.ProcessNode.set_exit_status',
287+
kind='method',
288+
signature='self, status: enum.Enum | int | None',
289+
),
290+
'new': module['ApiResource'](
291+
path='aiida.orm.ProcessNode.set_exit_status',
292+
kind='method',
293+
signature='self, status: enum.Enum | int | None=None',
294+
),
295+
}
296+
],
297+
)
298+
299+
module['SnapshotDiffer'].print_diff(diff, tmp_path / 'baseline.json')
300+
output = capsys.readouterr().out
301+
302+
assert ' ~ aiida.orm.ProcessNode.set_exit_status:' in output
303+
assert ' - method (self, status: enum.Enum | int | None)' in output
304+
assert ' + method (self, status: enum.Enum | int | None=None)' in output

0 commit comments

Comments
 (0)