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.
- py_api_dumper-1.0/LICENSE +18 -0
- py_api_dumper-1.0/MANIFEST.in +5 -0
- py_api_dumper-1.0/PKG-INFO +99 -0
- py_api_dumper-1.0/README.md +84 -0
- py_api_dumper-1.0/pyproject.toml +31 -0
- py_api_dumper-1.0/setup.cfg +4 -0
- py_api_dumper-1.0/src/py_api_dumper/__init__.py +418 -0
- py_api_dumper-1.0/src/py_api_dumper/cli.py +89 -0
- py_api_dumper-1.0/src/py_api_dumper.egg-info/PKG-INFO +99 -0
- py_api_dumper-1.0/src/py_api_dumper.egg-info/SOURCES.txt +20 -0
- py_api_dumper-1.0/src/py_api_dumper.egg-info/dependency_links.txt +1 -0
- py_api_dumper-1.0/src/py_api_dumper.egg-info/entry_points.txt +2 -0
- py_api_dumper-1.0/src/py_api_dumper.egg-info/top_level.txt +1 -0
- py_api_dumper-1.0/test/api_ref/__init__.py +36 -0
- py_api_dumper-1.0/test/api_ref/_priv_mod.py +3 -0
- py_api_dumper-1.0/test/api_ref/api_ref.txt +61 -0
- py_api_dumper-1.0/test/api_ref/ext_mod.c +29 -0
- py_api_dumper-1.0/test/api_ref/pub_mod.py +84 -0
- py_api_dumper-1.0/test/conftest.py +29 -0
- py_api_dumper-1.0/test/setup.py +10 -0
- py_api_dumper-1.0/test/test_diff.py +406 -0
- py_api_dumper-1.0/test/test_dump.py +79 -0
|
@@ -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,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,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")
|