api-parity-py 0.0.2__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.
- api_parity_py-0.0.2/.gitignore +23 -0
- api_parity_py-0.0.2/PKG-INFO +5 -0
- api_parity_py-0.0.2/pyproject.toml +16 -0
- api_parity_py-0.0.2/src/api_parity_py/__init__.py +5 -0
- api_parity_py-0.0.2/src/api_parity_py/__main__.py +148 -0
- api_parity_py-0.0.2/src/api_parity_py/parity.py +212 -0
- api_parity_py-0.0.2/src/api_parity_py/walk.py +242 -0
- api_parity_py-0.0.2/tests/conftest.py +6 -0
- api_parity_py-0.0.2/tests/fixtures/portpkg/__init__.py +3 -0
- api_parity_py-0.0.2/tests/fixtures/portpkg/core.py +42 -0
- api_parity_py-0.0.2/tests/fixtures/tinypkg/__init__.py +7 -0
- api_parity_py-0.0.2/tests/fixtures/tinypkg/core.py +40 -0
- api_parity_py-0.0.2/tests/fixtures/tinypkg/extras.py +10 -0
- api_parity_py-0.0.2/tests/test_cli.py +82 -0
- api_parity_py-0.0.2/tests/test_parity.py +115 -0
- api_parity_py-0.0.2/tests/test_walk.py +111 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
venv/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.ruff_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
|
|
14
|
+
# Rust
|
|
15
|
+
target/
|
|
16
|
+
# Library workspace — don't pin Cargo.lock
|
|
17
|
+
Cargo.lock
|
|
18
|
+
|
|
19
|
+
# Editors / OS
|
|
20
|
+
.idea/
|
|
21
|
+
.vscode/
|
|
22
|
+
.DS_Store
|
|
23
|
+
*.swp
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "api-parity-py"
|
|
3
|
+
version = "0.0.2"
|
|
4
|
+
description = "Python plugin for api-parity (reference walker + port annotations)."
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
dependencies = []
|
|
7
|
+
|
|
8
|
+
[project.scripts]
|
|
9
|
+
api-parity-py = "api_parity_py.__main__:main"
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["hatchling"]
|
|
13
|
+
build-backend = "hatchling.build"
|
|
14
|
+
|
|
15
|
+
[tool.hatch.build.targets.wheel]
|
|
16
|
+
packages = ["src/api_parity_py"]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""CLI: ``api-parity-py <kind> [--mode walker|annotation] <target> [-o PATH]``.
|
|
2
|
+
|
|
3
|
+
Supported combos:
|
|
4
|
+
reference walker walk a package's public API (default for reference)
|
|
5
|
+
reference annotation collect ``@parity_ref`` decorators in a package
|
|
6
|
+
port walker walk a package and synthesize implemented port entries
|
|
7
|
+
port annotation collect ``@parity_impl`` / ``@parity`` decorators
|
|
8
|
+
(default for port)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import importlib
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import walk
|
|
18
|
+
from .parity import collect_port_entries, collect_reference_entries
|
|
19
|
+
|
|
20
|
+
EXIT_USAGE = 64
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> int:
|
|
24
|
+
ap = argparse.ArgumentParser(prog="api-parity-py")
|
|
25
|
+
ap.add_argument("kind", choices=("reference", "port"))
|
|
26
|
+
ap.add_argument(
|
|
27
|
+
"--mode",
|
|
28
|
+
choices=("walker", "annotation"),
|
|
29
|
+
default=None,
|
|
30
|
+
help="how entries are produced (defaults: reference→walker, port→annotation)",
|
|
31
|
+
)
|
|
32
|
+
ap.add_argument(
|
|
33
|
+
"target",
|
|
34
|
+
help="package name (or comma-separated names, e.g. "
|
|
35
|
+
"'pyspark.sql.connect,pyspark.sql.session')",
|
|
36
|
+
)
|
|
37
|
+
ap.add_argument(
|
|
38
|
+
"--version-from",
|
|
39
|
+
default=None,
|
|
40
|
+
help="module to read `__version__` from (e.g. `pyspark`)",
|
|
41
|
+
)
|
|
42
|
+
ap.add_argument(
|
|
43
|
+
"-o",
|
|
44
|
+
"--output",
|
|
45
|
+
default="-",
|
|
46
|
+
help="output file path, or `-` for stdout (default)",
|
|
47
|
+
)
|
|
48
|
+
args = ap.parse_args()
|
|
49
|
+
|
|
50
|
+
mode = args.mode or _default_mode(args.kind)
|
|
51
|
+
envelope = _build_envelope(
|
|
52
|
+
kind=args.kind,
|
|
53
|
+
mode=mode,
|
|
54
|
+
target=args.target,
|
|
55
|
+
version_from=args.version_from,
|
|
56
|
+
)
|
|
57
|
+
if envelope is None:
|
|
58
|
+
sys.stderr.write(
|
|
59
|
+
f"api-parity-py: ({args.kind}, mode={mode}) is not yet implemented\n"
|
|
60
|
+
)
|
|
61
|
+
return EXIT_USAGE
|
|
62
|
+
|
|
63
|
+
text = json.dumps(envelope, indent=2)
|
|
64
|
+
if args.output == "-":
|
|
65
|
+
sys.stdout.write(text + "\n")
|
|
66
|
+
else:
|
|
67
|
+
Path(args.output).write_text(text + "\n")
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _default_mode(kind: str) -> str:
|
|
72
|
+
return "annotation" if kind == "port" else "walker"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_envelope(
|
|
76
|
+
*,
|
|
77
|
+
kind: str,
|
|
78
|
+
mode: str,
|
|
79
|
+
target: str,
|
|
80
|
+
version_from: str | None,
|
|
81
|
+
) -> dict | None:
|
|
82
|
+
if kind == "reference" and mode == "walker":
|
|
83
|
+
return walk.walk_package(target, version_from=version_from)
|
|
84
|
+
if kind == "reference" and mode == "annotation":
|
|
85
|
+
return _collect_annotations(target, version_from, kind="reference")
|
|
86
|
+
if kind == "port" and mode == "annotation":
|
|
87
|
+
return _collect_annotations(target, version_from, kind="port")
|
|
88
|
+
if kind == "port" and mode == "walker":
|
|
89
|
+
return _walker_as_port(target, version_from)
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _import_target_packages(target: str) -> list[str]:
|
|
94
|
+
"""Import every submodule of every requested package so decorators run."""
|
|
95
|
+
packages = [p.strip() for p in target.split(",") if p.strip()]
|
|
96
|
+
for pkg in packages:
|
|
97
|
+
walk._preload_submodules(pkg)
|
|
98
|
+
return packages
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _read_version(version_from: str | None) -> str | None:
|
|
102
|
+
if not version_from:
|
|
103
|
+
return None
|
|
104
|
+
try:
|
|
105
|
+
mod = importlib.import_module(version_from)
|
|
106
|
+
return getattr(mod, "__version__", None)
|
|
107
|
+
except Exception:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _collect_annotations(
|
|
112
|
+
target: str, version_from: str | None, *, kind: str,
|
|
113
|
+
) -> dict:
|
|
114
|
+
packages = _import_target_packages(target)
|
|
115
|
+
if kind == "port":
|
|
116
|
+
entries = collect_port_entries()
|
|
117
|
+
else:
|
|
118
|
+
entries = collect_reference_entries()
|
|
119
|
+
return {
|
|
120
|
+
"schema_version": 1,
|
|
121
|
+
"kind": kind,
|
|
122
|
+
"language": "python",
|
|
123
|
+
"version": _read_version(version_from),
|
|
124
|
+
"source": ",".join(packages),
|
|
125
|
+
"entries": entries,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _walker_as_port(target: str, version_from: str | None) -> dict:
|
|
130
|
+
"""Treat every walked public API as `status=implemented`. Useful for
|
|
131
|
+
py-vs-py comparisons where neither side is annotated."""
|
|
132
|
+
ref = walk.walk_package(target, version_from=version_from)
|
|
133
|
+
entries = []
|
|
134
|
+
for e in ref["entries"]:
|
|
135
|
+
impl = e["path"]
|
|
136
|
+
entries.append({
|
|
137
|
+
"path": e["path"],
|
|
138
|
+
"implementation": impl,
|
|
139
|
+
"status": "implemented",
|
|
140
|
+
"since": None,
|
|
141
|
+
"issue": None,
|
|
142
|
+
"comment": None,
|
|
143
|
+
})
|
|
144
|
+
return {**ref, "kind": "port", "entries": entries}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
sys.exit(main())
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Annotation API for Python plugins.
|
|
2
|
+
|
|
3
|
+
Three decorators:
|
|
4
|
+
|
|
5
|
+
- ``@parity_impl(path=..., status=...)`` on a class. Sets a parent path
|
|
6
|
+
for its methods; if both ``path`` and ``status`` are given, also
|
|
7
|
+
registers a class-level port entry.
|
|
8
|
+
- ``@parity(path=..., status=...)`` on a method. A leading ``.`` makes
|
|
9
|
+
the path relative to the enclosing ``@parity_impl``'s path. Free
|
|
10
|
+
functions (no enclosing class) are supported with absolute paths.
|
|
11
|
+
- ``@parity_ref(path=..., kind=...)`` on a class / method / function.
|
|
12
|
+
For declaring a reference inventory in code (rare; usually a walker
|
|
13
|
+
is better).
|
|
14
|
+
|
|
15
|
+
Decorators write to module-level registries that the CLI dumps in
|
|
16
|
+
``port`` / ``reference`` mode with ``--mode annotation``.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import Any, Callable
|
|
23
|
+
|
|
24
|
+
_PORT_ENTRIES: list[dict] = []
|
|
25
|
+
_REFERENCE_ENTRIES: list[dict] = []
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Status(str, Enum):
|
|
29
|
+
IMPLEMENTED = "implemented"
|
|
30
|
+
PARTIAL = "partial"
|
|
31
|
+
UNIMPLEMENTED = "unimplemented"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _normalize_status(status: Any) -> str:
|
|
35
|
+
if isinstance(status, Status):
|
|
36
|
+
return status.value
|
|
37
|
+
if isinstance(status, str) and status in {s.value for s in Status}:
|
|
38
|
+
return status
|
|
39
|
+
raise TypeError(
|
|
40
|
+
f"status must be a Status enum or one of {{implemented, partial, unimplemented}}, "
|
|
41
|
+
f"got {status!r}"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _validate_port_args(status: str, comment: str | None) -> None:
|
|
46
|
+
if status == Status.UNIMPLEMENTED.value and not comment:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"status=Unimplemented requires a comment explaining why"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def parity(
|
|
53
|
+
path: str,
|
|
54
|
+
status: Status | str,
|
|
55
|
+
*,
|
|
56
|
+
since: str | None = None,
|
|
57
|
+
issue: int | None = None,
|
|
58
|
+
comment: str | None = None,
|
|
59
|
+
) -> Callable:
|
|
60
|
+
"""Method-level / free-function port annotation.
|
|
61
|
+
|
|
62
|
+
Inside a ``@parity_impl`` class, a leading ``.`` in ``path`` is
|
|
63
|
+
rewritten as ``parent_path + child`` when the class decorator runs.
|
|
64
|
+
For free functions the path must be absolute.
|
|
65
|
+
"""
|
|
66
|
+
status_str = _normalize_status(status)
|
|
67
|
+
_validate_port_args(status_str, comment)
|
|
68
|
+
meta = {
|
|
69
|
+
"path": path,
|
|
70
|
+
"status": status_str,
|
|
71
|
+
"since": since,
|
|
72
|
+
"issue": issue,
|
|
73
|
+
"comment": comment,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
def decorate(fn: Callable) -> Callable:
|
|
77
|
+
if path.startswith("."):
|
|
78
|
+
# Relative path: stash meta so the enclosing `parity_impl`
|
|
79
|
+
# sweep can resolve it against the parent path.
|
|
80
|
+
fn._parity_meta = meta # type: ignore[attr-defined]
|
|
81
|
+
return fn
|
|
82
|
+
# Absolute path: register immediately. Works for free functions
|
|
83
|
+
# and for class methods whose path is unrelated to their enclosing
|
|
84
|
+
# type. We don't stash meta — that would cause `parity_impl` to
|
|
85
|
+
# double-register the same entry.
|
|
86
|
+
impl = f"{fn.__module__}.{fn.__qualname__}"
|
|
87
|
+
_PORT_ENTRIES.append({**meta, "implementation": impl})
|
|
88
|
+
return fn
|
|
89
|
+
|
|
90
|
+
return decorate
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parity_impl(
|
|
94
|
+
path: Any = None,
|
|
95
|
+
status: Status | str | None = None,
|
|
96
|
+
*,
|
|
97
|
+
since: str | None = None,
|
|
98
|
+
issue: int | None = None,
|
|
99
|
+
comment: str | None = None,
|
|
100
|
+
) -> Callable:
|
|
101
|
+
"""Class-level port annotation.
|
|
102
|
+
|
|
103
|
+
Two call forms:
|
|
104
|
+
|
|
105
|
+
- ``@parity_impl(path="...", status=...)`` — with args, registers a
|
|
106
|
+
class-level entry and provides a parent path for relative children.
|
|
107
|
+
- ``@parity_impl`` — bare, no args. Sweeps the class for relative
|
|
108
|
+
``@parity`` children but registers no class-level entry (and a
|
|
109
|
+
child with a leading-``.`` path is an error in this form).
|
|
110
|
+
|
|
111
|
+
Either way, the class's ``__dict__`` is swept and any
|
|
112
|
+
``@parity``-decorated method with relative path gets joined with the
|
|
113
|
+
parent's ``path`` and registered.
|
|
114
|
+
"""
|
|
115
|
+
# Bare-decorator form: Python passes the class as the first arg.
|
|
116
|
+
if isinstance(path, type) and status is None:
|
|
117
|
+
return parity_impl()(path)
|
|
118
|
+
|
|
119
|
+
parent_path = path
|
|
120
|
+
parent_status = _normalize_status(status) if status is not None else None
|
|
121
|
+
if parent_status is not None:
|
|
122
|
+
_validate_port_args(parent_status, comment)
|
|
123
|
+
|
|
124
|
+
def decorate(cls: type) -> type:
|
|
125
|
+
# Class-level entry, if both path and status given.
|
|
126
|
+
if parent_path and parent_status:
|
|
127
|
+
_PORT_ENTRIES.append({
|
|
128
|
+
"path": parent_path,
|
|
129
|
+
"implementation": f"{cls.__module__}.{cls.__qualname__}",
|
|
130
|
+
"status": parent_status,
|
|
131
|
+
"since": since,
|
|
132
|
+
"issue": issue,
|
|
133
|
+
"comment": comment,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
# Sweep methods. We look at __dict__ rather than dir() so we
|
|
137
|
+
# only see things defined on this class (not inherited), and so
|
|
138
|
+
# properties / classmethods come back as their raw descriptor
|
|
139
|
+
# (which is what the @parity decorator attached the meta to).
|
|
140
|
+
for name, value in cls.__dict__.items():
|
|
141
|
+
inner = value
|
|
142
|
+
if isinstance(value, (classmethod, staticmethod)):
|
|
143
|
+
inner = value.__func__
|
|
144
|
+
elif isinstance(value, property):
|
|
145
|
+
inner = value.fget
|
|
146
|
+
meta = getattr(inner, "_parity_meta", None)
|
|
147
|
+
if not meta:
|
|
148
|
+
continue
|
|
149
|
+
child_path = meta["path"]
|
|
150
|
+
if child_path.startswith("."):
|
|
151
|
+
if not parent_path:
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"@parity({child_path!r}) on {cls.__qualname__}.{name} "
|
|
154
|
+
f"requires the enclosing @parity_impl to declare a path"
|
|
155
|
+
)
|
|
156
|
+
full_path = f"{parent_path}{child_path}"
|
|
157
|
+
else:
|
|
158
|
+
full_path = child_path
|
|
159
|
+
_PORT_ENTRIES.append({
|
|
160
|
+
"path": full_path,
|
|
161
|
+
"implementation": f"{cls.__module__}.{cls.__qualname__}.{name}",
|
|
162
|
+
"status": meta["status"],
|
|
163
|
+
"since": meta["since"],
|
|
164
|
+
"issue": meta["issue"],
|
|
165
|
+
"comment": meta["comment"],
|
|
166
|
+
})
|
|
167
|
+
return cls
|
|
168
|
+
|
|
169
|
+
return decorate
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def parity_ref(path: str, kind: str) -> Callable:
|
|
173
|
+
"""Code-level reference entry. Apply to a class / method / function."""
|
|
174
|
+
if kind not in {"class", "method", "property", "function"}:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"kind must be one of class/method/property/function, got {kind!r}"
|
|
177
|
+
)
|
|
178
|
+
entry = {"path": path, "kind": kind}
|
|
179
|
+
|
|
180
|
+
def decorate(target: Any) -> Any:
|
|
181
|
+
_REFERENCE_ENTRIES.append(entry)
|
|
182
|
+
return target
|
|
183
|
+
|
|
184
|
+
return decorate
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def collect_port_entries() -> list[dict]:
|
|
188
|
+
"""Return all port entries registered so far, sorted+deduped by path."""
|
|
189
|
+
return _sort_dedup(_PORT_ENTRIES, key=("path",))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def collect_reference_entries() -> list[dict]:
|
|
193
|
+
"""Return all reference entries registered so far, sorted+deduped."""
|
|
194
|
+
return _sort_dedup(_REFERENCE_ENTRIES, key=("path", "kind"))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def reset_registries() -> None:
|
|
198
|
+
"""Clear both registries. Intended for tests."""
|
|
199
|
+
_PORT_ENTRIES.clear()
|
|
200
|
+
_REFERENCE_ENTRIES.clear()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _sort_dedup(entries: list[dict], key: tuple[str, ...]) -> list[dict]:
|
|
204
|
+
seen: set[tuple] = set()
|
|
205
|
+
out: list[dict] = []
|
|
206
|
+
for e in sorted(entries, key=lambda d: tuple(d.get(k) or "" for k in key)):
|
|
207
|
+
k = tuple(e.get(field) for field in key)
|
|
208
|
+
if k in seen:
|
|
209
|
+
continue
|
|
210
|
+
seen.add(k)
|
|
211
|
+
out.append(e)
|
|
212
|
+
return out
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Walk a Python package and emit a reference-side envelope (see SCHEMA.md).
|
|
2
|
+
|
|
3
|
+
# Algorithm
|
|
4
|
+
|
|
5
|
+
1. Preload every submodule of each requested package, so classes defined
|
|
6
|
+
in lazy-imported modules show up in `sys.modules` and forward-reference
|
|
7
|
+
strings are resolvable.
|
|
8
|
+
2. Walk the loaded modules. For each module under one of the requested
|
|
9
|
+
packages, list its public classes — but only the ones *defined* there
|
|
10
|
+
(i.e. `cls.__module__ == mod_name`), so re-exports don't double-count.
|
|
11
|
+
3. For each class, recurse into nested classes (e.g. `SparkSession.Builder`)
|
|
12
|
+
and emit one entry per public method/property. Class-level data attrs
|
|
13
|
+
(`MAX_MESSAGE_LENGTH = 128`) and nested classes are handled separately.
|
|
14
|
+
4. Module-level free functions are emitted with `kind = "function"`.
|
|
15
|
+
|
|
16
|
+
# Path keying
|
|
17
|
+
|
|
18
|
+
Each entry's `path` is `cls.__module__ + "." + cls.__qualname__` (for a
|
|
19
|
+
class) or `<class path>.<member>` (for a member). `__qualname__` includes
|
|
20
|
+
nesting, so `SparkSession.Builder` keeps its dotted lexical structure.
|
|
21
|
+
|
|
22
|
+
This means `pyspark.sql.session.SparkSession` and
|
|
23
|
+
`pyspark.sql.connect.session.SparkSession` are distinct entries — both
|
|
24
|
+
classes are tracked, no qualname collision, no priority/preference flag.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import importlib
|
|
28
|
+
import inspect
|
|
29
|
+
import pkgutil
|
|
30
|
+
import sys
|
|
31
|
+
from collections import Counter
|
|
32
|
+
|
|
33
|
+
# `inspect.getmembers(object)` brings in `__init__`, `__doc__`, etc.; we
|
|
34
|
+
# strip those by name to keep the public API list focused on real surface.
|
|
35
|
+
_OBJECT_MEMBERS = frozenset(name for name, _ in inspect.getmembers(object))
|
|
36
|
+
# Subclasses of these are "data shapes", not API surface. Filtering by
|
|
37
|
+
# class hierarchy avoids clutter from `Row` (a tuple subclass), exception
|
|
38
|
+
# types, etc.
|
|
39
|
+
_DATA_BASES = (tuple, list, dict, BaseException)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _preload_submodules(package_name: str) -> None:
|
|
43
|
+
"""Import every submodule of `package_name` into `sys.modules`.
|
|
44
|
+
|
|
45
|
+
Without this, classes defined in lazy-loaded submodules would never be
|
|
46
|
+
seen by the discovery walk, and string-form forward-reference type
|
|
47
|
+
annotations (`'SparkConnectClient'`) wouldn't resolve.
|
|
48
|
+
|
|
49
|
+
Submodules whose import raises (e.g. because an optional dep like
|
|
50
|
+
pandas isn't installed) are collected and summarized on stderr so an
|
|
51
|
+
incomplete walk is visibly incomplete instead of silently truncated.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
pkg = importlib.import_module(package_name)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
sys.stderr.write(
|
|
57
|
+
f"api-parity-py: failed to import top-level package "
|
|
58
|
+
f"{package_name!r}: {type(e).__name__}: {e}\n"
|
|
59
|
+
)
|
|
60
|
+
return
|
|
61
|
+
pkg_path = getattr(pkg, "__path__", None)
|
|
62
|
+
if pkg_path is None:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
failures: list[tuple[str, BaseException]] = []
|
|
66
|
+
total = 0
|
|
67
|
+
for _, mod_name, _ in pkgutil.walk_packages(pkg_path, prefix=package_name + "."):
|
|
68
|
+
total += 1
|
|
69
|
+
try:
|
|
70
|
+
importlib.import_module(mod_name)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
failures.append((mod_name, e))
|
|
73
|
+
|
|
74
|
+
if failures:
|
|
75
|
+
_report_skipped(package_name, total, failures)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _report_skipped(
|
|
79
|
+
package_name: str,
|
|
80
|
+
total: int,
|
|
81
|
+
failures: list[tuple[str, BaseException]],
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Print a grouped, terse summary of import failures to stderr."""
|
|
84
|
+
reasons: Counter[str] = Counter()
|
|
85
|
+
examples: dict[str, str] = {}
|
|
86
|
+
for mod, err in failures:
|
|
87
|
+
first_line = str(err).splitlines()[0][:120] if str(err) else ""
|
|
88
|
+
key = f"{type(err).__name__}: {first_line}" if first_line else type(err).__name__
|
|
89
|
+
reasons[key] += 1
|
|
90
|
+
examples.setdefault(key, mod)
|
|
91
|
+
|
|
92
|
+
sys.stderr.write(
|
|
93
|
+
f"api-parity-py: {package_name}: "
|
|
94
|
+
f"skipped {len(failures)}/{total} submodule(s) — inventory will be incomplete\n"
|
|
95
|
+
)
|
|
96
|
+
for reason, count in reasons.most_common():
|
|
97
|
+
sys.stderr.write(f" - [{count}x] {reason}\n")
|
|
98
|
+
sys.stderr.write(f" e.g. {examples[reason]}\n")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _raw_attr(cls: type, name: str) -> object:
|
|
102
|
+
"""Return the raw descriptor for `name` on `cls`, walking the MRO.
|
|
103
|
+
|
|
104
|
+
We can't use `getattr(cls, name)` to detect properties — that triggers
|
|
105
|
+
descriptor invocation and gives us the resolved value (e.g. a `Builder`
|
|
106
|
+
instance) instead of the descriptor itself. Looking up via `__dict__`
|
|
107
|
+
on each MRO class returns the raw `property` / `classmethod` object.
|
|
108
|
+
"""
|
|
109
|
+
for klass in cls.__mro__:
|
|
110
|
+
if name in klass.__dict__:
|
|
111
|
+
return klass.__dict__[name]
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _member_kind(cls: type, name: str, value: object) -> str | None:
|
|
116
|
+
"""Classify a class member, or `None` to drop it from the inventory.
|
|
117
|
+
|
|
118
|
+
`isinstance(raw, property)` catches `property` subclasses like pyspark's
|
|
119
|
+
`classproperty`. Nested classes return None — they're emitted as their
|
|
120
|
+
own top-level entries by the recursion in `_collect_class`. Callable
|
|
121
|
+
values (functions, classmethods after descriptor invocation, etc.)
|
|
122
|
+
become methods.
|
|
123
|
+
"""
|
|
124
|
+
raw = _raw_attr(cls, name)
|
|
125
|
+
if isinstance(raw, property):
|
|
126
|
+
return "property"
|
|
127
|
+
if isinstance(value, type):
|
|
128
|
+
return None
|
|
129
|
+
if callable(value):
|
|
130
|
+
return "method"
|
|
131
|
+
# Plain class attributes (constants, default values) are not API surface.
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_api_class(obj: object) -> bool:
|
|
136
|
+
return isinstance(obj, type) and not issubclass(obj, _DATA_BASES)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _collect_class(cls: type, entries: list[dict]) -> None:
|
|
140
|
+
"""Record `cls` and its members, then recurse into nested classes."""
|
|
141
|
+
full = f"{cls.__module__}.{cls.__qualname__}"
|
|
142
|
+
# Cheap dedup: re-exports may cause us to revisit a class. We bail
|
|
143
|
+
# before doing redundant member walks.
|
|
144
|
+
if any(e["path"] == full and e["kind"] == "class" for e in entries):
|
|
145
|
+
return
|
|
146
|
+
entries.append({"path": full, "kind": "class"})
|
|
147
|
+
|
|
148
|
+
for name, value in inspect.getmembers(cls):
|
|
149
|
+
if name.startswith("_") or name in _OBJECT_MEMBERS:
|
|
150
|
+
continue
|
|
151
|
+
kind = _member_kind(cls, name, value)
|
|
152
|
+
if kind is None:
|
|
153
|
+
continue
|
|
154
|
+
entries.append({"path": f"{full}.{name}", "kind": kind})
|
|
155
|
+
|
|
156
|
+
# Nested classes: their `__module__` matches the outer class's, and
|
|
157
|
+
# their `__qualname__` is prefixed with `<outer>.` — those two checks
|
|
158
|
+
# together filter out unrelated classes that happen to be exposed as
|
|
159
|
+
# attributes (e.g. types pulled in for type hints).
|
|
160
|
+
for name, obj in inspect.getmembers(cls):
|
|
161
|
+
if name.startswith("_") or not _is_api_class(obj):
|
|
162
|
+
continue
|
|
163
|
+
if obj.__module__ != cls.__module__:
|
|
164
|
+
continue
|
|
165
|
+
if not obj.__qualname__.startswith(cls.__qualname__ + "."):
|
|
166
|
+
continue
|
|
167
|
+
_collect_class(obj, entries)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _discover(packages: list[str]) -> list[dict]:
|
|
171
|
+
"""Walk every loaded module under `packages`, emit entries."""
|
|
172
|
+
entries: list[dict] = []
|
|
173
|
+
for mod_name, mod in list(sys.modules.items()):
|
|
174
|
+
if mod is None:
|
|
175
|
+
continue
|
|
176
|
+
if not any(mod_name == p or mod_name.startswith(p + ".") for p in packages):
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
# Top-level classes, recursing into their nested classes.
|
|
180
|
+
for name, obj in inspect.getmembers(mod):
|
|
181
|
+
if name.startswith("_") or not _is_api_class(obj):
|
|
182
|
+
continue
|
|
183
|
+
# Skip re-exports: only record a class in its defining module.
|
|
184
|
+
# Without this, `pyspark.sql.SparkSession` (re-export from
|
|
185
|
+
# `pyspark.sql.session`) would be listed twice with different
|
|
186
|
+
# paths.
|
|
187
|
+
if obj.__module__ != mod_name:
|
|
188
|
+
continue
|
|
189
|
+
_collect_class(obj, entries)
|
|
190
|
+
|
|
191
|
+
# Module-level free functions (e.g. `pyspark.sql.functions.col`).
|
|
192
|
+
for name, obj in inspect.getmembers(mod, inspect.isfunction):
|
|
193
|
+
if name.startswith("_"):
|
|
194
|
+
continue
|
|
195
|
+
if obj.__module__ != mod_name:
|
|
196
|
+
continue
|
|
197
|
+
entries.append({
|
|
198
|
+
"path": f"{obj.__module__}.{obj.__qualname__}",
|
|
199
|
+
"kind": "function",
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
# Sort + dedup by (path, kind). Stability matters because the JSON
|
|
203
|
+
# output is part of the contract — diffing two versions should produce
|
|
204
|
+
# minimal noise.
|
|
205
|
+
seen: set[tuple[str, str]] = set()
|
|
206
|
+
out: list[dict] = []
|
|
207
|
+
for e in sorted(entries, key=lambda e: (e["path"], e["kind"])):
|
|
208
|
+
key = (e["path"], e["kind"])
|
|
209
|
+
if key in seen:
|
|
210
|
+
continue
|
|
211
|
+
seen.add(key)
|
|
212
|
+
out.append(e)
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def walk_package(target: str, *, version_from: str | None = None) -> dict:
|
|
217
|
+
"""Build a reference envelope for one or more comma-separated packages.
|
|
218
|
+
|
|
219
|
+
`version_from` names a module to import for its `__version__` (e.g.
|
|
220
|
+
`"pyspark"`). If unimportable or missing the attribute, version is
|
|
221
|
+
left null; this is informational metadata, not part of the join key.
|
|
222
|
+
"""
|
|
223
|
+
packages = [p.strip() for p in target.split(",") if p.strip()]
|
|
224
|
+
for pkg in packages:
|
|
225
|
+
_preload_submodules(pkg)
|
|
226
|
+
|
|
227
|
+
version = None
|
|
228
|
+
if version_from:
|
|
229
|
+
try:
|
|
230
|
+
mod = importlib.import_module(version_from)
|
|
231
|
+
version = getattr(mod, "__version__", None)
|
|
232
|
+
except Exception:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
"schema_version": 1,
|
|
237
|
+
"kind": "reference",
|
|
238
|
+
"language": "python",
|
|
239
|
+
"version": version,
|
|
240
|
+
"source": ",".join(packages),
|
|
241
|
+
"entries": _discover(packages),
|
|
242
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Annotated classes/functions used by the port + reference annotation tests."""
|
|
2
|
+
|
|
3
|
+
from api_parity_py import Status, parity, parity_impl, parity_ref
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@parity_impl(path="ext.widget.Widget", status=Status.IMPLEMENTED, since="1.0")
|
|
7
|
+
class Widget:
|
|
8
|
+
@parity(path=".foo", status=Status.IMPLEMENTED)
|
|
9
|
+
def foo(self):
|
|
10
|
+
return 1
|
|
11
|
+
|
|
12
|
+
@parity(path=".bar", status=Status.PARTIAL, comment="missing batch mode")
|
|
13
|
+
def bar(self):
|
|
14
|
+
return 2
|
|
15
|
+
|
|
16
|
+
@parity(
|
|
17
|
+
path=".baz",
|
|
18
|
+
status=Status.UNIMPLEMENTED,
|
|
19
|
+
comment="todo",
|
|
20
|
+
issue=42,
|
|
21
|
+
)
|
|
22
|
+
def baz(self):
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@parity_impl
|
|
27
|
+
class Naked:
|
|
28
|
+
"""No class-level entry; child uses an absolute path."""
|
|
29
|
+
|
|
30
|
+
@parity(path="ext.naked.absolute_only", status=Status.IMPLEMENTED)
|
|
31
|
+
def whatever(self):
|
|
32
|
+
return 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@parity(path="ext.free.solo", status=Status.PARTIAL, comment="wip")
|
|
36
|
+
def free_fn():
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@parity_ref(path="ext.spec.Declared", kind="class")
|
|
41
|
+
class Declared:
|
|
42
|
+
pass
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Core module of the synthetic test package.
|
|
2
|
+
|
|
3
|
+
Each member is here to exercise one branch of the walker:
|
|
4
|
+
- `foo`: regular method
|
|
5
|
+
- `bar`: property (must be detected via raw __dict__ lookup, not getattr)
|
|
6
|
+
- `bake`: classmethod (callable, but the raw descriptor is a classmethod)
|
|
7
|
+
- `_private`: underscore-prefixed → must be skipped
|
|
8
|
+
- `Inner`: nested class → must be emitted with dotted qualname
|
|
9
|
+
- `RowLike`: tuple subclass → must be filtered as a data shape
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Widget:
|
|
14
|
+
"""Public widget."""
|
|
15
|
+
|
|
16
|
+
CONSTANT = 42 # plain class attribute — not API surface, must be skipped
|
|
17
|
+
|
|
18
|
+
def foo(self) -> int:
|
|
19
|
+
return 1
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def bar(self) -> str:
|
|
23
|
+
return "bar"
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def bake(cls) -> "Widget":
|
|
27
|
+
return cls()
|
|
28
|
+
|
|
29
|
+
def _private(self) -> None:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
class Inner:
|
|
33
|
+
"""Nested class — its qualname is `Widget.Inner`."""
|
|
34
|
+
|
|
35
|
+
def baz(self) -> None:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RowLike(tuple):
|
|
40
|
+
"""tuple subclass — counted as a data shape, must be excluded."""
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""End-to-end acceptance tests for `api-parity-py`.
|
|
2
|
+
|
|
3
|
+
One test per (kind, mode) cell — each invokes the CLI via `cli.main()`
|
|
4
|
+
with a monkeypatched `argv`, parses stdout, and asserts the envelope
|
|
5
|
+
shape against the synthetic fixtures.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from api_parity_py import __main__ as cli
|
|
14
|
+
from api_parity_py.parity import reset_registries
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _purge(*roots: str) -> None:
|
|
18
|
+
"""Drop cached fixture modules so re-imports re-run their decorators."""
|
|
19
|
+
for name in list(sys.modules):
|
|
20
|
+
if name in roots or any(name.startswith(r + ".") for r in roots):
|
|
21
|
+
del sys.modules[name]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture(autouse=True)
|
|
25
|
+
def _isolate():
|
|
26
|
+
reset_registries()
|
|
27
|
+
_purge("tinypkg", "portpkg")
|
|
28
|
+
yield
|
|
29
|
+
reset_registries()
|
|
30
|
+
_purge("tinypkg", "portpkg")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _run(*argv: str, capsys) -> dict:
|
|
34
|
+
"""Invoke the CLI and return the parsed-JSON stdout envelope."""
|
|
35
|
+
import sys as _sys
|
|
36
|
+
_sys.argv = ["api-parity-py", *argv]
|
|
37
|
+
rc = cli.main()
|
|
38
|
+
assert rc == 0, capsys.readouterr().err
|
|
39
|
+
out = capsys.readouterr().out
|
|
40
|
+
return json.loads(out)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_reference_walker_against_tinypkg(capsys):
|
|
44
|
+
"""`reference` defaults to mode=walker — walks tinypkg's public API."""
|
|
45
|
+
env = _run("reference", "tinypkg", capsys=capsys)
|
|
46
|
+
assert env["schema_version"] == 1
|
|
47
|
+
assert env["kind"] == "reference"
|
|
48
|
+
assert env["language"] == "python"
|
|
49
|
+
paths = {e["path"] for e in env["entries"]}
|
|
50
|
+
assert "tinypkg.core.Widget" in paths
|
|
51
|
+
assert "tinypkg.core.Widget.foo" in paths
|
|
52
|
+
assert "tinypkg.extras.helper" in paths
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_port_annotation_against_portpkg(capsys):
|
|
56
|
+
"""`port` defaults to mode=annotation — collects @parity_impl/@parity."""
|
|
57
|
+
env = _run("port", "portpkg", capsys=capsys)
|
|
58
|
+
assert env["kind"] == "port"
|
|
59
|
+
paths = {e["path"] for e in env["entries"]}
|
|
60
|
+
assert "ext.widget.Widget" in paths
|
|
61
|
+
assert "ext.widget.Widget.foo" in paths
|
|
62
|
+
assert "ext.free.solo" in paths
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_reference_annotation_against_portpkg(capsys):
|
|
66
|
+
"""`reference --mode=annotation` collects @parity_ref decorators."""
|
|
67
|
+
env = _run("reference", "--mode=annotation", "portpkg", capsys=capsys)
|
|
68
|
+
assert env["kind"] == "reference"
|
|
69
|
+
paths = {e["path"] for e in env["entries"]}
|
|
70
|
+
assert "ext.spec.Declared" in paths
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_port_walker_against_tinypkg(capsys):
|
|
74
|
+
"""`port --mode=walker` synthesizes implemented port entries from a walk.
|
|
75
|
+
|
|
76
|
+
Useful for py↔py comparisons where neither side is annotated."""
|
|
77
|
+
env = _run("port", "--mode=walker", "tinypkg", capsys=capsys)
|
|
78
|
+
assert env["kind"] == "port"
|
|
79
|
+
assert env["entries"], "expected at least one synthesized port entry"
|
|
80
|
+
for e in env["entries"]:
|
|
81
|
+
assert e["status"] == "implemented"
|
|
82
|
+
assert e["implementation"] == e["path"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Tests for the @parity / @parity_impl / @parity_ref decorators."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from api_parity_py import Status, parity, parity_impl
|
|
6
|
+
from api_parity_py.parity import (
|
|
7
|
+
collect_port_entries,
|
|
8
|
+
collect_reference_entries,
|
|
9
|
+
reset_registries,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture(autouse=True)
|
|
14
|
+
def _isolate_registries():
|
|
15
|
+
"""Each test starts with empty registries and restores nothing — tests
|
|
16
|
+
should not depend on residual state from earlier tests."""
|
|
17
|
+
reset_registries()
|
|
18
|
+
yield
|
|
19
|
+
reset_registries()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _import_fixture():
|
|
23
|
+
"""Re-import portpkg so its module-level decorators run again into the
|
|
24
|
+
freshly-reset registries."""
|
|
25
|
+
import sys
|
|
26
|
+
|
|
27
|
+
for name in list(sys.modules):
|
|
28
|
+
if name == "portpkg" or name.startswith("portpkg."):
|
|
29
|
+
del sys.modules[name]
|
|
30
|
+
import portpkg # noqa: F401
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_class_level_entry_and_relative_children():
|
|
34
|
+
_import_fixture()
|
|
35
|
+
entries = collect_port_entries()
|
|
36
|
+
by_path = {e["path"]: e for e in entries}
|
|
37
|
+
|
|
38
|
+
# Class-level entry: implementation = qualified class name, optional
|
|
39
|
+
# fields propagate from the impl-level args.
|
|
40
|
+
cls = by_path["ext.widget.Widget"]
|
|
41
|
+
assert cls["implementation"].endswith(".Widget")
|
|
42
|
+
assert cls["status"] == "implemented"
|
|
43
|
+
assert cls["since"] == "1.0"
|
|
44
|
+
|
|
45
|
+
# Method with no comment: comment is None, not inherited from parent.
|
|
46
|
+
foo = by_path["ext.widget.Widget.foo"]
|
|
47
|
+
assert foo["implementation"].endswith(".Widget.foo")
|
|
48
|
+
assert foo["status"] == "implemented"
|
|
49
|
+
assert foo["comment"] is None
|
|
50
|
+
|
|
51
|
+
# Unimplemented requires a comment; the issue field round-trips.
|
|
52
|
+
baz = by_path["ext.widget.Widget.baz"]
|
|
53
|
+
assert baz["status"] == "unimplemented"
|
|
54
|
+
assert baz["comment"] == "todo"
|
|
55
|
+
assert baz["issue"] == 42
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_parity_impl_without_args_keeps_absolute_children():
|
|
59
|
+
"""`@parity_impl` with no args registers no class entry, but a child
|
|
60
|
+
with an absolute path still works and gets the qualified-class
|
|
61
|
+
implementation prefix."""
|
|
62
|
+
_import_fixture()
|
|
63
|
+
entries = collect_port_entries()
|
|
64
|
+
naked = [e for e in entries if e["path"] == "ext.naked.absolute_only"]
|
|
65
|
+
assert len(naked) == 1
|
|
66
|
+
assert naked[0]["implementation"].endswith(".Naked.whatever")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_free_function_uses_absolute_path():
|
|
70
|
+
_import_fixture()
|
|
71
|
+
entries = collect_port_entries()
|
|
72
|
+
solo = next(e for e in entries if e["path"] == "ext.free.solo")
|
|
73
|
+
assert solo["status"] == "partial"
|
|
74
|
+
assert solo["comment"] == "wip"
|
|
75
|
+
assert solo["implementation"].endswith(".free_fn")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_parity_ref_registers_reference_entry():
|
|
79
|
+
_import_fixture()
|
|
80
|
+
refs = collect_reference_entries()
|
|
81
|
+
assert {"path": "ext.spec.Declared", "kind": "class"} in refs
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_unimplemented_without_comment_raises():
|
|
85
|
+
"""The validator runs at decoration time, so the failure surfaces
|
|
86
|
+
where the bad annotation lives — not at registry-dump time."""
|
|
87
|
+
with pytest.raises(ValueError, match="comment"):
|
|
88
|
+
@parity(path="x.y", status=Status.UNIMPLEMENTED)
|
|
89
|
+
def _f():
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_relative_path_without_parent_raises():
|
|
94
|
+
"""A leading-`.` path needs an enclosing `@parity_impl` with a path."""
|
|
95
|
+
with pytest.raises(ValueError, match="enclosing @parity_impl"):
|
|
96
|
+
@parity_impl # no args = no parent path
|
|
97
|
+
class _Nope:
|
|
98
|
+
@parity(path=".child", status=Status.IMPLEMENTED)
|
|
99
|
+
def child(self):
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_invalid_status_type_raises():
|
|
104
|
+
with pytest.raises(TypeError, match="Status"):
|
|
105
|
+
@parity(path="x.y", status="bogus")
|
|
106
|
+
def _f():
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_collect_returns_sorted_unique():
|
|
111
|
+
_import_fixture()
|
|
112
|
+
entries = collect_port_entries()
|
|
113
|
+
paths = [e["path"] for e in entries]
|
|
114
|
+
assert paths == sorted(paths)
|
|
115
|
+
assert len(paths) == len(set(paths))
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Tests for `walk.walk_package` against a synthetic `tinypkg` fixture."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from api_parity_py import walk
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _envelope() -> dict:
|
|
9
|
+
return walk.walk_package("tinypkg")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _path_kinds(env: dict) -> set[tuple[str, str]]:
|
|
13
|
+
return {(e["path"], e["kind"]) for e in env["entries"]}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _paths(env: dict) -> set[str]:
|
|
17
|
+
return {e["path"] for e in env["entries"]}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_envelope_metadata_matches_schema():
|
|
21
|
+
"""Top-level envelope keys are spelled exactly as SCHEMA.md requires."""
|
|
22
|
+
env = _envelope()
|
|
23
|
+
assert env["schema_version"] == 1
|
|
24
|
+
assert env["kind"] == "reference"
|
|
25
|
+
assert env["language"] == "python"
|
|
26
|
+
assert env["source"] == "tinypkg"
|
|
27
|
+
assert isinstance(env["entries"], list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_widget_class_and_members_are_emitted():
|
|
31
|
+
"""Regular method, property, and classmethod all appear with the
|
|
32
|
+
correct `kind`. The property must be detected via raw-attribute
|
|
33
|
+
lookup (a `getattr` on the class would invoke the descriptor)."""
|
|
34
|
+
pk = _path_kinds(_envelope())
|
|
35
|
+
assert ("tinypkg.core.Widget", "class") in pk
|
|
36
|
+
assert ("tinypkg.core.Widget.foo", "method") in pk
|
|
37
|
+
assert ("tinypkg.core.Widget.bar", "property") in pk
|
|
38
|
+
assert ("tinypkg.core.Widget.bake", "method") in pk
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_underscore_and_data_attrs_excluded():
|
|
42
|
+
"""Underscore-prefixed members and plain class attributes are not
|
|
43
|
+
API surface and must be omitted from the inventory."""
|
|
44
|
+
paths = _paths(_envelope())
|
|
45
|
+
assert "tinypkg.core.Widget._private" not in paths
|
|
46
|
+
assert "tinypkg.core.Widget.CONSTANT" not in paths
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_nested_class_emitted_with_dotted_qualname():
|
|
50
|
+
"""Nested classes use `cls.__qualname__`, so `Widget.Inner` keeps its
|
|
51
|
+
lexical structure in the path. Their members get the same treatment."""
|
|
52
|
+
pk = _path_kinds(_envelope())
|
|
53
|
+
assert ("tinypkg.core.Widget.Inner", "class") in pk
|
|
54
|
+
assert ("tinypkg.core.Widget.Inner.baz", "method") in pk
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_data_shape_subclass_filtered_out():
|
|
58
|
+
"""`RowLike(tuple)` is a data container, not API surface. The walker
|
|
59
|
+
filters subclasses of `(tuple, list, dict, BaseException)`."""
|
|
60
|
+
paths = _paths(_envelope())
|
|
61
|
+
assert "tinypkg.core.RowLike" not in paths
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_free_function_kind_is_function():
|
|
65
|
+
"""Module-level free functions are tagged `kind="function"`, distinct
|
|
66
|
+
from class methods (which use `kind="method"`)."""
|
|
67
|
+
pk = _path_kinds(_envelope())
|
|
68
|
+
assert ("tinypkg.extras.helper", "function") in pk
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_underscore_free_function_excluded():
|
|
72
|
+
paths = _paths(_envelope())
|
|
73
|
+
assert "tinypkg.extras._hidden" not in paths
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_reexports_not_duplicated():
|
|
77
|
+
"""`tinypkg/__init__.py` re-exports `Widget` and `helper`. They must
|
|
78
|
+
only appear under their *defining* module to keep the join key
|
|
79
|
+
unambiguous on the differ side."""
|
|
80
|
+
paths = _paths(_envelope())
|
|
81
|
+
assert "tinypkg.Widget" not in paths
|
|
82
|
+
assert "tinypkg.helper" not in paths
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_entries_are_sorted_and_deduped():
|
|
86
|
+
"""Output stability is part of the wire contract — diffs across two
|
|
87
|
+
runs of the walker must be minimal noise."""
|
|
88
|
+
entries = _envelope()["entries"]
|
|
89
|
+
keys = [(e["path"], e["kind"]) for e in entries]
|
|
90
|
+
assert keys == sorted(keys)
|
|
91
|
+
assert len(keys) == len(set(keys))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_submodule_import_failures_surface_on_stderr(capsys, monkeypatch):
|
|
95
|
+
"""A missing optional dep silently dropped submodules used to make an
|
|
96
|
+
incomplete walk look complete. The walker now prints a grouped stderr
|
|
97
|
+
summary so the user notices."""
|
|
98
|
+
original = walk.importlib.import_module
|
|
99
|
+
|
|
100
|
+
def flaky(name: str, *a, **kw):
|
|
101
|
+
# Make every submodule import after the top-level package raise.
|
|
102
|
+
if name.startswith("tinypkg.") and name != "tinypkg":
|
|
103
|
+
raise ImportError("simulated missing dep: pandas")
|
|
104
|
+
return original(name, *a, **kw)
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr(walk.importlib, "import_module", flaky)
|
|
107
|
+
walk._preload_submodules("tinypkg")
|
|
108
|
+
err = capsys.readouterr().err
|
|
109
|
+
assert "skipped" in err
|
|
110
|
+
assert "tinypkg" in err
|
|
111
|
+
assert "simulated missing dep: pandas" in err
|