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.
@@ -0,0 +1,3361 @@
1
+ """Resource-modeled .py codegen.
2
+
3
+ Input: an SDKSchema dict from the executor's ``/sdk/schemas`` endpoint
4
+ (see services/executor/routers/sdk.py). When the schema has a
5
+ ``resources`` block (i.e. the agent modeled the domain), this codegen
6
+ produces a real Python module:
7
+
8
+ parse_apis/<slug>/__init__.py
9
+
10
+ Module contents:
11
+ * Resource classes per ``resources`` entry — fields, methods,
12
+ sub-resource collections.
13
+ * Enum classes per ``enums`` entry — real ``enum.Enum`` subclasses.
14
+ * Error classes per ``errors`` entry — subclasses of ``ParseError``.
15
+ * The root client class (e.g. ``Reddit``) with top-level collections.
16
+ * A registry ``_RESOURCE_CLASSES`` so the runtime ``Resource._from_payload``
17
+ can resolve nested resource types during coercion.
18
+
19
+ The user imports `from parse_apis.<slug> import <RootClass>` — ``parse_apis``
20
+ is a real project-local package on disk (written by ``parse sync``), so the
21
+ import resolves with no import magic.
22
+
23
+ Specs without a ``resources`` block are rejected — the SDK is hard-cutover
24
+ to the resource-modeled world (per product decision). The raw HTTP path
25
+ through the executor still works for those APIs.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import functools
31
+ import json
32
+ import keyword
33
+ import logging
34
+ import re
35
+ from datetime import datetime, timezone
36
+ from typing import Any, Dict, FrozenSet, List, Optional, Set, Tuple
37
+
38
+ from parse_sdk import scaffold as _scaffold
39
+ from parse_sdk._labels import split_top
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ # The runtime BASE error names every per-slug module re-exports, sourced from
45
+ # ONE place (the committed scaffold's re-export set) filtered to the error
46
+ # names — so it cannot drift from the runtime/scaffold vocabulary. ``RequestMeta``
47
+ # is dropped (it's a value type, not an exception). These are pre-reserved in
48
+ # ``_ModuleNames`` so a domain error whose spec-key camels to a base name
49
+ # (``parse_error`` → ``ParseError``) deterministically suffixes, and are appended
50
+ # to ``__all__`` so a generic ``from parse_apis.<slug> import ParseError`` works.
51
+ _BASE_ERROR_EXPORTS: Tuple[str, ...] = tuple(
52
+ n for n in _scaffold._REEXPORTED if n != "RequestMeta"
53
+ )
54
+
55
+
56
+ # The per-module re-export of the runtime base errors is derived from the SAME
57
+ # single source as ``__all__`` (``_BASE_ERROR_EXPORTS``), so a base error added
58
+ # to ``scaffold._REEXPORTED`` lands in BOTH the import and ``__all__`` — never a
59
+ # name exported-but-undefined.
60
+ _HEADER_BASE_ERROR_IMPORTS = "".join(
61
+ f" {n},\n" for n in _BASE_ERROR_EXPORTS
62
+ )
63
+
64
+ _HEADER = '''"""Auto-generated by `parse sync`. Do not edit by hand.
65
+
66
+ Source: {base_url}
67
+ API: {api_name} ({slug})
68
+ ID: {scraper_id}
69
+ Engine: parse_sdk {engine_version}
70
+ Generated at: {timestamp}
71
+ """
72
+
73
+ from __future__ import annotations
74
+
75
+ import enum
76
+ from datetime import datetime
77
+ from decimal import Decimal
78
+ from typing import Any, Dict, Iterator, List, Literal, Optional, ClassVar
79
+
80
+ from parse_sdk._runtime import (
81
+ ''' + _HEADER_BASE_ERROR_IMPORTS + ''')
82
+ # Runtime bases the GENERATED CODE subclasses-or-references by name are bound
83
+ # under reserved private aliases so a DOMAIN resource named ``Collection`` /
84
+ # ``Resource`` / ``Paginator`` / ``BaseAPI`` (or an error named ``ParseError``)
85
+ # cannot shadow the base it must derive from. Domain names and runtime-base
86
+ # names then live in disjoint namespaces — correct-by-construction. The bare
87
+ # error subclasses above (incl. the public base ``ParseError``) stay importable
88
+ # for user re-export; the generated code subclasses the ``_ParseError`` private
89
+ # alias below, so the disjoint-namespace invariant is untouched.
90
+ from parse_sdk._runtime import (
91
+ BaseAPI as _BaseAPI,
92
+ Collection as _Collection,
93
+ Paginator as _Paginator,
94
+ PaginationError as _PaginationError,
95
+ ParseError as _ParseError,
96
+ Resource as _Resource,
97
+ )
98
+
99
+
100
+ '''
101
+
102
+
103
+ class CodegenError(Exception):
104
+ """A spec reached codegen in a state the gate should have rejected — a
105
+ collection op with no pagination block, or an unrecognized scheme. Codegen
106
+ REFUSES to guess the response shape (the legacy OR-chain is gone), so it
107
+ fails loud. ``parse sync`` catches this and skips+warns the API (the raw
108
+ HTTP endpoints still work); an in-process render surfaces it directly. This
109
+ is the SOLE presence-enforcer on the gate-less ``parse sync``/``row_to_spec``
110
+ path — not redundant defense-in-depth."""
111
+
112
+
113
+ # A scraper id / endpoint name becomes a URL path segment of every runtime
114
+ # request. The runtime sink percent-encodes both segments (the hard guarantee);
115
+ # this gate is the EARLY authoring signal — a draft with a traversal-shaped id
116
+ # or endpoint fails at render (``parse sync`` skips+warns; preview returns no
117
+ # module) instead of generating a client that 404s at the user's runtime.
118
+ # Verified against the real corpus: ids are UUIDs, endpoint charset is [a-z_],
119
+ # so this rejects nothing legitimate.
120
+ _WIRE_SEGMENT_RE = re.compile(r"[A-Za-z0-9_-]+")
121
+
122
+
123
+ def _require_wire_segment(value: Any, what: str) -> None:
124
+ """Raise ``CodegenError`` unless ``value`` is a clean URL path segment."""
125
+ if not isinstance(value, str) or not _WIRE_SEGMENT_RE.fullmatch(value):
126
+ raise CodegenError(
127
+ f"{what} {value!r} is not a valid URL path segment "
128
+ f"(must match [A-Za-z0-9_-]+)"
129
+ )
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Identifier / type-label helpers
134
+ # ---------------------------------------------------------------------------
135
+
136
+
137
+ _PY_KEYWORDS_AND_BUILTINS: Set[str] = set(keyword.kwlist) | {"None", "True", "False"}
138
+
139
+
140
+ def _safe_ident(name: str, fallback: str = "x") -> str:
141
+ s = re.sub(r"[^0-9a-zA-Z_]", "_", name or "")
142
+ if not s:
143
+ return fallback
144
+ if s[0].isdigit():
145
+ s = f"_{s}"
146
+ if s in _PY_KEYWORDS_AND_BUILTINS:
147
+ s = f"{s}_"
148
+ return s
149
+
150
+
151
+ def _docstring(text: str, indent: str = " ") -> str:
152
+ """One safe docstring block (closing ``\"\"\"`` on its own line).
153
+
154
+ The block form is immune to a trailing/leading ``"`` or backslash in the
155
+ agent-inferred text (which the inline ``f'{indent}\"\"\"{text}\"\"\"'`` form
156
+ is NOT — a ``text`` ending in ``"`` produces an unterminated literal, failing
157
+ the WHOLE module's import). Every emitter routes its docstrings through this
158
+ helper rather than hand-building the literal. Returns the block WITHOUT a
159
+ trailing newline; callers join with ``\\n``.
160
+
161
+ A backslash is escaped (an odd trailing ``\\`` would escape the closing
162
+ quote's newline-adjacency); an embedded ``\"\"\"`` is escaped so it can't
163
+ close the block early.
164
+ """
165
+ safe = text.replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
166
+ body = "\n".join(f"{indent}{ln}".rstrip() for ln in safe.split("\n"))
167
+ return f'{indent}"""\n{body}\n{indent}"""'
168
+
169
+
170
+ def _escape_header_field(value: Any) -> str:
171
+ """Neutralize a server/agent-authored value before it is ``.format()``-ed
172
+ into the triple-quoted module ``_HEADER`` docstring.
173
+
174
+ SEC: the header is the one schema sink that does NOT route through
175
+ ``_docstring()``. Apply the same triple-quote/backslash escaping (so a
176
+ ``name`` / ``base_url`` / ``slug`` / ``scraper_id`` carrying a triple-quote
177
+ cannot break out of the docstring into top-level generated statements) and
178
+ collapse newlines so a value cannot reshape the header layout.
179
+ """
180
+ s = str(value).replace("\\", "\\\\").replace('"""', '\\"\\"\\"')
181
+ return s.replace("\r", " ").replace("\n", " ")
182
+
183
+
184
+ def _camel(name: str) -> str:
185
+ parts = re.split(r"[^0-9a-zA-Z]+", name or "")
186
+ return "".join(p[:1].upper() + p[1:] for p in parts if p) or "X"
187
+
188
+
189
+ def _as_dict(v: object) -> Dict[str, Any]:
190
+ """Self-narrow a spec value to a dict, or ``{}`` — the codegen idiom for a
191
+ field that *should* be an object but may be malformed/absent. Replaces the
192
+ inline ``x = x if isinstance(x, dict) else {}`` so pyright narrows at the
193
+ call site without a per-site ternary. (Parent-guards — ``spec.get(k) if
194
+ isinstance(spec, dict) else …`` — guard a *different* object and are left
195
+ inline; this is only for the self-narrow.)"""
196
+ return v if isinstance(v, dict) else {}
197
+
198
+
199
+ def _as_str(v: object) -> Optional[str]:
200
+ """Self-narrow a spec value to a str, or ``None``. Replaces the inline
201
+ ``x = x if isinstance(x, str) else None``."""
202
+ return v if isinstance(v, str) else None
203
+
204
+
205
+ def _snake(name: str) -> str:
206
+ """snake_case a (possibly Pascal/camelCase) identifier so word boundaries
207
+ survive into the plural accessor: ``BankAccount`` → ``bank_account``,
208
+ ``ItemSummary`` → ``item_summary``, ``Series`` → ``series``,
209
+ ``TimeSeries`` → ``time_series``.
210
+
211
+ Load-bearing for pluralization: ``_pluralize`` lowercases its input on its FIRST line,
212
+ so ``_pluralize("BankAccount")`` collapses to ``bankaccounts`` (no boundary)
213
+ — the multiword accessor was unreadable AND its plural mis-derived
214
+ (``Series`` jammed to ``serieses`` only AFTER a boundary-less collapse lost
215
+ the ``series`` ending). Routing the heuristic through ``_pluralize(_snake(…))``
216
+ restores the boundary so the closed plural rule sees the FINAL word.
217
+
218
+ Boundaries are inserted before each uppercase run start (camel humps) and
219
+ before a digit run; existing non-alnum separators are normalized to ``_``.
220
+ The result is lowercased and collapsed (no leading/trailing/double ``_``).
221
+ Pluralization (``_pluralize``) still owns the final-word transform; this only
222
+ re-introduces the boundaries an all-lowercase collapse destroyed.
223
+ """
224
+ s = name or ""
225
+ # acronym→word boundary: HTTPServer → HTTP_Server ; camel hump: aB → a_B
226
+ s = re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", "_", s)
227
+ s = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", s)
228
+ # letter↔digit boundary: account2 / 2fa stay grouped per run
229
+ s = re.sub(r"(?<=[A-Za-z])(?=[0-9])", "_", s)
230
+ s = re.sub(r"(?<=[0-9])(?=[A-Za-z])", "_", s)
231
+ s = re.sub(r"[^0-9A-Za-z]+", "_", s)
232
+ return re.sub(r"_+", "_", s).strip("_").lower()
233
+
234
+
235
+ @functools.lru_cache(maxsize=1)
236
+ def _runtime_reserved_member_names() -> Set[str]:
237
+ """Identifiers a generated resource member (field / param / op / accessor)
238
+ must NOT take, because they are structural to the runtime ``Resource`` and
239
+ its codegen-emitted machinery.
240
+
241
+ Cached: the result is constant per process (``dir(_Resource)`` is fixed at
242
+ import; tests only ever REPLACE existing attrs), and it was recomputed for
243
+ every rendered op. Callers treat the set as read-only.
244
+
245
+ Seeded from ``dir(_Resource)`` (every runtime attr/method/ClassVar) ∪ the
246
+ construction kwargs ``_api``/``_parent``/``_extra`` ∪ the ``_sub_`` accessor
247
+ stash prefix's bare form. A wire field named ``_api`` or a ``takes``-key
248
+ ``self`` passes straight through ``_safe_ident`` (neither is a keyword/
249
+ builtin) and crashes — ``Resource.__init__() got multiple values for
250
+ '_api'`` at construction, or ``def search(self, self: …)`` SyntaxError at
251
+ import. Reserving them forces a deterministic suffix instead.
252
+ """
253
+ from parse_sdk._runtime import Resource as _Resource
254
+
255
+ return set(dir(_Resource)) | {"self", "_api", "_parent", "_extra"}
256
+
257
+
258
+ def _allocate_ident(
259
+ raw: str, taken: Set[str], *, fallback: str, reserved: Set[str]
260
+ ) -> str:
261
+ """Allocate ONE unique python identifier for ``raw`` against ``taken``.
262
+
263
+ Sanitizes via ``_safe_ident`` (single fallback so call-sites can't diverge),
264
+ then deterministically suffixes ``_`` until the result is neither in
265
+ ``reserved`` (runtime-structural names) nor ``taken`` (siblings already
266
+ allocated). Mutates + returns into ``taken``. Deterministic: same inputs in
267
+ the same order yield the same idents.
268
+ """
269
+ ident = _safe_ident(raw, fallback=fallback)
270
+ while ident in reserved or ident in taken:
271
+ ident = f"{ident}_"
272
+ taken.add(ident)
273
+ return ident
274
+
275
+
276
+ def _allocate_field_idents(
277
+ field_names: List[str], *, reserved: Set[str]
278
+ ) -> Dict[str, str]:
279
+ """``{wire field name → unique py ident}`` for one resource, computed ONCE.
280
+
281
+ The single source the field-emit loop, the ``keyed_by`` membership test,
282
+ ``_KEY_FIELD``, ``_FIELD_ALIASES``, and the constructible factory key all
283
+ read from — so a sanitize-collision (``a-b`` + ``a.b`` → ``a_b``) or a
284
+ sanitize-to-empty name (the ``""``/all-symbol fallback-divergence case)
285
+ can't drop a field or spuriously fail the key check. Reserved runtime names
286
+ are suffixed away (a field ``_api`` becomes ``_api_``).
287
+ """
288
+ taken: Set[str] = set()
289
+ out: Dict[str, str] = {}
290
+ for fname in field_names:
291
+ out[fname] = _allocate_ident(fname, taken, fallback="field", reserved=reserved)
292
+ return out
293
+
294
+
295
+ def _coerce_type_label(label: Optional[str]) -> str:
296
+ """Normalize an agent-written type label to what the runtime accepts.
297
+
298
+ Inputs: ``str``, ``int``, ``datetime``, ``url``, ``Decimal``, ``Post``,
299
+ ``List[Post]``, ``Optional[int]``, etc. Outputs: same shape, with
300
+ illegal forms degraded to ``Any``.
301
+ """
302
+ if not label:
303
+ return "Any"
304
+ s = str(label).strip()
305
+ # Normalize lowercase primitives (agent may write "string" / "integer")
306
+ primitives = {
307
+ "string": "str", "str": "str", "text": "str",
308
+ "integer": "int", "int": "int",
309
+ "number": "float", "float": "float", "double": "float",
310
+ "boolean": "bool", "bool": "bool",
311
+ "any": "Any", "object": "Any", "dict": "Any",
312
+ "array": "List[Any]", "list": "List[Any]",
313
+ "null": "None",
314
+ }
315
+ if s.lower() in primitives:
316
+ return primitives[s.lower()]
317
+ # List[X] / Optional[X]: normalize the single inner label so a composite
318
+ # with a non-canonical inner (e.g. ``List[integer]``) becomes ``List[int]``
319
+ # instead of degrading to ``Any`` at resolve time.
320
+ #
321
+ # ARITY IS PART OF THE GATE (audit 2026-06-11): a malformed-arity
322
+ # composite (``Dict[str]``, ``List[str, int]``, ``Optional[a, b]``) is a
323
+ # syntactically valid annotation that makes ``typing.get_type_hints``
324
+ # RAISE at first use — ``_field_hints`` swallows that into an EMPTY hint
325
+ # map, silently disabling coercion AND identity for EVERY field on the
326
+ # class; on the returns/param paths it NameError/TypeError-blinds the
327
+ # preview walker and pyright. This is the single normalization funnel for
328
+ # all three sinks (fields, returns, params), so the arity check lives
329
+ # here and degrades to ``Any`` — the documented malformed-label path.
330
+ # Frozen-corpus blast radius: 0 of 71,640 field / 9,538 returns / 17,726
331
+ # param labels (replay byte-identical); future-input verdicts only.
332
+ for prefix in ("List[", "Optional["):
333
+ if s.startswith(prefix) and s.endswith("]"):
334
+ inner = s[len(prefix):-1]
335
+ if len(split_top(inner)) != 1:
336
+ return "Any"
337
+ return f"{prefix}{_coerce_type_label(inner.strip())}]"
338
+ # Dict[K, V]: normalize both halves, splitting at the TOP-LEVEL comma so a
339
+ # nested composite (``Dict[str, List[integer]]``) still splits correctly.
340
+ # Exactly two halves — ``typing.Dict`` accepts nothing else.
341
+ if s.startswith("Dict[") and s.endswith("]"):
342
+ args = split_top(s[len("Dict["):-1])
343
+ if len(args) != 2:
344
+ return "Any"
345
+ return f"Dict[{_coerce_type_label(args[0])}, {_coerce_type_label(args[1])}]"
346
+ # datetime / Decimal / url / email — keep
347
+ if s in {"datetime", "Decimal", "url", "email"}:
348
+ return s
349
+ # Otherwise treat as a resource class name (e.g. "Post", "Site")
350
+ return s
351
+
352
+
353
+ def _allocate_returns(label: str, owning_alloc: str, owning_camel: str) -> str:
354
+ """Resolve a method ``returns`` label to the owning resource's ALLOCATED
355
+ class name when the label names the owning resource — `<Resource>` or
356
+ `List[<Resource>]` / `Optional[<Resource>]`, the only return shapes the
357
+ codegen emits and the routing predicates recognize.
358
+
359
+ This is the SINGLE point where a return label becomes an allocated name; the
360
+ emitted annotation, the `_coerce` label, and the `_RESOURCE_CLASSES` key all
361
+ read the rewritten value, so they cannot diverge. Identity-based (compares the leaf to THE one
362
+ owning resource), so it is unambiguous even when the owning resource's
363
+ `_camel` collides with another's. A label naming a DIFFERENT resource passes
364
+ through unchanged: for a non-colliding target its `_camel` form already equals
365
+ that resource's allocated name (a valid registry key); a reference to a
366
+ `_camel`-colliding other resource is an irreducible spec ambiguity (one
367
+ written name, two classes) and is out of scope. Idempotent when re-applied
368
+ with the SAME ``owning_alloc`` (which ``render_module`` guarantees — it is
369
+ stable per resource): a suffixed leaf `_camel`-collapses back to
370
+ ``owning_camel`` and re-resolves to the same ``owning_alloc``.
371
+ """
372
+ s = str(label).strip()
373
+ for prefix in ("List[", "Optional["):
374
+ if s.startswith(prefix) and s.endswith("]"):
375
+ return f"{prefix}{_allocate_returns(s[len(prefix):-1], owning_alloc, owning_camel)}]"
376
+ return owning_alloc if _camel(s) == owning_camel else s
377
+
378
+
379
+ def _is_collection_label(label: str) -> bool:
380
+ return label.startswith("List[") or label.startswith("Optional[List[")
381
+
382
+
383
+ def _is_container_field_label(label: str) -> bool:
384
+ """A field type label whose VALUE is a container (List/Dict/Set/Tuple,
385
+ possibly Optional-wrapped). Such a field can never serve as an identity
386
+ key: ``Resource.__hash__`` does ``hash((name, value))`` and a container
387
+ value raises ``TypeError`` — and a container has no stable identity
388
+ semantics to begin with."""
389
+ s = str(label).strip()
390
+ while s.startswith("Optional[") and s.endswith("]"):
391
+ s = s[len("Optional["):-1].strip()
392
+ return s.startswith(("List[", "Dict[", "Set[", "Tuple["))
393
+
394
+
395
+ def _collection_element(returns: str) -> str:
396
+ """The element type X of a collection label, unifying ``List[X]`` and
397
+ ``Optional[List[X]]`` to the same ``X``; a non-collection label is its own
398
+ element (unchanged)."""
399
+ if returns.startswith("List[") and returns.endswith("]"):
400
+ return returns[5:-1]
401
+ if returns.startswith("Optional[List[") and returns.endswith("]]"):
402
+ return returns[len("Optional[List["):-2]
403
+ return returns
404
+
405
+
406
+ def _resp_path_expr(path: Any, default_expr: Optional[str] = None) -> str:
407
+ """Source expression resolving one pagination response path off ``_resp``.
408
+
409
+ A FLAT (dot-free) ``path`` keeps the verbatim ``_resp.get(...)`` — BYTE-
410
+ IDENTICAL to the historical emit, so a non-dotted spec's module is unchanged.
411
+ A DOTTED path dot-walks via the runtime ``_walk`` helper: a flat
412
+ ``_resp.get('pagination.next_cursor')`` can never reach a nested cursor and
413
+ silently truncated every dotted-path spec to page 1. ``default_expr`` is an
414
+ already-rendered source fragment (e.g. ``'_page'``) for the present-with-
415
+ default reads; ``None`` omits the default. A non-str ``path`` is treated as
416
+ flat (the repr passes through exactly as before — no behavior change)."""
417
+ if isinstance(path, str) and "." in path:
418
+ if default_expr is not None:
419
+ return f"_walk(_resp, {path!r}, {default_expr})"
420
+ return f"_walk(_resp, {path!r})"
421
+ if default_expr is not None:
422
+ return f"_resp.get({path!r}, {default_expr})"
423
+ return f"_resp.get({path!r})"
424
+
425
+
426
+ def _emit_items(items_path: str, sample_keys: Optional[FrozenSet[str]]) -> List[str]:
427
+ """Emit the ``items_path`` extraction shared by every paginated arm — the
428
+ empty-results discriminator (emitted arm A + arm B).
429
+
430
+ ``_resp`` here is the RAW post-unwrap response value (the arms no longer
431
+ pre-coerce it to ``{}``), so it may be a dict, a top-level list, an
432
+ envelope-null ``None``, or empty. The single runtime helper
433
+ ``_extract_items`` owns the sample-INDEPENDENT normalization:
434
+
435
+ * top-level list → the value IS the items;
436
+ * envelope-null / ``None`` / empty / absent key → ``[]`` (legit zero);
437
+ * key present + list → the list; key present + non-list → fail loud
438
+ (``PaginationError``; mis-derived, sample-independent).
439
+
440
+ Arm B — the sample-PROVEN mis-derivation raise — is the ONLY home of the
441
+ sample-DEPENDENT verdict, and it is decided HERE at build time:
442
+
443
+ * ``sample_keys is None`` (no usable sample) → emit the bare
444
+ ``_extract_items`` call. Absent key → ``[]`` (never raise); a
445
+ present-but-non-list still fails loud (sample-independent).
446
+ * ``items_path`` **was** in the captured sample → the key is real; an
447
+ absent/empty response now is a legitimate zero-result → bare
448
+ ``_extract_items`` (same emit as no-sample).
449
+ * ``items_path`` was **NEVER** in a present sample → mis-derived. After
450
+ ``_extract_items`` (which fail-safes absent → ``[]``), raise the
451
+ enriched ``PaginationError`` when the live response is a POPULATED dict
452
+ that still omits the key — the populated-response-missing-the-key signal
453
+ that proves the array lives under a different key than the spec claims.
454
+ A genuinely-empty body (``{}`` / ``None`` / ``[]``) stays ``[]``: an
455
+ empty response is a zero-result, indistinguishable from a legit empty
456
+ page regardless of the sample, so it never heals-loud.
457
+ """
458
+ if isinstance(items_path, str) and "." in items_path:
459
+ # Dotted items_path ('envelope.items'): dot-walk to the leaf's PARENT,
460
+ # then hand the parent value + the leaf to _extract_items (which owns the
461
+ # absent / null / non-list / non-dict disposition — a missing segment
462
+ # makes _walk return None, which _extract_items maps to ``[]``). The
463
+ # sample-key discriminator keys on TOP-LEVEL response keys, so it cannot
464
+ # validate a nested leaf; skip the heal-loud arm — a dotted path is a
465
+ # structural spec assertion, not a sample-derived guess.
466
+ parent, leaf = items_path.rsplit(".", 1)
467
+ return [f" items = _extract_items(_walk(_resp, {parent!r}), {leaf!r})"]
468
+ lines = [f" items = _extract_items(_resp, {items_path!r})"]
469
+ if sample_keys is not None and items_path not in sample_keys:
470
+ # Sample present, key never in it → mis-derived. Heal loud only on a
471
+ # populated dict that omits the declared key (an empty body is a
472
+ # zero-result, not a mis-derivation signal).
473
+ lines += [
474
+ f" if not items and isinstance(_resp, dict) and _resp and {items_path!r} not in _resp:",
475
+ f" raise _PaginationError(0, f\"items_path {items_path!r} absent from a "
476
+ f"populated response; keys={{list(_resp)}}; the key was never in the captured "
477
+ f"sample — spec likely mis-derived; regenerate\", body=_resp)",
478
+ ]
479
+ return lines
480
+
481
+
482
+ def _annotation_label(label: str) -> str:
483
+ """Convert a codegen type label into a Python annotation pyright understands.
484
+
485
+ Codegen uses ``url`` / ``email`` as semantic types — the runtime treats
486
+ them as ``str``, but pyright sees an undefined name. Recursively rewrite
487
+ any occurrence of those tokens (including inside ``List[url]`` etc.) to
488
+ ``str``. Everything else passes through.
489
+ """
490
+ s = label
491
+ # Wrap with word boundaries via regex so we don't munge identifiers that
492
+ # happen to contain "url".
493
+ s = re.sub(r"\burl\b", "str", s)
494
+ s = re.sub(r"\bemail\b", "str", s)
495
+ # ``Any`` is fine; ``object`` should map to ``Any`` for typing purposes
496
+ s = re.sub(r"\bobject\b", "Any", s)
497
+ return s
498
+
499
+
500
+ # Type labels _coerce_type_label produces that are real, importable annotations
501
+ # for a wire INPUT param. Anything else it returns is its "treat as a resource
502
+ # class name" passthrough — never valid for an input param — so we degrade to Any.
503
+ _KNOWN_PARAM_LABELS = frozenset({
504
+ "str", "int", "float", "bool", "bytes", "Any", "None",
505
+ "datetime", "Decimal", "url", "email",
506
+ })
507
+
508
+ # Leaf labels that are always resolvable in a generated module (built-ins +
509
+ # the runtime-imported special types). ``url``/``email`` are launderable to
510
+ # ``str`` by _annotation_label; ``object`` → ``Any``. Composite *wrappers*
511
+ # (List/Optional/Dict) and a Literal[...] form are handled structurally.
512
+ _KNOWN_FIELD_LEAF_LABELS = frozenset({
513
+ "str", "int", "float", "bool", "bytes", "Any", "None",
514
+ "datetime", "Decimal", "url", "email", "object",
515
+ })
516
+
517
+ # A leaf name (identifier or dotted) that could appear inside a field
518
+ # annotation. Used to walk a composite's inner names so a dangling INNER ref
519
+ # (``List[UnknownResource]``) is caught — get_type_hints evaluates the whole
520
+ # annotation string, so every leaf must resolve.
521
+ _LEAF_NAME_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
522
+
523
+
524
+ def _referenced_resource_names(label: Optional[str], resource_names: Set[str]) -> Set[str]:
525
+ """Resource names a type label references, across composites (``List[X]`` /
526
+ ``Optional[X]`` / ``Dict[K, V]``).
527
+
528
+ The SINGLE source for "what resource types this label names" — used by the
529
+ field-resolvability gate AND the orphan/coverage check, so neither
530
+ re-implements composite parsing. The label is normalized through
531
+ ``_coerce_type_label`` first so a non-canonical inner (``List[integer]``)
532
+ can't masquerade as a resource leaf, then every identifier leaf is matched
533
+ and intersected with the emitted resource names.
534
+ """
535
+ if not label:
536
+ return set()
537
+ return {
538
+ m.group(0) for m in _LEAF_NAME_RE.finditer(_coerce_type_label(label))
539
+ } & resource_names
540
+
541
+
542
+ def _field_annotation_resolvable(
543
+ label: str,
544
+ *,
545
+ resource_names: Set[str],
546
+ enum_names: Set[str],
547
+ ) -> bool:
548
+ """Whether every non-keyword leaf name in ``label`` resolves against
549
+ ``{known leaf labels} ∪ {emitted resource names} ∪ {emitted enum names}``.
550
+
551
+ The label is assumed to already be a syntactically-plausible annotation
552
+ (``_coerce_type_label`` output): a leaf label, or a ``List[...]`` /
553
+ ``Optional[...]`` / ``Dict[...]`` / ``Literal[...]`` composite. We extract
554
+ every identifier and require each to be a known leaf, an emitted
555
+ resource/enum, or a typing wrapper — so a dangling enum/resource ref
556
+ (bare OR nested inside a composite) fails the gate and degrades to ``Any``.
557
+
558
+ A Literal[...]'s string members are NOT identifiers we resolve against
559
+ names; but ``_annotation_label``/``_coerce_type_label`` never produce
560
+ Literal for a FIELD (only params), so a Literal leaf here is treated as
561
+ unresolvable (degrade to Any) — conservative and lenient.
562
+ """
563
+ # Resource-name leaves come from the single composite-extraction helper, so
564
+ # this gate and the orphan/coverage check agree on what a label references.
565
+ referenced = _referenced_resource_names(label, resource_names)
566
+ known = _KNOWN_FIELD_LEAF_LABELS | referenced | enum_names
567
+ # Typing wrappers that may legitimately appear in a composite.
568
+ wrappers = {"List", "Optional", "Dict", "Union"}
569
+ for m in _LEAF_NAME_RE.finditer(label):
570
+ name = m.group(0)
571
+ if name in wrappers:
572
+ continue
573
+ if name in known:
574
+ continue
575
+ return False
576
+ return True
577
+
578
+
579
+ def _field_annotation(
580
+ coerced_label: str,
581
+ *,
582
+ resource_names: Set[str],
583
+ enum_names: Set[str],
584
+ ) -> str:
585
+ """The Python annotation to emit for a resource field, gated for
586
+ resolvability + injection.
587
+
588
+ ``coerced_label`` is ``_coerce_type_label`` output (or an enum-binding's
589
+ ``_camel(name)``). We:
590
+
591
+ 1. Reject anything that isn't a leaf or a plain List/Optional/Dict/Literal
592
+ composite shape (an injection like ``List[evil')]`` fails the bracket
593
+ match → ``Any``).
594
+ 2. Require every inner leaf name to resolve (dangling enum/resource ref,
595
+ incl. an unresolvable INNER name, → ``Any``).
596
+ 3. Launder ``url``/``email``/``object`` via ``_annotation_label``.
597
+
598
+ Degrades to ``Any`` on any failure — B's ``get_type_hints`` resolution is
599
+ load-bearing, so an unresolvable annotation would NameError-poison the
600
+ class, and a hostile token would SyntaxError the module. Never raises.
601
+ """
602
+ label = coerced_label.strip()
603
+ if not label:
604
+ return "Any"
605
+ # Structural shape check: a bare identifier-ish leaf, or a balanced
606
+ # List[..]/Optional[..]/Dict[..]/Literal[..] composite. Anything with
607
+ # stray quotes / parens / unbalanced brackets is an injection attempt.
608
+ if not _is_well_formed_annotation(label):
609
+ return "Any"
610
+ if not _field_annotation_resolvable(
611
+ label, resource_names=resource_names, enum_names=enum_names
612
+ ):
613
+ return "Any"
614
+ return _annotation_label(label)
615
+
616
+
617
+ def _is_well_formed_annotation(label: str) -> bool:
618
+ """A conservative structural check: the label is a leaf or a nested
619
+ composite built only from identifiers, the typing wrappers, brackets,
620
+ commas, and whitespace — OR a single top-level ``<X> | str`` union (the open
621
+ enum/leaf field form, C1/D2). Rejects quotes/parens/operators (injection),
622
+ unbalanced brackets, interior/multiple pipes, and a right arm other than
623
+ ``str``."""
624
+ # C1: admit exactly one top-level ``<X> | str`` union — validate the left arm
625
+ # as a plain leaf/composite and require the right arm to be exactly ``str``.
626
+ # ``partition`` splits on the FIRST pipe, so a second pipe (an interior pipe
627
+ # like ``List[A|B]`` or a third arm ``A | B | str``) lands in ``right`` and
628
+ # fails the ``str`` check → rejected. The left arm has no pipe, so the
629
+ # recursion terminates in the bracket/charset branch below.
630
+ if "|" in label:
631
+ left, _, right = label.partition("|")
632
+ if right.strip() != "str":
633
+ return False
634
+ return _is_well_formed_annotation(left.strip())
635
+ # Only allow identifier chars, brackets, commas, and whitespace. NO dots:
636
+ # codegen never emits a dotted annotation (every header import is a bare
637
+ # name), and a dotted label like ``datetime.datetime`` would pass the
638
+ # leaf-name check yet make ``get_type_hints`` raise ``AttributeError`` at
639
+ # first construction — poisoning EVERY field on the class. Reject it here so
640
+ # it degrades to ``Any`` (raw passthrough) instead.
641
+ if not re.fullmatch(r"[A-Za-z0-9_\[\],\s]*", label):
642
+ return False
643
+ # Balanced brackets.
644
+ depth = 0
645
+ for ch in label:
646
+ if ch == "[":
647
+ depth += 1
648
+ elif ch == "]":
649
+ depth -= 1
650
+ if depth < 0:
651
+ return False
652
+ # Whitespace is legal ONLY immediately after a comma (the ``, `` separator a
653
+ # ``Dict[K, V]`` composite emits). Any other interior space (``a b``) passes
654
+ # the charset/bracket checks yet is invalid in annotation position — it would
655
+ # SyntaxError at compile and (under ``from __future__ import annotations``)
656
+ # poison ``get_type_hints`` — so reject it and let the label degrade to
657
+ # ``Any``. The ``<X> | str`` union arm above returns BEFORE this guard (it
658
+ # recurses on the whitespace-free left arm), so open-enum unions are
659
+ # unaffected.
660
+ if any(ch.isspace() and (i == 0 or label[i - 1] != ",") for i, ch in enumerate(label)):
661
+ return False
662
+ return depth == 0
663
+
664
+
665
+ def _param_annotation(
666
+ info: Any,
667
+ enum_alloc: Optional[Dict[str, str]] = None,
668
+ enums: Optional[Dict[str, Any]] = None,
669
+ ) -> str:
670
+ """Python annotation string for one endpoint input param.
671
+
672
+ ``info`` is the per-param ``ParamSpec`` dict from ``input_params`` (or
673
+ anything at all — this is the join's defensive boundary). Precedence:
674
+
675
+ 1a. CLOSED enum-binding (``{"enum": "Sort", "closed": true}``) whose codes
676
+ resolve from ``enums`` → ``Sort | Literal['score', 'created_utc']``
677
+ (no ``| str`` — the value set is a contract, D2)
678
+ 1b. open enum-binding (``{"enum": "Sort"}``) → ``Sort | str``
679
+ 2. explicit closed ``choices`` (``["a", "b"]``) → ``Literal['a', 'b'] | str``
680
+ 3. base ``type`` → ``_annotation_label(_coerce_type_label(type))``
681
+ 4. anything unusable → ``Any``
682
+
683
+ Both the enum and ``choices`` forms are *open* (``| str``): they never
684
+ type-reject an unobserved-but-valid value, which is the right default for
685
+ an SDK whose value sets are agent-discovered. **Prose / ``description``
686
+ is never scraped** — only structured ``enum`` / ``choices`` are honored
687
+ (description inference is verified to mis-fire on real data).
688
+
689
+ ``enum_alloc`` maps ``_camel(enum-key) → allocated enum class name`` so an
690
+ enum-bound param references the real (possibly collision-suffixed)
691
+ class. When ``enum_alloc`` is supplied (the ``render_module`` path) the
692
+ binding resolves through it; without it (a standalone unit call) the binding
693
+ keeps its optimistic ``_camel(enum) | str`` form.
694
+
695
+ Always degrades to ``"Any"``; never raises. Handles ``info`` as a dict,
696
+ a dict missing ``type``, a bare string, or ``None``.
697
+ """
698
+ if not isinstance(info, dict):
699
+ return "Any"
700
+
701
+ # ``enum_alloc is not None`` discriminates the render_module path (the
702
+ # allocator dict is passed, possibly empty) from a standalone unit call
703
+ # (optimistic ``_camel(enum) | str`` form, pinned by tests) — capture it
704
+ # BEFORE the empty-dict normalization erases the distinction.
705
+ module_path = enum_alloc is not None
706
+ enum_alloc = enum_alloc or {}
707
+ enum_binding = info.get("enum")
708
+ if isinstance(enum_binding, str) and enum_binding.strip() and not (
709
+ # DANGLING binding on the module path (audit 2026-06-11): the enum is
710
+ # not emitted in this module, so ``<Undefined> | str`` would be a
711
+ # NameError that poisons get_type_hints over the method (silently
712
+ # fail-opening the preview walker on chains through it) and breaks
713
+ # pyright. Mirror the field path's resolvability degrade: fall
714
+ # through to the choices/type arms (an honest ``str``/``int``), the
715
+ # annotation the param would have carried without the binding.
716
+ # Frozen-corpus blast radius: 0 of 3,456 enum-bound params dangle.
717
+ module_path and _camel(enum_binding) not in enum_alloc
718
+ ):
719
+ # Resolve to the allocated class when the module allocator provided it
720
+ # (handles a collision suffix); else keep the canonical
721
+ # ``_camel`` spelling.
722
+ resolved = enum_alloc.get(_camel(enum_binding), _camel(enum_binding))
723
+ # A ``closed`` param is a contract: render ``Enum | Literal[codes]`` (no
724
+ # ``| str``) so an off-list value is a static type error — but ONLY when
725
+ # the codes actually resolve from ``enums`` (else stay open; ``| str``
726
+ # honestly admits an unobserved-but-valid value).
727
+ if info.get("closed") is True and isinstance(enums, dict):
728
+ codes = [c for _, c, _ in _enum_members(enums.get(enum_binding) or {},
729
+ enum_name=resolved)]
730
+ if codes:
731
+ literal = "Literal[" + ", ".join(repr(c) for c in codes) + "]"
732
+ return f"{resolved} | {literal}"
733
+ return f"{resolved} | str"
734
+
735
+ choices = info.get("choices")
736
+ if (
737
+ isinstance(choices, list)
738
+ and choices
739
+ and all(isinstance(c, (str, int, float, bool)) for c in choices)
740
+ ):
741
+ return "Literal[" + ", ".join(repr(c) for c in choices) + "] | str"
742
+
743
+ type_label = info.get("type")
744
+ if not type_label:
745
+ return "Any"
746
+ coerced = _coerce_type_label(type_label)
747
+ # Only honor recognized primitive/special/composite labels. An unknown
748
+ # bare label is _coerce_type_label's resource-name passthrough — never
749
+ # valid for a wire input param, and would emit an undefined-name
750
+ # annotation — so degrade to Any.
751
+ if coerced in _KNOWN_PARAM_LABELS or (
752
+ coerced.startswith(("List[", "Optional[", "Dict["))
753
+ and _is_well_formed_annotation(coerced)
754
+ ):
755
+ return _annotation_label(coerced)
756
+ return "Any"
757
+
758
+
759
+ def _params_for(
760
+ endpoint_input_params: Dict[str, Dict[str, Any]], op: Any
761
+ ) -> Dict[str, Any]:
762
+ """The ``input_params`` map for ``op``'s endpoint, or ``{}``.
763
+
764
+ Resolves the endpoint name defensively — a non-str / missing endpoint
765
+ yields an empty map so the op's kwargs degrade to ``Any`` rather than
766
+ keying the lookup with ``None``."""
767
+ endpoint = op.get("endpoint") if isinstance(op, dict) else None
768
+ if not isinstance(endpoint, str):
769
+ return {}
770
+ return endpoint_input_params.get(endpoint, {})
771
+
772
+
773
+ def _method_for(endpoint_methods: Dict[str, str], op: Any) -> str:
774
+ """The HTTP method for ``op``'s endpoint, defaulting to ``POST``.
775
+
776
+ Mirrors ``_params_for``'s defensive endpoint resolution. A missing /
777
+ non-str endpoint or an endpoint not in the map → POST (matching the
778
+ executor projector and the raw client's defaults — never GET)."""
779
+ endpoint = op.get("endpoint") if isinstance(op, dict) else None
780
+ if not isinstance(endpoint, str):
781
+ return "POST"
782
+ return endpoint_methods.get(endpoint, "POST")
783
+
784
+
785
+ def _desc_for(endpoint_descriptions: Dict[str, str], op: Any) -> Optional[str]:
786
+ """The endpoint ``description`` for ``op``'s endpoint, or ``None``.
787
+
788
+ Mirrors ``_method_for``'s defensive endpoint resolution — a missing /
789
+ non-str endpoint or one with no description yields ``None`` so the docstring
790
+ summary degrades to empty rather than fabricating one."""
791
+ endpoint = op.get("endpoint") if isinstance(op, dict) else None
792
+ if not isinstance(endpoint, str):
793
+ return None
794
+ return endpoint_descriptions.get(endpoint)
795
+
796
+
797
+ def _sample_top_keys(return_schema: Any) -> Optional[FrozenSet[str]]:
798
+ """The ENVELOPE-UNWRAPPED top-level key-set of a captured ``return_schema``
799
+ sample, or ``None`` when the op captured no usable sample.
800
+
801
+ The empty-results discriminator (emitted arm B) needs to know which keys
802
+ the API's response actually carried so it can tell a legitimate zero-result
803
+ (the declared ``items_path`` WAS in the sample, just empty/absent this call)
804
+ from a mis-derivation (the key was NEVER there). Unwraps through the SAME
805
+ ``unwrap_scraper_envelope`` the build-time projection / serving-time runtime
806
+ use, so the key-set matches what ``_extract_items`` sees at run time — never
807
+ a ``{data, status}`` envelope assumption.
808
+
809
+ Returns ``None`` (no-sample) for: no ``return_schema``, no ``sample``, or a
810
+ sample that does not unwrap to a dict (a top-level-list / scalar sample
811
+ cannot prove a key absent vs present, so it is treated as undecidable → the
812
+ no-sample fail-safe path)."""
813
+ if not isinstance(return_schema, dict):
814
+ return None
815
+ sample = return_schema.get("sample")
816
+ if sample is None:
817
+ return None
818
+ from parse_sdk._runtime import unwrap_scraper_envelope
819
+
820
+ body = unwrap_scraper_envelope(sample)
821
+ if not isinstance(body, dict):
822
+ return None
823
+ return frozenset(body.keys())
824
+
825
+
826
+ def _sample_keys_for(
827
+ endpoint_return_schemas: Dict[str, Any], op: Any
828
+ ) -> Optional[FrozenSet[str]]:
829
+ """The unwrapped sample top-level key-set for ``op``'s endpoint, or ``None``.
830
+
831
+ Parallel to ``_params_for`` — resolves the op's endpoint defensively (a
832
+ non-str / missing endpoint yields ``None``, the no-sample fail-safe) and
833
+ reads the captured ``return_schema`` for that endpoint through
834
+ ``_sample_top_keys``."""
835
+ endpoint = op.get("endpoint") if isinstance(op, dict) else None
836
+ if not isinstance(endpoint, str):
837
+ return None
838
+ return _sample_top_keys(endpoint_return_schemas.get(endpoint))
839
+
840
+
841
+ def _sample_item_rows(return_schema: Any, items_path: Any) -> Optional[List[Any]]:
842
+ """The list of item rows under ``items_path`` in the ENVELOPE-UNWRAPPED
843
+ sample, or ``None`` when undecidable (no sample / no list under the key).
844
+
845
+ Mirrors the Step-0 census's ``_sample_item_rows`` EXACTLY (same unwrap, same
846
+ "list-or-None" rule) so the build-time eligibility decision reproduces the
847
+ documented 38-op population. ``None`` is the undecidable / no-dedup side."""
848
+ if not isinstance(return_schema, dict) or not isinstance(items_path, str):
849
+ return None
850
+ sample = return_schema.get("sample")
851
+ if sample is None:
852
+ return None
853
+ from parse_sdk._runtime import unwrap_scraper_envelope
854
+
855
+ body = unwrap_scraper_envelope(sample)
856
+ if not isinstance(body, dict):
857
+ return None
858
+ v = body.get(items_path)
859
+ return v if isinstance(v, list) else None
860
+
861
+
862
+ def _dedup_eligible(return_schema: Any, items_path: Any, keyed_by: Optional[str]) -> bool:
863
+ """Whether the op is SAMPLE-PROVEN dedup-eligible: the element is
864
+ keyed AND the captured sample has >=2 item rows, EVERY one of which carries a
865
+ present (non-null) ``keyed_by`` value, AND those values are all distinct.
866
+
867
+ Tightened (F-4): the prior rule counted only the PRESENT keys and required
868
+ >=2 distinct, so it ignored rows with a missing/null ``keyed_by`` — a sample
869
+ like ``[{id:1},{title:x},{id:2}]`` read as "proven unique" off two keys while
870
+ a third row was unkeyed. That contradicts "sample-PROVEN-unique": a row whose
871
+ key the sample never showed is NOT proven uniquely identifiable, so emitting
872
+ dedup risks collapsing such rows by their id-hash fallback. Now require the
873
+ count of non-null keyed values to equal the TOTAL item-row count (every row
874
+ keyed — a non-dict row counts against the total and disqualifies) AND all
875
+ distinct. This is strictly MORE fail-safe (the plan's mandate).
876
+
877
+ Identity is compared by canonical JSON (``sort_keys``, ``default=str``) so
878
+ unhashable / non-primitive key values still compare. Keyless / no-sample /
879
+ <2-row / any-unkeyed-row / non-unique all return ``False`` (the fail-safe
880
+ NO-dedup disposition)."""
881
+ if not isinstance(keyed_by, str) or not keyed_by:
882
+ return False
883
+ rows = _sample_item_rows(return_schema, items_path)
884
+ if rows is None or len(rows) < 2:
885
+ return False
886
+ vals = [r.get(keyed_by) for r in rows if isinstance(r, dict)]
887
+ present = [v for v in vals if v is not None]
888
+ distinct = {json.dumps(v, sort_keys=True, default=str) for v in present}
889
+ # EVERY row must be keyed (present == total rows) AND keys all distinct. A
890
+ # non-dict row never contributes to ``present``, so it fails this equality.
891
+ return len(present) == len(rows) and len(distinct) == len(present)
892
+
893
+
894
+ def _dedup_eligible_for(
895
+ endpoint_return_schemas: Dict[str, Any],
896
+ op: Any,
897
+ pg: Optional[Dict[str, Any]],
898
+ element_keyed_by: Dict[str, Optional[str]],
899
+ elem: str,
900
+ ) -> bool:
901
+ """Resolve the dedup-eligibility for ``op`` at its call site (parallel to
902
+ ``_sample_keys_for``). Defensive endpoint resolution; reads the element's
903
+ ``keyed_by`` from the threaded ``{allocated_element: keyed_by}`` map (the
904
+ element resource's key is NOT in the collection emitter's scope, so it must
905
+ be supplied via this map — round-2 fix)."""
906
+ endpoint = op.get("endpoint") if isinstance(op, dict) else None
907
+ if not isinstance(endpoint, str) or not isinstance(pg, dict):
908
+ return False
909
+ items_path = pg.get("items_path")
910
+ keyed_by = element_keyed_by.get(elem)
911
+ return _dedup_eligible(endpoint_return_schemas.get(endpoint), items_path, keyed_by)
912
+
913
+
914
+ def error_class_names(schema: Dict[str, Any]) -> Dict[str, str]:
915
+ """``{error spec key → ALLOCATED class name}`` — what docgen's README
916
+ "Errors" section renders so it names the importable class even when the
917
+ module allocator suffixed a collision. Same normalize-then-allocate path
918
+ as ``render_module``/``op_call_descriptors`` (a standalone READ; no
919
+ emitted-source path is touched)."""
920
+ return dict(_ModuleNames(_normalize_schema(schema)).errors)
921
+
922
+
923
+ def endpoint_description_map(schema: Dict[str, Any]) -> Dict[str, str]:
924
+ """``{endpoint_name: description}`` — the single join rule for an
925
+ operation's narrative.
926
+
927
+ An operation's narrative is the ``description`` of the endpoint it calls.
928
+ Keyed on ``endpoints[].name`` (the shape both consumers pass: codegen
929
+ builds this after ``_normalize_schema`` folds raw ``endpoint_name`` → ``name``;
930
+ docgen reads the served ``SDKSchema`` whose endpoints already carry ``name``).
931
+ Both the docstring renderer (codegen) and the README "Description" column
932
+ (docgen) resolve this map by ``op["endpoint"]``. Only endpoints carrying a
933
+ non-empty ``description`` appear, so a missing/blank description degrades to
934
+ no narrative rather than an empty cell keyed on ``None``."""
935
+ out: Dict[str, str] = {}
936
+ for ep in schema.get("endpoints", []) or []:
937
+ if not isinstance(ep, dict):
938
+ continue
939
+ name = ep.get("name")
940
+ desc = ep.get("description")
941
+ if isinstance(name, str) and isinstance(desc, str) and desc.strip():
942
+ out[name] = desc.strip()
943
+ return out
944
+
945
+
946
+ def _param_decl(ident: str, anno: str, required: bool) -> str:
947
+ """Render one signature parameter declaration.
948
+
949
+ Required → ``ident: anno`` (no default, so pyright flags omission).
950
+ Optional → ``ident: Optional[anno] = None`` (bare ``ident: Any = None``
951
+ when ``anno`` is ``Any`` — ``Optional[Any]`` is redundant noise).
952
+ """
953
+ if required:
954
+ return f"{ident}: {anno}"
955
+ if anno == "Any":
956
+ return f"{ident}: Any = None"
957
+ return f"{ident}: Optional[{anno}] = None"
958
+
959
+
960
+ def _extract_field_spec(value: Any) -> Tuple[str, Optional[str], Optional[str]]:
961
+ """Pull (type_label, enum_binding, alias_from) out of a field declaration.
962
+
963
+ Backward-compatible: a bare string is treated as ``{type: <string>}``.
964
+ The dict form supports:
965
+ * ``{"type": "str", "enum": "Condition"}`` — field is typed Condition
966
+ * ``{"type": "datetime", "alias_from": "created_utc"}`` — wire-name override
967
+ """
968
+ if isinstance(value, str):
969
+ return value, None, None
970
+ if isinstance(value, dict):
971
+ type_label = value.get("type") or "Any"
972
+ enum_binding = value.get("enum")
973
+ enum_binding = _as_str(enum_binding)
974
+ alias_from = value.get("alias_from")
975
+ alias_from = _as_str(alias_from)
976
+ return type_label, enum_binding, alias_from
977
+ return "Any", None, None
978
+
979
+
980
+ def _field_description(value: Any) -> Optional[str]:
981
+ """The optional per-field ``description`` (dict-form FieldSpec only).
982
+
983
+ A bare-string field (``"int"``) has none. The dict form may carry a
984
+ ``description`` alongside ``type``/``enum``/``alias_from``; codegen
985
+ otherwise ignores it, so this is its one read. Returns a stripped
986
+ non-empty string or ``None``.
987
+ """
988
+ if isinstance(value, dict):
989
+ d = value.get("description")
990
+ if isinstance(d, str) and d.strip():
991
+ return d.strip()
992
+ return None
993
+
994
+
995
+ def _op_has_self_pin(op: Dict[str, Any]) -> bool:
996
+ """Whether ``op`` has any pin that references ``$self`` — i.e. a value that
997
+ can only be resolved from a live resource INSTANCE (``$self.name`` →
998
+ ``self._parent.name`` on a collection). Such an op cannot be emitted on a
999
+ resource's ROOT collection (``_parent`` is always None there → it would
1000
+ crash with ``AttributeError`` at call time); it lives only on the
1001
+ resource-nav path (``reddit.subreddit("x").search_posts(...)``).
1002
+
1003
+ This is the SINGLE routing invariant: an op is an instance method IFF it has
1004
+ a ``$self`` pin, and a root-collection method IFF it does NOT — the two emit
1005
+ sets are exact complements. A pinless op has no way to reference the instance
1006
+ (``$self`` is the spec's only instance-reference mechanism), so it is a
1007
+ lookup/factory that belongs on the collection by construction."""
1008
+ pins = op.get("pins")
1009
+ pins = _as_dict(pins)
1010
+ return any(isinstance(v, str) and v.startswith("$self") for v in pins.values())
1011
+
1012
+
1013
+ def _schema_has_paginated_op(resources: Dict[str, Any]) -> bool:
1014
+ """Whether ANY op in the (normalized) resources — top-level or sub-resource —
1015
+ declares a pagination scheme. Gates the emitted ``_extract_items`` import:
1016
+ the extraction helper is referenced ONLY inside a paginated ``_fetch_page`` body, so
1017
+ a non-paginated-only module must stay byte-identical (no unused import). One
1018
+ walk over the same resource/sub-resource/operations shape ``render_module``
1019
+ iterates."""
1020
+ def _ops_paginated(ops: Any) -> bool:
1021
+ if not isinstance(ops, dict):
1022
+ return False
1023
+ for opb in ops.values():
1024
+ if not isinstance(opb, dict):
1025
+ continue
1026
+ pg = opb.get("pagination")
1027
+ if isinstance(pg, dict) and pg.get("scheme"):
1028
+ return True
1029
+ return False
1030
+
1031
+ for rspec in resources.values():
1032
+ if not isinstance(rspec, dict):
1033
+ continue
1034
+ if _ops_paginated(rspec.get("operations")):
1035
+ return True
1036
+ for sub in (rspec.get("sub_resources") or {}).values():
1037
+ if isinstance(sub, dict) and _ops_paginated(sub.get("operations")):
1038
+ return True
1039
+ return False
1040
+
1041
+
1042
+ # Response-path fields the emitted ``_fetch_page`` resolves off ``_resp`` via the
1043
+ # dotted-aware helpers, keyed by scheme. ``items_path`` is read by EVERY scheme's
1044
+ # ``_emit_items``; the cursor / page metadata fields only by their own scheme.
1045
+ # This map is the SINGLE source the dotted-import gate and the per-op emit sites
1046
+ # both key on, so the emitted ``_walk`` import and its use can never diverge.
1047
+ _DOTTED_RESP_PATH_FIELDS_BY_SCHEME = {
1048
+ "single_page": ("items_path",),
1049
+ "time_cursor": ("items_path",),
1050
+ "cursor": ("items_path", "next_cursor_path", "has_more_path"),
1051
+ "page": ("items_path", "page_path", "total_pages_path"),
1052
+ }
1053
+
1054
+
1055
+ def _schema_has_dotted_pagination_path(resources: Dict[str, Any]) -> bool:
1056
+ """Whether ANY paginated op declares a DOTTED (``'a.b'``) value in a response-
1057
+ path field its scheme actually reads off ``_resp``. Gates the emitted
1058
+ ``_walk`` import EXACTLY: the dot-walk helper is referenced only at a dotted
1059
+ emit site, so a flat-path-only module stays byte-identical (no unused import,
1060
+ no behavior change). The frozen corpus has ZERO dotted paths, so this import
1061
+ is absent corpus-wide. Mirrors ``_schema_has_paginated_op``'s walk over the
1062
+ resource/sub-resource/operations shape."""
1063
+ def _is_dotted(v: Any) -> bool:
1064
+ return isinstance(v, str) and "." in v
1065
+
1066
+ def _ops_have_dotted(ops: Any) -> bool:
1067
+ if not isinstance(ops, dict):
1068
+ return False
1069
+ for opb in ops.values():
1070
+ if not isinstance(opb, dict):
1071
+ continue
1072
+ pg = opb.get("pagination")
1073
+ if not isinstance(pg, dict):
1074
+ continue
1075
+ scheme = pg.get("scheme")
1076
+ if not isinstance(scheme, str):
1077
+ continue
1078
+ fields = _DOTTED_RESP_PATH_FIELDS_BY_SCHEME.get(scheme)
1079
+ if fields and any(_is_dotted(pg.get(f)) for f in fields):
1080
+ return True
1081
+ return False
1082
+
1083
+ for rspec in resources.values():
1084
+ if not isinstance(rspec, dict):
1085
+ continue
1086
+ if _ops_have_dotted(rspec.get("operations")):
1087
+ return True
1088
+ for sub in (rspec.get("sub_resources") or {}).values():
1089
+ if isinstance(sub, dict) and _ops_have_dotted(sub.get("operations")):
1090
+ return True
1091
+ return False
1092
+
1093
+
1094
+ def _root_collection_emit_ops(operations: Any) -> List[str]:
1095
+ """The op names that render as ROOT-collection methods: PINLESS ops (a
1096
+ ``$self`` pin crashes on the root collection — ``_parent`` is None there) with
1097
+ a resolvable string ``endpoint`` (``_render_operation_method`` emits NOTHING
1098
+ for an endpoint-less op).
1099
+
1100
+ This is the SINGLE source the renderer, the docgen call-path, AND the
1101
+ collection-emit gate all derive from, so the gate and the renderer can't
1102
+ drift (a one-sided change would ``KeyError`` on the direct-indexed
1103
+ ``names.root_collections[res_name]``). It is a NECESSARY condition for a
1104
+ rendered method, not a sufficient one: an op that passes here can still fail
1105
+ to render — e.g. a ``List[X]`` return with no ``pagination`` block makes
1106
+ ``_render_operation_method`` raise ``CodegenError`` (fail-closed: the build
1107
+ aborts loudly rather than shipping a silent dead collection). The synthesized
1108
+ ``fetched_by`` ``.get`` is included automatically (``_normalize_schema``
1109
+ injects it as a pinless, endpoint'd op before this runs).
1110
+ """
1111
+ if not isinstance(operations, dict):
1112
+ return []
1113
+ return [
1114
+ n for n, b in operations.items()
1115
+ if isinstance(b, dict) and not _op_has_self_pin(b) and isinstance(b.get("endpoint"), str)
1116
+ ]
1117
+
1118
+
1119
+ def _constructible_unsafe_pin(spec: Dict[str, Any], keyed_by: str) -> Optional[str]:
1120
+ """Why a flagged-``constructible`` resource is NOT safe to hand-build, or
1121
+ ``None`` if it is safe.
1122
+
1123
+ A hand-built instance (``Resource(key=...)``) has ONLY ``keyed_by`` populated
1124
+ — every other field is ``None``. So any op reachable from it that pins a
1125
+ NON-key field of the instance silently sends ``None``. The resource is safely
1126
+ constructible iff every ``$self``-derived pin — in its OWN ops AND in its
1127
+ sub-resources' ops — references only ``keyed_by``.
1128
+
1129
+ Pin resolution mirrors ``_resolve_param_value`` + the collection remap
1130
+ (see codegen below): in a SUB-resource (collection) op, codegen rewrites BOTH
1131
+ ``$self.<f>`` and ``$self.parent.<f>`` onto the owning resource
1132
+ (``self._parent.<f>``) — so in a sub-resource op a bare ``$self.<f>``
1133
+ references the PARENT's field, identical to ``$self.parent.<f>``. Bare
1134
+ ``$self`` / ``$self.parent`` reference the key itself (safe).
1135
+
1136
+ Returns the first offending ``"resource_field"`` (for an observability
1137
+ warning), else ``None``.
1138
+ """
1139
+
1140
+ def _instance_field(value: Any, *, sub: bool) -> Optional[str]:
1141
+ """The owning-resource field a ``$self`` pin reads, or ``None`` when it
1142
+ reads the key (safe) or isn't a ``$self`` pin (irrelevant)."""
1143
+ if not isinstance(value, str):
1144
+ return None
1145
+ s = value.strip()
1146
+ if not s.startswith("$self"):
1147
+ return None
1148
+ if sub:
1149
+ # Every $self pin in a sub-resource op targets the PARENT.
1150
+ if s in ("$self", "$self.parent"):
1151
+ return None # the parent's key — safe
1152
+ if s.startswith("$self.parent."):
1153
+ return s[len("$self.parent."):]
1154
+ return s[len("$self."):] # bare `$self.<f>` == `$self.parent.<f>` here
1155
+ # Own op: bare `$self` is the key (safe); `$self.<f>` reads field f.
1156
+ if s == "$self":
1157
+ return None
1158
+ return s[len("$self."):]
1159
+
1160
+ def _first_unsafe(ops: Any, *, sub: bool) -> Optional[str]:
1161
+ if not isinstance(ops, dict):
1162
+ return None
1163
+ for op in ops.values():
1164
+ if not isinstance(op, dict):
1165
+ continue
1166
+ pins = op.get("pins")
1167
+ if not isinstance(pins, dict):
1168
+ continue
1169
+ for v in pins.values():
1170
+ f = _instance_field(v, sub=sub)
1171
+ if f is not None and f != keyed_by:
1172
+ return f
1173
+ return None
1174
+
1175
+ offending = _first_unsafe(spec.get("operations"), sub=False)
1176
+ if offending is not None:
1177
+ return offending
1178
+ # Sub-resources are scanned ONE level deep — matching codegen, which renders
1179
+ # only one level of sub-resource ops. A doubly-nested sub-op is never emitted,
1180
+ # so it's unreachable from a hand-built instance and can't send None; if the
1181
+ # renderer ever recurses, this scan must recurse in lockstep.
1182
+ sub_resources = spec.get("sub_resources")
1183
+ if isinstance(sub_resources, dict):
1184
+ for sub_spec in sub_resources.values():
1185
+ if isinstance(sub_spec, dict):
1186
+ offending = _first_unsafe(sub_spec.get("operations"), sub=True)
1187
+ if offending is not None:
1188
+ return offending
1189
+ return None
1190
+
1191
+
1192
+ # ---------------------------------------------------------------------------
1193
+ # Module-level class-name allocator (P0.2)
1194
+ # ---------------------------------------------------------------------------
1195
+
1196
+
1197
+ class _ModuleNames:
1198
+ """One module namespace shared by the root client, resource classes, enum
1199
+ classes, error classes, and every sub-resource / root **collection** class.
1200
+
1201
+ Each emitter historically assumed its namespace was private, but they all
1202
+ land in one module ``__init__.py`` and one ``__all__``. Two resources that
1203
+ ``_camel``-collapse to one name (``Post`` + ``post``), an enum named the same
1204
+ as a resource, an error == a resource, an enum == an error, ``root_name`` ==
1205
+ a resource, or two sub-resources colliding — each silently dropped a class
1206
+ or duplicated ``__all__`` on HEAD. The remedy is correct-by-construction:
1207
+ allocate every module-level class name ONCE here, deterministic suffix on
1208
+ ANY cross-kind collision, and thread the allocated name everywhere it's
1209
+ referenced (class def, ``_RESOURCE_CLASSES`` keys, enum-binding rewrite,
1210
+ accessors, collection class names, AND the resolvability gate's
1211
+ ``emitted_resource_names``/``emitted_enum_names``).
1212
+
1213
+ Reservation order is fixed (resources → enums → errors → collections →
1214
+ root) so the result is deterministic. Resources are reserved first because
1215
+ they're the most cross-referenced (enum bindings, ``returns`` labels,
1216
+ ``_RESOURCE_CLASSES`` keys all key on the resource's allocated name).
1217
+ """
1218
+
1219
+ def __init__(self, schema: Dict[str, Any]) -> None:
1220
+ self._taken: Set[str] = set()
1221
+ # wire/spec key → allocated class name, per kind.
1222
+ self.resources: Dict[str, str] = {}
1223
+ self.enums: Dict[str, str] = {}
1224
+ self.errors: Dict[str, str] = {}
1225
+ # base-error name → allocated name (always the verbatim base name —
1226
+ # they're reserved FIRST, so no suffix unless a later base would dup,
1227
+ # which can't happen as the source tuple is unique).
1228
+ self.base_errors: Dict[str, str] = {}
1229
+ # (parent_spec_key, sub_spec_key) → collection class name.
1230
+ self.sub_collections: Dict[Tuple[str, str], str] = {}
1231
+ # resource spec key → root collection class name.
1232
+ self.root_collections: Dict[str, str] = {}
1233
+ # resource spec key → root collection ACCESSOR attr (the ``@property``
1234
+ # name on the root client, e.g. ``boxes``). Allocated in its OWN
1235
+ # namespace (root-client instance attrs, NOT the module class namespace)
1236
+ # so two resources whose plurals collide (``box``→``boxes`` and
1237
+ # ``boxe``→``boxes``) deterministically suffix instead of both emitting
1238
+ # ``@property def boxes`` (Python keeps the last → one AttributeErrors).
1239
+ self.root_collection_attrs: Dict[str, str] = {}
1240
+ self._root_attr_taken: Set[str] = set()
1241
+
1242
+ # Pre-reserve the runtime BASE-error names FIRST (before resources /
1243
+ # enums / errors). This ordering is load-bearing: it makes a domain
1244
+ # error whose spec-key camels to a base name (``parse_error`` →
1245
+ # ``ParseError``) deterministically suffix to ``ParseError_`` via the
1246
+ # same allocator, while the bare base name stays bound to the runtime
1247
+ # class. Threaded into ``__all__`` by ``all_exports()``.
1248
+ for be in _BASE_ERROR_EXPORTS:
1249
+ self.base_errors[be] = self._alloc(be)
1250
+
1251
+ resources = schema.get("resources")
1252
+ resources = _as_dict(resources)
1253
+ enums = schema.get("enums")
1254
+ enums = _as_dict(enums)
1255
+ errors = schema.get("errors")
1256
+ errors = _as_dict(errors)
1257
+
1258
+ for rk, rv in resources.items():
1259
+ if isinstance(rv, dict):
1260
+ self.resources[rk] = self._alloc(_camel(rk))
1261
+ for ek, ev in enums.items():
1262
+ if isinstance(ev, dict):
1263
+ self.enums[ek] = self._alloc(_camel(ek))
1264
+ for erk, erv in errors.items():
1265
+ if isinstance(erv, dict):
1266
+ self.errors[erk] = self._alloc(_camel(erk))
1267
+
1268
+ # Sub-resource + root collection class names share the SAME namespace —
1269
+ # derived from the resource's ALLOCATED name (not a fresh _camel) so a
1270
+ # suffixed resource's collections stay consistent with it.
1271
+ for rk, rv in resources.items():
1272
+ if not isinstance(rv, dict):
1273
+ continue
1274
+ res_cls = self.resources[rk]
1275
+ subs = rv.get("sub_resources")
1276
+ subs = _as_dict(subs)
1277
+ for sk, sv in subs.items():
1278
+ if isinstance(sv, dict):
1279
+ self.sub_collections[(rk, sk)] = self._alloc(
1280
+ f"{res_cls}{_camel(sk)}Collection"
1281
+ )
1282
+
1283
+ # Root name reserved AFTER resources so a ``root_name`` colliding with a
1284
+ # resource name is the one suffixed (the resource keeps the clean name,
1285
+ # since it's the more cross-referenced symbol).
1286
+ slug = schema.get("slug") or ""
1287
+ root_raw = schema.get("root_name") or _camel(slug)
1288
+ if not isinstance(root_raw, str) or not root_raw.isidentifier():
1289
+ root_raw = _camel(slug)
1290
+ self.root = self._alloc(root_raw)
1291
+
1292
+ # Root collection class names — only for resources WITH operations (the
1293
+ # only ones that get a root collection). Reserved last so a collection
1294
+ # name colliding with anything prior is suffixed.
1295
+ #
1296
+ # The emit gate DERIVES from the shared, named predicate
1297
+ # ``resource_surface.resource_surface_facts`` (the SAME function docgen's
1298
+ # ``_reachability_warnings`` and external consumers read), via
1299
+ # the ``emits_client_accessor`` fact. Only resources whose root collection
1300
+ # would have ≥1 real method get a collection (name + class + @property): an
1301
+ # instance-only resource (all ops $self-pinned) or one whose only pinless
1302
+ # op renders nothing would ship a dead ``client.<plural>`` accessor —
1303
+ # suppressed by construction. The fact == ``has_root_collection`` ==
1304
+ # ``bool(_root_collection_emit_ops(...))``, so this is behaviour-preserving
1305
+ # — it removes the parallel computation, not the suppression.
1306
+ from parse_sdk.resource_surface import resource_surface_facts
1307
+ _facts = resource_surface_facts(schema)
1308
+ for rk, rv in resources.items():
1309
+ if not isinstance(rv, dict):
1310
+ continue
1311
+ rfacts = _facts.get(rk)
1312
+ if rfacts is None or not rfacts.emits_client_accessor:
1313
+ continue
1314
+ res_cls = self.resources[rk]
1315
+ plural = _plural_attr(res_cls, rv)
1316
+ self.root_collections[rk] = self._alloc(f"{self.root}{_camel(plural)}Collection")
1317
+ self.root_collection_attrs[rk] = self._alloc_root_attr(plural)
1318
+
1319
+ def _alloc_root_attr(self, name: str) -> str:
1320
+ """Allocate a root-client accessor attr in its OWN namespace (distinct
1321
+ from the module class namespace ``_taken``). Deterministic suffix on a
1322
+ colliding plural so two resources never emit duplicate ``@property``s."""
1323
+ ident = name
1324
+ while ident in self._root_attr_taken:
1325
+ ident = f"{ident}_"
1326
+ self._root_attr_taken.add(ident)
1327
+ return ident
1328
+
1329
+ def _alloc(self, name: str) -> str:
1330
+ # Suffix ONLY the value-keywords (None/True/False), which ARE valid
1331
+ # identifiers and so slipped past the old `name.isidentifier()` short-
1332
+ # circuit and emitted `class None(...)` -> SyntaxError. Every other name
1333
+ # (clean ASCII, Unicode identifiers like `Café`) is preserved verbatim —
1334
+ # routing them through _safe_ident's ASCII-only regex would needlessly
1335
+ # mangle valid class names. _safe_ident appends `_` to keywords.
1336
+ if name.isidentifier() and name not in _PY_KEYWORDS_AND_BUILTINS:
1337
+ ident = name
1338
+ else:
1339
+ ident = _safe_ident(name, fallback="X")
1340
+ while ident in self._taken:
1341
+ ident = f"{ident}_"
1342
+ self._taken.add(ident)
1343
+ return ident
1344
+
1345
+ def resource_values(self) -> Set[str]:
1346
+ return set(self.resources.values())
1347
+
1348
+ def enum_values(self) -> Set[str]:
1349
+ return set(self.enums.values())
1350
+
1351
+ def all_exports(self) -> List[str]:
1352
+ """``__all__`` content: root + resources + enums + domain-errors + the
1353
+ re-exported runtime base errors, dup-free by construction (the allocator
1354
+ guarantees uniqueness across kinds; base errors were pre-reserved so
1355
+ their verbatim names are present and any colliding domain error is
1356
+ suffixed)."""
1357
+ out = [self.root]
1358
+ out.extend(self.resources.values())
1359
+ out.extend(self.enums.values())
1360
+ out.extend(self.errors.values())
1361
+ out.extend(self.base_errors.values())
1362
+ return out
1363
+
1364
+
1365
+ # ---------------------------------------------------------------------------
1366
+ # Spec walker — turns the SDKSchema dict into rendered .py source
1367
+ # ---------------------------------------------------------------------------
1368
+
1369
+
1370
+ def _safe_self_tail(tail: str, *, op: str = "") -> str:
1371
+ """Validate a ``$self`` / ``$self.parent`` pin tail as a dotted chain of
1372
+ Python identifiers, returning it verbatim.
1373
+
1374
+ A pin value is agent-inferred (non-authoritative) and the ``$self`` tail is
1375
+ interpolated into generated SOURCE — ``_params['x'] = self.<tail>`` — so a
1376
+ tail that is NOT a dotted-identifier path is an injection attempt, not a
1377
+ renderable spec (``request_flow`` is JSONB, so a stored ``\\n`` is a real
1378
+ newline at render time, turning ``id\\n <stmt>`` into a body statement).
1379
+ Every sibling value path is already neutralized (``$arg`` via ``_safe_ident``,
1380
+ literals via ``repr``); this closes the ``$self`` hole.
1381
+
1382
+ Fail-closed (``CodegenError``) on a hostile tail — matching codegen's
1383
+ contract for a spec the gate should have rejected (``parse sync`` skip+warns
1384
+ that one API; the preview surfaces it). Empty tail is allowed (a bare
1385
+ ``$self`` / ``$self.parent`` reference — the base object itself)."""
1386
+ t = tail.strip()
1387
+ if t == "":
1388
+ return t
1389
+ # Each segment must be an identifier AND not a Python keyword: ``isidentifier()``
1390
+ # is True for keywords (``"class".isidentifier()`` → True), but ``self.class``
1391
+ # is a SyntaxError that fails the whole module import — reject it here.
1392
+ if not all(seg.isidentifier() and not keyword.iskeyword(seg) for seg in t.split(".")):
1393
+ where = f" in op {op!r}" if op else ""
1394
+ raise CodegenError(
1395
+ f"pin value $self tail {tail!r}{where} is not a dotted-identifier path "
1396
+ f"(gate should reject; refusing to interpolate into source)"
1397
+ )
1398
+ return t
1399
+
1400
+
1401
+ def _resolve_param_value(
1402
+ expr: Any,
1403
+ *,
1404
+ self_var: str = "self",
1405
+ op: str = "",
1406
+ ) -> str:
1407
+ """Turn a pin/takes value-spec into a Python expression string.
1408
+
1409
+ ``$self`` -> ``self`` (the resource itself; serialized to its key)
1410
+ ``$self.field`` -> ``self.field``
1411
+ ``$arg.name`` -> ``name`` (the method's local arg)
1412
+ ``$self.parent`` -> ``self._parent`` (the owning resource itself)
1413
+ ``$self.parent.field`` -> ``self._parent.field`` (one-level walk)
1414
+ Anything else -> repr() of the literal (str/int/bool/...).
1415
+
1416
+ ``$self`` tails are validated as dotted-identifier paths (``_safe_self_tail``)
1417
+ so a hostile pin can't inject statements into the emitted method body.
1418
+ """
1419
+ if not isinstance(expr, str):
1420
+ return repr(expr)
1421
+ s = expr.strip()
1422
+ # Bare forms (no trailing dot): handle BEFORE the dotted-prefix checks so they
1423
+ # don't fall through to the repr() literal (``$self`` → ``'$self'``) or to the
1424
+ # wrong ``$self.`` arm (``$self.parent`` → ``self.parent`` instead of
1425
+ # ``self._parent``). Must agree with ``_op_has_self_pin`` (startswith "$self"),
1426
+ # which routes such pins as instance methods.
1427
+ if s == "$self":
1428
+ return self_var
1429
+ if s == "$self.parent":
1430
+ return f"{self_var}._parent"
1431
+ if s.startswith("$self.parent."):
1432
+ tail = _safe_self_tail(s[len("$self.parent."):], op=op)
1433
+ return f"{self_var}._parent.{tail}" if tail else f"{self_var}._parent"
1434
+ if s.startswith("$self."):
1435
+ tail = _safe_self_tail(s[len("$self."):], op=op)
1436
+ return f"{self_var}.{tail}" if tail else self_var
1437
+ if s.startswith("$arg."):
1438
+ return _safe_ident(s[len("$arg."):])
1439
+ # Literal
1440
+ return repr(s)
1441
+
1442
+
1443
+ def _enum_members(
1444
+ spec: Dict[str, Any], *, enum_name: str = ""
1445
+ ) -> List[Tuple[str, str, Optional[str]]]:
1446
+ """Ordered ``(IDENT, wire_code, display)`` for an enum spec — the single
1447
+ source of an enum's member identity, shared by ``_render_enum`` (the class
1448
+ body) and ``_param_annotation`` (a ``closed`` param's ``Literal[...]``).
1449
+
1450
+ IDENT is ``_safe_ident(variant).upper()``, deterministically suffixed on a
1451
+ collision (two variants collapsing to one ident would ``TypeError`` at
1452
+ import). ``wire_code`` is the declared ``code`` (or ``IDENT.lower()`` when
1453
+ absent); an exact collision fails closed with ``CodegenError`` (a synthesized
1454
+ suffix would be a dead member matching no wire value) — so a ``closed``
1455
+ param's ``Literal`` lists exactly the values the generated enum carries.
1456
+ """
1457
+ values = spec.get("values") if isinstance(spec, dict) else None
1458
+ if not isinstance(values, dict) or not values:
1459
+ return []
1460
+ out: List[Tuple[str, str, Optional[str]]] = []
1461
+ seen_idents: Set[str] = set()
1462
+ seen_codes: Set[str] = set()
1463
+ for variant, body in values.items():
1464
+ ident = _safe_ident(variant, fallback="VALUE").upper()
1465
+ base_ident = ident
1466
+ n = 2
1467
+ while ident in seen_idents:
1468
+ ident = f"{base_ident}_{n}"
1469
+ n += 1
1470
+ seen_idents.add(ident)
1471
+ if not isinstance(body, dict):
1472
+ body = {}
1473
+ code = body.get("code")
1474
+ if not isinstance(code, str):
1475
+ code = ident.lower()
1476
+ if code in seen_codes:
1477
+ # Fail closed: a synthesized suffix ('a_2') matches no wire value —
1478
+ # a dead member that a closed Literal would then advertise. Corpus
1479
+ # has 0 collisions (2026-06-10 replay); the canonicalizer str-dedupes
1480
+ # codes, so only author-written duplicates can reach this.
1481
+ named = f"enum {enum_name!r}" if enum_name else "enum"
1482
+ raise CodegenError(
1483
+ f"{named} declares duplicate wire code {code!r} (variant {variant!r}); "
1484
+ "give each variant a distinct `code`"
1485
+ )
1486
+ c = code
1487
+ seen_codes.add(c)
1488
+ disp = body.get("display")
1489
+ disp = _as_str(disp)
1490
+ out.append((ident, c, disp))
1491
+ return out
1492
+
1493
+
1494
+ def _render_enum(name: str, spec: Dict[str, Any]) -> str:
1495
+ """Emit an ``enum.Enum`` subclass. Values use ``code`` if provided,
1496
+ else the variant identifier. ``display`` lands on a ``.display``
1497
+ property for nicer __str__."""
1498
+ values = spec.get("values") if isinstance(spec, dict) else {}
1499
+ if not isinstance(values, dict) or not values:
1500
+ return f"class {name}(str, enum.Enum):\n pass"
1501
+ # Case-uniqueness on the RAW declared codes, BEFORE the per-member exact
1502
+ # dedup in ``_enum_members``: two codes differing only by case ('Score' vs
1503
+ # 'score') would both lower()-collide on the wire. Fail closed (corpus has 0
1504
+ # such cases) rather than silently suffix-dropping one — a closed projection
1505
+ # (D5) reads these codes directly, so a case-collision would silently corrupt
1506
+ # the contract.
1507
+ raw_codes = [
1508
+ b["code"] for b in values.values()
1509
+ if isinstance(b, dict) and isinstance(b.get("code"), str)
1510
+ ]
1511
+ lowered = [c.lower() for c in raw_codes]
1512
+ if len(set(lowered)) != len(lowered):
1513
+ raise CodegenError(
1514
+ f"enum {name!r} declares codes colliding under case-fold: {sorted(raw_codes)}"
1515
+ )
1516
+ lines = [f"class {name}(str, enum.Enum):"]
1517
+ displays: Dict[str, str] = {}
1518
+ for ident, c, disp in _enum_members(spec, enum_name=name):
1519
+ lines.append(f" {ident} = {c!r}")
1520
+ if disp:
1521
+ displays[ident] = disp
1522
+ if displays:
1523
+ lines.append("")
1524
+ lines.append(" @property")
1525
+ lines.append(" def display(self) -> str:")
1526
+ lines.append(" return _DISPLAYS_" + name + ".get(self.name, self.name)")
1527
+ lines.append("")
1528
+ lines.append(f"_DISPLAYS_{name} = {{")
1529
+ for k, v in displays.items():
1530
+ lines.append(f" {k!r}: {v!r},")
1531
+ lines.append("}")
1532
+ return "\n".join(lines)
1533
+
1534
+
1535
+ def _render_error(
1536
+ name: str, spec: Dict[str, Any], input_param_union: Optional[Set[str]] = None
1537
+ ) -> str:
1538
+ """Emit a typed exception subclass of ParseError with the declared
1539
+ ``carries`` fields as keyword-only init args.
1540
+
1541
+ Also emits ``_CARRY_PARAMS: {attr_ident: wire_param_name}`` for every carry
1542
+ whose name appears in ``input_param_union`` (the schema-wide union of all
1543
+ endpoints' ``input_params`` keys). The runtime's ``_build_error`` hydrates
1544
+ those attrs from the failing call's REQUEST PARAMS — errors are
1545
+ module-global, so there is no owning operation in scope here and the union
1546
+ is the correct (widest-sound) source set. A carry outside the union stays a
1547
+ typed init arg but is omitted from the map: it hydrates to None by design.
1548
+ """
1549
+ when = spec.get("when") if isinstance(spec, dict) else None
1550
+ carries = spec.get("carries") if isinstance(spec, dict) else None
1551
+ if not isinstance(carries, list):
1552
+ carries = []
1553
+ doc = when or f"Raised when the API returns a {name} condition."
1554
+
1555
+ init_args = ["status_code: int = 0", "message: str = ''", "body: Any = None"]
1556
+ body_lines = ["super().__init__(status_code, message, body=body)"]
1557
+ # Sanitize ``carries``: each carry becomes an init arg appended to the fixed
1558
+ # ``[status_code, message, body]``. A carry colliding with a reserved arg
1559
+ # name, or an internal duplicate (after sanitize), would emit
1560
+ # ``SyntaxError: duplicate argument`` at import — and a carry named after
1561
+ # any ParseError BASE attribute (``meta``, ``code``, ``snippet``,
1562
+ # ``upstream_status_code``, ``retry_after``) would be silently clobbered by
1563
+ # the runtime's post-construction finishing, so the reserve set covers the
1564
+ # full base attribute surface. Dedup with a deterministic suffix.
1565
+ reserved_carry: Set[str] = {
1566
+ "self", "status_code", "message", "body",
1567
+ "meta", "retry_after", "code", "snippet", "upstream_status_code",
1568
+ }
1569
+ taken_carry: Set[str] = set()
1570
+ carry_params: List[Tuple[str, str]] = []
1571
+ for c in carries:
1572
+ ident = _allocate_ident(
1573
+ str(c), taken_carry, fallback="extra", reserved=reserved_carry
1574
+ )
1575
+ init_args.append(f"{ident}: Any = None")
1576
+ body_lines.append(f"self.{ident} = {ident}")
1577
+ if input_param_union and str(c) in input_param_union:
1578
+ carry_params.append((ident, str(c)))
1579
+ sig = ", ".join(["self"] + init_args)
1580
+ body = "\n ".join(body_lines)
1581
+ carry_map_line = ""
1582
+ if carry_params:
1583
+ entries = ", ".join(f"{a!r}: {w!r}" for a, w in carry_params)
1584
+ carry_map_line = (
1585
+ f" _CARRY_PARAMS: ClassVar[Dict[str, str]] = {{{entries}}}\n"
1586
+ )
1587
+ return (
1588
+ f"class {name}(_ParseError):\n"
1589
+ f"{_docstring(doc)}\n"
1590
+ f"\n"
1591
+ f"{carry_map_line}"
1592
+ f" def __init__({sig}) -> None:\n"
1593
+ f" {body}\n"
1594
+ )
1595
+
1596
+
1597
+ def _op_param_records(
1598
+ takes: Dict[str, Any],
1599
+ params_for_ep: Dict[str, Any],
1600
+ enum_alloc: Optional[Dict[str, str]] = None,
1601
+ enums: Optional[Dict[str, Any]] = None,
1602
+ ) -> Tuple[List[Tuple[str, str, bool, Optional[str]]], Dict[str, str]]:
1603
+ """Typed-kwarg signature records for one op: the SINGLE source consumed by
1604
+ the method emitter AND ``op_call_descriptors`` (README Call column), so the
1605
+ two cannot drift.
1606
+
1607
+ Param-ident allocation (P0.3): each ``takes`` KEY (method kwarg) becomes a
1608
+ signature param. A kwarg named after a runtime-structural ident (``self``
1609
+ → ``def search(self, self: …)`` SyntaxError; ``_api`` etc.) or two kwargs
1610
+ sanitizing to one ident must be suffixed, not emitted raw. The
1611
+ ``{method_arg → unique ident}`` map is allocated ONCE so the signature loop
1612
+ AND the body's ``_params`` loop read the SAME idents. ``limit`` is NOT
1613
+ reserved here: a non-paginated op may legitimately take a user-facing
1614
+ ``limit`` kwarg; the synthetic paginator ``limit`` is added by callers only
1615
+ when absent.
1616
+
1617
+ Each record is ``(ident, decl_str, required, description)``, typed by
1618
+ joining the ``takes`` VALUE (endpoint param name) against ``params_for_ep``
1619
+ and sorted required-first ONCE — signature decl order and docstring Args
1620
+ order both derive from it, so the two always match. Expects the
1621
+ paginator-swallowed param to be stripped from ``takes`` by the caller.
1622
+ """
1623
+ param_reserved = _runtime_reserved_member_names()
1624
+ param_taken: Set[str] = set()
1625
+ param_idents: Dict[str, str] = {}
1626
+ for method_arg in takes.keys():
1627
+ param_idents[method_arg] = _allocate_ident(
1628
+ str(method_arg), param_taken, fallback="arg", reserved=param_reserved
1629
+ )
1630
+ records: List[Tuple[str, str, bool, Optional[str]]] = []
1631
+ for method_arg, endpoint_param in takes.items():
1632
+ ident = param_idents[method_arg]
1633
+ info = params_for_ep.get(endpoint_param)
1634
+ anno = _param_annotation(info, enum_alloc, enums)
1635
+ required = bool(isinstance(info, dict) and info.get("required"))
1636
+ desc = info.get("description") if isinstance(info, dict) else None
1637
+ records.append(
1638
+ (ident, _param_decl(ident, anno, required), required,
1639
+ _as_str(desc))
1640
+ )
1641
+ records.sort(key=lambda r: 0 if r[2] else 1)
1642
+ return records, param_idents
1643
+
1644
+
1645
+ def _render_operation_method(
1646
+ op_name: str,
1647
+ op: Dict[str, Any],
1648
+ *,
1649
+ parent_kind: str, # "resource", "collection", "root"
1650
+ params_for_ep: Optional[Dict[str, Any]] = None,
1651
+ method: str = "POST",
1652
+ method_ident: Optional[str] = None,
1653
+ endpoint_description: Optional[str] = None,
1654
+ enum_alloc: Optional[Dict[str, str]] = None,
1655
+ enums: Optional[Dict[str, Any]] = None,
1656
+ element_keyed_by: Optional[Dict[str, Optional[str]]] = None,
1657
+ endpoint_return_schemas: Optional[Dict[str, Any]] = None,
1658
+ resource_field_labels: Optional[Dict[str, Dict[str, str]]] = None,
1659
+ ) -> str:
1660
+ """Emit one method on a resource / collection / root client.
1661
+
1662
+ Signature comes from ``takes`` (method kwarg → endpoint param name),
1663
+ typed by joining each kwarg's endpoint param (the VALUE in ``takes``)
1664
+ against ``params_for_ep`` — the ``input_params`` map of the op's
1665
+ endpoint. ``pins`` are pre-bound from $self / $arg / literals.
1666
+ Return type comes from ``returns``; pagination wraps in a Paginator
1667
+ when the op declares a ``pagination`` block.
1668
+
1669
+ Typed-param join (this is the M1 change): each kwarg's annotation comes
1670
+ from ``_param_annotation(params_for_ep[endpoint_param])``; required
1671
+ params (``ParamSpec.required``) emit with no default and sort first.
1672
+ The join degrades to ``Any`` on a missing endpoint / param / metadata,
1673
+ so a thin or malformed spec still renders a working (untyped) method.
1674
+ """
1675
+ endpoint = op.get("endpoint")
1676
+ if not isinstance(endpoint, str):
1677
+ return "" # no-op stub; safer than emitting a broken method
1678
+ # Authoring-time gate: this name is a URL path segment in every emitted
1679
+ # ``_request`` call. The runtime sink quotes it anyway; failing here gives
1680
+ # the agent the actionable signal at draft time, not a 404 at run time.
1681
+ _require_wire_segment(endpoint, "endpoint name")
1682
+ returns = _coerce_type_label(op.get("returns"))
1683
+ takes = op.get("takes")
1684
+ takes = _as_dict(takes)
1685
+ pins = op.get("pins")
1686
+ pins = _as_dict(pins)
1687
+ params_for_ep = _as_dict(params_for_ep)
1688
+ element_keyed_by = _as_dict(element_keyed_by)
1689
+ endpoint_return_schemas = _as_dict(endpoint_return_schemas)
1690
+ # Derived here, not threaded in: every caller passed exactly this — the
1691
+ # op's unwrapped-sample top-level key-set (used by _emit_items' thin
1692
+ # projection). Resolving internally drops a redundant pre-resolution param.
1693
+ sample_keys = _sample_keys_for(endpoint_return_schemas, op)
1694
+
1695
+ # --- Pagination normalization (one decision point) ---------------------
1696
+ # Every list operation is normalized to a single ``scheme`` and rendered
1697
+ # through one ``_fetch_page`` pipeline. The codegen reads the RAW
1698
+ # ``pagination`` dict keys (the cross-repo contract defined canonically in
1699
+ # the parse-core ``PaginationSpec`` model — codegen does not import it).
1700
+ # * structured block present → its declared ``scheme``
1701
+ # (single_page/cursor/page/time_cursor)
1702
+ # * a collection return (``List[X]`` / ``Optional[List[X]]``) with NO
1703
+ # block → fail-closed (``CodegenError``): the gate should have rejected
1704
+ # it; codegen refuses to guess the response shape (no legacy fallback).
1705
+ # * non-collection return → ``None`` (single-resource return, unchanged)
1706
+ _pg_raw = op.get("pagination")
1707
+ pg: Optional[Dict[str, Any]] = _pg_raw if isinstance(_pg_raw, dict) else None
1708
+ is_collection = _is_collection_label(returns)
1709
+ elem = _collection_element(returns)
1710
+ scheme = pg.get("scheme") if pg else None
1711
+ if is_collection and scheme is None:
1712
+ raise CodegenError(
1713
+ f"op {op_name!r} (endpoint {endpoint!r}) returns {returns!r} but has "
1714
+ f"no pagination block (gate should reject; refusing to guess)"
1715
+ )
1716
+
1717
+ # Envelope-return pagination: when ``returns`` is a NON-collection resource
1718
+ # (an envelope) but the op paginates via an ``items_path`` naming one of that
1719
+ # resource's ``List[X]`` fields, the paginator yields ELEMENTS ``X`` — not the
1720
+ # envelope. ``_collection_element`` leaves a bare resource label unchanged, so
1721
+ # without this each row coerces to the envelope husk (whose fields are absent
1722
+ # on a row → ``AttributeError`` on element access). Resolve the element from
1723
+ # the envelope's items_path field type; unresolvable → unchanged (fail-safe).
1724
+ if scheme is not None and not is_collection and resource_field_labels:
1725
+ _items_path = pg.get("items_path") if pg else None
1726
+ if isinstance(_items_path, str) and _items_path and "." not in _items_path:
1727
+ _field_label = resource_field_labels.get(returns, {}).get(_items_path)
1728
+ if isinstance(_field_label, str) and _is_collection_label(_field_label):
1729
+ elem = _collection_element(_field_label)
1730
+
1731
+ # The paginator OWNS the pagination ``request_param``: the scheme arms below
1732
+ # inject/override that wire param on every advance, so a ``takes`` entry
1733
+ # bound to the same endpoint param would emit a user-facing kwarg the
1734
+ # paginator immediately fights (pages 2+ override it; a leaked ``page``/
1735
+ # ``cursor`` kwarg can only skew page 1). Strip such entries from the
1736
+ # emitted surface — the signature, docstring, and body loops below all
1737
+ # derive from ``takes``. Matched on the ENDPOINT-param side (takes maps
1738
+ # method_kwarg → endpoint_param; the kwarg may be renamed). ``single_page``
1739
+ # declares no request_param, so manual-paging takes on a single_page op
1740
+ # (e.g. an explicit offset param) survive untouched.
1741
+ _paginator_param = pg.get("request_param") if pg else None
1742
+ if _paginator_param:
1743
+ takes = {k: v for k, v in takes.items() if v != _paginator_param}
1744
+
1745
+ # Signature records — SINGLE-SOURCED with op_call_descriptors via
1746
+ # ``_op_param_records`` so the README "Call" column cannot drift from the
1747
+ # emitted signature (same consolidation precedent as D4's member
1748
+ # allocation).
1749
+ records, param_idents = _op_param_records(takes, params_for_ep, enum_alloc, enums)
1750
+ arg_names: List[str] = [r[0] for r in records]
1751
+ arg_decls: List[str] = ["self"] + [r[1] for r in records]
1752
+ arg_descs: List[Tuple[str, Optional[str]]] = [(r[0], r[3]) for r in records]
1753
+ has_cap_arg = False
1754
+ has_max_fetches_arg = False
1755
+ if scheme is not None:
1756
+ # Add a synthetic ``limit`` arg to cap the paginator's TOTAL yield at the
1757
+ # call site (optional, trails the typed params). But when the op ALREADY
1758
+ # takes a wire param named ``limit`` (a PER-PAGE size, not a total cap),
1759
+ # do NOT reuse it as the cap: wiring the per-page size into the
1760
+ # Paginator's total-yield ceiling made ``search(limit=N)`` yield N items
1761
+ # and STOP, so the client could never reach page 2. In that case no cap
1762
+ # arg is added and the Paginator gets ``limit=None`` (see the emit below).
1763
+ if "limit" not in arg_names:
1764
+ arg_decls.append("limit: Optional[int] = None")
1765
+ has_cap_arg = True
1766
+ if "max_fetches" not in arg_names:
1767
+ arg_decls.append("max_fetches: int = 1000")
1768
+ has_max_fetches_arg = True
1769
+
1770
+ # Body — build the params dict from takes + pins, call request, coerce.
1771
+ body_lines: List[str] = []
1772
+ body_lines.append("_params: Dict[str, Any] = {}")
1773
+ for method_arg, endpoint_param in takes.items():
1774
+ arg_ident = param_idents[method_arg]
1775
+ body_lines.append(f"if {arg_ident} is not None: _params[{endpoint_param!r}] = {arg_ident}")
1776
+ for endpoint_param, value_expr in pins.items():
1777
+ py_expr = _resolve_param_value(value_expr, self_var="self", op=op_name)
1778
+ # On generated Collection methods, ``self`` is the collection handle and
1779
+ # ``self._parent`` is the resource exposing that collection. The corpus
1780
+ # uses both ``$self.X`` and ``$self.parent.X`` for that owning resource
1781
+ # (e.g. ``post.comments.list`` pins ``post_id`` from ``Post.id``), so the
1782
+ # collection fixup intentionally keeps both spellings on ``self._parent``.
1783
+ _ve = value_expr.strip() if isinstance(value_expr, str) else value_expr
1784
+ if parent_kind == "collection" and _ve in ("$self", "$self.parent"):
1785
+ py_expr = "self._parent"
1786
+ elif (
1787
+ parent_kind == "collection"
1788
+ and isinstance(_ve, str)
1789
+ and _ve.startswith("$self.")
1790
+ and not _ve.startswith("$self.parent.")
1791
+ ):
1792
+ tail = _safe_self_tail(_ve[len("$self."):], op=op_name)
1793
+ py_expr = f"self._parent.{tail}" if tail else "self._parent"
1794
+ body_lines.append(f"_params[{endpoint_param!r}] = {py_expr}")
1795
+
1796
+ # The actual call — through the root API
1797
+ api_attr = "self._api" if parent_kind in ("collection", "resource") else "self"
1798
+ body_lines.append("")
1799
+
1800
+ if scheme is not None:
1801
+ # One ``_fetch_page(_cursor) -> (items, next_cursor)`` pipeline. The
1802
+ # per-scheme arm sets ``items``/``nxt``; the Paginator stops on
1803
+ # ``nxt is None``. Item coercion is shared (``elem`` is the element
1804
+ # type for a ``List[X]`` return, else the bare ``returns`` label).
1805
+ # ``dedup_arg`` is appended to the SHARED ``_Paginator(...)`` emit below;
1806
+ # only the ``time_cursor`` arm sets it (its re-seed-by-boundary advance
1807
+ # can repeat the boundary item on an inclusive-boundary API).
1808
+ assert pg is not None # scheme is not None ⟹ a pagination block is present
1809
+ # Each known scheme dereferences certain spec fields unguarded; a missing
1810
+ # one would emit silently-broken code (a None ``items_path`` →
1811
+ # ``if None not in _resp`` always raises ``PaginationError``; a None
1812
+ # ``cursor_from_field`` → first-page-only truncation). Fail loud at render
1813
+ # so ``parse sync`` skips+warns the op instead of promoting a broken
1814
+ # module — mirroring the unrecognized-scheme backstop below.
1815
+ _required = {
1816
+ "single_page": ("items_path",),
1817
+ "cursor": ("items_path", "request_param", "next_cursor_path"),
1818
+ "page": ("items_path", "request_param"),
1819
+ "time_cursor": ("items_path", "request_param", "cursor_from_field"),
1820
+ }.get(scheme, ())
1821
+ _missing = [f for f in _required if not pg.get(f)]
1822
+ if _missing:
1823
+ raise CodegenError(
1824
+ f"pagination scheme {scheme!r} for op {endpoint!r} is missing required "
1825
+ f"field(s) {_missing}; codegen refuses to emit a broken paginator"
1826
+ )
1827
+ # time_cursor advances by reading ``cursor_from_field`` off each item, so
1828
+ # the element MUST be a Resource (the emitted body dereferences
1829
+ # ``_coerced[-1]._FIELD_ALIASES`` and ``items[-1].get(...)``). A scalar /
1830
+ # Any element has neither — reject at render rather than emit code that
1831
+ # AttributeErrors at the user's runtime (correct-by-construction: a
1832
+ # cursor-on-a-scalar spec is incoherent, not a runtime contingency).
1833
+ if scheme == "time_cursor" and elem in _KNOWN_FIELD_LEAF_LABELS:
1834
+ raise CodegenError(
1835
+ f"pagination scheme 'time_cursor' for op {endpoint!r} returns a non-Resource "
1836
+ f"element {elem!r}; cursor_from_field {pg.get('cursor_from_field')!r} has no "
1837
+ f"field to read from"
1838
+ )
1839
+
1840
+ dedup_arg = ""
1841
+ # Identity-dedup (page/cursor only): emit ``dedup=True,
1842
+ # identity_dedup=True`` ONLY where the captured sample PROVES the element
1843
+ # key unique (>=2 item rows, all keys present + distinct — the Step-0
1844
+ # ``dedup_eligible`` bucket, ~38 ops corpus-wide). Every other page/cursor
1845
+ # op (keyless / no-sample / <2-row / non-unique) gets NO identity-dedup —
1846
+ # it relies on the loud content-stall guard + the keyless _page_signature
1847
+ # fix (runtime arm). The element's ``keyed_by`` is supplied via the
1848
+ # ``element_keyed_by`` map because it is NOT in the collection emitter's
1849
+ # scope (the element resource's key, not the owning collection's).
1850
+ if scheme in ("cursor", "page") and _dedup_eligible_for(
1851
+ endpoint_return_schemas, op, pg, element_keyed_by, elem
1852
+ ):
1853
+ dedup_arg = ", dedup=True, identity_dedup=True"
1854
+ body_lines.append("def _fetch_page(_cursor: Optional[Any]):")
1855
+ body_lines.append(" _p = dict(_params)")
1856
+
1857
+ if scheme == "single_page":
1858
+ # The whole response is one page: extract the declared ``items_path``
1859
+ # array and never advance. Yields exactly one page through a
1860
+ # ``Paginator[X]``. NEVER coerce the raw dict (that path reaches
1861
+ # ``_unwrap_single_list``); extract the declared key verbatim.
1862
+ items_path: Any = pg.get("items_path")
1863
+ body_lines.append(f" _resp = {api_attr}._request({method!r}, {endpoint!r}, _p)")
1864
+ body_lines += _emit_items(items_path, sample_keys)
1865
+ # Per-element paginator rows are SEPARATE top-level _coerce
1866
+ # calls (NOT List[X] recursion) at mode='element' — a bad/husk row
1867
+ # degrades to raw, never aborts the page. The strict return verdict
1868
+ # is reserved for the single-resource mode='return' site.
1869
+ body_lines.append(f" return ([_coerce(_i, {elem!r}, self, mode='element') for _i in items], None)")
1870
+
1871
+ elif scheme == "cursor":
1872
+ request_param: Any = pg.get("request_param")
1873
+ items_path: Any = pg.get("items_path")
1874
+ next_cursor_path = pg.get("next_cursor_path")
1875
+ has_more_path = pg.get("has_more_path")
1876
+ body_lines.append(f" if _cursor is not None: _p[{request_param!r}] = _cursor")
1877
+ body_lines.append(f" _resp = {api_attr}._request({method!r}, {endpoint!r}, _p)")
1878
+ body_lines += _emit_items(items_path, sample_keys)
1879
+ # Re-coerce _resp to a dict AFTER extraction so the cursor/has_more
1880
+ # ``.get()`` calls below survive a top-level-list / None body (a list
1881
+ # has no ``.get``). _extract_items already pulled the items from the
1882
+ # raw body; these reads only mine cursor metadata, absent on a list.
1883
+ body_lines.append(" _resp = _resp if isinstance(_resp, dict) else {}")
1884
+ body_lines.append(f" nxt = {_resp_path_expr(next_cursor_path)}")
1885
+ if has_more_path:
1886
+ body_lines.append(f" if {_resp_path_expr(has_more_path)} is False: nxt = None")
1887
+ body_lines.append(" if not items: nxt = None")
1888
+ # Per-element rows at mode='element' (lenient degrade-to-raw).
1889
+ body_lines.append(f" return ([_coerce(_i, {elem!r}, self, mode='element') for _i in items], nxt)")
1890
+
1891
+ elif scheme == "page":
1892
+ request_param: Any = pg.get("request_param")
1893
+ items_path: Any = pg.get("items_path")
1894
+ page_path = pg.get("page_path")
1895
+ total_pages_path = pg.get("total_pages_path")
1896
+ # Base page = the request_param's declared integer ``default`` (0 for
1897
+ # zero-based bwb/HN, 1 for one-based). The gate (Task 13) requires a
1898
+ # declared integer default; codegen defaults to 1 only as a last
1899
+ # resort. Sending the wrong base silently skips the first page.
1900
+ _base = (params_for_ep.get(request_param) or {})
1901
+ _base = _base.get("default") if isinstance(_base, dict) else None
1902
+ _base = _base if isinstance(_base, int) else 1
1903
+ body_lines.append(f" _page = _cursor if isinstance(_cursor, int) else {_base!r}")
1904
+ body_lines.append(f" _p[{request_param!r}] = _page")
1905
+ body_lines.append(f" _resp = {api_attr}._request({method!r}, {endpoint!r}, _p)")
1906
+ body_lines += _emit_items(items_path, sample_keys)
1907
+ # Re-coerce _resp to a dict AFTER extraction so the page/total ``.get()``
1908
+ # calls below survive a top-level-list / None body (a list has no
1909
+ # ``.get``). _extract_items already pulled the items.
1910
+ body_lines.append(" _resp = _resp if isinstance(_resp, dict) else {}")
1911
+ if total_pages_path:
1912
+ body_lines.append(f" try: _total = int({_resp_path_expr(total_pages_path)})")
1913
+ body_lines.append(" except (TypeError, ValueError): _total = None")
1914
+ else:
1915
+ body_lines.append(" _total = None")
1916
+ if page_path:
1917
+ body_lines.append(f" try: _cur = int({_resp_path_expr(page_path, '_page')})")
1918
+ body_lines.append(" except (TypeError, ValueError): _cur = None")
1919
+ else:
1920
+ body_lines.append(" _cur = _page")
1921
+ # Base-relative termination: a zero-based API on its last page reports
1922
+ # _cur = _base + (_total - 1), so (_cur - _base + 1) >= _total stops
1923
+ # after the last page regardless of base (no off-by-one over-fetch).
1924
+ body_lines.append(" if not items: nxt = None")
1925
+ body_lines.append(f" elif _total is not None and _cur is not None and (_cur - {_base!r} + 1) >= _total: nxt = None")
1926
+ body_lines.append(" else: nxt = (_cur + 1) if _cur is not None else (_page + 1)")
1927
+ # Per-element rows at mode='element' (lenient degrade-to-raw).
1928
+ body_lines.append(f" return ([_coerce(_i, {elem!r}, self, mode='element') for _i in items], nxt)")
1929
+
1930
+ elif scheme == "time_cursor":
1931
+ request_param: Any = pg.get("request_param")
1932
+ items_path: Any = pg.get("items_path")
1933
+ cursor_from_field = pg.get("cursor_from_field")
1934
+ # Dedup-by-identity: an inclusive-boundary API repeats the boundary
1935
+ # item across pages; the runtime drops already-seen items (by
1936
+ # _Resource identity) and stops on a 0-new page. ``dedup=True`` is a
1937
+ # literal — no keyed_by threading needed (identity eq/hash key on
1938
+ # _KEY_FIELD already).
1939
+ dedup_arg = ", dedup=True"
1940
+ body_lines.append(f" if _cursor is not None: _p[{request_param!r}] = _cursor")
1941
+ body_lines.append(f" _resp = {api_attr}._request({method!r}, {endpoint!r}, _p)")
1942
+ body_lines += _emit_items(items_path, sample_keys)
1943
+ # Per-element rows at mode='element' (lenient degrade-to-raw).
1944
+ body_lines.append(f" _coerced = [_coerce(_i, {elem!r}, self, mode='element') for _i in items]")
1945
+ # Re-seed from the RAW wire value, not the coerced attribute: a
1946
+ # coercing cursor field (datetime/Decimal/…) would otherwise send the
1947
+ # coerced object back to the API (e.g. a datetime isoformatted onto an
1948
+ # integer param). Resolve cursor_from_field → wire key via the element's
1949
+ # _FIELD_ALIASES (same `.get(name, name)` _from_payload uses), so an
1950
+ # aliased field reads its wire key, not the python name.
1951
+ body_lines.append(" _last = _coerced[-1] if _coerced else None")
1952
+ body_lines.append(" if isinstance(_last, _Resource) and isinstance(items[-1], dict):")
1953
+ body_lines.append(
1954
+ f" nxt = items[-1].get(_last._FIELD_ALIASES.get({cursor_from_field!r}, {cursor_from_field!r}))"
1955
+ )
1956
+ body_lines.append(" else:")
1957
+ body_lines.append(" nxt = None")
1958
+ body_lines.append(" return (_coerced, nxt)")
1959
+
1960
+ else:
1961
+ # An unrecognized scheme reached codegen — fail-closed rather than
1962
+ # fall back to ``_coerce(_resp, "List[X]")`` (that path reaches
1963
+ # ``_unwrap_single_list`` and silently single-pages). The gate
1964
+ # rejects unknown schemes pre-render; this is the codegen backstop.
1965
+ raise CodegenError(
1966
+ f"unrecognized pagination scheme {scheme!r} on op {op_name!r} "
1967
+ f"(endpoint {endpoint!r}) — known schemes: single_page, cursor, "
1968
+ f"page, time_cursor"
1969
+ )
1970
+
1971
+ # A wire ``limit`` param is a per-page size, NOT the paginator's total
1972
+ # cap — emit ``limit=None`` so it cannot truncate the stream at one page
1973
+ # (Fix B). Only the synthetic cap arg (added above when no wire ``limit``)
1974
+ # bounds the total yield.
1975
+ _cap = "limit" if has_cap_arg else "None"
1976
+ max_fetches_expr = "max_fetches" if has_max_fetches_arg else "1000"
1977
+ body_lines.append(
1978
+ f"return _Paginator(_fetch_page, limit={_cap}, max_fetches={max_fetches_expr}{dedup_arg}, _api={api_attr})"
1979
+ )
1980
+ # Emit _Paginator[Element] so `for x in api.things.search()` widens
1981
+ # the loop variable to the element type, not Any. The element comes
1982
+ # from the ``List[X]`` / ``Optional[List[X]]`` return (unified to X);
1983
+ # for non-collection returns we fall back to bare _Paginator. The
1984
+ # reserved alias guards against a domain resource named ``Paginator``
1985
+ # shadowing the runtime base.
1986
+ # ``elem`` is an agent-authored label reaching an ANNOTATION position
1987
+ # (``_Paginator[{elem}]``) raw — gate it the SAME way fields are
1988
+ # (`_is_well_formed_annotation`) so a hostile label (`List[X]: pass\n…`)
1989
+ # degrades to a bare ``_Paginator`` instead of injecting source. (The
1990
+ # ``_coerce(_i, {elem!r}, …)`` use is repr'd, already safe.)
1991
+ if is_collection and _is_well_formed_annotation(elem):
1992
+ return_anno = f"_Paginator[{elem}]"
1993
+ else:
1994
+ return_anno = "_Paginator"
1995
+ else:
1996
+ body_lines.append(f"_resp = {api_attr}._request({method!r}, {endpoint!r}, _params)")
1997
+ # A single-resource return crosses the return-type contract boundary,
1998
+ # so emit mode='return' — the strict husk/scalar fail-loud verdicts fire
1999
+ # here (and ONLY in regenerated modules; old 3-arg callers keep the
2000
+ # lenient default). List[X] / Dict / Optional inner recursion flips back
2001
+ # to boundary=False inside coerce(), so element disposition stays lenient.
2002
+ body_lines.append(f"return _coerce(_resp, {returns!r}, self, mode='return')")
2003
+ # ``returns`` reaches the ``-> {return_anno}:`` annotation position raw;
2004
+ # gate it like fields so a hostile label degrades to ``Any`` rather than
2005
+ # breaking out of the annotation and injecting a class-body statement
2006
+ # (``from __future__ import annotations`` does NOT protect — the payload
2007
+ # injects a sibling statement, it never evaluates the annotation). The
2008
+ # ``_coerce(_resp, {returns!r}, …)`` use is repr'd, already safe.
2009
+ return_anno = returns if _is_well_formed_annotation(returns) else "Any"
2010
+
2011
+ sig = ", ".join(arg_decls)
2012
+ indented_body = "\n ".join(body_lines)
2013
+ # The method name is allocated by the caller's per-resource member namespace
2014
+ # (P0.4) when provided; collections allocate their own (no field/sub clash).
2015
+ method_name = method_ident or _safe_ident(op_name, fallback="call")
2016
+ docstring = _render_method_docstring(op, arg_descs, endpoint_description)
2017
+ return (
2018
+ f" def {method_name}({sig}) -> {return_anno}:\n"
2019
+ f"{docstring}"
2020
+ f" {indented_body}\n"
2021
+ )
2022
+
2023
+
2024
+ def _render_method_docstring(
2025
+ op: Dict[str, Any],
2026
+ arg_descs: List[Tuple[str, Optional[str]]],
2027
+ endpoint_description: Optional[str] = None,
2028
+ ) -> str:
2029
+ """Build the method docstring block (indented, trailing newline).
2030
+
2031
+ Carries the operation's narrative — the ``description`` of the endpoint it
2032
+ calls (resolved by the caller via ``endpoint_description_map`` and passed in
2033
+ as ``endpoint_description``) — plus a Google-style ``Args:`` block listing
2034
+ each typed param's ``description``. A literal op ``description`` (legacy;
2035
+ gate-rejected for new specs) still takes precedence; otherwise the endpoint
2036
+ description is the single source. This is the non-brittle home for the prose
2037
+ the typed signature intentionally drops. Returns ``""`` when there is
2038
+ nothing to document.
2039
+ """
2040
+ summary = op.get("description") or endpoint_description
2041
+ summary = summary.strip() if isinstance(summary, str) else ""
2042
+ documented = [(ident, d.strip()) for ident, d in arg_descs if d and d.strip()]
2043
+ if not summary and not documented:
2044
+ return ""
2045
+
2046
+ lines: List[str] = []
2047
+ if summary:
2048
+ lines.append(summary)
2049
+ if documented:
2050
+ if summary:
2051
+ lines.append("")
2052
+ lines.append("Args:")
2053
+ for ident, desc in documented:
2054
+ lines.append(f" {ident}: {desc}")
2055
+
2056
+ # Route through the shared safe-docstring helper (block form + backslash /
2057
+ # triple-quote escaping) so no emitter hand-builds a docstring literal.
2058
+ return _docstring("\n".join(lines), indent=" ") + "\n"
2059
+
2060
+
2061
+ def _render_resource_class(
2062
+ name: str,
2063
+ spec: Dict[str, Any],
2064
+ *,
2065
+ res_key: str,
2066
+ names: "_ModuleNames",
2067
+ enum_alloc: Optional[Dict[str, str]] = None,
2068
+ enums: Optional[Dict[str, Any]] = None,
2069
+ endpoint_input_params: Optional[Dict[str, Dict[str, Any]]] = None,
2070
+ endpoint_methods: Optional[Dict[str, str]] = None,
2071
+ endpoint_descriptions: Optional[Dict[str, str]] = None,
2072
+ endpoint_return_schemas: Optional[Dict[str, Any]] = None,
2073
+ element_keyed_by: Optional[Dict[str, Optional[str]]] = None,
2074
+ resource_names: Optional[Set[str]] = None,
2075
+ enum_names: Optional[Set[str]] = None,
2076
+ resource_field_labels: Optional[Dict[str, Dict[str, str]]] = None,
2077
+ ) -> str:
2078
+ """Emit a Resource subclass with fields, operations, and sub-resource
2079
+ property accessors.
2080
+
2081
+ ``res_key`` is the resource's spec key; ``names`` is the module-level
2082
+ name allocator (P0.2) — the sub-resource collection class names are read
2083
+ from it so a ``_camel`` collision between two sub-resources doesn't emit a
2084
+ duplicate class + duplicate property.
2085
+
2086
+ ``endpoint_input_params`` is the ``{endpoint_name: input_params}`` lookup
2087
+ threaded from ``render_module`` so each operation method can type its
2088
+ kwargs from the matching endpoint's params. ``endpoint_methods`` is the
2089
+ parallel ``{endpoint_name: HTTP method}`` lookup so each method emits the
2090
+ real verb.
2091
+
2092
+ ``resource_names`` / ``enum_names`` are the FULL emitted (ALLOCATED) name
2093
+ sets for the module — the field-annotation resolvability gate validates
2094
+ every field's annotation against them, degrading an unresolvable or hostile
2095
+ label to ``Any``. They derive from the allocator's VALUES (not a fresh
2096
+ ``_camel`` comprehension) so a suffixed resource/enum doesn't silently
2097
+ degrade a referencing field to ``Any``.
2098
+
2099
+ Member-namespace reservation (P0.4): fields, operation methods, and
2100
+ sub-resource accessors all land on ONE class namespace. A field, an op, and
2101
+ a sub-resource sharing a name (``comments``) would emit three same-named
2102
+ members — Python keeps the last, the others' behavior is wrong. We allocate
2103
+ one per-resource member namespace, seeded with the runtime-structural
2104
+ reserved names, with precedence sub-resource > operation > field (the loser
2105
+ is suffixed; a suffixed FIELD keeps its wire read via ``_FIELD_ALIASES``).
2106
+ """
2107
+ endpoint_input_params = endpoint_input_params or {}
2108
+ endpoint_methods = endpoint_methods or {}
2109
+ endpoint_descriptions = endpoint_descriptions or {}
2110
+ endpoint_return_schemas = endpoint_return_schemas or {}
2111
+ element_keyed_by = element_keyed_by or {}
2112
+ resource_names = resource_names or set()
2113
+ enum_names = enum_names or set()
2114
+ enum_alloc = enum_alloc or {}
2115
+ fields = spec.get("fields")
2116
+ fields = _as_dict(fields)
2117
+ keyed_by = spec.get("keyed_by")
2118
+ keyed_by = _as_str(keyed_by)
2119
+ constructible = bool(spec.get("constructible"))
2120
+ operations = spec.get("operations")
2121
+ operations = _as_dict(operations)
2122
+ sub_resources = spec.get("sub_resources")
2123
+ sub_resources = _as_dict(sub_resources)
2124
+
2125
+ reserved = _runtime_reserved_member_names()
2126
+ # ONE member namespace for this class — shared single source with
2127
+ # ``op_call_descriptors`` (which previously re-ran these loops by hand,
2128
+ # "byte-for-byte"). Precedence contract lives in ``_member_allocation``.
2129
+ sub_attrs, op_idents, field_idents = _member_allocation(
2130
+ operations, sub_resources, fields, reserved=reserved
2131
+ )
2132
+ # Insertion order of ``op_idents`` IS the emit order (dicts preserve it).
2133
+ emitted_ops: List[str] = list(op_idents)
2134
+
2135
+ # Walk fields once — extract type label (with enum override), aliases. Record
2136
+ # the real wire name in _FIELD_ALIASES whenever the allocated ident differs
2137
+ # from the wire name (a sanitize/collision/reserved suffix), so _from_payload
2138
+ # reads the wire key.
2139
+ typed_fields: Dict[str, str] = {}
2140
+ aliases: Dict[str, str] = {}
2141
+ for fname, fval in fields.items():
2142
+ type_label, enum_binding, alias_from = _extract_field_spec(fval)
2143
+ # An enum-bound field is OPEN — ``Enum | str`` (D2): output values are
2144
+ # observed, not contract-bound, so tolerant coercion maps an observed
2145
+ # value to the member and keeps an unobserved-but-valid value RAW rather
2146
+ # than type-rejecting it. Under single-source coercion the runtime
2147
+ # resolves this annotation; the resolvability gate below degrades a
2148
+ # DANGLING enum binding (no matching emitted enum) to Any.
2149
+ if enum_binding:
2150
+ # Resolve the binding to the enum's ALLOCATED name (a collision
2151
+ # suffix can move it off ``_camel(enum_binding)``); the
2152
+ # resolvability gate keys on allocated names, so an unresolved
2153
+ # binding would wrongly degrade to ``Any``.
2154
+ enum_class = enum_alloc.get(_camel(enum_binding), _camel(enum_binding))
2155
+ typed_fields[fname] = f"{enum_class} | str"
2156
+ else:
2157
+ typed_fields[fname] = _coerce_type_label(type_label)
2158
+ ident = field_idents[fname]
2159
+ # Explicit alias wins; else if the ident was suffixed/sanitized away from
2160
+ # the wire name, record the wire name so the value isn't lost.
2161
+ if alias_from:
2162
+ aliases[ident] = alias_from
2163
+ elif ident != fname:
2164
+ aliases[ident] = fname
2165
+
2166
+ # Flat-inheritance invariant (paired with ``Resource._field_hints``'s
2167
+ # own-annotations filter): every resource class derives DIRECTLY from
2168
+ # ``_Resource`` — single-level. There is no ``extends``/``base`` spec key
2169
+ # that would introduce a parent resource, so the runtime's own-class hint
2170
+ # filter is correct-by-construction and needs no MRO walk. Assert the spec
2171
+ # carries no such key at the single emission site, so a future spec-contract
2172
+ # addition can't silently break the filter's assumption.
2173
+ if "extends" in spec or "base" in spec:
2174
+ # A bare assert is stripped under ``-O`` — this guard must survive it.
2175
+ raise CodegenError(
2176
+ "resource spec introduced inheritance (extends/base) — the flat-base "
2177
+ "invariant in Resource._field_hints no longer holds; add an MRO walk"
2178
+ )
2179
+ lines: List[str] = []
2180
+ lines.append(f"class {name}(_Resource):")
2181
+ # Class docstring — the durable home for per-field reference text. PEP-526
2182
+ # bare annotations (``title: str``) can't carry a runtime attribute
2183
+ # docstring, so documented fields are collected into a Google-style
2184
+ # ``Fields:`` block here (read order = spec order). Emitted only when at
2185
+ # least one field declares a ``description``; routed through the shared
2186
+ # safe-docstring helper. It is the FIRST statement in the class body, so a
2187
+ # field annotation can still follow it (a docstring + annotations is valid).
2188
+ _doc_fields = [(field_idents[fn], _field_description(fv))
2189
+ for fn, fv in fields.items()]
2190
+ _doc_fields = [(ident, d) for ident, d in _doc_fields if d]
2191
+ if _doc_fields:
2192
+ _cls_lines = ["Fields:"]
2193
+ for ident, desc in _doc_fields:
2194
+ _cls_lines.append(f" {ident}: {desc}")
2195
+ lines.append(_docstring("\n".join(_cls_lines), indent=" "))
2196
+ # PEP-526 field annotations are the SINGLE per-field type source: they
2197
+ # drive both static analysis (pyright sees ``story.title: str``) AND
2198
+ # runtime per-field coercion (``get_type_hints`` at first construction).
2199
+ #
2200
+ # Each annotation is gated for resolvability + injection: an unresolvable
2201
+ # name (dangling enum/resource ref, incl. an unresolvable inner name) or a
2202
+ # hostile/composite-malformed label degrades to ``Any`` so the module
2203
+ # imports and the runtime resolution never NameError-poisons the class.
2204
+ for fname in fields.keys():
2205
+ ident = field_idents[fname]
2206
+ anno = _field_annotation(
2207
+ typed_fields[fname], resource_names=resource_names, enum_names=enum_names
2208
+ )
2209
+ lines.append(f" {ident}: {anno}")
2210
+ lines.append(f" _RESOURCE_NAME = {name!r}")
2211
+ # keyed_by ∈ fields invariant: under single-source coercion, __init__ only
2212
+ # sets annotated fields, so a _KEY_FIELD naming an ABSENT field points at a
2213
+ # never-set attribute → pagination false-positives + broken eq/hash/factory.
2214
+ # Emit None when the key isn't a declared field. Test MEMBERSHIP in the
2215
+ # shared field-ident map (not truthiness) so a declared key whose wire name
2216
+ # sanitizes to empty (``""``) still resolves to its real allocated ident.
2217
+ # A container-typed key (List/Dict/…, even Optional-wrapped) is demoted to
2218
+ # value-object semantics (``_KEY_FIELD = None``): hashing a container key
2219
+ # raises ``TypeError`` at runtime (_runtime ``__hash__``), and a container
2220
+ # is not an identity. An ``Optional[scalar]`` key KEEPS its ``_KEY_FIELD``
2221
+ # — the runtime identity-key guard skips identity on null values per
2222
+ # instance instead of demoting the whole resource.
2223
+ _kb_is_container = (
2224
+ keyed_by is not None
2225
+ and keyed_by in typed_fields
2226
+ and _is_container_field_label(typed_fields[keyed_by])
2227
+ )
2228
+ if keyed_by is not None and keyed_by in field_idents and not _kb_is_container:
2229
+ lines.append(f" _KEY_FIELD = {field_idents[keyed_by]!r}")
2230
+ else:
2231
+ lines.append(" _KEY_FIELD = None")
2232
+ if aliases:
2233
+ alias_lit = "{" + ", ".join(
2234
+ f"{k!r}: {v!r}" for k, v in aliases.items()
2235
+ ) + "}"
2236
+ lines.append(f" _FIELD_ALIASES = {alias_lit}")
2237
+ if constructible:
2238
+ lines.append(" _CONSTRUCTIBLE = True")
2239
+
2240
+ # Instance methods — pinless (lookup/factory) ops were already filtered out
2241
+ # of ``emitted_ops`` (so their member slots weren't reserved).
2242
+ for op_name in emitted_ops:
2243
+ op_body = operations[op_name]
2244
+ method_src = _render_operation_method(
2245
+ op_name, op_body,
2246
+ parent_kind="resource",
2247
+ params_for_ep=_params_for(endpoint_input_params, op_body),
2248
+ method=_method_for(endpoint_methods, op_body),
2249
+ endpoint_description=_desc_for(endpoint_descriptions, op_body),
2250
+ method_ident=op_idents[op_name],
2251
+ enum_alloc=enum_alloc,
2252
+ enums=enums,
2253
+ element_keyed_by=element_keyed_by,
2254
+ endpoint_return_schemas=endpoint_return_schemas,
2255
+ resource_field_labels=resource_field_labels,
2256
+ )
2257
+ if method_src:
2258
+ lines.append("")
2259
+ lines.append(method_src.rstrip())
2260
+
2261
+ # Sub-resource properties — each backs a per-class Collection subclass whose
2262
+ # name comes from the module allocator (dup-safe across colliding subs).
2263
+ for sub_name, sub_body in sub_resources.items():
2264
+ if not isinstance(sub_body, dict):
2265
+ continue
2266
+ coll_class_name = names.sub_collections[(res_key, sub_name)]
2267
+ attr = sub_attrs[sub_name]
2268
+ lines.append("")
2269
+ lines.append(f" @property")
2270
+ lines.append(f" def {attr}(self) -> '{coll_class_name}':")
2271
+ lines.append(f" _stash = getattr(self, '_sub_{attr}', None)")
2272
+ lines.append(f" if _stash is None:")
2273
+ lines.append(f" _stash = {coll_class_name}(_api=self._api, _parent=self)")
2274
+ lines.append(f" object.__setattr__(self, '_sub_{attr}', _stash)")
2275
+ lines.append(f" return _stash")
2276
+
2277
+ return "\n".join(lines)
2278
+
2279
+
2280
+ def _collection_op_idents(
2281
+ op_names: List[str], *, reserved: Set[str]
2282
+ ) -> Dict[str, str]:
2283
+ """``{op name → unique method ident}`` for one collection class, so two ops
2284
+ whose names ``_safe_ident``-collapse don't emit duplicate methods (Python
2285
+ keeps the last). Reserves the runtime-structural ``_Collection`` members."""
2286
+ taken: Set[str] = set()
2287
+ out: Dict[str, str] = {}
2288
+ for op_name in op_names:
2289
+ out[op_name] = _allocate_ident(op_name, taken, fallback="call", reserved=reserved)
2290
+ return out
2291
+
2292
+
2293
+ @functools.lru_cache(maxsize=1)
2294
+ def _collection_reserved() -> Set[str]:
2295
+ # Cached like _runtime_reserved_member_names: constant per process,
2296
+ # previously recomputed per rendered collection. Read-only for callers.
2297
+ from parse_sdk._runtime import Collection as _Collection
2298
+
2299
+ return set(dir(_Collection)) | {"self", "_api", "_parent"}
2300
+
2301
+
2302
+ def _render_collection(
2303
+ coll_class_name: str,
2304
+ emit_ops: List[str],
2305
+ operations: Dict[str, Any],
2306
+ *,
2307
+ endpoint_input_params: Optional[Dict[str, Dict[str, Any]]] = None,
2308
+ endpoint_methods: Optional[Dict[str, str]] = None,
2309
+ endpoint_descriptions: Optional[Dict[str, str]] = None,
2310
+ endpoint_return_schemas: Optional[Dict[str, Any]] = None,
2311
+ element_keyed_by: Optional[Dict[str, Optional[str]]] = None,
2312
+ enum_alloc: Optional[Dict[str, str]] = None,
2313
+ enums: Optional[Dict[str, Any]] = None,
2314
+ resource_field_labels: Optional[Dict[str, Dict[str, str]]] = None,
2315
+ ) -> str:
2316
+ """Emit ONE Collection subclass — the single emitter behind both the
2317
+ sub-resource collections (``PostCommentsCollection``: every dict-valued
2318
+ op) and the root collections (``RedditPostsCollection``: the pinless
2319
+ ``_root_collection_emit_ops`` single source). The caller owns the emit-op
2320
+ selection; everything below it — ident allocation, method rendering, the
2321
+ empty-body ``pass`` fallback — is identical by construction instead of
2322
+ maintained in two near-duplicate renderers.
2323
+
2324
+ ``coll_class_name`` is the module-allocated class name (dup-safe across
2325
+ colliding sub-resources / a root collision with anything prior)."""
2326
+ endpoint_input_params = endpoint_input_params or {}
2327
+ endpoint_methods = endpoint_methods or {}
2328
+ endpoint_descriptions = endpoint_descriptions or {}
2329
+ endpoint_return_schemas = endpoint_return_schemas or {}
2330
+ element_keyed_by = element_keyed_by or {}
2331
+ lines = [f"class {coll_class_name}(_Collection):"]
2332
+ op_idents = _collection_op_idents(emit_ops, reserved=_collection_reserved())
2333
+ emitted = False
2334
+ for op_name in emit_ops:
2335
+ method_src = _render_operation_method(
2336
+ op_name, operations[op_name],
2337
+ parent_kind="collection",
2338
+ params_for_ep=_params_for(endpoint_input_params, operations[op_name]),
2339
+ method=_method_for(endpoint_methods, operations[op_name]),
2340
+ endpoint_description=_desc_for(endpoint_descriptions, operations[op_name]),
2341
+ method_ident=op_idents[op_name],
2342
+ enum_alloc=enum_alloc,
2343
+ enums=enums,
2344
+ element_keyed_by=element_keyed_by,
2345
+ endpoint_return_schemas=endpoint_return_schemas,
2346
+ resource_field_labels=resource_field_labels,
2347
+ )
2348
+ if method_src:
2349
+ lines.append(method_src.rstrip())
2350
+ emitted = True
2351
+ # Empty body when every op was skipped (no ops, or — root case — all
2352
+ # $self-pinned) → ``pass`` keeps the class syntactically valid.
2353
+ if not emitted:
2354
+ lines.append(" pass")
2355
+ return "\n".join(lines)
2356
+
2357
+
2358
+ # ONLY genuinely-irregular plurals belong here — ones the closed suffix
2359
+ # heuristic in ``_pluralize`` cannot reproduce. Do NOT add convenience entries:
2360
+ # regulars like ``category→categories`` or ``site→sites`` are already produced by
2361
+ # the heuristic, and listing them here masks heuristic gaps and invites the
2362
+ # whack-a-mole ``_pluralize`` warns against. A resource needing a bespoke plural
2363
+ # declares ``plural`` in its spec.
2364
+ _IRREGULAR_PLURALS = {
2365
+ "person": "people",
2366
+ "child": "children",
2367
+ }
2368
+
2369
+
2370
+ def _pluralize(name: str) -> str:
2371
+ """Default accessor plural — a CLOSED heuristic. Do NOT add rules here:
2372
+ suffix rules are whack-a-mole (a broad ``-is`` rule garbles Iris/Tennis the
2373
+ way the old ``+es`` garbled Analysis). A resource needing a different
2374
+ plural declares ``plural`` in its spec (read by ``_plural_for``) — naming
2375
+ is spec-owned; this is only the fallback."""
2376
+ low = name.lower()
2377
+ if low in _IRREGULAR_PLURALS:
2378
+ return _IRREGULAR_PLURALS[low]
2379
+ if low.endswith("series"): # invariant: Series, TimeSeries, …
2380
+ return low
2381
+ if low.endswith(("sis", "xis")): # analysis→analyses, axis→axes
2382
+ return low[:-2] + "es"
2383
+ if low.endswith("z") and not low.endswith("zz"): # quiz→quizzes (jazz keeps +s)
2384
+ return low + "zes"
2385
+ if low.endswith("y") and len(low) > 1 and low[-2] not in "aeiou":
2386
+ return low[:-1] + "ies"
2387
+ if low.endswith(("s", "x", "ch", "sh")):
2388
+ return low + "es"
2389
+ return low + "s"
2390
+
2391
+
2392
+ def _plural_attr(res_cls: str, rspec: Any) -> str:
2393
+ """The attr-safe accessor plural for a resource: spec-owned override first
2394
+ (``plural`` on the resource spec), else the closed heuristic — sanitized
2395
+ through ``_safe_ident`` HERE (the single site) so an override can never
2396
+ inject an unsafe identifier and call sites can't drift on the wrapping.
2397
+
2398
+ The plural heuristic is fed ``_snake(res_cls)`` so a Pascal/camelCase class name
2399
+ pluralizes on a WORD boundary — ``BankAccount`` → ``bank_accounts``,
2400
+ ``ItemSummary`` → ``item_summaries``, ``Series`` → ``series`` (not the old
2401
+ boundary-less ``bankaccounts`` / ``serieses``). A spec-owned ``plural``
2402
+ override is verbatim (user-declared), so it bypasses ``_snake``. This is the
2403
+ SINGLE site behind all three accessor uses (the root-client collection attr,
2404
+ ``op_call_descriptors`` fallback, and ``_factory_rows`` fallback), so the
2405
+ three can never drift on the boundary fix.
2406
+ """
2407
+ p = rspec.get("plural") if isinstance(rspec, dict) else None
2408
+ raw = p.strip() if isinstance(p, str) and p.strip() else _pluralize(_snake(res_cls))
2409
+ return _safe_ident(raw.lower(), fallback="things")
2410
+
2411
+
2412
+ def _factory_key_annotation(res_spec: Dict[str, Any], keyed_by: str) -> str:
2413
+ """Annotation for a constructible resource's factory key param.
2414
+
2415
+ Types from the keyed_by field's declared type, gated to known scalar
2416
+ labels only (a constructible key is always a scalar). A composite /
2417
+ unknown label degrades to ``Any`` — never injected into the annotation,
2418
+ which a malformed value (``List[evil')]``) could otherwise turn into a
2419
+ module ``SyntaxError``.
2420
+ """
2421
+ fields = res_spec.get("fields")
2422
+ fields = _as_dict(fields)
2423
+ type_label, _enum, _alias = _extract_field_spec(fields.get(keyed_by))
2424
+ coerced = _coerce_type_label(type_label)
2425
+ if coerced in _KNOWN_PARAM_LABELS:
2426
+ return _annotation_label(coerced)
2427
+ return "Any"
2428
+
2429
+
2430
+ def _render_root_client(
2431
+ root_name: str,
2432
+ schema: Dict[str, Any],
2433
+ root_collections: List[Tuple[str, str]], # [(attr, coll_class_name), ...]
2434
+ root_factories: List[Tuple[str, str, str, str]], # [(attr, ResourceClass, key_ident, key_anno), ...]
2435
+ error_classes: Optional[List[Tuple[str, str]]] = None, # [(code, AllocatedClassName), ...]
2436
+ ) -> str:
2437
+ """Emit the root client class (Reddit / Craigslist / ...).
2438
+
2439
+ ``root_factories`` are no-network constructible-resource accessors —
2440
+ ``reddit.subreddit("learnmachinelearning")`` builds a ``Subreddit`` handle
2441
+ so the navigable idiom ``.subreddit(...).search_posts(...)`` works without
2442
+ a fetch. The key param is required + typed; construction uses keyword args
2443
+ (matching ``Resource.__init__(self, *, _api, _parent=None, **fields)``).
2444
+
2445
+ ``error_classes`` is ``[(wire_kind, AllocatedErrorClassName), ...]`` for
2446
+ every ``ErrorSpec`` that declared a ``code``. It is emitted as the
2447
+ ``_ERROR_CLASSES`` ClassVar (mirroring ``SCRAPER_ID``); ``_request`` forwards
2448
+ it to ``_build_error`` so a wire ``kind`` raises the domain error class. The
2449
+ gate guarantees each ``code`` is unique per API, so the dict is unambiguous.
2450
+ """
2451
+ # Cosmetic docstring text: a non-string description/name degrades to empty
2452
+ # rather than crashing (.strip() on a non-str) — don't skip a whole working
2453
+ # API over a malformed cosmetic field.
2454
+ _desc_src = schema.get("description") or schema.get("name") or ""
2455
+ desc = (_desc_src if isinstance(_desc_src, str) else "").strip()
2456
+ lines = [f"class {root_name}(_BaseAPI):"]
2457
+ if desc:
2458
+ # Route through the shared safe-docstring helper (block form) so a
2459
+ # description ending in a bare ``"`` / backslash can't produce an
2460
+ # unterminated literal that fails the whole module's import (P0.1).
2461
+ lines.append(_docstring(desc))
2462
+ lines.append(f" SCRAPER_ID: ClassVar[str] = {schema['id']!r}")
2463
+ # Pinned marketplace canonical: bake the recorded snapshot version so the
2464
+ # client sends API-Snapshot-Version on every call (execution stays frozen to
2465
+ # that release). Emitted ONLY when the CLI stamped `_pinned_version` (a
2466
+ # positive int) — account/owned APIs omit it, so their output is unchanged.
2467
+ _pin = schema.get("_pinned_version")
2468
+ if isinstance(_pin, int) and not isinstance(_pin, bool) and _pin >= 1:
2469
+ lines.append(f" PINNED_VERSION: ClassVar[Optional[int]] = {_pin}")
2470
+ if error_classes:
2471
+ entries = ", ".join(f"{code!r}: {cls}" for code, cls in error_classes)
2472
+ lines.append(f" _ERROR_CLASSES: ClassVar[Dict[str, type]] = {{{entries}}}")
2473
+ for attr, coll_cls in root_collections:
2474
+ lines.append("")
2475
+ lines.append(f" @property")
2476
+ lines.append(f" def {attr}(self) -> '{coll_cls}':")
2477
+ lines.append(f" return {coll_cls}(_api=self, _parent=None)")
2478
+ for attr, res_cls, key_ident, key_anno in root_factories:
2479
+ lines.append("")
2480
+ lines.append(f" def {attr}(self, {key_ident}: {key_anno}) -> '{res_cls}':")
2481
+ lines.append(f" return {res_cls}(_api=self, {key_ident}={key_ident})")
2482
+ return "\n".join(lines)
2483
+
2484
+
2485
+ # ---------------------------------------------------------------------------
2486
+ # Top-level entry point
2487
+ # ---------------------------------------------------------------------------
2488
+
2489
+
2490
+ def _normalize_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
2491
+ """Cross-SDK consistency pass on the schema before render.
2492
+
2493
+ Mutates a shallow copy of the schema:
2494
+ * Enum naming: ``SortBy``/``SortOrder`` → ``Sort``. Any field that
2495
+ used the old name as an enum binding gets rewritten.
2496
+ * Canonical fetcher: when a resource declares ``fetched_by``, ensure
2497
+ the matching op is also reachable as ``.get(<keyed_by>=...)`` on
2498
+ the root collection. (If a ``get`` op already exists, leave it.)
2499
+ * Pagination is NOT normalized here. A ``returns: "List[X]"`` op with no
2500
+ ``pagination`` block fails closed at render (``CodegenError``; ``parse
2501
+ sync`` skips+warns it), and the preview/complete gates reject it at
2502
+ authoring time — so a blockless list op never silently renders.
2503
+ """
2504
+ schema = dict(schema) # shallow copy — we'll deep-copy mutable nested bits below
2505
+
2506
+ # Endpoint-shape normalization (Option B, locus-internal).
2507
+ # Two of the four schema-build paths that reach render_module (preview's
2508
+ # ``_schema_from_draft`` and the complete-time gate) pass RAW ``apis`` keyed
2509
+ # ``endpoint_name`` with no ``name`` — which every ``ep['name']`` index below
2510
+ # silently drops, starving the typed-signature join AND the sample
2511
+ # reconciliation. Normalize raw endpoints to the executor's ``name`` shape
2512
+ # here so all four paths render identically. We also detach each endpoint's
2513
+ # param dicts up front because later enum rewrites are local to the
2514
+ # normalized schema, never the caller's stored spec.
2515
+ raw_eps = schema.get("endpoints") or []
2516
+ if isinstance(raw_eps, list):
2517
+ projected_eps: List[Any] = []
2518
+ for e in raw_eps:
2519
+ if not isinstance(e, dict):
2520
+ projected_eps.append(e)
2521
+ continue
2522
+ ep = dict(e)
2523
+ is_raw_endpoint = not ep.get("name") and ep.get("endpoint_name")
2524
+ if is_raw_endpoint:
2525
+ ep["name"] = ep.get("endpoint_name")
2526
+ ep["method"] = str(ep.get("method") or "POST").upper()
2527
+ ip = ep.get("input_params")
2528
+ if isinstance(ip, dict):
2529
+ params: Dict[str, Any] = {}
2530
+ for k, v in ip.items():
2531
+ if isinstance(v, dict):
2532
+ params[k] = dict(v)
2533
+ elif is_raw_endpoint:
2534
+ params[k] = {"type": "string", "description": str(v)}
2535
+ else:
2536
+ params[k] = v
2537
+ ep["input_params"] = params
2538
+ if is_raw_endpoint:
2539
+ rs = ep.get("return_schema")
2540
+ ep["return_schema"] = rs if isinstance(rs, dict) else None
2541
+ projected_eps.append(ep)
2542
+ schema["endpoints"] = projected_eps
2543
+
2544
+ # Enum names pass through VERBATIM. The consumer-side canonicalizer (Parse
2545
+ # repo, ``canonicalize_closed_enums``) is the single owner of enum naming;
2546
+ # its contract is spec-name == emitted-name (its ``_camel`` clone +
2547
+ # ``test_camel_matches_sdk`` depend on it).
2548
+ # Fail closed on a non-dict enums (a list/scalar would crash dict() with an
2549
+ # uncaught ValueError/TypeError, OR silently drop the API's enum types).
2550
+ _enums = schema.get("enums")
2551
+ if _enums is not None and not isinstance(_enums, dict):
2552
+ raise CodegenError(f"schema enums must be a mapping, got {type(_enums).__name__}")
2553
+ enums = dict(_enums or {})
2554
+ schema["enums"] = enums
2555
+
2556
+ # Canonical fetcher: synthesize a typed ``.get(<keyed_by>=...)`` on the
2557
+ # root collection ONLY for a genuine point-lookup.
2558
+ #
2559
+ # ``fetched_by`` is dual-spelled: it holds either an OP NAME (reddit
2560
+ # ``Post.fetched_by == "search"``, an op whose endpoint is ``search_posts``)
2561
+ # or an ENDPOINT NAME directly (RS ``fetched_by == "get_post"``). Resolve it
2562
+ # to a concrete endpoint, then gate the synthesis on the endpoint actually
2563
+ # accepting the key — a search-style endpoint (``search_posts`` / ``search``)
2564
+ # does NOT take ``id``, so it's no point-lookup and we synthesize nothing
2565
+ # (leaving navigation to the constructible factory + search). HN/airbnb
2566
+ # never reach the synthesis: they declare their own ``get`` op, short-
2567
+ # circuited by ``if "get" in ops`` below.
2568
+ #
2569
+ # The key param types itself from the resolved endpoint's ParamSpec in
2570
+ # ``_render_operation_method`` (the gate guarantees ``keyed_by`` is a real
2571
+ # param of that endpoint under its own name) — never ``Any``.
2572
+ endpoint_input_params: Dict[str, Dict[str, Any]] = {
2573
+ ep["name"]: (ep.get("input_params") or {})
2574
+ for ep in schema.get("endpoints", [])
2575
+ if isinstance(ep, dict) and isinstance(ep.get("name"), str)
2576
+ }
2577
+ resources = dict(schema.get("resources") or {})
2578
+ for rname, rspec in resources.items():
2579
+ if not isinstance(rspec, dict):
2580
+ continue
2581
+ fetched_by = rspec.get("fetched_by")
2582
+ keyed_by = rspec.get("keyed_by")
2583
+ if not (isinstance(fetched_by, str) and isinstance(keyed_by, str)):
2584
+ continue
2585
+ ops = dict(rspec.get("operations") or {})
2586
+ if "get" in ops:
2587
+ continue # spec already has it
2588
+ # Resolve fetched_by → concrete endpoint, handling both spellings.
2589
+ if fetched_by in ops and isinstance(ops[fetched_by], dict):
2590
+ endpoint = ops[fetched_by].get("endpoint")
2591
+ else:
2592
+ endpoint = fetched_by
2593
+ if not isinstance(endpoint, str):
2594
+ continue
2595
+ # Gate: only a genuine point-lookup (the endpoint accepts the key).
2596
+ if keyed_by not in (endpoint_input_params.get(endpoint) or {}):
2597
+ continue
2598
+ ops["get"] = {
2599
+ "endpoint": endpoint,
2600
+ "returns": _camel(rname),
2601
+ "takes": {keyed_by: keyed_by},
2602
+ }
2603
+ rspec = {**rspec, "operations": ops}
2604
+ resources[rname] = rspec
2605
+ schema["resources"] = resources
2606
+
2607
+ # Field-type reconciliation — floor a field whose declared
2608
+ # type contradicts the captured sample to the sample-gate's Dict[str,Any]
2609
+ # form (copy-on-write, so the stored spec stays raw). Lives in the focused
2610
+ # ``codegen_reconcile`` sibling, called here as one named pass; lazily
2611
+ # imported (codegen_reconcile back-imports this module) to stay cycle-safe.
2612
+ from parse_sdk import codegen_reconcile as _reconcile
2613
+ schema["resources"] = _reconcile.reconcile_resources(schema)
2614
+
2615
+ return schema
2616
+
2617
+
2618
+ def resolve_root_name(schema: Dict[str, Any]) -> str:
2619
+ """The root client class name codegen WILL emit for ``schema`` — the SINGLE
2620
+ source of truth. ``root_name`` (or ``_camel(slug)``) is collision-suffixed
2621
+ against the resource/enum/error names by the shared ``_ModuleNames`` allocator
2622
+ (resources are reserved first, so a ``root_name`` equal to a resource name is
2623
+ the one suffixed). docgen and the CLI MUST call this instead of re-deriving the
2624
+ root, or a published import line names a class codegen never emitted (the agent
2625
+ copying it gets ImportError)."""
2626
+ # Normalize first so the allocation context matches render_module exactly
2627
+ # (render_module allocates names on the normalized schema).
2628
+ return _ModuleNames(_normalize_schema(schema)).root
2629
+
2630
+
2631
+ def _member_allocation(
2632
+ operations: Dict[str, Any],
2633
+ sub_resources: Dict[str, Any],
2634
+ fields: Dict[str, Any],
2635
+ *,
2636
+ reserved: Set[str],
2637
+ ) -> Tuple[Dict[str, str], Dict[str, str], Dict[str, str]]:
2638
+ """SINGLE source of per-resource member idents, returned as
2639
+ ``(sub_attrs, op_idents, field_idents)``.
2640
+
2641
+ ONE member namespace per class. Allocate in precedence order
2642
+ (sub-resource accessor > operation method > field) so a lower-precedence
2643
+ member colliding with a higher one is the one suffixed. Seeded with the
2644
+ runtime-structural reserved names so e.g. a field ``_api`` becomes
2645
+ ``_api_`` (raw it crashes construction).
2646
+
2647
+ Instance-op routing is one symmetric invariant: an op is an instance
2648
+ method IFF it has a ``$self`` pin. The spec has no instance-reference
2649
+ mechanism OTHER than ``$self``, so a pinless op cannot be instance-bound
2650
+ by construction — it is a lookup/factory and belongs on the root
2651
+ collection (whose emit set is the exact complement:
2652
+ ``not _op_has_self_pin``).
2653
+
2654
+ Both the emitter (``_render_resource_class``) and ``op_call_descriptors``
2655
+ call THIS function — the descriptor walk previously re-ran these loops by
2656
+ hand ("byte-for-byte") and could drift silently.
2657
+ """
2658
+ member_taken: Set[str] = set()
2659
+
2660
+ # 1) Sub-resource accessor attrs (highest precedence).
2661
+ sub_attrs: Dict[str, str] = {}
2662
+ for sub_name, sub_body in sub_resources.items():
2663
+ if not isinstance(sub_body, dict):
2664
+ continue
2665
+ sub_attrs[sub_name] = _allocate_ident(
2666
+ sub_name, member_taken, fallback="sub", reserved=reserved
2667
+ )
2668
+
2669
+ # 2) Operation method names — only the ops emitted on the instance (the
2670
+ # collection-level lookups/factories must NOT consume a member slot).
2671
+ op_idents: Dict[str, str] = {}
2672
+ for op_name, op_body in operations.items():
2673
+ if not isinstance(op_body, dict):
2674
+ continue
2675
+ if not _op_has_self_pin(op_body):
2676
+ continue
2677
+ op_idents[op_name] = _allocate_ident(
2678
+ op_name, member_taken, fallback="call", reserved=reserved
2679
+ )
2680
+
2681
+ # 3) Field idents (lowest precedence) — reserving the runtime names AND
2682
+ # the already-taken sub/op members. A wire field that collides keeps its
2683
+ # wire read via the alias the emitter records.
2684
+ field_idents: Dict[str, str] = {}
2685
+ for fname in fields.keys():
2686
+ field_idents[fname] = _allocate_ident(
2687
+ fname, member_taken, fallback="field", reserved=reserved
2688
+ )
2689
+ return sub_attrs, op_idents, field_idents
2690
+
2691
+
2692
+ def op_call_descriptors(
2693
+ schema: Dict[str, Any], *, _norm: Optional[Dict[str, Any]] = None
2694
+ ) -> Dict[str, str]:
2695
+ """``{docgen-ref → call_expr}`` for every operation codegen emits a call path
2696
+ for — the SINGLE source docgen reads so the README "Call" column can't
2697
+ diverge from the emitted code. ``call_expr`` is the user-facing call shape
2698
+ (e.g. ``client.subreddits.search()``, ``subreddit.refresh()``,
2699
+ ``post.comments.list()``).
2700
+
2701
+ ``_norm`` (private) lets a caller that already normalized the schema —
2702
+ ``render_readme`` consumes three normalized reads per API — share one
2703
+ normalization instead of paying it per read; it must be exactly
2704
+ ``_normalize_schema(schema)``.
2705
+
2706
+ A standalone READ: it does NOT touch ``render_module`` or any emitted-source
2707
+ path, so already-generated clients and the runtime are unaffected (additive).
2708
+
2709
+ The walk iterates the NORMALIZED schema's resources/ops — the EMITTED op
2710
+ set, including a ``fetched_by``-synthesized ``get`` — so every emitted
2711
+ callable has a descriptor. Normalize only ADDS ops and never
2712
+ removes/renames an op or resource key, so this is a superset of the raw
2713
+ walk: every raw docgen ref still resolves (no silent ref fall-back), and
2714
+ the README's normalized row source finds its synthesized rows too. Name
2715
+ ALLOCATION runs against the same normalized view (``_ModuleNames`` + the
2716
+ per-resource member/collection ident maps), matching the emitter
2717
+ byte-for-byte. The key is EXACTLY docgen's ``ref`` format
2718
+ (``<resource_spec_key>.<op>`` or ``<resource_spec_key>.<sub>.<op>``).
2719
+
2720
+ Routing branches on the single ``_op_has_self_pin`` invariant:
2721
+ * collection (pinless) → ``client.<accessor>.<method>()`` — accessor is the
2722
+ allocator-assigned root-collection attr (matching the emitted ``@property``,
2723
+ suffixed on a plural collision);
2724
+ * ``$self``-pinned → ``<instance>.<method>()`` active-record (the root
2725
+ collection is ``pass``-bodied for these, so a ``client.<plural>.<op>``
2726
+ call would AttributeError);
2727
+ * sub-resource → ``<instance>.<sub_attr>.<method>()`` active-record (the
2728
+ sub-resource accessor is a class-level ``@property``, so the class-dotted
2729
+ ``<ResourceCls>.<sub_attr>`` form AttributeErrors at runtime).
2730
+ """
2731
+ norm = _norm if _norm is not None else _normalize_schema(schema)
2732
+ norm_resources = norm.get("resources")
2733
+ norm_resources = _as_dict(norm_resources)
2734
+ raw_resources = schema.get("resources")
2735
+ raw_resources = _as_dict(raw_resources)
2736
+ names = _ModuleNames(norm)
2737
+ out: Dict[str, str] = {}
2738
+ reserved_member = _runtime_reserved_member_names()
2739
+ coll_reserved = _collection_reserved()
2740
+
2741
+ # Signature rendering inputs — the same joins the emitter uses.
2742
+ endpoint_input_params: Dict[str, Dict[str, Any]] = {
2743
+ ep["name"]: (ep.get("input_params") or {})
2744
+ for ep in norm.get("endpoints", [])
2745
+ if isinstance(ep, dict) and isinstance(ep.get("name"), str)
2746
+ }
2747
+ descr_enums = norm.get("enums")
2748
+ descr_enums = _as_dict(descr_enums)
2749
+ descr_enum_alloc: Dict[str, str] = {_camel(ek): ev for ek, ev in names.enums.items()}
2750
+
2751
+ def _sig(op_body: Dict[str, Any]) -> str:
2752
+ """The op's full emitted parameter list (no ``self``), via the same
2753
+ ``_op_param_records`` the emitter consumes — required params as
2754
+ ``name: type``, optionals as ``name: type = None``, then the synthetic
2755
+ paginator kwargs (real, useful: the runtime's window-exhaustion remedy
2756
+ is ``limit=``). NEVER raises: the descriptor map must stay TOTAL even
2757
+ for a malformed op (the emitter is where malformed specs fail loud),
2758
+ so any error degrades to the previous empty-paren form."""
2759
+ try:
2760
+ raw_takes = op_body.get("takes")
2761
+ takes: Dict[str, Any] = dict(raw_takes) if isinstance(raw_takes, dict) else {}
2762
+ raw_pg = op_body.get("pagination")
2763
+ pg: Optional[Dict[str, Any]] = raw_pg if isinstance(raw_pg, dict) else None
2764
+ paginator_param = pg.get("request_param") if pg else None
2765
+ if paginator_param:
2766
+ takes = {k: v for k, v in takes.items() if v != paginator_param}
2767
+ endpoint = op_body.get("endpoint")
2768
+ params_for_ep: Dict[str, Any] = (
2769
+ endpoint_input_params.get(endpoint) if isinstance(endpoint, str) else None
2770
+ ) or {}
2771
+ records, _ = _op_param_records(takes, params_for_ep, descr_enum_alloc, descr_enums)
2772
+ decls = [r[1] for r in records]
2773
+ if pg and pg.get("scheme") is not None:
2774
+ idents = {r[0] for r in records}
2775
+ if "limit" not in idents:
2776
+ decls.append("limit: Optional[int] = None")
2777
+ if "max_fetches" not in idents:
2778
+ decls.append("max_fetches: int = 1000")
2779
+ return ", ".join(decls)
2780
+ except Exception: # noqa: BLE001 — totality over fidelity, by contract
2781
+ return ""
2782
+
2783
+ for rname, raw_rspec in raw_resources.items():
2784
+ if not isinstance(raw_rspec, dict):
2785
+ continue
2786
+ # Allocation context is the NORMALIZED resource (matches the emitter);
2787
+ # the ref walk below iterates the RAW resource (matches docgen).
2788
+ norm_rspec = norm_resources.get(rname)
2789
+ if not isinstance(norm_rspec, dict):
2790
+ norm_rspec = raw_rspec
2791
+ res_cls = names.resources[rname]
2792
+ operations = norm_rspec.get("operations")
2793
+ operations = _as_dict(operations)
2794
+ sub_resources = norm_rspec.get("sub_resources")
2795
+ sub_resources = _as_dict(sub_resources)
2796
+
2797
+ # Shared single-source allocation — ``_member_allocation`` is the same
2798
+ # function the emitter calls (sub-attr > instance-op > field
2799
+ # precedence), so these idents CANNOT drift from the emitted class.
2800
+ norm_fields = norm_rspec.get("fields")
2801
+ norm_fields = _as_dict(norm_fields)
2802
+ sub_attrs, instance_op_idents, _field_idents = _member_allocation(
2803
+ operations, sub_resources, norm_fields,
2804
+ reserved=reserved_member,
2805
+ )
2806
+
2807
+ # Collection-level op idents — same single source as the renderer + gate,
2808
+ # so a docgen "Call" cell never references a suppressed accessor.
2809
+ coll_emit = _root_collection_emit_ops(operations)
2810
+ coll_op_idents = _collection_op_idents(coll_emit, reserved=coll_reserved)
2811
+ # The allocator-assigned root-collection accessor (suffixed on a plural
2812
+ # collision) — matches the emitted ``@property``. Falls back to the raw
2813
+ # plural only for a resource with no root collection (no ops).
2814
+ accessor = names.root_collection_attrs.get(
2815
+ rname, _plural_attr(res_cls, norm_rspec)
2816
+ )
2817
+ instance_var = _safe_ident(res_cls.lower())
2818
+
2819
+ for op_name, op_body in operations.items():
2820
+ if not isinstance(op_body, dict):
2821
+ continue
2822
+ ref = f"{rname}.{op_name}"
2823
+ if _op_has_self_pin(op_body):
2824
+ method = instance_op_idents[op_name]
2825
+ out[ref] = f"{instance_var}.{method}({_sig(op_body)})"
2826
+ else:
2827
+ # ``coll_op_idents`` is the renderer's emit set (pinless ops with a
2828
+ # resolvable endpoint). A pinless op that renders no method (e.g.
2829
+ # no `endpoint`) is absent — emit a bare, non-callable ref so the
2830
+ # descriptor map stays TOTAL (no KeyError → no aborted sync) and
2831
+ # never spells the suppressed ``client.<plural>`` accessor.
2832
+ method = coll_op_idents.get(op_name)
2833
+ out[ref] = (
2834
+ f"client.{accessor}.{method}({_sig(op_body)})"
2835
+ if method else f"{rname}.{op_name}"
2836
+ )
2837
+
2838
+ # Sub-resource ops: active-record <instance>.<sub_attr>.<method>().
2839
+ for sub_name, sub_body in sub_resources.items():
2840
+ if not isinstance(sub_body, dict):
2841
+ continue
2842
+ sub_attr = sub_attrs.get(sub_name)
2843
+ if sub_attr is None:
2844
+ continue
2845
+ _norm_sub_ops = sub_body.get("operations")
2846
+ sub_ops: Dict[str, Any] = _norm_sub_ops if isinstance(_norm_sub_ops, dict) else {}
2847
+ valid_sub_ops = [n for n, b in sub_ops.items() if isinstance(b, dict)]
2848
+ sub_op_idents = _collection_op_idents(valid_sub_ops, reserved=coll_reserved)
2849
+ for op_name in valid_sub_ops:
2850
+ ref = f"{rname}.{sub_name}.{op_name}"
2851
+ method = sub_op_idents[op_name]
2852
+ # Active-record form on the lowercase INSTANCE var — the
2853
+ # sub-resource accessor is a class-level ``@property`` so the
2854
+ # class-dotted ``<ResourceCls>.<sub_attr>`` form AttributeErrors.
2855
+ out[ref] = f"{instance_var}.{sub_attr}.{method}({_sig(sub_ops[op_name])})"
2856
+
2857
+ return out
2858
+
2859
+
2860
+ def _factory_gate(
2861
+ res_spec: Dict[str, Any], ops: Dict[str, Any], subs: Dict[str, Any]
2862
+ ) -> Optional[Tuple[str, Optional[str]]]:
2863
+ """The constructible-factory eligibility gate — the SINGLE source shared by
2864
+ codegen's ``_factory_rows`` pass and ``resource_surface``'s reachability
2865
+ facts (a divergence silently changes which ``client.<plural>`` accessors
2866
+ codegen suppresses vs. which docgen credits). Returns ``None`` when the
2867
+ resource is not an eligible factory at all (not constructible / no
2868
+ instance-navigable surface / no ``str`` key), else ``(keyed_by, unsafe_pin)``
2869
+ where ``unsafe_pin`` is the offending NON-key pin that makes the factory
2870
+ derived-unsafe (suppressed), or ``None`` for a SAFE factory."""
2871
+ instance_ops = [n for n, b in ops.items() if isinstance(b, dict) and _op_has_self_pin(b)]
2872
+ keyed_by = res_spec.get("keyed_by")
2873
+ if not (res_spec.get("constructible") and (instance_ops or subs)
2874
+ and isinstance(keyed_by, str) and keyed_by):
2875
+ return None
2876
+ return keyed_by, _constructible_unsafe_pin(res_spec, keyed_by)
2877
+
2878
+
2879
+ def _is_safe_factory(
2880
+ res_spec: Dict[str, Any], ops: Dict[str, Any], subs: Dict[str, Any]
2881
+ ) -> bool:
2882
+ """Whether ``res_spec`` is a SAFE constructible factory (eligible AND no
2883
+ unsafe non-key pin). Thin reachability view over :func:`_factory_gate`."""
2884
+ gate = _factory_gate(res_spec, ops, subs)
2885
+ return gate is not None and gate[1] is None
2886
+
2887
+
2888
+ def _factory_rows(
2889
+ resources: Dict[str, Any],
2890
+ names: "_ModuleNames",
2891
+ *,
2892
+ reserved_root_attrs: Set[str],
2893
+ emitted_root_attrs: Set[str],
2894
+ ) -> Tuple[List[Tuple[str, str, str, str]], List[Tuple[str, str, str]]]:
2895
+ """Constructible-factory rows + suppression records — the SINGLE source
2896
+ for the emitter's factory pass, the README's factory rows, and the CLI's
2897
+ suppression warnings. The factory-attr allocation is order-dependent on
2898
+ ``emitted_root_attrs`` (suffixing on clashes), which this function MUTATES
2899
+ exactly as the emitter's inline pass did — a standalone re-derivation of
2900
+ these gates is the drift class the descriptor machinery exists to prevent.
2901
+
2902
+ Returns ``(rows, suppressed)``: ``rows`` are
2903
+ ``(factory_attr, ResourceClass, key_ident, key_anno)`` in resource order;
2904
+ ``suppressed`` are ``(res_name, unsafe_field, keyed_by)`` for factories
2905
+ the derived-safety gate refused.
2906
+ """
2907
+ rows: List[Tuple[str, str, str, str]] = []
2908
+ suppressed: List[Tuple[str, str, str]] = []
2909
+ for res_name, res_spec in resources.items():
2910
+ if not isinstance(res_spec, dict):
2911
+ continue
2912
+ # Emit a factory only when the resource is an eligible, derived-SAFE
2913
+ # constructible: an INSTANCE-navigable surface (a `$self`-pinned op or a
2914
+ # sub-resource — a pinless-only constructible routes ops to the root
2915
+ # collection, so a bare `resource(key)` handle would be dead) AND a str
2916
+ # key AND no NON-key pin (don't trust the agent's flag — a handle whose
2917
+ # ops silently send None on a non-key pin is suppressed; the resource is
2918
+ # still reachable via its collection/search results). _factory_gate is
2919
+ # the single source the reachability facts also consume.
2920
+ ops = _as_dict(res_spec.get("operations"))
2921
+ subs = _as_dict(res_spec.get("sub_resources"))
2922
+ gate = _factory_gate(res_spec, ops, subs)
2923
+ if gate is None:
2924
+ continue
2925
+ keyed_by, unsafe_pin = gate
2926
+ if unsafe_pin is not None:
2927
+ suppressed.append((res_name, unsafe_pin, keyed_by))
2928
+ continue
2929
+ res_cls = names.resources[res_name]
2930
+ # This resource's emitted collection accessor (allocator-suffixed on a
2931
+ # plural collision) — falls back to the raw plural for resources without
2932
+ # a root collection (no ops → not in the allocated map).
2933
+ plural_attr = names.root_collection_attrs.get(
2934
+ res_name, _plural_attr(res_cls, res_spec)
2935
+ )
2936
+ factory_attr = _safe_ident(res_cls.lower())
2937
+ # Skip when the factory name would collide with THIS resource's own
2938
+ # plural collection attr — the collection wins.
2939
+ if factory_attr == plural_attr and plural_attr in emitted_root_attrs:
2940
+ continue
2941
+ # Otherwise suffix on any reserved / already-emitted clash.
2942
+ while factory_attr in reserved_root_attrs or factory_attr in emitted_root_attrs:
2943
+ factory_attr = f"{factory_attr}_"
2944
+ key_ident = _safe_ident(keyed_by)
2945
+ key_anno = _factory_key_annotation(res_spec, keyed_by)
2946
+ rows.append((factory_attr, res_cls, key_ident, key_anno))
2947
+ emitted_root_attrs.add(factory_attr)
2948
+ return rows, suppressed
2949
+
2950
+
2951
+ def factory_descriptors(
2952
+ schema: Dict[str, Any], *, _norm: Optional[Dict[str, Any]] = None
2953
+ ) -> Dict[str, list]:
2954
+ """Standalone read of the constructible-factory surface for the docs layer
2955
+ — the same ``_factory_rows`` the emitter consumes, computed over the
2956
+ normalized schema with the same pre-factory accumulator state (the
2957
+ collection accessors), so a documented factory row CANNOT drift from the
2958
+ emitted one. Additive: touches no emitted-source path. ``_norm`` (private)
2959
+ shares a caller's existing ``_normalize_schema(schema)`` result.
2960
+
2961
+ Returns ``{"rows": [(factory_attr, ResourceClass, key_ident, key_anno)],
2962
+ "suppressed": [(res_name, unsafe_field, keyed_by)]}`` in emission order.
2963
+ """
2964
+ norm = _norm if _norm is not None else _normalize_schema(schema)
2965
+ resources = norm.get("resources")
2966
+ if not isinstance(resources, dict) or not resources:
2967
+ return {"rows": [], "suppressed": []}
2968
+ names = _ModuleNames(norm)
2969
+ from parse_sdk._runtime import BaseAPI as _BaseAPI
2970
+ reserved_root_attrs: Set[str] = set(dir(_BaseAPI)) | {"get", "list", "search"}
2971
+ # Reproduce the emitter's pre-factory accumulator: every root-collection
2972
+ # accessor is emitted before any factory (same gate as the emit loop).
2973
+ emitted_root_attrs: Set[str] = {
2974
+ names.root_collection_attrs[res_name]
2975
+ for res_name, res_spec in resources.items()
2976
+ if isinstance(res_spec, dict)
2977
+ and _root_collection_emit_ops(res_spec.get("operations"))
2978
+ }
2979
+ rows, suppressed = _factory_rows(
2980
+ resources, names,
2981
+ reserved_root_attrs=reserved_root_attrs,
2982
+ emitted_root_attrs=emitted_root_attrs,
2983
+ )
2984
+ return {"rows": rows, "suppressed": suppressed}
2985
+
2986
+
2987
+ def render_module(schema: Dict[str, Any], *, base_url: str) -> Optional[str]:
2988
+ """Render the full ``__init__.py`` for a single API. Returns None if
2989
+ the schema can't be modeled (no ``resources`` block — hard cutover)."""
2990
+ resources = schema.get("resources")
2991
+ if not isinstance(resources, dict) or not resources:
2992
+ return None
2993
+
2994
+ # Normalize cross-SDK before render — see _normalize_schema for the rules.
2995
+ schema = _normalize_schema(schema)
2996
+ resources = schema["resources"]
2997
+
2998
+ # The id becomes ``SCRAPER_ID`` — a URL path segment of every request the
2999
+ # generated client makes. Gate it before any rendering (early authoring
3000
+ # signal; the runtime sink percent-encodes regardless).
3001
+ _require_wire_segment(schema.get("id"), "scraper id")
3002
+
3003
+ # Fail closed on a missing/non-string slug (it becomes the package/module/path
3004
+ # name). A raw KeyError/TypeError here would escape render_preview (which only
3005
+ # catches CodegenError) and abort the whole `parse sync` for every API.
3006
+ slug = schema.get("slug")
3007
+ if not isinstance(slug, str) or not slug:
3008
+ raise CodegenError(f"schema slug {slug!r} is missing or not a non-empty string")
3009
+
3010
+ # Module-level name allocator (P0.2): reserves ONE namespace across
3011
+ # root + resources + enums + errors + collections, suffix-on-collision. Every
3012
+ # class def, registry key, enum-binding ref, accessor, and the resolvability
3013
+ # gate's name sets derive from its allocated VALUES.
3014
+ names = _ModuleNames(schema)
3015
+ root_name = names.root
3016
+
3017
+ # ``_camel(enum spec key) → allocated enum class name``. Enum bindings on
3018
+ # fields/params reference an enum by name; resolve them to the allocated
3019
+ # class (a collision suffix can move it). Keyed on the
3020
+ # canonical ``_camel`` form so a differently-spelled binding still matches.
3021
+ enum_alloc: Dict[str, str] = {
3022
+ _camel(ek): ev for ek, ev in names.enums.items()
3023
+ }
3024
+
3025
+ # SINGLE SOURCE OF TRUTH for return types: rewrite each top-level resource
3026
+ # op's ``returns`` to the ALLOCATED owning-resource class name, BEFORE any
3027
+ # rendering. The emitted annotation, the _coerce label, and the
3028
+ # _RESOURCE_CLASSES key all read this rewritten value, so they cannot diverge.
3029
+ # Identity-keyed (per owning resource), so it is unambiguous under _camel
3030
+ # collisions. Copy-on-write: the caller's schema dict is left untouched.
3031
+ rewritten_resources: Dict[str, Any] = {}
3032
+ for _rk, _rspec in resources.items():
3033
+ if not (isinstance(_rspec, dict) and isinstance(_rspec.get("operations"), dict)):
3034
+ rewritten_resources[_rk] = _rspec
3035
+ continue
3036
+ _owning_alloc = names.resources[_rk]
3037
+ _owning_camel = _camel(_rk)
3038
+ _new_ops: Dict[str, Any] = {}
3039
+ for _op_name, _op in _rspec["operations"].items():
3040
+ if isinstance(_op, dict) and "returns" in _op:
3041
+ _new_ops[_op_name] = {
3042
+ **_op,
3043
+ "returns": _allocate_returns(_op["returns"], _owning_alloc, _owning_camel),
3044
+ }
3045
+ else:
3046
+ _new_ops[_op_name] = _op
3047
+ rewritten_resources[_rk] = {**_rspec, "operations": _new_ops}
3048
+ # Self-contained copy-on-write: rebind ``schema`` to a NEW dict rather than
3049
+ # mutating ``schema["resources"]`` in place, so the no-caller-mutation
3050
+ # invariant holds LOCALLY and does not silently depend on _normalize_schema's
3051
+ # leading shallow-copy staying first.
3052
+ schema = {**schema, "resources": rewritten_resources}
3053
+ resources = rewritten_resources
3054
+
3055
+ # Build the {endpoint_name: input_params} lookup that types each
3056
+ # operation's kwargs. Key is ``endpoints`` (the SDKSchema shape from the
3057
+ # executor's _row_to_schema) — NOT ``apis``. An op's kwarg is typed by
3058
+ # joining its ``takes`` VALUE (endpoint param name) against this map.
3059
+ endpoint_input_params: Dict[str, Dict[str, Any]] = {
3060
+ ep["name"]: (ep.get("input_params") or {})
3061
+ for ep in schema.get("endpoints", [])
3062
+ if isinstance(ep, dict) and isinstance(ep.get("name"), str)
3063
+ }
3064
+
3065
+ # {endpoint_name: HTTP method}. The real verb lives on the normalized
3066
+ # ``endpoints[].method`` (the executor's SDKEndpoint.method). An op's
3067
+ # request uses this rather than a hardcoded GET. A missing entry defaults
3068
+ # to POST — matching the executor schema projector's default — so the
3069
+ # emitted verb tracks the real endpoint method.
3070
+ endpoint_methods: Dict[str, str] = {
3071
+ ep["name"]: str(ep.get("method") or "POST").upper()
3072
+ for ep in schema.get("endpoints", [])
3073
+ if isinstance(ep, dict) and isinstance(ep.get("name"), str)
3074
+ }
3075
+
3076
+ # {endpoint_name: description}. An operation's narrative is the description
3077
+ # of the endpoint it calls — resolved at render time by ``op["endpoint"]``,
3078
+ # the same inline join docgen uses (D5: one rule, one mechanism, no stamp).
3079
+ endpoint_descriptions: Dict[str, str] = endpoint_description_map(schema)
3080
+
3081
+ # {endpoint_name: return_schema}. The captured response sample lives here
3082
+ # The empty-results discriminator (emitted arm B) reads the
3083
+ # unwrapped sample's top-level key-set per op (``_sample_keys_for``) to tell
3084
+ # a legit zero-result from a mis-derived ``items_path``. Same ``endpoints``
3085
+ # source + defensive-resolution pattern as ``endpoint_input_params``.
3086
+ endpoint_return_schemas: Dict[str, Any] = {
3087
+ ep["name"]: ep.get("return_schema")
3088
+ for ep in schema.get("endpoints", [])
3089
+ if isinstance(ep, dict) and isinstance(ep.get("name"), str)
3090
+ }
3091
+
3092
+ # {ALLOCATED element resource name -> its declared ``keyed_by`` (or None when
3093
+ # keyless)}. The identity-dedup arm needs the element resource's
3094
+ # ``keyed_by`` at BOTH the resource-op site and the collection site — but a
3095
+ # collection method only has the element LABEL (``_collection_element(returns)``)
3096
+ # in scope, not the element resource's spec. This map supplies it. Keyed on
3097
+ # the ALLOCATED class name because ``returns`` were rewritten to allocated
3098
+ # names above, so ``_collection_element`` yields the allocated label. The
3099
+ # value is the RAW declared ``keyed_by`` (matching the Step-0 census's
3100
+ # ``_keyed_by_for``); a container/absent key still emits no dedup because the
3101
+ # sample-uniqueness gate (``_dedup_eligible``) fails on it, and even if it
3102
+ # didn't the runtime ``_KEY_FIELD`` would be None and identity-dedup would
3103
+ # no-op (``_identity_key`` → None → never deduped → no data loss).
3104
+ element_keyed_by: Dict[str, Optional[str]] = {}
3105
+ for _rk, _rspec in resources.items():
3106
+ if not isinstance(_rspec, dict):
3107
+ continue
3108
+ _kb = _rspec.get("keyed_by")
3109
+ element_keyed_by[names.resources[_rk]] = _kb if isinstance(_kb, str) and _kb else None
3110
+
3111
+ # Read the engine version lazily to avoid an import cycle — codegen_v2 is
3112
+ # imported by cli, and parse_sdk/__init__ imports only _runtime, so
3113
+ # importing parse_sdk here is safe.
3114
+ from parse_sdk import __version__ as _engine_version
3115
+
3116
+ parts: List[str] = []
3117
+ parts.append(_HEADER.format(
3118
+ base_url=_escape_header_field(base_url),
3119
+ api_name=_escape_header_field(schema.get("name", slug)),
3120
+ slug=_escape_header_field(slug),
3121
+ scraper_id=_escape_header_field(schema["id"]),
3122
+ timestamp=datetime.now(timezone.utc).isoformat(),
3123
+ engine_version=_engine_version,
3124
+ ))
3125
+
3126
+ # Stamp the engine version the client was built against so a runtime/build
3127
+ # version skew is detectable (prisma's silent-corruption mode).
3128
+ parts.append(f'__generated_with__ = "{_engine_version}"\n\n')
3129
+
3130
+ # Module-level coerce helper that knows about the resource registry.
3131
+ parts.append("from parse_sdk._runtime import coerce as _runtime_coerce\n\n")
3132
+ parts.append("def _coerce(value, type_label, ctx, mode='element'):\n")
3133
+ parts.append(" # `ctx` is the calling object (resource instance or collection).\n")
3134
+ parts.append(" # `mode` (S5) selects the return boundary: a single-resource return\n")
3135
+ parts.append(" # emits mode='return' (strict husk/scalar fail-loud); per-element\n")
3136
+ parts.append(" # paginator rows emit mode='element' (lenient degrade-to-raw, the\n")
3137
+ parts.append(" # default). The strict verdict needs the return contract boundary,\n")
3138
+ parts.append(" # so boundary is True exactly when mode=='return'.\n")
3139
+ parts.append(" api = getattr(ctx, '_api', None) or (ctx if isinstance(ctx, _BaseAPI) else None)\n")
3140
+ parts.append(" parent = ctx if not isinstance(ctx, _BaseAPI) else None\n")
3141
+ parts.append(" return _runtime_coerce(value, type_label, resource_classes=_RESOURCE_CLASSES,\n")
3142
+ parts.append(" api=api, parent=parent, boundary=(mode == 'return'), mode=mode)\n\n\n")
3143
+
3144
+ # Empty-results extraction helper — imported ONLY when the module has at
3145
+ # least one paginated op (the sole reference site is a ``_fetch_page`` body).
3146
+ # A non-paginated-only module stays byte-identical (no unused import), so the
3147
+ # the emitted byte-delta stays confined to the paginated-op population.
3148
+ if _schema_has_paginated_op(resources):
3149
+ parts.append("from parse_sdk._runtime import _extract_items\n\n\n")
3150
+
3151
+ # Dotted-pagination-path resolver — imported ONLY when some paginated op
3152
+ # declares a dotted response path (the sole reference sites are dotted
3153
+ # ``_walk(_resp, ...)`` emits inside a ``_fetch_page`` body). A flat-path-only
3154
+ # module stays byte-identical (no unused import); the frozen corpus has zero
3155
+ # dotted paths, so this import is absent corpus-wide.
3156
+ if _schema_has_dotted_pagination_path(resources):
3157
+ parts.append("from parse_sdk._runtime import _walk\n\n\n")
3158
+
3159
+ # Enums — emit under their ALLOCATED names (P0.2).
3160
+ enums = schema.get("enums")
3161
+ enums = _as_dict(enums)
3162
+ for enum_name, enum_spec in enums.items():
3163
+ if not isinstance(enum_spec, dict):
3164
+ continue
3165
+ parts.append(_render_enum(names.enums[enum_name], enum_spec))
3166
+ parts.append("\n\n\n")
3167
+
3168
+ # Errors — emit under their ALLOCATED names (P0.2). Also collect the
3169
+ # ``(wire_kind, AllocatedClassName)`` map for every error declaring a
3170
+ # ``code`` — emitted as ``_ERROR_CLASSES`` on the root client so the runtime
3171
+ # raises the domain class for a matching wire ``kind``. Authoring-time the
3172
+ # gate enforces ``code`` ∈ the closed kind set AND uniqueness-per-API, so
3173
+ # this map is unambiguous by construction; we keep the FIRST occurrence if a
3174
+ # malformed (un-gated) spec ever repeats a code, so codegen never crashes.
3175
+ errors = schema.get("errors")
3176
+ errors = _as_dict(errors)
3177
+ error_code_classes: List[Tuple[str, str]] = []
3178
+ _seen_codes: Set[str] = set()
3179
+ # Schema-wide union of endpoint input-param names — the source set for
3180
+ # carry hydration (_CARRY_PARAMS); see _render_error's docstring.
3181
+ input_param_union: Set[str] = {
3182
+ p for ep_params in endpoint_input_params.values() for p in ep_params
3183
+ }
3184
+ for err_name, err_spec in errors.items():
3185
+ if not isinstance(err_spec, dict):
3186
+ continue
3187
+ parts.append(_render_error(names.errors[err_name], err_spec, input_param_union))
3188
+ parts.append("\n\n")
3189
+ code = err_spec.get("code")
3190
+ if isinstance(code, str) and code and code not in _seen_codes:
3191
+ _seen_codes.add(code)
3192
+ error_code_classes.append((code, names.errors[err_name]))
3193
+
3194
+ # Full emitted name sets — the field-annotation resolvability gate validates
3195
+ # every field annotation against these. Derived from the allocator's VALUES
3196
+ # (not a fresh ``{_camel(rn)}`` comprehension) so a suffixed resource/enum
3197
+ # doesn't silently degrade a referencing field to ``Any``.
3198
+ emitted_resource_names: Set[str] = names.resource_values()
3199
+ emitted_enum_names: Set[str] = names.enum_values()
3200
+
3201
+ # Field-type labels per emitted resource — so an op whose ``returns`` is a
3202
+ # NON-collection ENVELOPE resource but which paginates via ``items_path`` into
3203
+ # one of its ``List[X]`` fields coerces rows to the ELEMENT ``X``, not the
3204
+ # envelope husk. Keyed by allocated resource name; labels via the same
3205
+ # ``_coerce_type_label`` path as the emitted field annotations (enum-bound
3206
+ # fields excluded — never a resource list).
3207
+ #
3208
+ # KNOWN LIMITATION (PR #43 review): the stored labels are ``_coerce_type_label``
3209
+ # output, so an inner element token is the spec-camel name (``Paper``) — NOT a
3210
+ # collision-suffixed allocated name. This is consistent with the pre-existing
3211
+ # ``_collection_element(returns)`` path for direct ``List[X]`` returns
3212
+ # (``_allocate_returns`` rewrites only the owning resource, not cross-resource
3213
+ # refs), so a resource-name collision could emit a ``_coerce`` label that misses
3214
+ # its ``_RESOURCE_CLASSES`` key. Zero corpus cases today; a uniform cross-resource
3215
+ # element allocation (here AND on the direct-return path) is a tracked follow-up.
3216
+ resource_field_labels: Dict[str, Dict[str, str]] = {}
3217
+ for _rn, _rspec in resources.items():
3218
+ if not isinstance(_rspec, dict):
3219
+ continue
3220
+ _flabels: Dict[str, str] = {}
3221
+ for _fn, _fv in _as_dict(_rspec.get("fields")).items():
3222
+ _tlabel, _ebind, _ = _extract_field_spec(_fv)
3223
+ if not _ebind:
3224
+ _flabels[_fn] = _coerce_type_label(_tlabel)
3225
+ resource_field_labels[names.resources[_rn]] = _flabels
3226
+
3227
+ # Resource classes (and their sub-resource collections)
3228
+ for res_name, res_spec in resources.items():
3229
+ if not isinstance(res_spec, dict):
3230
+ continue
3231
+ parts.append(_render_resource_class(
3232
+ names.resources[res_name], res_spec,
3233
+ res_key=res_name,
3234
+ names=names,
3235
+ enum_alloc=enum_alloc,
3236
+ enums=enums,
3237
+ endpoint_input_params=endpoint_input_params,
3238
+ endpoint_methods=endpoint_methods,
3239
+ endpoint_descriptions=endpoint_descriptions,
3240
+ endpoint_return_schemas=endpoint_return_schemas,
3241
+ element_keyed_by=element_keyed_by,
3242
+ resource_names=emitted_resource_names,
3243
+ enum_names=emitted_enum_names,
3244
+ resource_field_labels=resource_field_labels,
3245
+ ))
3246
+ parts.append("\n\n")
3247
+ # Sub-resource collections — emit AFTER the resource class but they
3248
+ # need to be importable before the property uses them. Forward refs
3249
+ # in the property annotation make this work.
3250
+ for sub_name, sub_spec in (res_spec.get("sub_resources") or {}).items():
3251
+ if isinstance(sub_spec, dict):
3252
+ sub_ops = sub_spec.get("operations")
3253
+ sub_ops = _as_dict(sub_ops)
3254
+ parts.append(_render_collection(
3255
+ names.sub_collections[(res_name, sub_name)],
3256
+ [n for n, b in sub_ops.items() if isinstance(b, dict)],
3257
+ sub_ops,
3258
+ endpoint_input_params=endpoint_input_params,
3259
+ endpoint_methods=endpoint_methods,
3260
+ endpoint_descriptions=endpoint_descriptions,
3261
+ endpoint_return_schemas=endpoint_return_schemas,
3262
+ element_keyed_by=element_keyed_by,
3263
+ enum_alloc=enum_alloc,
3264
+ enums=enums,
3265
+ resource_field_labels=resource_field_labels,
3266
+ ))
3267
+ parts.append("\n\n")
3268
+
3269
+ # Root collections (one per top-level resource that has operations)
3270
+ root_collections: List[Tuple[str, str]] = []
3271
+ # Root factories — no-network accessors for constructible resources
3272
+ # (``reddit.subreddit("x")``). Built in the same loop so collision
3273
+ # resolution can see the collection attrs (emitted first).
3274
+ root_factories: List[Tuple[str, str, str, str]] = []
3275
+ # Reserved names a factory attr must not shadow: BaseAPI's members plus
3276
+ # a few collection-verb words, plus every root attr already emitted
3277
+ # (collections precede factories). Built from dir(BaseAPI), not a
3278
+ # hand-literal, so it tracks the runtime base.
3279
+ from parse_sdk._runtime import BaseAPI as _BaseAPI
3280
+ reserved_root_attrs: Set[str] = set(dir(_BaseAPI)) | {"get", "list", "search"}
3281
+ emitted_root_attrs: Set[str] = set()
3282
+ for res_name, res_spec in resources.items():
3283
+ if not isinstance(res_spec, dict):
3284
+ continue
3285
+ # Same gate as the allocator (codegen_v2 `_ModuleNames`): a resource with
3286
+ # no real root-collection method gets no collection. MUST match the
3287
+ # allocator's condition exactly — the lines below direct-index
3288
+ # `names.root_collections[res_name]`, so a one-sided change would KeyError.
3289
+ if not _root_collection_emit_ops(res_spec.get("operations")):
3290
+ continue
3291
+ res_ops = res_spec.get("operations")
3292
+ res_ops = _as_dict(res_ops)
3293
+ attr = names.root_collection_attrs[res_name]
3294
+ coll_cls = names.root_collections[res_name]
3295
+ parts.append(_render_collection(
3296
+ coll_cls,
3297
+ _root_collection_emit_ops(res_ops),
3298
+ res_ops,
3299
+ endpoint_input_params=endpoint_input_params,
3300
+ endpoint_methods=endpoint_methods,
3301
+ endpoint_descriptions=endpoint_descriptions,
3302
+ endpoint_return_schemas=endpoint_return_schemas,
3303
+ element_keyed_by=element_keyed_by,
3304
+ enum_alloc=enum_alloc,
3305
+ enums=enums,
3306
+ resource_field_labels=resource_field_labels,
3307
+ ))
3308
+ parts.append("\n\n")
3309
+ root_collections.append((attr, coll_cls))
3310
+ emitted_root_attrs.add(attr)
3311
+
3312
+ # Factory pass — runs AFTER all collection attrs are recorded so the
3313
+ # collision check sees the full set. Gates + suffix allocation live in
3314
+ # `_factory_rows` (shared with the docs-layer `factory_descriptors` read,
3315
+ # so the documented surface cannot drift from the emitted one).
3316
+ factory_rows, factory_suppressed = _factory_rows(
3317
+ resources, names,
3318
+ reserved_root_attrs=reserved_root_attrs,
3319
+ emitted_root_attrs=emitted_root_attrs,
3320
+ )
3321
+ for _sup_name, _sup_unsafe, _sup_key in factory_suppressed:
3322
+ logger.warning(
3323
+ "constructible factory for %r suppressed: a reachable op pins "
3324
+ "non-key field %r (keyed_by=%r) — hand-built instance unsafe",
3325
+ _sup_name, _sup_unsafe, _sup_key,
3326
+ )
3327
+ root_factories.extend(factory_rows)
3328
+
3329
+ # Resource registry — for the runtime string-label coerce router. Keyed by
3330
+ # the ALLOCATED class name, which is exactly the label the rewritten
3331
+ # ``returns`` / `_coerce` sites emit. Allocated names are unique by
3332
+ # construction (_ModuleNames._alloc suffixes on collision), so each key maps
3333
+ # 1:1 to its class — no dedup required.
3334
+ parts.append("# Resource registry — populated below so each Resource class can resolve\n")
3335
+ parts.append("# nested types during _from_payload coercion.\n")
3336
+ parts.append("_RESOURCE_CLASSES: Dict[str, type] = {\n")
3337
+ for res_name in resources:
3338
+ if not isinstance(resources[res_name], dict):
3339
+ continue
3340
+ cls = names.resources[res_name]
3341
+ parts.append(f" {cls!r}: {cls},\n")
3342
+ parts.append("}\n\n")
3343
+ parts.append("for _cls in _RESOURCE_CLASSES.values():\n")
3344
+ parts.append(" _cls._RESOURCE_REGISTRY = _RESOURCE_CLASSES\n\n\n")
3345
+
3346
+ # Root client
3347
+ parts.append(_render_root_client(
3348
+ root_name, schema, root_collections, root_factories,
3349
+ error_classes=error_code_classes,
3350
+ ))
3351
+ parts.append("\n")
3352
+
3353
+ # __all__ for clean imports — built from the allocator (dup-free by
3354
+ # construction: root + resources + enums + errors all in one namespace).
3355
+ exports = names.all_exports()
3356
+ parts.append("\n__all__ = [\n")
3357
+ for e in exports:
3358
+ parts.append(f" {e!r},\n")
3359
+ parts.append("]\n")
3360
+
3361
+ return "".join(parts)