python-interactive-courses 1.0.0__py3-none-any.whl
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.
- pyics/__init__.py +0 -0
- pyics/app/actions.py +179 -0
- pyics/app/cli.py +335 -0
- pyics/app/key_listener.py +133 -0
- pyics/app/prompts.py +439 -0
- pyics/app/pyics.py +454 -0
- pyics/data/__init__.py +4 -0
- pyics/data/course.py +531 -0
- pyics/data/task.py +744 -0
- pyics/distribution/__init__.py +5 -0
- pyics/distribution/archive.py +667 -0
- pyics/distribution/dependencies.py +452 -0
- pyics/distribution/fetch.py +128 -0
- pyics/distribution/pull.py +376 -0
- pyics/distribution/slugs.py +24 -0
- pyics/distribution/sources.py +561 -0
- pyics/notebook/__init__.py +1 -0
- pyics/notebook/cells.py +184 -0
- pyics/notebook/loader.py +590 -0
- pyics/notebook/sections.py +189 -0
- pyics/plugins.py +90 -0
- pyics/scripts/console.py +86 -0
- pyics/scripts/create_course.py +301 -0
- pyics/scripts/create_task.py +850 -0
- pyics/scripts/edit_course.py +133 -0
- pyics/scripts/edit_task.py +109 -0
- pyics/scripts/main.py +118 -0
- pyics/scripts/pack.py +198 -0
- pyics/scripts/pull.py +123 -0
- pyics/scripts/sources.py +118 -0
- pyics/tests/__init__.py +3 -0
- pyics/tests/capture.py +114 -0
- pyics/tests/check_specs.py +528 -0
- pyics/tests/check_structure.py +769 -0
- pyics/tests/compare.py +184 -0
- pyics/tests/pytest_integration.py +228 -0
- pyics/tests/results.py +145 -0
- pyics/tests/tests.py +440 -0
- pyics/tests/trace.py +156 -0
- pyics/util/discovery.py +92 -0
- pyics/util/json_handling.py +182 -0
- pyics/util/logging.py +39 -0
- pyics/util/paths.py +38 -0
- pyics/util/terminal.py +57 -0
- python_interactive_courses-1.0.0.dist-info/METADATA +245 -0
- python_interactive_courses-1.0.0.dist-info/RECORD +49 -0
- python_interactive_courses-1.0.0.dist-info/WHEEL +4 -0
- python_interactive_courses-1.0.0.dist-info/entry_points.txt +3 -0
- python_interactive_courses-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Compare symbols against a specification.
|
|
3
|
+
|
|
4
|
+
Specifications are plain dicts, so that they can be stored as json. Classes and callables
|
|
5
|
+
are accepted as well, and converted using ``to_spec_dict``.
|
|
6
|
+
|
|
7
|
+
Spec dict format
|
|
8
|
+
----------------
|
|
9
|
+
A function spec maps parameter names to parameter specs, plus a ``return`` type::
|
|
10
|
+
|
|
11
|
+
{"is_class": False, "x": "int", "flag": None, "return": "bool"}
|
|
12
|
+
|
|
13
|
+
A parameter spec is either a type string, ``None`` (no type hint required), or a dict with
|
|
14
|
+
the (optional) keys ``type``, ``default`` and ``kind``::
|
|
15
|
+
|
|
16
|
+
{"type": "int", "default": 0, "kind": "KEYWORD_ONLY"}
|
|
17
|
+
|
|
18
|
+
A missing ``kind`` means ``POSITIONAL_OR_KEYWORD``, a missing ``default`` means that no
|
|
19
|
+
default value is expected. ``self``/``cls`` are never listed.
|
|
20
|
+
|
|
21
|
+
A class spec maps attribute names to type strings (or ``None``), and method, property and
|
|
22
|
+
nested class names to nested specs::
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
"is_class": True,
|
|
26
|
+
"name": "str",
|
|
27
|
+
"__init__": {"is_class": False, "name": "str", "return": "None"},
|
|
28
|
+
"size": {
|
|
29
|
+
"is_class": False,
|
|
30
|
+
"is_property": True,
|
|
31
|
+
"return": "int",
|
|
32
|
+
"setter": False,
|
|
33
|
+
"deleter": False,
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
Class specs include inherited attributes, methods, properties and nested classes. A class
|
|
38
|
+
without an ``__init__`` is compared as if it had ``def __init__(self) -> None``.
|
|
39
|
+
|
|
40
|
+
``is_class`` and ``return`` are reserved keys, so they can not be used as parameter or
|
|
41
|
+
attribute names.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import annotationlib
|
|
45
|
+
import builtins
|
|
46
|
+
import inspect
|
|
47
|
+
import re
|
|
48
|
+
import sys
|
|
49
|
+
import typing
|
|
50
|
+
from collections.abc import Callable
|
|
51
|
+
from dataclasses import dataclass, field
|
|
52
|
+
from typing import Any
|
|
53
|
+
|
|
54
|
+
from rich.markup import escape
|
|
55
|
+
|
|
56
|
+
SpecDict = dict[str, Any]
|
|
57
|
+
SpecType = Callable | type | SpecDict
|
|
58
|
+
|
|
59
|
+
_POK = inspect.Parameter.POSITIONAL_OR_KEYWORD
|
|
60
|
+
_KINDS = {kind.name: kind for kind in type(_POK)}
|
|
61
|
+
_PARAM_KEYS = {"type", "default", "kind"}
|
|
62
|
+
_PROPERTY_KEYS = {"is_class", "is_property", "return", "setter", "deleter"}
|
|
63
|
+
# dunder methods which are part of a class spec, all others are ignored
|
|
64
|
+
_CHECKED_DUNDERS = ("__init__", "__call__", "__str__", "__repr__")
|
|
65
|
+
# module prefixes stripped from type strings, in addition to the symbol's own module
|
|
66
|
+
_STRIPPED_MODULES = ("typing", "collections.abc", "builtins")
|
|
67
|
+
_NO_DEFAULT = object()
|
|
68
|
+
# compared against if a class defines no ``__init__``, i.e. inherits ``object.__init__``
|
|
69
|
+
_IMPLICIT_INIT: SpecDict = {"is_class": False, "return": "None"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def compare_spec(
|
|
73
|
+
expected: SpecType,
|
|
74
|
+
other: Any,
|
|
75
|
+
*,
|
|
76
|
+
by_type: bool = False,
|
|
77
|
+
use_rich_markup: bool = True,
|
|
78
|
+
flag_type_hints: bool = True,
|
|
79
|
+
exact: bool = False,
|
|
80
|
+
) -> list[str]:
|
|
81
|
+
"""
|
|
82
|
+
Compare ``other`` against an ``expected`` specification.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
expected: SpecType
|
|
87
|
+
The expected specification: a spec dict (see module docstring), a class, or a
|
|
88
|
+
function
|
|
89
|
+
other: Any
|
|
90
|
+
The symbol to compare against ``expected``
|
|
91
|
+
by_type: bool (optional, keyword only)
|
|
92
|
+
Default False. If set, match parameters and attributes purely by their type,
|
|
93
|
+
ignoring their names and order. Methods, properties and nested classes are still
|
|
94
|
+
matched by name, as they define the class' public API.
|
|
95
|
+
use_rich_markup: bool (optional, keyword only)
|
|
96
|
+
Use ``rich`` markup in diff strings for additional highlighting.
|
|
97
|
+
Default True.
|
|
98
|
+
flag_type_hints: bool (optional, keyword only)
|
|
99
|
+
Default True. If set to false, do not flag missing or mismatching type hints.
|
|
100
|
+
Ignored if ``by_type`` is set.
|
|
101
|
+
exact: bool (optional, keyword only)
|
|
102
|
+
Require an exact, i.e. two-way match (no extra parameters/attributes/methods on
|
|
103
|
+
the other side, no default values on the other side if none are expected)
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
list[str]
|
|
108
|
+
List of differences between ``expected`` and ``other``. Empty if match.
|
|
109
|
+
|
|
110
|
+
Raises
|
|
111
|
+
------
|
|
112
|
+
TypeError
|
|
113
|
+
if ``expected`` is of an invalid type
|
|
114
|
+
ValueError
|
|
115
|
+
if ``expected`` is a malformed spec dict
|
|
116
|
+
"""
|
|
117
|
+
exp = to_spec_dict(expected)
|
|
118
|
+
module = getattr(other, "__module__", None)
|
|
119
|
+
opts = _Options(
|
|
120
|
+
by_type=by_type,
|
|
121
|
+
rich=use_rich_markup,
|
|
122
|
+
check_types=by_type or flag_type_hints,
|
|
123
|
+
exact=exact,
|
|
124
|
+
module=module,
|
|
125
|
+
namespace=vars(sys.modules[module]) if module in sys.modules else {},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if exp["is_class"]:
|
|
129
|
+
if not inspect.isclass(other):
|
|
130
|
+
return [f"Expected {opts.b('class')}, got {opts.b(type(other))}"]
|
|
131
|
+
elif not (inspect.isfunction(other) or inspect.ismethod(other)):
|
|
132
|
+
return [f"Expected a {opts.b('function/callable')}, got {opts.b(type(other))}"]
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
oth = to_spec_dict(other)
|
|
136
|
+
except ValueError:
|
|
137
|
+
return ["No valid callable found."]
|
|
138
|
+
|
|
139
|
+
return _compare(exp, oth, opts)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def to_spec_dict(obj: SpecType) -> SpecDict:
|
|
143
|
+
"""
|
|
144
|
+
Convert a class or function into a spec dict (see module docstring). Spec dicts are
|
|
145
|
+
validated and returned as-is.
|
|
146
|
+
|
|
147
|
+
Raises
|
|
148
|
+
------
|
|
149
|
+
TypeError
|
|
150
|
+
if ``obj`` is neither a class, a function, nor a dict
|
|
151
|
+
ValueError
|
|
152
|
+
if ``obj`` is a malformed spec dict
|
|
153
|
+
"""
|
|
154
|
+
if isinstance(obj, dict):
|
|
155
|
+
_validate(obj, "spec")
|
|
156
|
+
return obj
|
|
157
|
+
if inspect.isclass(obj):
|
|
158
|
+
return _class_to_dict(obj)
|
|
159
|
+
if inspect.isfunction(obj) or inspect.ismethod(obj):
|
|
160
|
+
return _func_to_dict(obj, obj.__module__)
|
|
161
|
+
raise TypeError("Expected specification must be a class, function, or spec dict.")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# conversion ###########################################################################
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _format_type(annotation: Any, module: str | None) -> str | None:
|
|
168
|
+
"""format a type annotation as a string, without module prefixes"""
|
|
169
|
+
if annotation is inspect.Parameter.empty:
|
|
170
|
+
return None
|
|
171
|
+
if isinstance(annotation, annotationlib.ForwardRef):
|
|
172
|
+
annotation = annotation.__forward_arg__
|
|
173
|
+
if isinstance(annotation, str):
|
|
174
|
+
return " ".join(annotation.split())
|
|
175
|
+
|
|
176
|
+
text = inspect.formatannotation(annotation)
|
|
177
|
+
for mod in (*_STRIPPED_MODULES, module):
|
|
178
|
+
if mod:
|
|
179
|
+
text = re.sub(rf"(?<![\w.]){re.escape(mod)}\.", "", text)
|
|
180
|
+
return text
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _func_to_dict(func: Callable, module: str | None, *, drop_first=False) -> SpecDict:
|
|
184
|
+
sig = inspect.signature(func, annotation_format=annotationlib.Format.FORWARDREF)
|
|
185
|
+
params = list(sig.parameters.values())[1 if drop_first else 0 :]
|
|
186
|
+
|
|
187
|
+
spec: SpecDict = {"is_class": False}
|
|
188
|
+
for p in params:
|
|
189
|
+
type_str = _format_type(p.annotation, module)
|
|
190
|
+
if p.kind is _POK and p.default is p.empty:
|
|
191
|
+
spec[p.name] = type_str
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
param: dict[str, Any] = {"type": type_str}
|
|
195
|
+
if p.kind is not _POK:
|
|
196
|
+
param["kind"] = p.kind.name
|
|
197
|
+
if p.default is not p.empty:
|
|
198
|
+
param["default"] = p.default
|
|
199
|
+
spec[p.name] = param
|
|
200
|
+
|
|
201
|
+
spec["return"] = _format_type(sig.return_annotation, module)
|
|
202
|
+
return spec
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _property_to_dict(prop: property, module: str | None) -> SpecDict:
|
|
206
|
+
getter = _func_to_dict(prop.fget, module, drop_first=True) if prop.fget else {}
|
|
207
|
+
return {
|
|
208
|
+
"is_class": False,
|
|
209
|
+
"is_property": True,
|
|
210
|
+
"return": getter.get("return"),
|
|
211
|
+
"setter": prop.fset is not None,
|
|
212
|
+
"deleter": prop.fdel is not None,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _class_to_dict(cls: type) -> SpecDict:
|
|
217
|
+
spec: SpecDict = {"is_class": True}
|
|
218
|
+
# the class itself and its bases, most derived first. ``object`` contributes no
|
|
219
|
+
# Python functions, its dunders would only be noise
|
|
220
|
+
bases = [klass for klass in cls.__mro__ if klass is not object]
|
|
221
|
+
|
|
222
|
+
# attributes: annotated ones and those assigned in methods, incl. inherited ones
|
|
223
|
+
hints = typing.get_type_hints(cls, format=annotationlib.Format.FORWARDREF)
|
|
224
|
+
static = [a for k in reversed(bases) for a in getattr(k, "__static_attributes__", ())]
|
|
225
|
+
for name in dict.fromkeys([*hints, *static]):
|
|
226
|
+
spec[name] = _format_type(hints.get(name, inspect.Parameter.empty), cls.__module__)
|
|
227
|
+
|
|
228
|
+
# methods, properties and nested classes, incl. inherited ones. The most derived
|
|
229
|
+
# definition of a name wins, even if it is no method (e.g. ``method = None``)
|
|
230
|
+
seen: set[str] = set()
|
|
231
|
+
for klass in bases:
|
|
232
|
+
module = klass.__module__
|
|
233
|
+
for name, member in klass.__dict__.items():
|
|
234
|
+
if name in seen or (name.startswith("__") and name not in _CHECKED_DUNDERS):
|
|
235
|
+
continue
|
|
236
|
+
seen.add(name)
|
|
237
|
+
|
|
238
|
+
if isinstance(member, staticmethod):
|
|
239
|
+
spec[name] = _func_to_dict(member.__func__, module)
|
|
240
|
+
elif isinstance(member, classmethod):
|
|
241
|
+
spec[name] = _func_to_dict(member.__func__, module, drop_first=True)
|
|
242
|
+
elif inspect.isfunction(member):
|
|
243
|
+
spec[name] = _func_to_dict(member, module, drop_first=True)
|
|
244
|
+
elif isinstance(member, property):
|
|
245
|
+
spec[name] = _property_to_dict(member, module)
|
|
246
|
+
elif (
|
|
247
|
+
inspect.isclass(member)
|
|
248
|
+
and member.__qualname__ == f"{klass.__qualname__}.{name}"
|
|
249
|
+
):
|
|
250
|
+
spec[name] = _class_to_dict(member)
|
|
251
|
+
|
|
252
|
+
return spec
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# validation ###########################################################################
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _param(value: Any) -> dict[str, Any]:
|
|
259
|
+
"""normalize a parameter spec to a dict with a ``type``, ``kind`` and optionally a
|
|
260
|
+
``default`` key"""
|
|
261
|
+
if not isinstance(value, dict):
|
|
262
|
+
return {"type": value, "kind": _POK.name}
|
|
263
|
+
param = {"type": value.get("type"), "kind": value.get("kind", _POK.name).upper()}
|
|
264
|
+
if "default" in value:
|
|
265
|
+
param["default"] = value["default"]
|
|
266
|
+
return param
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _validate(spec: SpecDict, path: str) -> None:
|
|
270
|
+
if not isinstance(spec.get("is_class"), bool):
|
|
271
|
+
raise ValueError(f"{path}: must have a boolean 'is_class' key.") # noqa: TRY004
|
|
272
|
+
|
|
273
|
+
if spec.get("is_property"):
|
|
274
|
+
if unknown := spec.keys() - _PROPERTY_KEYS:
|
|
275
|
+
raise ValueError(f"{path}: unknown property keys {sorted(unknown)}.")
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
for name, value in spec.items():
|
|
279
|
+
if name in ("is_class", "return"):
|
|
280
|
+
if name == "return" and spec["is_class"]:
|
|
281
|
+
raise ValueError(f"{path}: 'return' is reserved for function specs.")
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
sub_path = f"{path}.{name}"
|
|
285
|
+
if spec["is_class"] and isinstance(value, dict):
|
|
286
|
+
_validate(value, sub_path)
|
|
287
|
+
elif isinstance(value, dict):
|
|
288
|
+
if unknown := value.keys() - _PARAM_KEYS:
|
|
289
|
+
raise ValueError(f"{sub_path}: unknown parameter keys {sorted(unknown)}.")
|
|
290
|
+
kind = value.get("kind", _POK.name)
|
|
291
|
+
if not isinstance(kind, str) or kind.upper() not in _KINDS:
|
|
292
|
+
raise ValueError(f"{sub_path}: kind must be one of {list(_KINDS)}.")
|
|
293
|
+
if not isinstance(value.get("type"), str | None):
|
|
294
|
+
raise ValueError(f"{sub_path}: type must be a string or None.")
|
|
295
|
+
elif not isinstance(value, str | None):
|
|
296
|
+
raise ValueError(f"{sub_path}: must be a type string, None, or a dict.")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# comparison ###########################################################################
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@dataclass
|
|
303
|
+
class _Options:
|
|
304
|
+
by_type: bool
|
|
305
|
+
rich: bool
|
|
306
|
+
check_types: bool
|
|
307
|
+
exact: bool
|
|
308
|
+
module: str | None
|
|
309
|
+
namespace: dict[str, Any]
|
|
310
|
+
_types: dict[str, str] = field(default_factory=dict)
|
|
311
|
+
|
|
312
|
+
def b(self, text: Any) -> str:
|
|
313
|
+
"""bold ``text``, if rich markup is used"""
|
|
314
|
+
return f"[b]{escape(str(text))}[/]" if self.rich else str(text)
|
|
315
|
+
|
|
316
|
+
def q(self, name: str) -> str:
|
|
317
|
+
"""quote and bold ``name``"""
|
|
318
|
+
return self.b(f"'{name}'")
|
|
319
|
+
|
|
320
|
+
def type(self, type_str: str | None) -> str | None:
|
|
321
|
+
"""normalize a type string by evaluating it in the compared symbol's module, so
|
|
322
|
+
e.g. ``Optional[int]`` and ``int | None`` compare equal"""
|
|
323
|
+
if type_str is None:
|
|
324
|
+
return None
|
|
325
|
+
if type_str not in self._types:
|
|
326
|
+
try:
|
|
327
|
+
globals_ = {"__builtins__": builtins, **vars(typing), **self.namespace}
|
|
328
|
+
normalized = _format_type(eval(type_str, globals_), self.module)
|
|
329
|
+
except Exception: # noqa: BLE001 - not a valid expression, compare as-is
|
|
330
|
+
normalized = type_str
|
|
331
|
+
self._types[type_str] = normalized or type_str
|
|
332
|
+
return self._types[type_str]
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _describe(spec: SpecDict) -> str:
|
|
336
|
+
if spec.get("is_property"):
|
|
337
|
+
return "property"
|
|
338
|
+
return "class" if spec["is_class"] else "method"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _compare(exp: SpecDict, oth: SpecDict, o: _Options) -> list[str]:
|
|
342
|
+
if exp.get("is_property"):
|
|
343
|
+
return _compare_property(exp, oth, o)
|
|
344
|
+
if exp["is_class"]:
|
|
345
|
+
return _compare_class(exp, oth, o)
|
|
346
|
+
return _compare_func(exp, oth, o)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _compare_type(label: str, exp_t: str | None, oth_t: str | None, o: _Options):
|
|
350
|
+
if not o.check_types or exp_t is None:
|
|
351
|
+
return []
|
|
352
|
+
exp_t, oth_t = o.type(exp_t), o.type(oth_t)
|
|
353
|
+
if oth_t is None:
|
|
354
|
+
return [f"{label} is missing its type hint."]
|
|
355
|
+
if exp_t != oth_t:
|
|
356
|
+
return [f"{label} type mismatch. Expected {o.b(exp_t)}, got {o.b(oth_t)}."]
|
|
357
|
+
return []
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _match_by_type(
|
|
361
|
+
what: str, exp_types: list[str | None], oth_types: list[str | None], o: _Options
|
|
362
|
+
) -> list[str]:
|
|
363
|
+
"""match two lists of types as multisets. ``None`` in ``exp_types`` matches any type,
|
|
364
|
+
after all typed entries have been matched"""
|
|
365
|
+
diffs = []
|
|
366
|
+
unmatched = [o.type(t) for t in oth_types]
|
|
367
|
+
|
|
368
|
+
for t in (o.type(t) for t in exp_types if t is not None):
|
|
369
|
+
if t in unmatched:
|
|
370
|
+
unmatched.remove(t)
|
|
371
|
+
else:
|
|
372
|
+
diffs.append(f"Missing {what} of type {o.b(t)}.")
|
|
373
|
+
|
|
374
|
+
for _ in (t for t in exp_types if t is None):
|
|
375
|
+
if unmatched:
|
|
376
|
+
unmatched.pop(0)
|
|
377
|
+
else:
|
|
378
|
+
diffs.append(f"Missing {what}.")
|
|
379
|
+
|
|
380
|
+
if o.exact:
|
|
381
|
+
diffs.extend(
|
|
382
|
+
f"Additional {what} of type {o.b(t)}." if t else f"Additional {what}."
|
|
383
|
+
for t in unmatched
|
|
384
|
+
)
|
|
385
|
+
return diffs
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _compare_func(exp: SpecDict, oth: SpecDict, o: _Options) -> list[str]:
|
|
389
|
+
exp_params = {n: _param(v) for n, v in exp.items() if n not in ("is_class", "return")}
|
|
390
|
+
oth_params = {n: _param(v) for n, v in oth.items() if n not in ("is_class", "return")}
|
|
391
|
+
|
|
392
|
+
if o.by_type:
|
|
393
|
+
diffs = _match_by_type(
|
|
394
|
+
"parameter",
|
|
395
|
+
[p["type"] for p in exp_params.values()],
|
|
396
|
+
[p["type"] for p in oth_params.values()],
|
|
397
|
+
o,
|
|
398
|
+
)
|
|
399
|
+
else:
|
|
400
|
+
diffs = _compare_params_by_name(exp_params, oth_params, o)
|
|
401
|
+
|
|
402
|
+
diffs.extend(_compare_type("Return value", exp.get("return"), oth.get("return"), o))
|
|
403
|
+
return diffs
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _compare_params_by_name(
|
|
407
|
+
exp_params: dict[str, dict], oth_params: dict[str, dict], o: _Options
|
|
408
|
+
) -> list[str]:
|
|
409
|
+
diffs = []
|
|
410
|
+
for name, exp_p in exp_params.items():
|
|
411
|
+
if name not in oth_params:
|
|
412
|
+
diffs.append(f"Missing parameter: {o.q(name)}.")
|
|
413
|
+
continue
|
|
414
|
+
oth_p = oth_params[name]
|
|
415
|
+
|
|
416
|
+
diffs.extend(
|
|
417
|
+
_compare_type(f"Parameter {o.q(name)}", exp_p["type"], oth_p["type"], o)
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
exp_default = exp_p.get("default", _NO_DEFAULT)
|
|
421
|
+
oth_default = oth_p.get("default", _NO_DEFAULT)
|
|
422
|
+
if exp_default is _NO_DEFAULT and oth_default is not _NO_DEFAULT and o.exact:
|
|
423
|
+
diffs.append(f"Parameter {o.q(name)} should not have a default value.")
|
|
424
|
+
elif exp_default is not _NO_DEFAULT and oth_default is _NO_DEFAULT:
|
|
425
|
+
diffs.append(
|
|
426
|
+
f"Parameter {o.q(name)} is missing its default value "
|
|
427
|
+
f"{o.b(repr(exp_default))}."
|
|
428
|
+
)
|
|
429
|
+
elif exp_default is not _NO_DEFAULT and exp_default != oth_default:
|
|
430
|
+
diffs.append(
|
|
431
|
+
f"Parameter {o.q(name)} default value mismatch. Expected "
|
|
432
|
+
f"{o.b(repr(exp_default))}, got {o.b(repr(oth_default))}."
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
if exp_p["kind"] != oth_p["kind"]:
|
|
436
|
+
diffs.append(
|
|
437
|
+
f"Parameter {o.q(name)} kind mismatch. Expected "
|
|
438
|
+
f"{o.b(_KINDS[exp_p['kind']].description)}, got "
|
|
439
|
+
f"{o.b(_KINDS[oth_p['kind']].description)}."
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
if o.exact:
|
|
443
|
+
diffs.extend(
|
|
444
|
+
f"Additional parameter: {o.q(name)}."
|
|
445
|
+
for name in oth_params
|
|
446
|
+
if name not in exp_params
|
|
447
|
+
)
|
|
448
|
+
return diffs
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _compare_property(exp: SpecDict, oth: SpecDict, o: _Options) -> list[str]:
|
|
452
|
+
diffs = _compare_type("Getter return value", exp.get("return"), oth.get("return"), o)
|
|
453
|
+
for key in ("setter", "deleter"):
|
|
454
|
+
if exp.get(key) and not oth.get(key):
|
|
455
|
+
diffs.append(f"Missing {key}.")
|
|
456
|
+
elif oth.get(key) and not exp.get(key):
|
|
457
|
+
diffs.append(f"Should not have a {key}.")
|
|
458
|
+
return diffs
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _compare_class(exp: SpecDict, oth: SpecDict, o: _Options) -> list[str]:
|
|
462
|
+
def split(spec: SpecDict) -> tuple[dict, dict]:
|
|
463
|
+
items = [(k, v) for k, v in spec.items() if k != "is_class"]
|
|
464
|
+
return (
|
|
465
|
+
{k: v for k, v in items if not isinstance(v, dict)},
|
|
466
|
+
{k: v for k, v in items if isinstance(v, dict)},
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
exp_attrs, exp_members = split(exp)
|
|
470
|
+
oth_attrs, oth_members = split(oth)
|
|
471
|
+
diffs = []
|
|
472
|
+
|
|
473
|
+
# attributes
|
|
474
|
+
if o.by_type:
|
|
475
|
+
diffs.extend(
|
|
476
|
+
_match_by_type(
|
|
477
|
+
"attribute", list(exp_attrs.values()), list(oth_attrs.values()), o
|
|
478
|
+
)
|
|
479
|
+
)
|
|
480
|
+
else:
|
|
481
|
+
for name, exp_t in exp_attrs.items():
|
|
482
|
+
if name in oth_members:
|
|
483
|
+
diffs.append(
|
|
484
|
+
f"{o.q(name)} should be an {o.b('attribute')}, but found a "
|
|
485
|
+
f"{o.b(_describe(oth_members[name]))}."
|
|
486
|
+
)
|
|
487
|
+
elif name not in oth_attrs:
|
|
488
|
+
diffs.append(f"Missing attribute: {o.q(name)}.")
|
|
489
|
+
else:
|
|
490
|
+
diffs.extend(
|
|
491
|
+
_compare_type(f"Attribute {o.q(name)}", exp_t, oth_attrs[name], o)
|
|
492
|
+
)
|
|
493
|
+
if o.exact:
|
|
494
|
+
diffs.extend(
|
|
495
|
+
f"Additional attribute: {o.q(name)}."
|
|
496
|
+
for name in oth_attrs
|
|
497
|
+
if name not in exp_attrs and name not in exp_members
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
# methods, properties and nested classes, always matched by name
|
|
501
|
+
for name, exp_m in exp_members.items():
|
|
502
|
+
what = _describe(exp_m)
|
|
503
|
+
if name == "__init__" and name not in oth_members and name not in oth_attrs:
|
|
504
|
+
oth_members = {**oth_members, name: _IMPLICIT_INIT}
|
|
505
|
+
if name in oth_members and _describe(oth_members[name]) == what:
|
|
506
|
+
diffs.extend(
|
|
507
|
+
f"{what.capitalize()} {o.q(name)}: {d}"
|
|
508
|
+
for d in _compare(exp_m, oth_members[name], o)
|
|
509
|
+
)
|
|
510
|
+
elif name in oth_members:
|
|
511
|
+
diffs.append(
|
|
512
|
+
f"{o.q(name)} should be a {o.b(what)}, but found a "
|
|
513
|
+
f"{o.b(_describe(oth_members[name]))}."
|
|
514
|
+
)
|
|
515
|
+
elif name in oth_attrs and not o.by_type:
|
|
516
|
+
diffs.append(
|
|
517
|
+
f"{o.q(name)} should be a {o.b(what)}, but found an {o.b('attribute')}."
|
|
518
|
+
)
|
|
519
|
+
else:
|
|
520
|
+
diffs.append(f"Missing {what}: {o.q(name)}.")
|
|
521
|
+
|
|
522
|
+
if o.exact:
|
|
523
|
+
diffs.extend(
|
|
524
|
+
f"Additional {_describe(oth_m)}: {o.q(name)}."
|
|
525
|
+
for name, oth_m in oth_members.items()
|
|
526
|
+
if name not in exp_members and (o.by_type or name not in exp_attrs)
|
|
527
|
+
)
|
|
528
|
+
return diffs
|