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/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