py-api-dumper 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.
@@ -0,0 +1,18 @@
1
+ Copyright 2025 Karl Wette
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the “Software”), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7
+ the Software, and to permit persons to whom the Software is furnished to do so,
8
+ subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,5 @@
1
+ prune test
2
+ include test/*.py
3
+ include test/api_ref/*.c
4
+ include test/api_ref/*.py
5
+ include test/api_ref/*.txt
@@ -0,0 +1,99 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-api-dumper
3
+ Version: 1.0
4
+ Summary: Python API dumping and comparison tool
5
+ Author-email: Karl Wette <karl.wette@anu.edu.au>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/kwwette/py-api-dumper
8
+ Project-URL: Issues, https://github.com/kwwette/py-api-dumper/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Dynamic: license-file
15
+
16
+ # Python API dumping and comparison tool
17
+
18
+ Dumps the public API of a Python module and its members to a file, which can
19
+ then be used to show the differences in the public between two dumps, e.g. of
20
+ different versions of the same module.
21
+
22
+ ## Command-line interface
23
+
24
+ * To dump the public API of a module `mymod`:
25
+
26
+ ```bash
27
+ $ py-api-dumper dump -o mymod1.dump mymod
28
+ ```
29
+
30
+ `mymod1.dump` will record the public API of `mymod` in a reloadable format.
31
+
32
+ * To print the API of `mymod` in text format:
33
+ ```bash
34
+ $ py-api-dumper dump mymod
35
+ MODULE : mymod
36
+ CLASS : myclass
37
+ FUNCTION : __init__ : no-return-type
38
+ REQUIRED : 0 : x : int
39
+ ...
40
+ ```
41
+
42
+ The text format is a tree of entries of each nested class, class method,
43
+ function, member variable, etc. in the public API.
44
+
45
+ * To compare the API of `mymod` between different versions:
46
+ ```bash
47
+ $ py-api-dumper diff mymod-old.dump mymod-new.dump
48
+ --- mymod-old.dump mymod=1.0
49
+ +++ mymod-new.dump mymod=2.0
50
+ +MODULE : mymod
51
+ + CLASS : myclass
52
+ + FUNCTION : __init__ : no-return-type
53
+ + REQUIRED : 1 : b : no-type
54
+ ```
55
+
56
+ The above output shows the expected output if, between `mymod` versions 1.0
57
+ and 2.0, an additional positional argument `b` had been added to the
58
+ `__init__` method of the class `mymod.myclass`.
59
+
60
+ ```bash
61
+ $ py-api-dumper diff -o mymod.diff ...
62
+ ```
63
+
64
+ This will write out the API differences to `mymod.diff` in JSON format:
65
+
66
+ * the paths to the dumps of the old and new APIs, e.g. `mymod-old.dump`
67
+ vs. `mymod-new.dump`;
68
+ * the version of `mymod` at the old and new API dumps, e.g. mymod `1.0`
69
+ vs. `2.0`;
70
+ * API entries which have been *removed*, i.e. present in the old API but not
71
+ in the new API (e.g. none in the above example);
72
+ * API entries which have been *added*, i.e. present in the new API but not in
73
+ the old API (e.g. the `b` argument in the above example).
74
+
75
+ ## Python interface
76
+
77
+ ```python
78
+ from py_api_dumper import APIDump, APIDiff
79
+ ```
80
+
81
+ * To dump the public API of a module `mymod`:
82
+ ```python
83
+ import mymod
84
+ dump = APIDump.from_modules(mymod) # OR: APIDump.from_modules("mymod")
85
+ dump.save_to_file("mymod.dump")
86
+ ```
87
+
88
+ * To print the API of `mymod` in text format:
89
+ ```python
90
+ dump.print_as_text()
91
+ ```
92
+
93
+ * To compare the API of `mymod` between different versions:
94
+ ```python
95
+ diff = APIDiff.from_files("mymod-old.dump", "mymod-new.dump")
96
+ diff.print_as_text()
97
+ with open("mymod.diff", as file):
98
+ diff.print_as_json(file)
99
+ ```
@@ -0,0 +1,84 @@
1
+ # Python API dumping and comparison tool
2
+
3
+ Dumps the public API of a Python module and its members to a file, which can
4
+ then be used to show the differences in the public between two dumps, e.g. of
5
+ different versions of the same module.
6
+
7
+ ## Command-line interface
8
+
9
+ * To dump the public API of a module `mymod`:
10
+
11
+ ```bash
12
+ $ py-api-dumper dump -o mymod1.dump mymod
13
+ ```
14
+
15
+ `mymod1.dump` will record the public API of `mymod` in a reloadable format.
16
+
17
+ * To print the API of `mymod` in text format:
18
+ ```bash
19
+ $ py-api-dumper dump mymod
20
+ MODULE : mymod
21
+ CLASS : myclass
22
+ FUNCTION : __init__ : no-return-type
23
+ REQUIRED : 0 : x : int
24
+ ...
25
+ ```
26
+
27
+ The text format is a tree of entries of each nested class, class method,
28
+ function, member variable, etc. in the public API.
29
+
30
+ * To compare the API of `mymod` between different versions:
31
+ ```bash
32
+ $ py-api-dumper diff mymod-old.dump mymod-new.dump
33
+ --- mymod-old.dump mymod=1.0
34
+ +++ mymod-new.dump mymod=2.0
35
+ +MODULE : mymod
36
+ + CLASS : myclass
37
+ + FUNCTION : __init__ : no-return-type
38
+ + REQUIRED : 1 : b : no-type
39
+ ```
40
+
41
+ The above output shows the expected output if, between `mymod` versions 1.0
42
+ and 2.0, an additional positional argument `b` had been added to the
43
+ `__init__` method of the class `mymod.myclass`.
44
+
45
+ ```bash
46
+ $ py-api-dumper diff -o mymod.diff ...
47
+ ```
48
+
49
+ This will write out the API differences to `mymod.diff` in JSON format:
50
+
51
+ * the paths to the dumps of the old and new APIs, e.g. `mymod-old.dump`
52
+ vs. `mymod-new.dump`;
53
+ * the version of `mymod` at the old and new API dumps, e.g. mymod `1.0`
54
+ vs. `2.0`;
55
+ * API entries which have been *removed*, i.e. present in the old API but not
56
+ in the new API (e.g. none in the above example);
57
+ * API entries which have been *added*, i.e. present in the new API but not in
58
+ the old API (e.g. the `b` argument in the above example).
59
+
60
+ ## Python interface
61
+
62
+ ```python
63
+ from py_api_dumper import APIDump, APIDiff
64
+ ```
65
+
66
+ * To dump the public API of a module `mymod`:
67
+ ```python
68
+ import mymod
69
+ dump = APIDump.from_modules(mymod) # OR: APIDump.from_modules("mymod")
70
+ dump.save_to_file("mymod.dump")
71
+ ```
72
+
73
+ * To print the API of `mymod` in text format:
74
+ ```python
75
+ dump.print_as_text()
76
+ ```
77
+
78
+ * To compare the API of `mymod` between different versions:
79
+ ```python
80
+ diff = APIDiff.from_files("mymod-old.dump", "mymod-new.dump")
81
+ diff.print_as_text()
82
+ with open("mymod.diff", as file):
83
+ diff.print_as_json(file)
84
+ ```
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "py-api-dumper"
7
+ version = "1.0"
8
+ description = "Python API dumping and comparison tool"
9
+ authors = [
10
+ { name = "Karl Wette", email = "karl.wette@anu.edu.au" },
11
+ ]
12
+ requires-python = ">=3.9"
13
+ readme = "README.md"
14
+ license = "MIT"
15
+ license-files = ["LICENSE"]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://github.com/kwwette/py-api-dumper"
23
+ Issues = "https://github.com/kwwette/py-api-dumper/issues"
24
+
25
+ [project.scripts]
26
+ py-api-dumper = "py_api_dumper.cli:cli"
27
+
28
+ [tool.pytest.ini_options]
29
+ minversion = "6.0"
30
+ addopts = "--cov=py_api_dumper --cov-report=term-missing --cov-fail-under=100"
31
+ testpaths = ["test"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,418 @@
1
+ import importlib
2
+ import importlib.metadata
3
+ import inspect
4
+ import json
5
+ import pkgutil
6
+ import sys
7
+ from pathlib import Path
8
+ from types import ModuleType
9
+ from typing import List, Optional, TextIO, Tuple, Type, TypeVar, Union
10
+
11
+ __author__ = "Karl Wette"
12
+ __version__ = "1.0"
13
+
14
+ APIDumpType = TypeVar("APIDumpType", bound="APIDump")
15
+
16
+
17
+ class APIDump:
18
+ """
19
+ Dump the public API of a Python module and its members.
20
+ """
21
+
22
+ def __init__(self, *, api, versions, file_path=None):
23
+ self._api = api
24
+ self._versions = versions
25
+ self._file_path = file_path
26
+
27
+ def __eq__(self, other):
28
+ return self._api == other._api and self._versions == other._versions
29
+
30
+ @classmethod
31
+ def from_modules(
32
+ cls: Type[APIDumpType], *modules: Union[ModuleType, str]
33
+ ) -> APIDumpType:
34
+ """
35
+ Dump the public API of the given Python modules.
36
+
37
+ Args:
38
+ *modules (Union[ModuleType, str]):
39
+ List of modules and/or their string names.
40
+
41
+ Returns:
42
+ APIDumpType: APIDump instance.
43
+ """
44
+
45
+ # Create instance
46
+ inst = cls(api=set(), versions=dict())
47
+
48
+ # Load all modules
49
+ all_modules = inst._load_all_modules(modules)
50
+
51
+ # Dump module APIs
52
+ for module in all_modules:
53
+ module_prefix = [("MODULE", m) for m in module.__name__.split(".")]
54
+ inst._dump_struct(module_prefix, module, module)
55
+
56
+ return inst
57
+
58
+ def _load_all_modules(self, modules):
59
+
60
+ # Walk and load (sub)modules
61
+ all_modules = dict()
62
+ for module_or_name in modules:
63
+
64
+ # Load module if supplied a string name
65
+ if isinstance(module_or_name, ModuleType):
66
+ module = module_or_name
67
+ else:
68
+ module = importlib.import_module(module_or_name)
69
+
70
+ # Save module
71
+ if module.__name__ not in all_modules:
72
+ all_modules[module.__name__] = module
73
+
74
+ # Save module version
75
+ try:
76
+ module_version = importlib.metadata.version(module.__name__)
77
+ except importlib.metadata.PackageNotFoundError:
78
+ try:
79
+ module_version = module.__version__
80
+ except AttributeError:
81
+ module_version = None
82
+ self._versions[module.__name__] = module_version
83
+
84
+ # Walk submodules
85
+ for submodule_info in pkgutil.walk_packages(
86
+ module.__path__, module.__name__ + "."
87
+ ):
88
+
89
+ # Exclude private submodules
90
+ if any(m.startswith("_") for m in submodule_info.name.split(".")):
91
+ continue
92
+
93
+ # Load submodule
94
+ submodule = importlib.import_module(submodule_info.name)
95
+
96
+ # Save submodule
97
+ if submodule.__name__ not in all_modules:
98
+ all_modules[submodule.__name__] = submodule
99
+
100
+ return list(all_modules.values())
101
+
102
+ def _add_api_entry(self, entry):
103
+
104
+ # Check that `entry` only contains `str` or `int` values
105
+ _allowed_types = (str, int)
106
+ assert all(
107
+ all(isinstance(e, _allowed_types) for e in ee) for ee in entry
108
+ ), tuple(tuple((e, isinstance(e, _allowed_types)) for e in ee) for ee in entry)
109
+
110
+ # Add API entry
111
+ self._api.add(tuple(entry))
112
+
113
+ def _dump_struct(self, prefix, struct, module):
114
+
115
+ # Add base entry
116
+ self._add_api_entry(prefix)
117
+
118
+ # Iterate over struct members
119
+ members = inspect.getmembers(struct)
120
+ for member_name, member in members:
121
+
122
+ # Exclude any modules
123
+ # - all relevant modules have already been found by _load_all_modules()
124
+ if inspect.ismodule(member):
125
+ continue
126
+
127
+ # Exclude any private members, except class constructors
128
+ if member_name.startswith("_") and member_name != "__init__":
129
+ continue
130
+
131
+ # Exclude any members defined in another module
132
+ # - this should catch any `import`ed members
133
+ if hasattr(member, "__module__") and member.__module__ != module.__name__:
134
+ continue
135
+
136
+ # Dump classes
137
+ if inspect.isclass(member):
138
+ class_prefix = prefix + [("CLASS", member.__name__)]
139
+ self._dump_struct(class_prefix, member, module)
140
+
141
+ # Dump methods and functions
142
+ elif inspect.isroutine(member):
143
+ if isinstance(
144
+ inspect.getattr_static(struct, member.__name__), staticmethod
145
+ ):
146
+ self._dump_function(prefix, "STATICMETHOD", member)
147
+ if inspect.ismethod(member) and isinstance(member.__self__, type):
148
+ self._dump_function(prefix, "CLASSMETHOD", member)
149
+ else:
150
+ self._dump_function(prefix, "FUNCTION", member)
151
+
152
+ # Dump properties
153
+ elif isinstance(member, property) or inspect.isgetsetdescriptor(member):
154
+ self._dump_property(prefix, member_name)
155
+
156
+ else:
157
+ # Dump everything else
158
+ self._dump_member(prefix, member_name, member)
159
+
160
+ def _dump_function(self, prefix, fun_type, fun):
161
+
162
+ # Try to get function signature
163
+ try:
164
+ sig = inspect.signature(fun)
165
+ except ValueError:
166
+ sig = None
167
+
168
+ # Add function entry
169
+ if sig is not None:
170
+ if sig.return_annotation != sig.empty:
171
+ return_type = str(sig.return_annotation)
172
+ else:
173
+ return_type = "no-return-type"
174
+ func_entry = prefix + [(fun_type, fun.__name__, return_type)]
175
+ else:
176
+ func_entry = prefix + [(fun_type, fun.__name__, "no-signature")]
177
+ self._add_api_entry(func_entry)
178
+
179
+ # Add function signature, if available
180
+ if sig is not None:
181
+ n_req_arg = 0
182
+ for n, par in enumerate(sig.parameters.values()):
183
+ if par.annotation != par.empty:
184
+ par_type = str(par.annotation)
185
+ else:
186
+ par_type = "no-type"
187
+ if par.default != par.empty or par.kind in (
188
+ par.VAR_POSITIONAL,
189
+ par.VAR_KEYWORD,
190
+ ):
191
+ par_entry = [("OPTIONAL", par.name, par_type)]
192
+ else:
193
+ par_entry = [("REQUIRED", n_req_arg, par.name, par_type)]
194
+ n_req_arg += 1
195
+ self._add_api_entry(func_entry + par_entry)
196
+
197
+ def _dump_property(self, prefix, name):
198
+
199
+ # Add property entry
200
+ entry = prefix + [("PROPERTY", name)]
201
+ self._add_api_entry(entry)
202
+
203
+ def _dump_member(self, prefix, name, val):
204
+
205
+ # Exclude any private types
206
+ typ = type(val).__name__
207
+ if typ.startswith("_"):
208
+ return
209
+
210
+ # Add member entry
211
+ entry = prefix + [("MEMBER", name, typ)]
212
+ self._add_api_entry(entry)
213
+
214
+ def print_as_text(self, file: Optional[TextIO] = None) -> None:
215
+ """
216
+ Print the API dump as text to a file.
217
+
218
+ Args:
219
+ file (Optional[TextIO]):
220
+ File to print to (default: standard output).
221
+ """
222
+ if file is None:
223
+ file = sys.stdout
224
+
225
+ # Print API dump
226
+ for entry in sorted(self._api):
227
+ indent = "\t" * (len(entry) - 1)
228
+ entry_str = " : ".join(str(e) for e in entry[-1])
229
+ print(indent + entry_str, file=file)
230
+
231
+ def save_to_file(self, file_path: Union[Path, str]) -> None:
232
+ """
233
+ Save the API dump to a file in a reloadable format.
234
+
235
+ Args:
236
+ file_path (Union[Path, str]):
237
+ Name of file to save to.
238
+ """
239
+ file_path = Path(file_path)
240
+
241
+ # Assemble file content
242
+ content = {"versions": self._versions, "api": list(sorted(self._api))}
243
+
244
+ # Save to file as JSON
245
+ with file_path.open("wt") as file:
246
+ json.dump(content, file)
247
+ file.write("\n")
248
+
249
+ @classmethod
250
+ def load_from_file(
251
+ cls: Type[APIDumpType], file_path: Union[Path, str]
252
+ ) -> APIDumpType:
253
+ """
254
+ Load an API dump from a file.
255
+
256
+ Args:
257
+ file_path (Union[Path, str]):
258
+ Name of file to load.
259
+
260
+ Returns:
261
+ APIDumpType: APIDump instance.
262
+ """
263
+ file_path = Path(file_path)
264
+
265
+ # Load from file as JSON
266
+ with file_path.open("rt") as file:
267
+ content = json.load(file)
268
+
269
+ # Create instance
270
+ inst = cls(
271
+ api=set(tuple(tuple(e) for e in entry) for entry in content["api"]),
272
+ versions=dict(
273
+ (module, version) for module, version in content["versions"].items()
274
+ ),
275
+ file_path=file_path,
276
+ )
277
+
278
+ return inst
279
+
280
+
281
+ APIDiffType = TypeVar("APIDiffType", bound="APIDiff")
282
+
283
+
284
+ class APIDiff:
285
+ """
286
+ Show the differences between two Python public API dumps.
287
+ """
288
+
289
+ def __init__(
290
+ self,
291
+ old: APIDump,
292
+ new: APIDump,
293
+ ):
294
+ """
295
+ Differences between two Python public API dumps.
296
+
297
+ Args:
298
+ old (APIDump):
299
+ Dump of the old public API.
300
+ new (APIDump):
301
+ Dump of the new public API.
302
+ """
303
+
304
+ self._old_versions = old._versions
305
+ self._old_path = old._file_path
306
+
307
+ self._new_versions = new._versions
308
+ self._new_path = new._file_path
309
+
310
+ # Entries added to `new` that are not in `old`
311
+ self._added = new._api - old._api
312
+
313
+ # Entries removed from `new` that remain in `old`
314
+ self._removed = old._api - new._api
315
+
316
+ @classmethod
317
+ def from_files(
318
+ cls: Type[APIDiffType], old_path: Union[Path, str], new_path: Union[Path, str]
319
+ ) -> APIDiffType:
320
+ """
321
+ Differences between two Python public API dumps loaded from files.
322
+
323
+ Args:
324
+ old_path (Union[Path, str]):
325
+ Name of file containing dump of the old public API.
326
+ new_path (Union[Path, str]):
327
+ Name of file containing dump of the new public API.
328
+
329
+ Returns:
330
+ APIDiffType: APIDiff instance.
331
+ """
332
+
333
+ # Load dumps from files
334
+ old = APIDump.load_from_file(old_path)
335
+ new = APIDump.load_from_file(new_path)
336
+
337
+ # Create instance
338
+ inst = cls(old, new)
339
+
340
+ return inst
341
+
342
+ def equal(self):
343
+ """
344
+ Return True if there are no differences, False otherwise.
345
+ """
346
+ return len(self._added) == 0 and len(self._removed) == 0
347
+
348
+ def print_as_text(self, file: Optional[TextIO] = None) -> None:
349
+ """
350
+ Print the API differences as text to a file.
351
+
352
+ Args:
353
+ file (Optional[TextIO]):
354
+ File to print to (default: standard output).
355
+ """
356
+ file = file or sys.stdout
357
+
358
+ # Print file names and versions
359
+ for prefix, file_path, versions in (
360
+ ("---", self._old_path, self._old_versions),
361
+ ("+++", self._new_path, self._new_versions),
362
+ ):
363
+ print(
364
+ prefix,
365
+ "/dev/null" if file_path is None else str(file_path),
366
+ " ".join(
367
+ f"{module}={version}"
368
+ for module, version in versions.items()
369
+ if version is not None
370
+ ),
371
+ file=file,
372
+ )
373
+
374
+ # Print API entries added and removed
375
+ for prefix, entries in (("-", self._removed), ("+", self._added)):
376
+ stack: List[Tuple] = []
377
+ for entry in sorted(entries):
378
+
379
+ # Find the longest common prefix with respect to previously-printed entries
380
+ i_start = 0
381
+ while len(stack) > 0:
382
+ for i in range(max(len(stack[-1]), len(entry))):
383
+ if stack[-1][0:i] == entry[0:i]:
384
+ i_start = i
385
+ if i_start > 0:
386
+ break
387
+ stack.pop() # pragma: no cover
388
+
389
+ # Print entry without common prefix; add to stack of printed entries
390
+ for i in range(i_start, len(entry)):
391
+ indent = "\t" * i
392
+ entry_str = " : ".join(str(e) for e in entry[i])
393
+ print(prefix + indent + entry_str, file=file)
394
+ stack.append(entry)
395
+
396
+ def print_as_json(self, file: Optional[TextIO] = None) -> None:
397
+ """
398
+ Print the API differences as JSON to a file.
399
+
400
+ Args:
401
+ file (Optional[TextIO]):
402
+ File to print to (default: standard output).
403
+ """
404
+ file = file or sys.stdout
405
+
406
+ # Assemble file content
407
+ content = {
408
+ "old_dump": str(self._old_path),
409
+ "new_dump": str(self._new_path),
410
+ "old_versions": self._old_versions,
411
+ "new_versions": self._new_versions,
412
+ "removed": list(sorted(self._removed)),
413
+ "added": list(sorted(self._added)),
414
+ }
415
+
416
+ # Save to file as JSON
417
+ json.dump(content, file)
418
+ file.write("\n")