parse-sdk 0.1.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.
- parse_sdk/__init__.py +57 -0
- parse_sdk/_labels.py +51 -0
- parse_sdk/_oauth.py +301 -0
- parse_sdk/_project.py +184 -0
- parse_sdk/_runtime.py +2142 -0
- parse_sdk/_sanitize.py +15 -0
- parse_sdk/_sync.py +887 -0
- parse_sdk/checks.py +601 -0
- parse_sdk/cli.py +1667 -0
- parse_sdk/cli_help.py +110 -0
- parse_sdk/codegen_reconcile.py +157 -0
- parse_sdk/codegen_v2.py +3361 -0
- parse_sdk/config.py +349 -0
- parse_sdk/docgen.py +587 -0
- parse_sdk/doctor.py +348 -0
- parse_sdk/migrate.py +212 -0
- parse_sdk/preview.py +588 -0
- parse_sdk/py.typed +0 -0
- parse_sdk/resource_surface.py +196 -0
- parse_sdk/sample_gate.py +245 -0
- parse_sdk/scaffold.py +273 -0
- parse_sdk-0.1.0.dist-info/METADATA +177 -0
- parse_sdk-0.1.0.dist-info/RECORD +26 -0
- parse_sdk-0.1.0.dist-info/WHEEL +4 -0
- parse_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- parse_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
parse_sdk/preview.py
ADDED
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
"""In-process preview of a generated SDK module — used by the agent's
|
|
2
|
+
compile-check tool to validate an example before `complete()`.
|
|
3
|
+
|
|
4
|
+
Pipeline:
|
|
5
|
+
1. Agent emits a draft spec.
|
|
6
|
+
2. Dispatcher calls ``render_preview(schema)`` — codegen runs, the
|
|
7
|
+
result is written under one per-process temp dir (a per-slug
|
|
8
|
+
subdir, overwritten on re-render) and imported via importlib.
|
|
9
|
+
No ``sys.path`` mutation.
|
|
10
|
+
3. Dispatcher calls ``check_example(module, example_src)`` — AST-walks
|
|
11
|
+
the example, resolves every reference (imports, attributes, method
|
|
12
|
+
calls + kwargs) against the live module. Returns a structured
|
|
13
|
+
report.
|
|
14
|
+
|
|
15
|
+
No subprocess, no executor round-trip, no live HTTP. The example is
|
|
16
|
+
NOT executed — purely static. That's the whole point: catch the
|
|
17
|
+
ergonomic surface bugs (wrong method name, missing kwarg, dropped
|
|
18
|
+
enum) without needing an API key or a target site.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import ast
|
|
24
|
+
import importlib.util
|
|
25
|
+
import inspect
|
|
26
|
+
import itertools
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
import threading
|
|
31
|
+
import textwrap
|
|
32
|
+
import types
|
|
33
|
+
import typing
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from types import ModuleType
|
|
37
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
38
|
+
|
|
39
|
+
from .codegen_v2 import CodegenError, render_module
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class CheckViolation:
|
|
44
|
+
"""One ergonomics finding from the AST walk over an example."""
|
|
45
|
+
|
|
46
|
+
kind: str # "import", "attribute", "method", "kwarg", "syntax"
|
|
47
|
+
location: str # "<file>:<line>" or "line N"
|
|
48
|
+
message: str
|
|
49
|
+
|
|
50
|
+
def __str__(self) -> str:
|
|
51
|
+
return f"[{self.kind}] {self.location}: {self.message}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class CheckReport:
|
|
56
|
+
"""Result of running ``check_example`` on a draft."""
|
|
57
|
+
|
|
58
|
+
ok: bool
|
|
59
|
+
# Always "" from check_example — callers that need the generated source
|
|
60
|
+
# already hold it from render_preview. (Field kept: the dataclass shape
|
|
61
|
+
# is a stable public contract — changing its verdict is a breaking change.)
|
|
62
|
+
module_source: str
|
|
63
|
+
public_symbols: List[str] = field(default_factory=list)
|
|
64
|
+
violations: List[CheckViolation] = field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
def summary(self) -> str:
|
|
67
|
+
"""Human-readable summary for the agent to act on."""
|
|
68
|
+
if self.ok:
|
|
69
|
+
return f"OK — example compiles against the generated SDK ({len(self.public_symbols)} public symbols)."
|
|
70
|
+
lines = [f"FAIL — {len(self.violations)} violation(s):"]
|
|
71
|
+
for v in self.violations:
|
|
72
|
+
lines.append(f" • {v}")
|
|
73
|
+
return "\n".join(lines)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Preview module renderer
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def render_preview(
|
|
82
|
+
schema: Dict[str, Any],
|
|
83
|
+
*,
|
|
84
|
+
base_url: str = "http://preview.local",
|
|
85
|
+
) -> Tuple[Optional[ModuleType], str]:
|
|
86
|
+
"""Render ``schema`` to a real ``parse_sdk._preview.<slug>`` module
|
|
87
|
+
and import it. Returns ``(module, source)``. ``module`` is None when
|
|
88
|
+
the schema has no resources block or codegen rejects it outright
|
|
89
|
+
(``CodegenError`` — e.g. a hostile scraper id / endpoint name, or a
|
|
90
|
+
list op with no pagination block). The authoring tool must report
|
|
91
|
+
"no module" for a bad draft, never crash with a traceback — `parse
|
|
92
|
+
sync` already degrades the same way (skip + warn).
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
source = render_module(schema, base_url=base_url)
|
|
96
|
+
except CodegenError:
|
|
97
|
+
return None, ""
|
|
98
|
+
if source is None:
|
|
99
|
+
return None, ""
|
|
100
|
+
|
|
101
|
+
slug = schema.get("slug") or schema.get("id") or "preview"
|
|
102
|
+
# The module is loaded under a hidden ``_preview`` namespace so it can
|
|
103
|
+
# coexist with whatever the user's already imported. We don't want
|
|
104
|
+
# ``parse_sdk.reddit`` and ``parse_sdk._preview.reddit`` to collide.
|
|
105
|
+
mod_name = f"parse_sdk._preview.{slug}"
|
|
106
|
+
|
|
107
|
+
# One per-process temp dir (removed at interpreter exit by the stdlib
|
|
108
|
+
# finalizer) with a unique subdir per render — a fresh path every time,
|
|
109
|
+
# so a stale bytecode-cache hit (pyc is keyed on mtime+size, and a fast
|
|
110
|
+
# re-render of a changed draft at a REUSED path can collide on both) is
|
|
111
|
+
# unrepresentable, and nothing survives the process the way the old
|
|
112
|
+
# one-mkdtemp-per-call dirs did. importlib needs an on-disk location for
|
|
113
|
+
# the spec because the generated module's ``__all__`` and relative
|
|
114
|
+
# imports expect a real path.
|
|
115
|
+
safe_slug = re.sub(r"[^A-Za-z0-9_.-]", "_", str(slug))
|
|
116
|
+
tmpdir = _preview_root() / f"{safe_slug}-{next(_PREVIEW_SEQ)}"
|
|
117
|
+
tmpdir.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
init_path = tmpdir / "__init__.py"
|
|
119
|
+
init_path.write_text(source)
|
|
120
|
+
|
|
121
|
+
spec = importlib.util.spec_from_file_location(mod_name, init_path)
|
|
122
|
+
if spec is None or spec.loader is None:
|
|
123
|
+
raise RuntimeError(f"importlib could not build a spec for {init_path}")
|
|
124
|
+
module = importlib.util.module_from_spec(spec)
|
|
125
|
+
# Same-name registration is load-bearing: get_type_hints resolution in
|
|
126
|
+
# the walker and Resource._field_hints resolve via cls.__module__, and a
|
|
127
|
+
# re-render of the same slug REPLACES (frees) the prior module.
|
|
128
|
+
sys.modules[mod_name] = module
|
|
129
|
+
try:
|
|
130
|
+
spec.loader.exec_module(module)
|
|
131
|
+
except SyntaxError:
|
|
132
|
+
# A non-compiling generated module is a bad draft, not a crash: mirror the
|
|
133
|
+
# CodegenError path (return None) so render_preview never leaks a traceback
|
|
134
|
+
# — the documented contract above. Narrow to SyntaxError (covers
|
|
135
|
+
# IndentationError); a genuine runtime error in generated code must still
|
|
136
|
+
# propagate via the BaseException arm below.
|
|
137
|
+
sys.modules.pop(mod_name, None)
|
|
138
|
+
return None, ""
|
|
139
|
+
except BaseException:
|
|
140
|
+
# Never leave a half-initialized module registered — a later import
|
|
141
|
+
# of the same name would silently see the broken partial.
|
|
142
|
+
sys.modules.pop(mod_name, None)
|
|
143
|
+
raise
|
|
144
|
+
return module, source
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_PREVIEW_TMP: Optional[tempfile.TemporaryDirectory] = None
|
|
148
|
+
_PREVIEW_LOCK = threading.Lock()
|
|
149
|
+
_PREVIEW_SEQ = itertools.count()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _preview_root() -> Path:
|
|
153
|
+
"""The per-process preview staging dir, created lazily. The stdlib
|
|
154
|
+
finalizer removes it (and every per-render subdir) at interpreter exit."""
|
|
155
|
+
global _PREVIEW_TMP
|
|
156
|
+
if _PREVIEW_TMP is None:
|
|
157
|
+
with _PREVIEW_LOCK:
|
|
158
|
+
if _PREVIEW_TMP is None:
|
|
159
|
+
_PREVIEW_TMP = tempfile.TemporaryDirectory(prefix="parse_sdk_preview_")
|
|
160
|
+
return Path(_PREVIEW_TMP.name)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def public_symbols(module: ModuleType) -> List[str]:
|
|
164
|
+
"""The codegen-emitted ``__all__`` (root class + resources + enums + errors)."""
|
|
165
|
+
return list(getattr(module, "__all__", []))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# AST walker — validate an example against the preview module
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _build_symbol_index(module: ModuleType) -> Dict[str, Any]:
|
|
174
|
+
"""Top-level callables / classes / enums users might import."""
|
|
175
|
+
out: Dict[str, Any] = {}
|
|
176
|
+
for name in public_symbols(module):
|
|
177
|
+
if hasattr(module, name):
|
|
178
|
+
out[name] = getattr(module, name)
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def check_example(module: ModuleType, example_src: str) -> CheckReport:
|
|
183
|
+
"""Static-check an example against a preview module.
|
|
184
|
+
|
|
185
|
+
Catches:
|
|
186
|
+
* Syntax errors.
|
|
187
|
+
* Imports from ``parse_apis.<slug>`` of names that don't exist (and flags
|
|
188
|
+
the dead ``parse_sdk.<slug>`` form).
|
|
189
|
+
* Method calls referencing methods that don't exist on the resource /
|
|
190
|
+
collection / root client.
|
|
191
|
+
* Method calls passing kwargs the method doesn't accept.
|
|
192
|
+
|
|
193
|
+
Does NOT catch:
|
|
194
|
+
* Wrong types on positional args (no type inference).
|
|
195
|
+
* Whether the example actually returns data (no execution).
|
|
196
|
+
"""
|
|
197
|
+
source = textwrap.dedent(example_src).strip()
|
|
198
|
+
public = _build_symbol_index(module)
|
|
199
|
+
|
|
200
|
+
# 1) Syntax
|
|
201
|
+
try:
|
|
202
|
+
tree = ast.parse(source)
|
|
203
|
+
except SyntaxError as e:
|
|
204
|
+
return CheckReport(
|
|
205
|
+
ok=False,
|
|
206
|
+
module_source="",
|
|
207
|
+
public_symbols=public_symbols(module),
|
|
208
|
+
violations=[CheckViolation("syntax", f"line {e.lineno}", e.msg or "syntax error")],
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
violations: List[CheckViolation] = []
|
|
212
|
+
|
|
213
|
+
# 2) Imports
|
|
214
|
+
slug = module.__name__.rsplit('.', 1)[-1]
|
|
215
|
+
imported_names: Dict[str, Any] = dict(public) # built-ins from this module pre-resolved
|
|
216
|
+
for node in ast.walk(tree):
|
|
217
|
+
if isinstance(node, ast.ImportFrom):
|
|
218
|
+
if not node.module:
|
|
219
|
+
continue
|
|
220
|
+
# The dead generated path: parse_sdk.<slug> resolution was removed.
|
|
221
|
+
# Flag it explicitly so the agent rewrites to parse_apis.<slug>.
|
|
222
|
+
if node.module.startswith("parse_sdk.") and node.module not in (
|
|
223
|
+
"parse_sdk._runtime",
|
|
224
|
+
):
|
|
225
|
+
violations.append(
|
|
226
|
+
CheckViolation(
|
|
227
|
+
"import",
|
|
228
|
+
f"line {node.lineno}",
|
|
229
|
+
f"`from {node.module} import …` is no longer importable — generated "
|
|
230
|
+
f"clients live in `parse_apis.{slug}`. Use `from parse_apis.{slug} import …`.",
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
continue
|
|
234
|
+
if node.module.startswith("parse_apis."):
|
|
235
|
+
for alias in node.names:
|
|
236
|
+
name = alias.name
|
|
237
|
+
if name not in public:
|
|
238
|
+
violations.append(
|
|
239
|
+
CheckViolation(
|
|
240
|
+
"import",
|
|
241
|
+
f"line {node.lineno}",
|
|
242
|
+
f"`from {node.module} import {name}`: {name!r} is not exported by the generated SDK. "
|
|
243
|
+
f"Available: {sorted(public)}",
|
|
244
|
+
)
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
local = alias.asname or name
|
|
248
|
+
imported_names[local] = public[name]
|
|
249
|
+
|
|
250
|
+
# 3) Method calls — best-effort attribute resolution
|
|
251
|
+
name_bindings: Dict[str, Any] = dict(imported_names)
|
|
252
|
+
|
|
253
|
+
_UNRESOLVED = object() # sentinel: a callable hop whose return type can't
|
|
254
|
+
# be determined — the caller must IGNORE the chain (fail-open), never walk
|
|
255
|
+
# attribute lookups against the bare method object (that false-flags the
|
|
256
|
+
# next REAL member: paginator.first().details).
|
|
257
|
+
|
|
258
|
+
def _class_origin(tp: Any) -> Optional[type]:
|
|
259
|
+
"""The origin CLASS of a parameterized generic alias
|
|
260
|
+
(``_Paginator[Thing]`` → ``Paginator``, ``Dict[str, X]`` → ``dict``),
|
|
261
|
+
or None when ``tp`` isn't one. Unions are explicitly excluded: on
|
|
262
|
+
3.14 ``typing.Union`` itself became a class, so a bare
|
|
263
|
+
``inspect.isclass(get_origin(tp))`` misclassifies ``X | None`` as a
|
|
264
|
+
class-origin generic and derails the Optional/Union unwrap."""
|
|
265
|
+
origin = typing.get_origin(tp)
|
|
266
|
+
if origin is None or not inspect.isclass(origin):
|
|
267
|
+
return None
|
|
268
|
+
if origin is getattr(types, "UnionType", None) or origin is typing.Union:
|
|
269
|
+
return None
|
|
270
|
+
return origin
|
|
271
|
+
|
|
272
|
+
def _step_through_return(obj: Any, typevar_map: Dict[Any, Any]) -> Any:
|
|
273
|
+
"""The class — or parameterized runtime generic alias like
|
|
274
|
+
``_Paginator[Thing]`` — a property/method returns. Resolution EVALUATES
|
|
275
|
+
the annotation (``typing.get_type_hints``), first in the module's own
|
|
276
|
+
namespace, then in the function's defining module (runtime methods
|
|
277
|
+
reference runtime-private names like ``_PaginatorItem``) — the
|
|
278
|
+
emitter's vocabulary IS the resolver, so a prefix-spelling drift (the
|
|
279
|
+
old ``'Paginator['`` strip vs the emitted ``'_Paginator['``) is
|
|
280
|
+
unrepresentable. TypeVar returns (``Paginator.first -> _PaginatorItem``)
|
|
281
|
+
substitute through ``typevar_map`` (bound by the caller from the
|
|
282
|
+
enclosing generic alias), so the paginator's ELEMENT type survives the
|
|
283
|
+
hop. Non-callables pass through; anything unresolvable yields
|
|
284
|
+
``_UNRESOLVED``."""
|
|
285
|
+
if _class_origin(obj) is not None:
|
|
286
|
+
# A parameterized runtime generic (``_Paginator[Thing]``) arriving
|
|
287
|
+
# as a chain ROOT — e.g. a name bound to a paginator-returning
|
|
288
|
+
# call result. Pass it through untouched; the caller's
|
|
289
|
+
# origin/TypeVar-binding path owns it. (Without this, the alias
|
|
290
|
+
# falls into the callable arm, get_type_hints fails, and the
|
|
291
|
+
# whole chain silently fails open.)
|
|
292
|
+
return obj
|
|
293
|
+
fn = None
|
|
294
|
+
if isinstance(obj, property) and obj.fget is not None:
|
|
295
|
+
fn = obj.fget
|
|
296
|
+
elif callable(obj) and not inspect.isclass(obj):
|
|
297
|
+
fn = obj
|
|
298
|
+
else:
|
|
299
|
+
return obj
|
|
300
|
+
try:
|
|
301
|
+
hints = typing.get_type_hints(fn, globalns=vars(module))
|
|
302
|
+
except Exception:
|
|
303
|
+
try:
|
|
304
|
+
hints = typing.get_type_hints(fn) # fn's own module namespace
|
|
305
|
+
except Exception:
|
|
306
|
+
return _UNRESOLVED
|
|
307
|
+
tp = hints.get("return")
|
|
308
|
+
# Unwrap typing containers (List/Optional/Union) down to the element,
|
|
309
|
+
# substituting TypeVars from the enclosing generic as they surface.
|
|
310
|
+
for _ in range(6):
|
|
311
|
+
if isinstance(tp, typing.TypeVar):
|
|
312
|
+
if tp not in typevar_map:
|
|
313
|
+
return _UNRESOLVED
|
|
314
|
+
tp = typevar_map[tp]
|
|
315
|
+
continue
|
|
316
|
+
origin = typing.get_origin(tp)
|
|
317
|
+
if origin is None:
|
|
318
|
+
break
|
|
319
|
+
if _class_origin(tp) is not None:
|
|
320
|
+
# A runtime generic (``_Paginator[Thing]``): keep the ALIAS so
|
|
321
|
+
# the element TypeVar stays bound for later hops — ``.list()``/
|
|
322
|
+
# ``.first()`` live on Paginator, their returns are the element.
|
|
323
|
+
return tp
|
|
324
|
+
args = [a for a in typing.get_args(tp) if a is not type(None)]
|
|
325
|
+
if not args:
|
|
326
|
+
return _UNRESOLVED
|
|
327
|
+
tp = args[0]
|
|
328
|
+
# Resolve runtime bases (Resource/Paginator/BaseAPI) by their module
|
|
329
|
+
# ORIGIN, prefix-tolerant so a future ``_runtime`` package split (where a
|
|
330
|
+
# base's __module__ becomes ``parse_sdk._runtime._core`` etc.) keeps the
|
|
331
|
+
# frozen check_example VERDICT identical instead of degrading the type to
|
|
332
|
+
# _UNRESOLVED. Behaviour-identical today (the flat module is the exact
|
|
333
|
+
# match); pinned by test_lockstep_contracts.
|
|
334
|
+
if inspect.isclass(tp) and (
|
|
335
|
+
tp.__module__ == module.__name__
|
|
336
|
+
or tp.__module__ == "parse_sdk._runtime"
|
|
337
|
+
or tp.__module__.startswith("parse_sdk._runtime.")
|
|
338
|
+
):
|
|
339
|
+
return tp
|
|
340
|
+
return _UNRESOLVED
|
|
341
|
+
|
|
342
|
+
def _declared_field_type(cls: type, attr: str) -> Any:
|
|
343
|
+
"""The (unwrapped) annotation type of a PEP-526 declared field.
|
|
344
|
+
|
|
345
|
+
Bare annotations (``title: str`` on a generated resource) create NO
|
|
346
|
+
class attribute, so ``hasattr`` alone false-flags every valid field
|
|
347
|
+
hop (``…first().title.upper()``). Returns the class / class-origin
|
|
348
|
+
generic alias to keep walking; the ``_UNRESOLVED`` sentinel when the
|
|
349
|
+
field exists but can't be usefully typed (``Any``, unresolvable —
|
|
350
|
+
fail open); ``None`` when ``attr`` is not a declared field at all
|
|
351
|
+
(a real violation)."""
|
|
352
|
+
if not any(attr in getattr(c, "__annotations__", {}) for c in cls.__mro__):
|
|
353
|
+
return None
|
|
354
|
+
try:
|
|
355
|
+
hints = typing.get_type_hints(cls, globalns=vars(module))
|
|
356
|
+
except Exception:
|
|
357
|
+
try:
|
|
358
|
+
hints = typing.get_type_hints(cls)
|
|
359
|
+
except Exception:
|
|
360
|
+
return _UNRESOLVED
|
|
361
|
+
tp = hints.get(attr)
|
|
362
|
+
for _ in range(6):
|
|
363
|
+
# Unwrap Optional/Union to the first real arm; keep class-origin
|
|
364
|
+
# aliases (Dict[str, X]) intact for the origin walk.
|
|
365
|
+
origin = typing.get_origin(tp)
|
|
366
|
+
if origin is None or _class_origin(tp) is not None:
|
|
367
|
+
break
|
|
368
|
+
args = [a for a in typing.get_args(tp) if a is not type(None)]
|
|
369
|
+
if not args:
|
|
370
|
+
return _UNRESOLVED
|
|
371
|
+
tp = args[0]
|
|
372
|
+
if tp is None or tp is typing.Any:
|
|
373
|
+
return _UNRESOLVED
|
|
374
|
+
if inspect.isclass(tp) or _class_origin(tp) is not None:
|
|
375
|
+
return tp
|
|
376
|
+
return _UNRESOLVED
|
|
377
|
+
|
|
378
|
+
def _resolve_attribute(
|
|
379
|
+
node: ast.AST,
|
|
380
|
+
) -> Tuple[Optional[Any], Optional[str], Dict[Any, Any]]:
|
|
381
|
+
"""Walk an Attribute chain back to a known root binding.
|
|
382
|
+
|
|
383
|
+
Returns ``(resolved_obj, failed_attr, typevar_map)`` — the map holds
|
|
384
|
+
the TypeVar bindings accumulated through generic hops so a caller
|
|
385
|
+
binding the LEAF's call result can substitute the element type
|
|
386
|
+
(``res.first()`` → ``Thing``). ``(resolved_obj, failed_attr)``:
|
|
387
|
+
* ``(obj, None)`` — fully resolved, ``obj`` is the leaf.
|
|
388
|
+
* ``(None, attr)`` — chain broke at ``attr``; the parent resolved
|
|
389
|
+
to a known class/object but ``attr`` is not on it. Caller can
|
|
390
|
+
report this as a violation.
|
|
391
|
+
* ``(None, None)`` — couldn't even find the root binding, or hit a
|
|
392
|
+
hop we cannot type; ignore (fail-open, never a false violation).
|
|
393
|
+
"""
|
|
394
|
+
chain: List[str] = []
|
|
395
|
+
cur = node
|
|
396
|
+
while True:
|
|
397
|
+
if isinstance(cur, ast.Attribute):
|
|
398
|
+
chain.append(cur.attr)
|
|
399
|
+
cur = cur.value
|
|
400
|
+
elif isinstance(cur, ast.Call):
|
|
401
|
+
# Step INTO the call result: descend to the callee — the walk
|
|
402
|
+
# below steps through its return annotation at this hop, so
|
|
403
|
+
# chains THROUGH calls (`api.things.search(q).bogus()`) are
|
|
404
|
+
# finally validated instead of silently dropped.
|
|
405
|
+
cur = cur.func
|
|
406
|
+
else:
|
|
407
|
+
break
|
|
408
|
+
if not isinstance(cur, ast.Name):
|
|
409
|
+
return None, None, {}
|
|
410
|
+
root = name_bindings.get(cur.id)
|
|
411
|
+
if root is None:
|
|
412
|
+
return None, None, {}
|
|
413
|
+
chain.reverse()
|
|
414
|
+
obj: Any = root
|
|
415
|
+
typevar_map: Dict[Any, Any] = {}
|
|
416
|
+
for i, attr in enumerate(chain):
|
|
417
|
+
# Step through property/method return types so subsequent attrs
|
|
418
|
+
# resolve against the class they return.
|
|
419
|
+
obj = _step_through_return(obj, typevar_map)
|
|
420
|
+
if obj is _UNRESOLVED:
|
|
421
|
+
# Fail-open: a hop we cannot type ends VALIDATION of this
|
|
422
|
+
# chain — it must never end at a false violation.
|
|
423
|
+
return None, None, typevar_map
|
|
424
|
+
origin = _class_origin(obj)
|
|
425
|
+
if origin is not None:
|
|
426
|
+
# Parameterized runtime generic: bind its TypeVars (element
|
|
427
|
+
# type), then walk the origin class's real attributes.
|
|
428
|
+
# (3.14: ``__parameters__`` on a plain builtin origin like
|
|
429
|
+
# ``dict`` resolves to a non-iterable descriptor — only a
|
|
430
|
+
# real tuple carries TypeVars to bind.)
|
|
431
|
+
params = getattr(origin, "__parameters__", ())
|
|
432
|
+
if not isinstance(params, tuple):
|
|
433
|
+
params = ()
|
|
434
|
+
typevar_map = dict(zip(params, typing.get_args(obj)))
|
|
435
|
+
obj = origin
|
|
436
|
+
if inspect.isclass(obj):
|
|
437
|
+
if hasattr(obj, attr):
|
|
438
|
+
obj = getattr(obj, attr)
|
|
439
|
+
continue
|
|
440
|
+
ftp = _declared_field_type(obj, attr)
|
|
441
|
+
if ftp is None:
|
|
442
|
+
return None, attr, typevar_map
|
|
443
|
+
if ftp is _UNRESOLVED:
|
|
444
|
+
return None, None, typevar_map
|
|
445
|
+
if i == len(chain) - 1:
|
|
446
|
+
# The leaf is a field VALUE (an instance of ftp). Our only
|
|
447
|
+
# callers are about to treat the leaf as a call target —
|
|
448
|
+
# modelling instance callability is out of scope, so fail
|
|
449
|
+
# open rather than validate against the class's ctor.
|
|
450
|
+
return None, None, typevar_map
|
|
451
|
+
obj = ftp
|
|
452
|
+
continue
|
|
453
|
+
if hasattr(obj, attr):
|
|
454
|
+
obj = getattr(obj, attr)
|
|
455
|
+
else:
|
|
456
|
+
return None, attr, typevar_map
|
|
457
|
+
return obj, None, typevar_map
|
|
458
|
+
|
|
459
|
+
def _bind_value(value: ast.expr) -> Optional[Any]:
|
|
460
|
+
"""What a bound name should resolve to, or None to leave it unbound
|
|
461
|
+
(fail-open). A Name passes its existing binding through (aliases,
|
|
462
|
+
``with client as c``); a Name-constructor call binds the class itself
|
|
463
|
+
(instances walk attrs via the class — the original semantics); an
|
|
464
|
+
attribute-chain call binds the call's RESULT type by stepping through
|
|
465
|
+
the resolved callee's return annotation, so
|
|
466
|
+
``res = c.things.search(q)`` binds ``res`` to ``_Paginator[Thing]``."""
|
|
467
|
+
if isinstance(value, ast.Name):
|
|
468
|
+
return name_bindings.get(value.id)
|
|
469
|
+
if not isinstance(value, ast.Call):
|
|
470
|
+
return None
|
|
471
|
+
fn = value.func
|
|
472
|
+
if isinstance(fn, ast.Name):
|
|
473
|
+
return name_bindings.get(fn.id)
|
|
474
|
+
if isinstance(fn, ast.Attribute):
|
|
475
|
+
resolved, _failed, tv = _resolve_attribute(fn)
|
|
476
|
+
if resolved is None:
|
|
477
|
+
return None
|
|
478
|
+
stepped = _step_through_return(resolved, tv)
|
|
479
|
+
return None if stepped is _UNRESOLVED else stepped
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
def _validate_call_kwargs(target: Any, node: ast.Call) -> None:
|
|
483
|
+
"""Flag kwargs the resolved callable does not accept."""
|
|
484
|
+
try:
|
|
485
|
+
sig = inspect.signature(target)
|
|
486
|
+
except (TypeError, ValueError):
|
|
487
|
+
return # C-implemented callables without introspectable signatures
|
|
488
|
+
accepted = set(sig.parameters.keys())
|
|
489
|
+
# Chains resolve methods on the CLASS (plain functions), so inspect
|
|
490
|
+
# keeps the 'self' slot — never a real keyword for the caller:
|
|
491
|
+
# `search(self=1, …)` must flag, and the Accepted list must not
|
|
492
|
+
# advertise it.
|
|
493
|
+
accepted.discard("self")
|
|
494
|
+
has_var_kw = any(
|
|
495
|
+
p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
|
|
496
|
+
)
|
|
497
|
+
for kw in node.keywords:
|
|
498
|
+
if kw.arg is None:
|
|
499
|
+
continue # **kwargs splat — can't statically check
|
|
500
|
+
if kw.arg not in accepted and not has_var_kw:
|
|
501
|
+
violations.append(
|
|
502
|
+
CheckViolation(
|
|
503
|
+
"kwarg",
|
|
504
|
+
f"line {node.lineno}",
|
|
505
|
+
f"{ast.unparse(node.func)}(...) does not accept keyword '{kw.arg}'. "
|
|
506
|
+
f"Accepted: {sorted(accepted)}",
|
|
507
|
+
)
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
def _iter_source_order(t: ast.AST):
|
|
511
|
+
"""Pre-order DFS = source order for statements. ``ast.walk`` is
|
|
512
|
+
BREADTH-first: a module-level rebinding on line 25 would be processed
|
|
513
|
+
before a call on line 20 inside a line-18 loop body, so the late
|
|
514
|
+
binding would pollute validation of earlier, deeper code (a measured
|
|
515
|
+
false positive on the corpus). Flow-insensitive bindings are only
|
|
516
|
+
sound when they apply to code at-or-after their own line."""
|
|
517
|
+
for child in ast.iter_child_nodes(t):
|
|
518
|
+
yield child
|
|
519
|
+
yield from _iter_source_order(child)
|
|
520
|
+
|
|
521
|
+
for node in _iter_source_order(tree):
|
|
522
|
+
# Track bindings so subsequent attribute walks have a starting class:
|
|
523
|
+
# plain assigns, annotated assigns (`c: Shop = Shop()` is an
|
|
524
|
+
# ast.AnnAssign, NOT an Assign), and with-items (`with Shop() as c:`
|
|
525
|
+
# — BaseAPI.__enter__ returns self, so the context class is exact).
|
|
526
|
+
# Each previously fell open silently, skipping ALL validation of the
|
|
527
|
+
# bound name's chains.
|
|
528
|
+
if isinstance(node, ast.Assign) and isinstance(node.value, (ast.Call, ast.Name)):
|
|
529
|
+
bound = _bind_value(node.value)
|
|
530
|
+
if bound is not None:
|
|
531
|
+
for target in node.targets:
|
|
532
|
+
if isinstance(target, ast.Name):
|
|
533
|
+
name_bindings[target.id] = bound
|
|
534
|
+
elif (
|
|
535
|
+
isinstance(node, ast.AnnAssign)
|
|
536
|
+
and node.value is not None
|
|
537
|
+
and isinstance(node.target, ast.Name)
|
|
538
|
+
):
|
|
539
|
+
bound = _bind_value(node.value)
|
|
540
|
+
if bound is not None:
|
|
541
|
+
name_bindings[node.target.id] = bound
|
|
542
|
+
elif isinstance(node, (ast.With, ast.AsyncWith)):
|
|
543
|
+
for item in node.items:
|
|
544
|
+
if isinstance(item.optional_vars, ast.Name):
|
|
545
|
+
bound = _bind_value(item.context_expr)
|
|
546
|
+
if bound is not None:
|
|
547
|
+
name_bindings[item.optional_vars.id] = bound
|
|
548
|
+
|
|
549
|
+
# Validate method calls + kwargs against resolved targets.
|
|
550
|
+
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
|
|
551
|
+
resolved, failed_attr, _tv = _resolve_attribute(node.func)
|
|
552
|
+
if resolved is None:
|
|
553
|
+
if failed_attr is not None:
|
|
554
|
+
# Chain resolved partway but the final method isn't on
|
|
555
|
+
# the resolved class — that's a real ergonomics bug.
|
|
556
|
+
violations.append(
|
|
557
|
+
CheckViolation(
|
|
558
|
+
"method",
|
|
559
|
+
f"line {node.lineno}",
|
|
560
|
+
f"{ast.unparse(node.func)}: no method/attribute {failed_attr!r} on the resolved type.",
|
|
561
|
+
)
|
|
562
|
+
)
|
|
563
|
+
# else: couldn't resolve root — ignore (runtime-only binding).
|
|
564
|
+
continue
|
|
565
|
+
if not callable(resolved):
|
|
566
|
+
violations.append(
|
|
567
|
+
CheckViolation(
|
|
568
|
+
"method",
|
|
569
|
+
f"line {node.lineno}",
|
|
570
|
+
f"{ast.unparse(node.func)} is not callable (resolved to {type(resolved).__name__}).",
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
continue
|
|
574
|
+
_validate_call_kwargs(resolved, node)
|
|
575
|
+
elif isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
|
|
576
|
+
# Constructor / factory calls (`Reddit(api_keyy='x')`) were never
|
|
577
|
+
# kwarg-validated. inspect.signature on a class binds through
|
|
578
|
+
# __init__ with 'self' already excluded.
|
|
579
|
+
target = name_bindings.get(node.func.id)
|
|
580
|
+
if inspect.isclass(target):
|
|
581
|
+
_validate_call_kwargs(target, node)
|
|
582
|
+
|
|
583
|
+
return CheckReport(
|
|
584
|
+
ok=not violations,
|
|
585
|
+
module_source="", # caller can fetch source separately
|
|
586
|
+
public_symbols=public_symbols(module),
|
|
587
|
+
violations=violations,
|
|
588
|
+
)
|
parse_sdk/py.typed
ADDED
|
File without changes
|