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/docgen.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
"""Offline docs renderer for `parse sync`.
|
|
2
|
+
|
|
3
|
+
Single-purpose companion to ``codegen_v2`` (which owns the typed ``.py``
|
|
4
|
+
module + its docstrings). ``docgen`` renders the human/agent-facing prose
|
|
5
|
+
artifacts from the SAME stored schema (the ``/sdk/schemas`` ``SDKSchema``
|
|
6
|
+
shape), deterministically and offline — no LLM, no network:
|
|
7
|
+
|
|
8
|
+
* ``render_readme(schema, *, has_example)`` → per-API ``README.md``
|
|
9
|
+
(endpoint index table; the ``example.py`` pointer is emitted only when
|
|
10
|
+
``has_example``).
|
|
11
|
+
* ``render_example(schema, *, slug)`` → the shippable per-API ``example.py``
|
|
12
|
+
(the stored ``sdk_usage_example`` with its self-import rebound to the
|
|
13
|
+
assigned ``slug``), or ``None`` when there is no shippable example (absent,
|
|
14
|
+
unparseable, or a collision-broken self-import).
|
|
15
|
+
* ``render_agents_index(schemas)``/``render_claude_index(schemas)`` → the
|
|
16
|
+
two identical root index files (``AGENTS.md`` / ``CLAUDE.md``).
|
|
17
|
+
* ``coverage_warnings(schema)`` → the missing-narrative gaps the CLI
|
|
18
|
+
surfaces (degraded path).
|
|
19
|
+
|
|
20
|
+
Every file carries :data:`BANNER_TEXT` so a human/agent never edits a
|
|
21
|
+
generated file expecting it to survive the next sync.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import ast
|
|
27
|
+
import re
|
|
28
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
29
|
+
|
|
30
|
+
BANNER_TEXT = "Generated by `parse sync` — edits will be overwritten. Edit the API in Parse."
|
|
31
|
+
|
|
32
|
+
# Markdown comment so the banner renders invisibly in a rendered README but is
|
|
33
|
+
# present in the raw file an agent reads.
|
|
34
|
+
_MD_BANNER = f"<!-- {BANNER_TEXT} -->"
|
|
35
|
+
# Python comment for example.py.
|
|
36
|
+
_PY_BANNER = f"# {BANNER_TEXT}"
|
|
37
|
+
|
|
38
|
+
# How many APIs the cross-API index lists inline before collapsing to the
|
|
39
|
+
# "…and M more" pointer. Module-level so tests assert against the source of
|
|
40
|
+
# truth (keeps the index O(1) in API count — see `_render_index`).
|
|
41
|
+
_INDEX_PREVIEW_COUNT = 6
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _rewrite_import_modules(code: str, nodes: "List[ast.ImportFrom]", new_module: str) -> str:
|
|
45
|
+
"""Rewrite ONLY the module token of each ``from <module> import …`` statement
|
|
46
|
+
that begins on ``node.lineno``, to ``new_module`` — preserving the imported
|
|
47
|
+
names list (and any aliases / trailing comment / parenthesised continuation)
|
|
48
|
+
byte-for-byte. The module token is the single contiguous dotted identifier
|
|
49
|
+
right after ``from`` on the statement's first physical line, so a regex bound
|
|
50
|
+
to that one line swaps it without touching anything else.
|
|
51
|
+
"""
|
|
52
|
+
lines = code.splitlines(keepends=True)
|
|
53
|
+
for n in nodes:
|
|
54
|
+
i = n.lineno - 1
|
|
55
|
+
lines[i] = re.sub(
|
|
56
|
+
r"(^\s*from\s+)[\w.]+",
|
|
57
|
+
lambda m: m.group(1) + new_module,
|
|
58
|
+
lines[i],
|
|
59
|
+
count=1,
|
|
60
|
+
)
|
|
61
|
+
return "".join(lines)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_parse_pkg_module(module: "Optional[str]") -> bool:
|
|
65
|
+
"""True iff ``module`` is the generated-package namespace — ``parse_apis`` /
|
|
66
|
+
``parse_sdk`` or a submodule of either. The agent's self-import always lives
|
|
67
|
+
here (its guess is the *submodule*, never the top-level package or a foreign
|
|
68
|
+
one), so both the rebind anchor and the collision-broken check restrict to it
|
|
69
|
+
— a genuine third-party import that happens to share the root class name is
|
|
70
|
+
left alone, not clobbered."""
|
|
71
|
+
return bool(module) and (
|
|
72
|
+
module == "parse_apis" or module.startswith("parse_apis.")
|
|
73
|
+
or module == "parse_sdk" or module.startswith("parse_sdk."))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _imports_from_parse_pkg(tree: ast.AST, name: str) -> bool:
|
|
77
|
+
"""True iff ``name`` is imported via ``from parse_apis[.…] / parse_sdk[.…]
|
|
78
|
+
import …name…`` (used to recognise a collision-broken self-import)."""
|
|
79
|
+
return any(
|
|
80
|
+
isinstance(n, ast.ImportFrom) and not n.level and _is_parse_pkg_module(n.module)
|
|
81
|
+
and any(a.name == name for a in n.names)
|
|
82
|
+
for n in ast.walk(tree))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _root_import_nodes(tree: ast.AST, root: str) -> "List[ast.ImportFrom]":
|
|
86
|
+
"""The ``from parse_apis[.…] / parse_sdk[.…] import …`` statements whose names
|
|
87
|
+
include ``root`` — this API's self-import, anchored on the one symbol codegen
|
|
88
|
+
emits AND restricted to the generated-package namespace (matching
|
|
89
|
+
:func:`_imports_from_parse_pkg`) so a foreign ``from <lib> import <Root>`` that
|
|
90
|
+
merely shares the root class name is never rebound to a wrong module."""
|
|
91
|
+
return [n for n in ast.walk(tree)
|
|
92
|
+
if isinstance(n, ast.ImportFrom) and not n.level and _is_parse_pkg_module(n.module)
|
|
93
|
+
and any(a.name == root for a in n.names)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _root_name(schema: Dict[str, Any]) -> str:
|
|
97
|
+
"""The root client class name codegen emits — delegated to codegen's single
|
|
98
|
+
allocator so a published import line can never diverge from the emitted class
|
|
99
|
+
(a ``root_name`` colliding with a resource name is suffixed there, and the
|
|
100
|
+
slug→CamelCase fallback uses codegen's ``_camel``, not a partial ``split('_')``)."""
|
|
101
|
+
from parse_sdk.codegen_v2 import resolve_root_name
|
|
102
|
+
return resolve_root_name(schema)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _import_line(schema: Dict[str, Any]) -> str:
|
|
106
|
+
"""The blessed Phase-1 import line for this API."""
|
|
107
|
+
return f"from parse_apis.{schema.get('slug')} import {_root_name(schema)}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _operations(schema: Dict[str, Any]):
|
|
111
|
+
"""Yield ``(ref, op_dict)`` for every resource + sub-resource operation,
|
|
112
|
+
in a stable (resource, op) order. ``ref`` is ``<Resource>.<op>`` (or
|
|
113
|
+
``<Resource>.<sub>.<op>``), matching the coverage unit + the gate's walk.
|
|
114
|
+
"""
|
|
115
|
+
resources = schema.get("resources")
|
|
116
|
+
if not isinstance(resources, dict):
|
|
117
|
+
return
|
|
118
|
+
for rname, rspec in resources.items():
|
|
119
|
+
if not isinstance(rspec, dict):
|
|
120
|
+
continue
|
|
121
|
+
for opname, op in (rspec.get("operations") or {}).items():
|
|
122
|
+
if isinstance(op, dict):
|
|
123
|
+
yield f"{rname}.{opname}", op
|
|
124
|
+
for sname, sspec in (rspec.get("sub_resources") or {}).items():
|
|
125
|
+
if isinstance(sspec, dict):
|
|
126
|
+
for subop, op in (sspec.get("operations") or {}).items():
|
|
127
|
+
if isinstance(op, dict):
|
|
128
|
+
yield f"{rname}.{sname}.{subop}", op
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _md_cell(text: Optional[str]) -> str:
|
|
132
|
+
"""Escape a value for a single markdown table cell (pipes + newlines)."""
|
|
133
|
+
if not isinstance(text, str):
|
|
134
|
+
return ""
|
|
135
|
+
return text.replace("\n", " ").replace("|", "\\|").strip()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _returns_label(op: Dict[str, Any]) -> str:
|
|
139
|
+
"""The README ``Returns`` cell. A paginated op (one declaring a ``pagination``
|
|
140
|
+
block) is emitted by codegen as ``Paginator[Element]`` — so surface that, not
|
|
141
|
+
the raw ``List[Element]`` spec label, which would teach a return type the SDK
|
|
142
|
+
never produces. Single-sources the element extraction from codegen."""
|
|
143
|
+
label = op.get("returns")
|
|
144
|
+
if not isinstance(label, str) or not label.strip():
|
|
145
|
+
return "—"
|
|
146
|
+
if isinstance(op.get("pagination"), dict):
|
|
147
|
+
from parse_sdk.codegen_v2 import _collection_element
|
|
148
|
+
return f"Paginator[{_collection_element(label)}]"
|
|
149
|
+
return _md_cell(label) or "—"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _reachability_warnings(schema: Dict[str, Any]) -> List[str]:
|
|
153
|
+
"""Deterministic dead-code check: a resource with NO root accessor ships as
|
|
154
|
+
unreachable code.
|
|
155
|
+
|
|
156
|
+
This is easy to miss in review (a github-shaped SDK
|
|
157
|
+
whose ``User`` was unreachable as ``high``), so this is the structural
|
|
158
|
+
backstop. A resource is REACHABLE iff ANY of (collection / safe factory /
|
|
159
|
+
referenced-as-a-type).
|
|
160
|
+
|
|
161
|
+
**This DERIVES from the shared, named predicate**
|
|
162
|
+
``parse_sdk.resource_surface.resource_surface_facts`` — the SAME function
|
|
163
|
+
codegen's ``client.<plural>`` emission gate and downstream tooling
|
|
164
|
+
consume — so the three notions of reachability cannot desync. A resource is
|
|
165
|
+
warned iff ``not is_class_reachable`` (no root collection, no safe factory,
|
|
166
|
+
not returned by any operation). Type-name matching, the ``_camel``-canonical
|
|
167
|
+
resource universe, and the normalized-schema handling all live in the shared
|
|
168
|
+
predicate now; this function only renders the warning text.
|
|
169
|
+
"""
|
|
170
|
+
from parse_sdk.codegen_v2 import _camel
|
|
171
|
+
from parse_sdk.resource_surface import resource_surface_facts
|
|
172
|
+
|
|
173
|
+
facts = resource_surface_facts(schema)
|
|
174
|
+
warns: List[str] = []
|
|
175
|
+
for rk, rfacts in facts.items():
|
|
176
|
+
if rfacts.is_class_reachable:
|
|
177
|
+
continue
|
|
178
|
+
name = _camel(rk)
|
|
179
|
+
warns.append(
|
|
180
|
+
f"{schema.get('slug')}: Resource {name!r} is unreachable: no root "
|
|
181
|
+
f"collection, no safe factory, and not returned by any operation — "
|
|
182
|
+
f"it ships as dead code."
|
|
183
|
+
)
|
|
184
|
+
return warns
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _suppressed_factory_warnings(schema: Dict[str, Any]) -> List[str]:
|
|
188
|
+
"""Slug-attributed warnings for constructible factories codegen suppresses.
|
|
189
|
+
|
|
190
|
+
Consumes the SAME ``factory_descriptors`` read the emitter's factory pass
|
|
191
|
+
is built on (one home for the suppression gates), so the CLI can print the
|
|
192
|
+
suppression through its change-gated, slugged warning channel. The
|
|
193
|
+
codegen-side ``logger.warning`` stays for library consumers with real
|
|
194
|
+
logging configs; it is not CLI output.
|
|
195
|
+
"""
|
|
196
|
+
from parse_sdk.codegen_v2 import _camel, _safe_ident, factory_descriptors
|
|
197
|
+
|
|
198
|
+
warns: List[str] = []
|
|
199
|
+
for res_name, unsafe, keyed_by in factory_descriptors(schema)["suppressed"]:
|
|
200
|
+
name = _camel(res_name)
|
|
201
|
+
warns.append(
|
|
202
|
+
f"{schema.get('slug')}: constructible factory for {name!r} suppressed — "
|
|
203
|
+
f"a reachable op pins non-key field {unsafe!r} (keyed_by={keyed_by!r}); "
|
|
204
|
+
f"no `client.{_safe_ident(name.lower())}(<{keyed_by}>)` factory; reach "
|
|
205
|
+
f"{name} via operation results."
|
|
206
|
+
)
|
|
207
|
+
return warns
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def coverage_warnings(schema: Dict[str, Any]) -> List[Tuple[str, str]]:
|
|
211
|
+
"""Per-API ``(severity, text)`` warnings. Empty when the spec is fully
|
|
212
|
+
enriched and structurally sound.
|
|
213
|
+
|
|
214
|
+
Two tiers, consumed by the CLI's echo logic: ``"structural"`` — something
|
|
215
|
+
is wrong or dead in the emitted surface (unreachable resource, suppressed
|
|
216
|
+
factory, prose contradicting the emitted signature) and always prints
|
|
217
|
+
per-line; ``"enrichment"`` — missing narrative (an endpoint with no
|
|
218
|
+
``description``) that degrades the README but breaks nothing, aggregated to
|
|
219
|
+
one line per API so dozens of identical advisories cannot drown the real
|
|
220
|
+
problems.
|
|
221
|
+
|
|
222
|
+
Also surfaces a DETERMINISTIC dead-code warning for any resource with no
|
|
223
|
+
root accessor (no collection, no safe factory, never returned) — the LLM
|
|
224
|
+
reviewer does not reliably catch this.
|
|
225
|
+
"""
|
|
226
|
+
warns: List[Tuple[str, str]] = []
|
|
227
|
+
from parse_sdk.codegen_v2 import endpoint_description_map
|
|
228
|
+
ep_descriptions = endpoint_description_map(schema)
|
|
229
|
+
missing_desc = [ref for ref, op in _operations(schema)
|
|
230
|
+
if not (isinstance(op.get("endpoint"), str)
|
|
231
|
+
and ep_descriptions.get(op["endpoint"]))]
|
|
232
|
+
if missing_desc:
|
|
233
|
+
warns.append((
|
|
234
|
+
"enrichment",
|
|
235
|
+
f"{schema.get('slug')}: {len(missing_desc)} operation(s) whose endpoint has no "
|
|
236
|
+
f"`description` ({', '.join(missing_desc)}) — their README rows will have no description."
|
|
237
|
+
))
|
|
238
|
+
# Endpoint prose still teaching a paginator-swallowed param: the
|
|
239
|
+
# emitted signature has no such kwarg, so the description contradicts the
|
|
240
|
+
# generated method (e.g. "per_page x page controls the window" with no
|
|
241
|
+
# `page` param). Surfaced for an upstream re-edit; server prose is never
|
|
242
|
+
# auto-rewritten.
|
|
243
|
+
drifted: List[str] = []
|
|
244
|
+
for ref, op in _operations(schema):
|
|
245
|
+
pg = op.get("pagination")
|
|
246
|
+
rp = pg.get("request_param") if isinstance(pg, dict) else None
|
|
247
|
+
ep = op.get("endpoint")
|
|
248
|
+
ep_desc = ep_descriptions.get(ep) if isinstance(ep, str) else None
|
|
249
|
+
if (isinstance(rp, str) and rp and isinstance(ep_desc, str)
|
|
250
|
+
and re.search(rf"\b{re.escape(rp)}\b", ep_desc)):
|
|
251
|
+
drifted.append(f"{ref} (`{rp}`)")
|
|
252
|
+
if drifted:
|
|
253
|
+
warns.append((
|
|
254
|
+
"structural",
|
|
255
|
+
f"{schema.get('slug')}: endpoint description(s) mention a pagination "
|
|
256
|
+
f"param the SDK handles automatically — {', '.join(drifted)}; edit "
|
|
257
|
+
f"the description(s) in the Parse dashboard."
|
|
258
|
+
))
|
|
259
|
+
# Deterministic dead-code (unreachable-resource) backstop — a different
|
|
260
|
+
# concern than the narrative gaps above, surfaced through the same channel.
|
|
261
|
+
warns.extend(("structural", w) for w in _reachability_warnings(schema))
|
|
262
|
+
# Factory suppression, slug-attributed (codegen's logger.warning is a
|
|
263
|
+
# library channel, not CLI output — see _suppressed_factory_warnings).
|
|
264
|
+
warns.extend(("structural", w) for w in _suppressed_factory_warnings(schema))
|
|
265
|
+
return warns
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def render_readme(schema: Dict[str, Any], *, has_example: bool = True) -> str:
|
|
269
|
+
"""Render the per-API ``README.md``.
|
|
270
|
+
|
|
271
|
+
Renders the structural reference only (title + optional API description +
|
|
272
|
+
import line + endpoint index table) — there is no separate prose narrative
|
|
273
|
+
section. Per-operation prose comes from each endpoint's ``description``,
|
|
274
|
+
surfaced in the index table's Description column.
|
|
275
|
+
|
|
276
|
+
``has_example`` says whether ``example.py`` was actually written for this
|
|
277
|
+
API (an unshippable example is dropped — see :func:`render_example`). When
|
|
278
|
+
it is false, every reference to ``example.py`` is omitted so the README does
|
|
279
|
+
not link a file that is not on disk.
|
|
280
|
+
"""
|
|
281
|
+
name = schema.get("name") or schema.get("slug") or "API"
|
|
282
|
+
lines: List[str] = [_MD_BANNER, "", f"# {name}", ""]
|
|
283
|
+
|
|
284
|
+
desc = schema.get("description")
|
|
285
|
+
if isinstance(desc, str) and desc.strip():
|
|
286
|
+
lines += [desc.strip(), ""]
|
|
287
|
+
|
|
288
|
+
lines += ["## Install / import", "", "```python", _import_line(schema), "```", ""]
|
|
289
|
+
|
|
290
|
+
# Endpoint / operation index table — always rendered (structural). The
|
|
291
|
+
# "Call" cell shows the REAL callable expression (sourced from codegen's
|
|
292
|
+
# single allocator), not the bare ``<Resource>.<op>`` ref, which is never a
|
|
293
|
+
# callable symbol. The row source is the NORMALIZED schema — the table
|
|
294
|
+
# documents the EMITTED surface, so a ``fetched_by``-synthesized ``get``
|
|
295
|
+
# gets a row (it exists in code; the raw spec never declared it).
|
|
296
|
+
# ``coverage_warnings``' own walk stays on the raw schema — its counts are
|
|
297
|
+
# a different consumer and must not change here.
|
|
298
|
+
# Normalize ONCE and share it with the two codegen reads — each would
|
|
299
|
+
# otherwise re-normalize the same schema (3× per README at corpus scale).
|
|
300
|
+
from parse_sdk.codegen_v2 import _normalize_schema, factory_descriptors
|
|
301
|
+
norm = _normalize_schema(schema)
|
|
302
|
+
ops = list(_operations(norm))
|
|
303
|
+
factories = factory_descriptors(schema, _norm=norm)["rows"]
|
|
304
|
+
if ops or factories:
|
|
305
|
+
from parse_sdk.codegen_v2 import endpoint_description_map, op_call_descriptors
|
|
306
|
+
descriptors = op_call_descriptors(schema, _norm=norm)
|
|
307
|
+
# An op's Description is the `description` of the endpoint it calls —
|
|
308
|
+
# resolved by `op["endpoint"]` (the same inline join codegen uses for the
|
|
309
|
+
# method docstring summary). Per-op `when` is retired and not read.
|
|
310
|
+
ep_descriptions = endpoint_description_map(schema)
|
|
311
|
+
intro = (
|
|
312
|
+
"Call these on the client's collection accessors "
|
|
313
|
+
"(e.g. `client.<collection>.<op>(…)`) or via active-record navigation "
|
|
314
|
+
"on returned objects"
|
|
315
|
+
)
|
|
316
|
+
intro += (
|
|
317
|
+
" — see [`example.py`](./example.py) for the exact call spelling."
|
|
318
|
+
if has_example else "."
|
|
319
|
+
)
|
|
320
|
+
intro += " List operations return a lazy `Paginator`."
|
|
321
|
+
lines += [
|
|
322
|
+
"## Operations", "",
|
|
323
|
+
intro, "",
|
|
324
|
+
"| Call | Returns | Description |",
|
|
325
|
+
"|---|---|---|",
|
|
326
|
+
]
|
|
327
|
+
for ref, op in ops:
|
|
328
|
+
returns = _returns_label(op)
|
|
329
|
+
ep = op.get("endpoint")
|
|
330
|
+
desc = _md_cell(ep_descriptions.get(ep) if isinstance(ep, str) else None) or "—"
|
|
331
|
+
pg = op.get("pagination")
|
|
332
|
+
if isinstance(pg, dict):
|
|
333
|
+
# Deterministic, ADDITIVE clarification — the endpoint's prose
|
|
334
|
+
# may still describe the raw API's page/cursor param, which the
|
|
335
|
+
# paginator swallowed from the emitted signature. Server
|
|
336
|
+
# prose is never rewritten; the drift itself is surfaced via
|
|
337
|
+
# coverage_warnings for an upstream re-edit. The note tells the
|
|
338
|
+
# truth per scheme: a single_page op never advances, so
|
|
339
|
+
# promising "pagination is handled" teaches depth that doesn't
|
|
340
|
+
# exist (most corpus ops are single_page).
|
|
341
|
+
note = (
|
|
342
|
+
"Single-fetch result set; the iterator yields one page."
|
|
343
|
+
if pg.get("scheme") == "single_page" else
|
|
344
|
+
"Pagination is handled by the returned iterator; "
|
|
345
|
+
"page/cursor parameters are not exposed."
|
|
346
|
+
)
|
|
347
|
+
desc = note if desc == "—" else f"{desc} {note}"
|
|
348
|
+
call = descriptors.get(ref, ref)
|
|
349
|
+
# Signatures carry raw pipes (`Sort | Literal['a'] | str`); an
|
|
350
|
+
# unescaped `|` splits a GFM cell even inside a code span.
|
|
351
|
+
lines.append(f"| `{_md_cell(call)}` | `{_md_cell(returns)}` | {desc} |")
|
|
352
|
+
# Constructible root factories — emitted callables that are not spec
|
|
353
|
+
# operations (the factory pass synthesizes them), so the raw/normalized
|
|
354
|
+
# op walk can never produce their rows.
|
|
355
|
+
for attr, res_cls, key_ident, key_anno in factories:
|
|
356
|
+
call = f"client.{attr}({key_ident}: {key_anno})"
|
|
357
|
+
desc = (f"Constructs a local `{res_cls}` handle from a known "
|
|
358
|
+
f"`{key_ident}` — no request is made.")
|
|
359
|
+
lines.append(f"| `{_md_cell(call)}` | `{_md_cell(res_cls)}` | {desc} |")
|
|
360
|
+
lines.append("")
|
|
361
|
+
|
|
362
|
+
# Typed-error reference — API-level by design: errors are declared per-API
|
|
363
|
+
# (`schema['errors']` → one `_ERROR_CLASSES` registry); no per-operation
|
|
364
|
+
# `raises` exists in the schema, so rows name what each error means, not
|
|
365
|
+
# which op raises it. Class names come from the module allocator so the
|
|
366
|
+
# table names the importable spelling even under a collision suffix.
|
|
367
|
+
errors = schema.get("errors")
|
|
368
|
+
if isinstance(errors, dict) and errors:
|
|
369
|
+
from parse_sdk.codegen_v2 import error_class_names
|
|
370
|
+
alloc = error_class_names(schema)
|
|
371
|
+
lines += [
|
|
372
|
+
"## Errors", "",
|
|
373
|
+
"| Error | Raised when | Carries |",
|
|
374
|
+
"|---|---|---|",
|
|
375
|
+
]
|
|
376
|
+
for err_name, err_spec in errors.items():
|
|
377
|
+
if not isinstance(err_spec, dict):
|
|
378
|
+
continue
|
|
379
|
+
cls = alloc.get(err_name, err_name)
|
|
380
|
+
when = _md_cell(err_spec.get("when")) or "—"
|
|
381
|
+
carries = err_spec.get("carries")
|
|
382
|
+
carries_cell = (
|
|
383
|
+
", ".join(f"`{_md_cell(str(c))}`" for c in carries)
|
|
384
|
+
if isinstance(carries, list) and carries else "—"
|
|
385
|
+
)
|
|
386
|
+
lines.append(f"| `{_md_cell(cls)}` | {when} | {carries_cell} |")
|
|
387
|
+
lines += [
|
|
388
|
+
"",
|
|
389
|
+
"Runtime errors (`RateLimitError`, `UpstreamError`, "
|
|
390
|
+
"`PaginationLimitError`, …) are importable from `parse_apis` and can "
|
|
391
|
+
"occur on any call.",
|
|
392
|
+
"",
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
# F10 (generic) — when this API declares any enum, warn that a param may
|
|
396
|
+
# accept an enum whose corresponding OUTPUT field is raw `str`/`list`, so
|
|
397
|
+
# comparing it against an enum member never matches. Generic (no strict
|
|
398
|
+
# same-name join — for some APIs the param is singular and the output
|
|
399
|
+
# field is a plural list, which an exact-name detector would miss). Per-API
|
|
400
|
+
# (README), NOT the shared Conventions block, so AGENTS==CLAUDE identity holds.
|
|
401
|
+
enums = schema.get("enums")
|
|
402
|
+
if isinstance(enums, dict) and enums:
|
|
403
|
+
lines += [
|
|
404
|
+
"> **Note.** Some params accept an enum whose corresponding output "
|
|
405
|
+
"field is raw `str`/`list` (the wire value isn't bound to the enum). "
|
|
406
|
+
"Enum members never compare equal to raw strings — compare such "
|
|
407
|
+
"output values against `member.value`, or use plain string literals.",
|
|
408
|
+
"",
|
|
409
|
+
]
|
|
410
|
+
|
|
411
|
+
if has_example:
|
|
412
|
+
lines += [
|
|
413
|
+
"## Example", "",
|
|
414
|
+
"See [`example.py`](./example.py) for a runnable usage example.", "",
|
|
415
|
+
]
|
|
416
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def render_example(schema: Dict[str, Any], *, slug: str) -> Optional[str]:
|
|
420
|
+
"""Render the shippable per-API ``example.py`` from the stored example, or
|
|
421
|
+
``None`` when there is no shippable example.
|
|
422
|
+
|
|
423
|
+
The single render-time chokepoint for "is this example's self-import
|
|
424
|
+
correct". The agent authored the example before the slug existed, so its
|
|
425
|
+
self-import names the right client class but (often) the wrong module. We
|
|
426
|
+
bind the import path correct-by-construction:
|
|
427
|
+
|
|
428
|
+
* ``schema`` is the schema codegen RENDERED (so ``resolve_root_name`` is
|
|
429
|
+
the exact root class the module emits), and ``slug`` is the ASSIGNED slug
|
|
430
|
+
(the on-disk directory + import path) — passed explicitly, never the raw
|
|
431
|
+
``schema['slug']``.
|
|
432
|
+
* The self-import is the ``from parse_apis[.…] / parse_sdk[.…] import …``
|
|
433
|
+
statement that imports the emitted root; its module token is rebound to
|
|
434
|
+
``parse_apis.<slug>`` and the imported-names list is preserved verbatim.
|
|
435
|
+
This subsumes the legacy ``parse_sdk.`` prefix and mis-dotted cases — all
|
|
436
|
+
just "root imported from the wrong module". The parse-namespace
|
|
437
|
+
restriction (matching the collision check) means a foreign
|
|
438
|
+
``from <lib> import <Root>`` sharing the root class name is left alone.
|
|
439
|
+
|
|
440
|
+
Returns ``None`` (the example is dropped) when it is unshippable:
|
|
441
|
+
|
|
442
|
+
* the stored code is unparseable (fail-closed — never abort the sync);
|
|
443
|
+
* it carries a CONFIRMED collision-broken self-import — it imports the
|
|
444
|
+
DECLARED root (``root_name``) that codegen suffixed away
|
|
445
|
+
(``declared != emitted``) from the generated-package namespace, which the
|
|
446
|
+
module-rebind cannot make correct (it would import the resource class).
|
|
447
|
+
|
|
448
|
+
An example with no self-import of THIS API (a minimal snippet, or one that
|
|
449
|
+
imports only OTHER APIs) carries nothing broken and ships as-is.
|
|
450
|
+
|
|
451
|
+
Scope: only the import naming the emitted root is rebound. A self-import that
|
|
452
|
+
names ONLY a resource class (e.g. ``from parse_apis.<wrong> import Listing``)
|
|
453
|
+
on its own line — not the root — is not corrected. A real usage example
|
|
454
|
+
imports the root client (handled here, mis-dotted module and all); a
|
|
455
|
+
resource-only self-import is not observed in agent output, and re-deriving the
|
|
456
|
+
module from a slug-string match is exactly the brittle predicate this rebind
|
|
457
|
+
replaced.
|
|
458
|
+
"""
|
|
459
|
+
code = schema.get("sdk_usage_example")
|
|
460
|
+
if not (isinstance(code, str) and code.strip()):
|
|
461
|
+
return None
|
|
462
|
+
try:
|
|
463
|
+
tree = ast.parse(code)
|
|
464
|
+
except (SyntaxError, ValueError):
|
|
465
|
+
return None # unparseable → unshippable (fail-closed)
|
|
466
|
+
|
|
467
|
+
emitted_root = _root_name(schema)
|
|
468
|
+
targets = _root_import_nodes(tree, emitted_root)
|
|
469
|
+
if targets:
|
|
470
|
+
code = _rewrite_import_modules(code, targets, f"parse_apis.{slug}")
|
|
471
|
+
return f"{_PY_BANNER}\n{code.rstrip()}\n"
|
|
472
|
+
|
|
473
|
+
declared_root = schema.get("root_name")
|
|
474
|
+
if (declared_root and declared_root != emitted_root
|
|
475
|
+
and _imports_from_parse_pkg(tree, declared_root)):
|
|
476
|
+
return None # collision-broken self-import → drop
|
|
477
|
+
|
|
478
|
+
return f"{_PY_BANNER}\n{code.rstrip()}\n"
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _api_one_liner(schema: Dict[str, Any]) -> str:
|
|
482
|
+
"""A one-line summary for the cross-API index — the description's first
|
|
483
|
+
line, else the slug."""
|
|
484
|
+
desc = schema.get("description")
|
|
485
|
+
if isinstance(desc, str) and desc.strip():
|
|
486
|
+
return desc.strip().splitlines()[0]
|
|
487
|
+
return str(schema.get("slug") or "")
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _render_index(schemas: List[Dict[str, Any]], *, base_url: str) -> str:
|
|
491
|
+
"""The shared body for ``AGENTS.md`` / ``CLAUDE.md`` — a cross-API TOC
|
|
492
|
+
plus global SDK conventions. Both files get byte-identical content (two
|
|
493
|
+
real files, not a symlink — symlinks are flaky on Windows/git).
|
|
494
|
+
|
|
495
|
+
This file loads on demand (when an agent works in the generated tree), not
|
|
496
|
+
every session. The ``## APIs`` table is a bounded PREVIEW — the first 6
|
|
497
|
+
APIs (slug-sorted), each as import line + one-line summary + example
|
|
498
|
+
availability — so the index stays ~O(1) regardless of how many APIs are
|
|
499
|
+
connected; the rest are enumerated on demand from the generated tree or
|
|
500
|
+
``parse list --json``. Each API's detail surface is its ``<slug>/README.md``.
|
|
501
|
+
"""
|
|
502
|
+
lines: List[str] = [
|
|
503
|
+
_MD_BANNER, "",
|
|
504
|
+
"# Parse typed APIs", "",
|
|
505
|
+
"Typed Python clients generated by `parse sync` from your Parse APIs. "
|
|
506
|
+
"They are installed as the editable `parse_apis` package, so "
|
|
507
|
+
"`from parse_apis.<slug> import <Root>` resolves from any directory. "
|
|
508
|
+
"Each API's reference is `<slug>/README.md`; APIs marked ✓ below also "
|
|
509
|
+
"ship a runnable `<slug>/example.py`.", "",
|
|
510
|
+
"## APIs", "",
|
|
511
|
+
"| Import (client) | Summary | Example |",
|
|
512
|
+
"|---|---|---|",
|
|
513
|
+
]
|
|
514
|
+
_ordered = sorted(schemas, key=lambda x: str(x.get("slug") or ""))
|
|
515
|
+
for s in _ordered[:_INDEX_PREVIEW_COUNT]:
|
|
516
|
+
# An unshippable example is dropped (see render_example) — the row
|
|
517
|
+
# says so instead of silently omitting it, so its absence is a fact
|
|
518
|
+
# with a remedy, not a mystery.
|
|
519
|
+
example_cell = ("✓" if s.get("_has_example")
|
|
520
|
+
else "unavailable — fix in Parse")
|
|
521
|
+
lines.append(
|
|
522
|
+
f"| `{_md_cell(_import_line(s))}` | {_md_cell(_api_one_liner(s))} "
|
|
523
|
+
f"| {example_cell} |")
|
|
524
|
+
lines.append("")
|
|
525
|
+
if len(_ordered) > _INDEX_PREVIEW_COUNT:
|
|
526
|
+
# Don't blur local-importable (the generated tree) vs live (the server)
|
|
527
|
+
# inventory: imports come from the tree; `parse list` is server-side.
|
|
528
|
+
lines += [
|
|
529
|
+
f"_…and {len(_ordered) - _INDEX_PREVIEW_COUNT} more. For local "
|
|
530
|
+
"importable clients, "
|
|
531
|
+
"list the slug directories under `src/parse_apis/` and read each "
|
|
532
|
+
"`<slug>/README.md`; for live APIs your key can call, run "
|
|
533
|
+
"`uv run parse list --json`._",
|
|
534
|
+
"",
|
|
535
|
+
]
|
|
536
|
+
|
|
537
|
+
lines += [
|
|
538
|
+
"## Conventions", "",
|
|
539
|
+
"- **Import model.** `from parse_apis.<slug> import <Root>`. `parse_apis` is "
|
|
540
|
+
"an installed editable package, so this import resolves from any directory — "
|
|
541
|
+
"no need to run from a particular folder.",
|
|
542
|
+
"- **Auth.** Construct a client with `api_key=...`, or rely on the configured "
|
|
543
|
+
"Parse credentials (`parse login`).",
|
|
544
|
+
"- **Pagination.** List operations return a lazy paginator — iterate it; it "
|
|
545
|
+
"fetches pages on demand and stops at the declared boundary. If the upstream "
|
|
546
|
+
"refuses deeper pages (a result-window cap), iteration raises "
|
|
547
|
+
"`PaginationLimitError` — items already yielded are valid; bound the scan "
|
|
548
|
+
"with `limit=`, or catch it (`.list()` attaches the collected items as "
|
|
549
|
+
"`err.partial_items`).",
|
|
550
|
+
"- **Field typing is best-effort.** Field types are coerced best-effort at "
|
|
551
|
+
"construction; construction never raises. An un-parseable-but-present value "
|
|
552
|
+
"is kept RAW — a `float`-typed field may hold a `str` (e.g. `'negotiable'`), "
|
|
553
|
+
"and a `bool`-typed field parses common string values like `'false'` / `'0'` "
|
|
554
|
+
"without falling back to Python truthiness. A typed field may also be "
|
|
555
|
+
"`None` if the scraper did not capture it on this object — e.g. a "
|
|
556
|
+
"detail-only field read off a summary is absent there. Validate values you "
|
|
557
|
+
"branch on; don't assume the annotation was enforced.",
|
|
558
|
+
"- **Errors.** The runtime error classes (`ParseError`, `AuthError`, "
|
|
559
|
+
"`NotFoundError`, `RateLimitError`, `UpstreamError`, "
|
|
560
|
+
"`PaginationLimitError`, …) are importable from `parse_apis`. On a caught "
|
|
561
|
+
"error read `str(e)` (the actionable message), `e.code` (the stable wire "
|
|
562
|
+
"kind), `e.status_code` (the Parse GATEWAY's HTTP status), and "
|
|
563
|
+
"`e.upstream_status_code` / `e.snippet` / `e.url` (the TARGET SITE's "
|
|
564
|
+
"status, body excerpt, and URL when the gateway relayed one). APIs also "
|
|
565
|
+
"declare typed per-API errors — subclasses listed in each README — you "
|
|
566
|
+
"can catch by name. Retries: the SDK auto-retries ONLY gateway 429 on "
|
|
567
|
+
"GET/HEAD (honoring `Retry-After`); everything else surfaces immediately.",
|
|
568
|
+
"- **Self-pacing (optional).** Each response's cost/limit headers are "
|
|
569
|
+
"parsed onto `client.last_meta` (after a successful call) and `err.meta` "
|
|
570
|
+
"(on a raised error): `credits_remaining`, `rate_limit_remaining` / "
|
|
571
|
+
"`rate_limit_reset`, `retry_after`. Read them only if you want to throttle "
|
|
572
|
+
"proactively — the SDK already handles 429 backpressure for you.",
|
|
573
|
+
f"- **Source.** Generated from {base_url}. Re-run `parse sync` to refresh; do "
|
|
574
|
+
"not hand-edit generated files.",
|
|
575
|
+
"",
|
|
576
|
+
]
|
|
577
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def render_agents_index(schemas: List[Dict[str, Any]], *, base_url: str) -> str:
|
|
581
|
+
"""Root ``AGENTS.md`` content (cross-API index + conventions)."""
|
|
582
|
+
return _render_index(schemas, base_url=base_url)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def render_claude_index(schemas: List[Dict[str, Any]], *, base_url: str) -> str:
|
|
586
|
+
"""Root ``CLAUDE.md`` content — byte-identical to ``AGENTS.md``."""
|
|
587
|
+
return _render_index(schemas, base_url=base_url)
|