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