parse-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- parse_sdk/__init__.py +57 -0
- parse_sdk/_labels.py +51 -0
- parse_sdk/_oauth.py +301 -0
- parse_sdk/_project.py +184 -0
- parse_sdk/_runtime.py +2142 -0
- parse_sdk/_sanitize.py +15 -0
- parse_sdk/_sync.py +887 -0
- parse_sdk/checks.py +601 -0
- parse_sdk/cli.py +1667 -0
- parse_sdk/cli_help.py +110 -0
- parse_sdk/codegen_reconcile.py +157 -0
- parse_sdk/codegen_v2.py +3361 -0
- parse_sdk/config.py +349 -0
- parse_sdk/docgen.py +587 -0
- parse_sdk/doctor.py +348 -0
- parse_sdk/migrate.py +212 -0
- parse_sdk/preview.py +588 -0
- parse_sdk/py.typed +0 -0
- parse_sdk/resource_surface.py +196 -0
- parse_sdk/sample_gate.py +245 -0
- parse_sdk/scaffold.py +273 -0
- parse_sdk-0.1.0.dist-info/METADATA +177 -0
- parse_sdk-0.1.0.dist-info/RECORD +26 -0
- parse_sdk-0.1.0.dist-info/WHEEL +4 -0
- parse_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- parse_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
parse_sdk/codegen_v2.py
ADDED
|
@@ -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)
|