parse-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ """The ONE named, stable SDK reachability/entity predicate.
2
+
3
+ This module is the single source of truth for "what is the emitted client surface
4
+ of a resource". It is imported by THREE consumers that previously each computed
5
+ reachability independently and could silently desync:
6
+
7
+ 1. **codegen** (``codegen_v2._ModuleNames``) — gates the ``client.<plural>``
8
+ collection ``@property`` (``emits_client_accessor``);
9
+ 2. an external consumer — asserts the
10
+ exact complement (a resource it flags unreachable must NOT emit an accessor),
11
+ and pins lockstep tests against THIS named API so a future SDK refactor can't
12
+ silently break backend validation;
13
+ 3. **docgen** (``docgen._reachability_warnings``) — warns on a resource that
14
+ ships as dead code (``not is_class_reachable``).
15
+
16
+ Because consumer 2 lives in a different repository, this is a **stable named
17
+ API** — ``parse_sdk.resource_surface.resource_surface_facts`` — NOT an
18
+ SDK-internal helper. It is additive (a NEW module / the cut's only NEW exported
19
+ symbol); it does not rename or remove any existing symbol.
20
+
21
+ Per-resource facts (each a SEPARATE, independently-meaningful signal):
22
+
23
+ * ``has_root_collection`` — a pinless op with a string endpoint routes to a
24
+ root collection (``_root_collection_emit_ops``). The synthesized
25
+ ``fetched_by`` ``.get`` is folded in by ``_normalize_schema``, so this single
26
+ fact already covers the "root-collection emit-ops OR fetched_by" entity arm.
27
+ * ``has_safe_factory`` — ``constructible`` AND an instance-navigable surface (a
28
+ ``$self``-pinned op OR a sub-resource) AND a ``str`` ``keyed_by`` AND every
29
+ ``$self`` pin uses the key (``_constructible_unsafe_pin`` clean) — the SAME
30
+ gate codegen's factory pass uses. This is the safe-``constructible`` entity arm.
31
+ * ``is_type_referenced`` — the resource's emitted class name is named by some op
32
+ ``returns`` (excluding its OWN ops — a resource can't bootstrap itself), a
33
+ field type, a sub-resource ``of``, or a sub-resource op ``returns``.
34
+ * ``is_class_reachable`` = ``has_root_collection or has_safe_factory or
35
+ is_type_referenced`` — the resource's class is genuinely handed to the user
36
+ SOMEHOW; its negation is docgen's "ships as dead code" warning.
37
+ * ``emits_client_accessor`` = ``has_root_collection`` — whether codegen emits a
38
+ ``client.<plural>`` collection ``@property`` for this resource. A safe-factory
39
+ resource is reachable but its client surface is the no-network factory
40
+ ``@property`` (``_factory_rows``), NOT a ``client.<plural>`` collection; a pure
41
+ value-object (type-referenced only) emits NEITHER. So this fact is the
42
+ collection-accessor gate specifically, and ``is_class_reachable`` is the
43
+ broader "not dead code" question — keeping them SEPARATE is what lets codegen
44
+ suppress dead ``client.<plural>`` while docgen still credits factory/type
45
+ reachability.
46
+
47
+ The facts mirror existing codegen/docgen behavior EXACTLY (this is a
48
+ centralization, not a behavior change): emitting bytes are unchanged — it
49
+ removes the parallel computations so the three consumers can never drift.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ from typing import Any, Dict, NamedTuple, Optional, Set
55
+
56
+
57
+ class ResourceFacts(NamedTuple):
58
+ """The reachability/entity surface of a single resource. Field names are the
59
+ a stable public contract — external lockstep tests read them by name."""
60
+
61
+ has_root_collection: bool
62
+ has_safe_factory: bool
63
+ is_type_referenced: bool
64
+ is_class_reachable: bool
65
+ emits_client_accessor: bool
66
+
67
+
68
+ def resource_surface_facts(schema: Dict[str, Any]) -> Dict[str, ResourceFacts]:
69
+ """``{resource_spec_key -> ResourceFacts}`` for every resource in ``schema``.
70
+
71
+ Computed on the NORMALIZED schema (so a ``fetched_by``-synthesized ``.get``
72
+ counts as a root collection and a normalized-vs-raw caller agree). The key is
73
+ the resource's spec key in the normalized schema; type-reference matching is
74
+ against the ``_camel``-canonical resource universe, mirroring codegen's
75
+ ``_allocate_returns`` resolution and the field-resolvability gate.
76
+
77
+ Accepts a raw OR already-normalized schema (it normalizes internally and is
78
+ idempotent under re-normalization), so a cross-repo caller need not import the
79
+ SDK's private normalizer. Returns ``{}`` when the schema declares no resources.
80
+ """
81
+ # Imported lazily so this module has no import-time cycle with codegen_v2
82
+ # (which is the heavier module and may import this in turn).
83
+ from parse_sdk.codegen_v2 import (
84
+ _camel,
85
+ _is_safe_factory,
86
+ _normalize_schema,
87
+ _referenced_resource_names,
88
+ _root_collection_emit_ops,
89
+ )
90
+
91
+ norm = _normalize_schema(schema)
92
+ resources = norm.get("resources")
93
+ if not isinstance(resources, dict) or not resources:
94
+ return {}
95
+
96
+ # Codegen resolves a return/field leaf to a resource by ``_camel`` identity
97
+ # (``_allocate_returns``), so the resource-name universe is the canonical
98
+ # ``_camel`` form of every resource key.
99
+ camel_names: Set[str] = {
100
+ _camel(rk) for rk, rv in resources.items() if isinstance(rv, dict)
101
+ }
102
+ # Under a ``_camel`` collision (e.g. ``Post`` + ``post``) codegen allocates
103
+ # the FIRST resource the un-suffixed class (``Post``) and the collider a
104
+ # suffix (``Post_`` — see ``_allocate_ident``); a bare-name reference
105
+ # (``featured: "Post"``) resolves to the un-suffixed class only. So a
106
+ # ``_camel`` reference credits ONLY the canonical (first-allocated) resource
107
+ # for that name — else the dead collider inherits the canonical's
108
+ # reachability and the fact API lies to its consumers (codegen/docgen/the
109
+ # external lockstep). ``has_root_collection``/``has_safe_factory`` are
110
+ # per-resource and unaffected; only the type-reference credit needs gating.
111
+ _canonical_rk_for_camel: Dict[str, str] = {}
112
+ for rk, rv in resources.items():
113
+ if isinstance(rv, dict):
114
+ _canonical_rk_for_camel.setdefault(_camel(rk), rk)
115
+
116
+ # --- gather every type label that could name a resource, ONCE -------------
117
+ # (op returns, field types, sub-resource ``of`` + sub-op returns). An own
118
+ # op's ``returns`` must NOT self-credit the owner (a resource can't bootstrap
119
+ # itself; the legit "collection op returns self" case is clause 1's job).
120
+ referenced_names: Set[str] = set()
121
+
122
+ def _credit(label: Any, *, exclude: Optional[str] = None) -> None:
123
+ if not isinstance(label, str):
124
+ return
125
+ names = _referenced_resource_names(label, camel_names)
126
+ if exclude is not None:
127
+ names = names - {exclude}
128
+ referenced_names.update(names)
129
+
130
+ for rk, rspec in resources.items():
131
+ if not isinstance(rspec, dict):
132
+ continue
133
+ own_name = _camel(rk)
134
+ ops = rspec.get("operations")
135
+ ops = ops if isinstance(ops, dict) else {}
136
+ fields = rspec.get("fields")
137
+ fields = fields if isinstance(fields, dict) else {}
138
+ subs = rspec.get("sub_resources")
139
+ subs = subs if isinstance(subs, dict) else {}
140
+ for op in ops.values():
141
+ if isinstance(op, dict):
142
+ _credit(op.get("returns"), exclude=own_name)
143
+ for fval in fields.values():
144
+ if isinstance(fval, str):
145
+ _credit(fval)
146
+ elif isinstance(fval, dict):
147
+ _credit(fval.get("type"))
148
+ for sspec in subs.values():
149
+ if not isinstance(sspec, dict):
150
+ continue
151
+ _credit(sspec.get("of"))
152
+ sops = sspec.get("operations")
153
+ sops = sops if isinstance(sops, dict) else {}
154
+ for sop in sops.values():
155
+ if isinstance(sop, dict):
156
+ _credit(sop.get("returns"))
157
+
158
+ out: Dict[str, ResourceFacts] = {}
159
+ for rk, rspec in resources.items():
160
+ if not isinstance(rspec, dict):
161
+ continue
162
+ ops = rspec.get("operations")
163
+ ops = ops if isinstance(ops, dict) else {}
164
+ subs = rspec.get("sub_resources")
165
+ subs = subs if isinstance(subs, dict) else {}
166
+
167
+ # 1. Root collection (== the client.<plural> collection-accessor gate).
168
+ has_root_collection = bool(_root_collection_emit_ops(ops))
169
+
170
+ # 2. Safe factory — codegen's _factory_gate is the single source (so this
171
+ # reachability fact can never drift from the emitter's factory pass).
172
+ has_safe_factory = _is_safe_factory(rspec, ops, subs)
173
+
174
+ # 3. Referenced as a type (its emitted class is genuinely returned).
175
+ # Only the canonical resource for a colliding ``_camel`` is the target
176
+ # of a bare-name reference (the collider gets a suffixed class no
177
+ # bare label names) — so a non-canonical collider is NOT type-referenced.
178
+ _rk_camel = _camel(rk)
179
+ is_type_referenced = (
180
+ _rk_camel in referenced_names
181
+ and _canonical_rk_for_camel.get(_rk_camel) == rk
182
+ )
183
+
184
+ is_class_reachable = has_root_collection or has_safe_factory or is_type_referenced
185
+
186
+ out[rk] = ResourceFacts(
187
+ has_root_collection=has_root_collection,
188
+ has_safe_factory=has_safe_factory,
189
+ is_type_referenced=is_type_referenced,
190
+ is_class_reachable=is_class_reachable,
191
+ # The client.<plural> collection accessor is gated by the
192
+ # root-collection fact alone (factory/value-object resources do not
193
+ # get a collection accessor) — so codegen suppresses dead client.<plural>.
194
+ emits_client_accessor=has_root_collection,
195
+ )
196
+ return out
@@ -0,0 +1,245 @@
1
+ """Deterministic field-type ⟷ sample reconciliation.
2
+
3
+ The generated SDKs faithfully try to coerce whatever field type the LLM author
4
+ wrote. When that type is wrong (``release_date: Dict[str, str]`` over a real
5
+ ``{"date": str, "coming_soon": bool}`` payload), the SDK can still type-check
6
+ while exposing raw or best-effort-coerced values at runtime. This module is the
7
+ deterministic verifier that catches the mismatch against the captured
8
+ ``return_schema.sample``.
9
+
10
+ ``gate_field(declared_label, samples, *, is_key=False) -> Verdict`` is pure: it
11
+ reads value-types only, never emits the sample into output, and floors a
12
+ contradicted ``Dict[str, SCALAR]`` / ``List[Dict[str, SCALAR]]`` / ``List[SCALAR]``
13
+ field to the Any-form of the OBSERVED outer shape (``Any`` is sound — raw
14
+ passthrough at runtime). The repair label is always one ``_field_annotation``
15
+ emits verbatim (the resolvability invariant).
16
+
17
+ ``samples`` is the list of OBSERVED VALUES for ONE field, already projected by
18
+ The projection (``resource → operation → endpoint → return_schema.sample`` with envelope
19
+ descent). The gate never extracts a wire key itself.
20
+
21
+ Built-in invariants:
22
+ * a declared ``Optional`` is preserved on repair independent of
23
+ whether the sample showed null (dropping it makes ``x = None`` a type error).
24
+ * the ``_KEY_FIELD`` is never rewritten (``is_key=True`` →
25
+ ``CONFIRMED``); widening a key to ``Any`` breaks ``__eq__``/``__hash__``/dedup.
26
+ * ``samples`` is guarded: ``None → []``; a non-list (a bare dict/str
27
+ would silently iterate keys/chars) → ``UNDECIDABLE``.
28
+ * an inner null in a ``List``/``Dict`` re-wraps the inner ``Optional``.
29
+ * Repair floors to the **observed** outer shape (fixes outer-container flip:
30
+ ``List[str]`` over a dict payload → ``Dict[str, Any]``, not ``List[Any]``).
31
+ * Sanitized placeholder leaves (``REDACTED_*`` etc.) are treated as
32
+ ``UNDECIDABLE`` evidence — residual serving-time defense, not the guarantee.
33
+
34
+ Scalar-declared fields (``str``/``int``/...) get a verdict but are never
35
+ repaired here (``repaired_label is None``); scalar mistypes route to the
36
+ runtime backstop. Everything else (``X | str`` open unions, ``Dict[int, X]``,
37
+ nesting beyond one level, resources, ``datetime``/``Decimal``/``Any``) is
38
+ out-of-scope → the declared label is kept (``CONFIRMED``, never touched).
39
+ """
40
+ from __future__ import annotations
41
+
42
+ from dataclasses import dataclass
43
+ from typing import Any, List, Optional, Tuple
44
+
45
+ from parse_sdk._labels import split_top as _split_top
46
+ from parse_sdk._labels import strip_wrapper as _inner
47
+
48
+ _SCALARS = ("str", "int", "float", "bool")
49
+
50
+ # Sanitizer placeholder leaves — a fixed allow-list is residual defense only
51
+ # (an LLM sanitizer can emit any string; the build-time persisted repair is the
52
+ # real guarantee). A leaf equal to one of these is "no evidence", not proof.
53
+ _PLACEHOLDER_EXACT = frozenset({
54
+ "CRITICAL_PII_REDACTED", "John Doe", "Jane Doe",
55
+ "user@example.com", "+1 (555) 012-3456", "123 Main St",
56
+ })
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Verdict:
61
+ status: str # "CONFIRMED" | "CONTRADICTED" | "UNDECIDABLE"
62
+ repaired_label: Optional[str] # non-None only when a rewrite is warranted
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class _Parsed:
67
+ kind: str # "scalar" | "dict_scalar" | "list_scalar" | "list_dict_scalar" | "opaque"
68
+ scalar: Optional[str]
69
+ optional: bool
70
+
71
+
72
+ def _is_placeholder(v: Any) -> bool:
73
+ if not isinstance(v, str):
74
+ return False
75
+ return v.startswith("REDACTED_") or v in _PLACEHOLDER_EXACT
76
+
77
+
78
+ # Depth-0 comma split — single source in parse_sdk._labels (the hand-rolled
79
+ # copies here, in codegen_reconcile, and in the runtime drifted historically);
80
+ # imported at the top as ``_split_top``.
81
+
82
+
83
+ def _parse_label(label: Any) -> _Parsed:
84
+ """Classify a declared field label into its reconciliation kind. A non-string
85
+ label (a malformed FieldSpec like ``{"type": 123}``) is out-of-scope/opaque —
86
+ never reconciled, never raised on (the downstream codegen path handles it
87
+ leniently)."""
88
+ if not isinstance(label, str):
89
+ return _Parsed("opaque", None, False)
90
+ s = label.strip()
91
+ optional = False
92
+ inner = _inner(s, "Optional[")
93
+ if inner is not None:
94
+ optional, s = True, inner
95
+ # A top-level union (``X | str``) is an open-enum/leaf form — never touched.
96
+ if "|" in s:
97
+ return _Parsed("opaque", None, optional)
98
+ if s in _SCALARS:
99
+ return _Parsed("scalar", s, optional)
100
+ d = _inner(s, "Dict[")
101
+ if d is not None:
102
+ parts = _split_top(d)
103
+ if len(parts) == 2 and parts[0] == "str" and parts[1] in _SCALARS:
104
+ return _Parsed("dict_scalar", parts[1], optional)
105
+ return _Parsed("opaque", None, optional)
106
+ li = _inner(s, "List[")
107
+ if li is not None:
108
+ if li in _SCALARS:
109
+ return _Parsed("list_scalar", li, optional)
110
+ dd = _inner(li, "Dict[")
111
+ if dd is not None:
112
+ parts = _split_top(dd)
113
+ if len(parts) == 2 and parts[0] == "str" and parts[1] in _SCALARS:
114
+ return _Parsed("list_dict_scalar", parts[1], optional)
115
+ return _Parsed("opaque", None, optional)
116
+ return _Parsed("opaque", None, optional)
117
+
118
+
119
+ def _is_scalar_instance(v: Any, scalar: "Optional[str]") -> bool:
120
+ # ``scalar`` is Optional because ``_Parsed.scalar`` is — a scalar-kinded
121
+ # parse always sets it (invariant by construction), and the str fallthrough
122
+ # is total either way.
123
+ # ``bool`` subclasses ``int`` — check bool first, and exclude bool from
124
+ # int/float so a wire ``True`` never satisfies an ``int``/``float`` field.
125
+ if scalar == "bool":
126
+ return isinstance(v, bool)
127
+ if scalar == "int":
128
+ return isinstance(v, int) and not isinstance(v, bool)
129
+ if scalar == "float":
130
+ return isinstance(v, float) and not isinstance(v, bool)
131
+ return isinstance(v, str)
132
+
133
+
134
+ def _wrap(base: str, optional: bool) -> str:
135
+ return f"Optional[{base}]" if optional else base
136
+
137
+
138
+ def _observed_floor(non_null: List[Any], optional: bool) -> str:
139
+ """Floor to the Any-form of the OBSERVED outer shape across all samples."""
140
+ saw_dict = any(isinstance(v, dict) for v in non_null)
141
+ lists = [v for v in non_null if isinstance(v, list)]
142
+ saw_list = bool(lists)
143
+ if saw_dict and not saw_list:
144
+ return _wrap("Dict[str, Any]", optional)
145
+ if saw_list and not saw_dict:
146
+ elems = [e for lst in lists for e in lst]
147
+ if elems and all(isinstance(e, dict) for e in elems):
148
+ return _wrap("List[Dict[str, Any]]", optional)
149
+ return _wrap("List[Any]", optional)
150
+ # Scalars-only, or a mixed dict+list across samples — the honest floor is
151
+ # bare Any.
152
+ return _wrap("Any", optional)
153
+
154
+
155
+ def _real_leaves(values: List[Any]) -> Tuple[List[Any], bool]:
156
+ """Drop nulls + sanitized placeholders; report whether a null was present."""
157
+ saw_null = any(v is None for v in values)
158
+ leaves = [v for v in values if v is not None and not _is_placeholder(v)]
159
+ return leaves, saw_null
160
+
161
+
162
+ def gate_field(declared_label: str, samples: Any, *, is_key: bool = False) -> Verdict:
163
+ # Never rewrite a key field (owned by the original coercer).
164
+ if is_key:
165
+ return Verdict("CONFIRMED", None)
166
+
167
+ parsed = _parse_label(declared_label)
168
+ if parsed.kind == "opaque":
169
+ return Verdict("CONFIRMED", None) # keep the declared label, untouched
170
+
171
+ # Guard the samples arg.
172
+ if samples is None:
173
+ samples = []
174
+ if not isinstance(samples, list):
175
+ return Verdict("UNDECIDABLE", None)
176
+
177
+ non_null = [s for s in samples if s is not None]
178
+
179
+ if parsed.kind == "scalar":
180
+ leaves, _ = _real_leaves(samples)
181
+ if not leaves:
182
+ return Verdict("UNDECIDABLE", None)
183
+ if all(_is_scalar_instance(v, parsed.scalar) for v in leaves):
184
+ return Verdict("CONFIRMED", None)
185
+ # A scalar mismatch is DETECTED but deliberately NOT repaired here
186
+ # (deferred). Runtime coercion is best-effort and keeps
187
+ # unparseable values raw; to close a mismatch deterministically at
188
+ # codegen time, floor to ``Any`` here.
189
+ return Verdict("CONTRADICTED", None)
190
+
191
+ # Repairable container kinds: dict_scalar / list_scalar / list_dict_scalar.
192
+ declared_outer = "dict" if parsed.kind == "dict_scalar" else "list"
193
+
194
+ def _outer(v: Any) -> str:
195
+ if isinstance(v, dict):
196
+ return "dict"
197
+ if isinstance(v, list):
198
+ return "list"
199
+ return "scalar"
200
+
201
+ if non_null and any(_outer(v) != declared_outer for v in non_null):
202
+ # Outer-container flip — floor to the OBSERVED shape, not the declared.
203
+ return Verdict("CONTRADICTED", _observed_floor(non_null, parsed.optional))
204
+
205
+ # Outer shape matches the declaration → inspect inner leaf value-types.
206
+ leaf_values: List[Any] = []
207
+ saw_inner_null = False
208
+ if parsed.kind == "dict_scalar":
209
+ for v in non_null:
210
+ vals = list(v.values())
211
+ leaves, snull = _real_leaves(vals)
212
+ leaf_values.extend(leaves); saw_inner_null |= snull
213
+ elif parsed.kind == "list_scalar":
214
+ for v in non_null:
215
+ leaves, snull = _real_leaves(v)
216
+ leaf_values.extend(leaves); saw_inner_null |= snull
217
+ else: # list_dict_scalar
218
+ for v in non_null:
219
+ for d in v:
220
+ if isinstance(d, dict):
221
+ leaves, snull = _real_leaves(list(d.values()))
222
+ leaf_values.extend(leaves); saw_inner_null |= snull
223
+ elif d is not None:
224
+ # A non-dict element flips the inner shape (the element
225
+ # should be a record, not a scalar) — an outer-shape
226
+ # contradiction; floor to the OBSERVED shape. Do NOT fold it
227
+ # into the scalar leaf check (a str element would falsely
228
+ # CONFIRM a List[Dict[str,str]] over a wire List[str]).
229
+ return Verdict("CONTRADICTED", _observed_floor(non_null, parsed.optional))
230
+
231
+ if not leaf_values and not saw_inner_null:
232
+ return Verdict("UNDECIDABLE", None)
233
+
234
+ contradicted = any(not _is_scalar_instance(v, parsed.scalar) for v in leaf_values)
235
+ if contradicted:
236
+ return Verdict("CONTRADICTED", _observed_floor(non_null, parsed.optional))
237
+
238
+ # Inner nulls observed on an otherwise-CONFIRMED List[SCALAR] re-wrap
239
+ # the inner Optional (latent, non-corrupting).
240
+ if saw_inner_null and parsed.kind == "list_scalar":
241
+ return Verdict("CONTRADICTED",
242
+ _wrap(f"List[Optional[{parsed.scalar}]]", parsed.optional))
243
+ if not leaf_values:
244
+ return Verdict("UNDECIDABLE", None)
245
+ return Verdict("CONFIRMED", None)