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/cli_help.py ADDED
@@ -0,0 +1,110 @@
1
+ """Static plain-text + JSON orientation for the `parse` CLI ("parse help").
2
+
3
+ The command list is DERIVED from the live Click registry (so it can never list a
4
+ command that doesn't exist or omit a new one); per-command ``requires_auth`` /
5
+ ``requires_init`` facts are read from attributes co-located on each ``Command``
6
+ and OMITTED when unset (an honest "unknown", never a silent ``false``). All prose
7
+ lives in module constants read by BOTH the text and JSON renderings, so the two
8
+ cannot drift. ``parse help`` ORIENTS (what commands exist, the workflow, how to
9
+ auth) and routes diagnosis to ``parse doctor`` — it has no state detection of its
10
+ own.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from typing import Any, Dict, List, Tuple
16
+
17
+ import click
18
+
19
+ from parse_sdk.config import CREDENTIALS_PATH, DEFAULT_BASE_URL
20
+
21
+ # The happy-path workflow, in order. Other registered commands (whoami, clean,
22
+ # doctor, add, search, remove, …) are listed after, alphabetically — the list
23
+ # itself comes from the registry, so nothing goes stale.
24
+ _ORDER = ["login", "init", "list", "sync"]
25
+
26
+ _AGENT_MODES = {
27
+ "json": "add --json to list/sync/whoami/doctor for stable machine output; prefer it",
28
+ "check": "`uv run parse sync --check` is a CI dry-run (render + gate, no promote, "
29
+ "exit 1 on findings)",
30
+ }
31
+ _AUTH_HOWTO = (
32
+ "Create an API key in the Parse dashboard, then `uv run parse login` "
33
+ "(or `uv run parse login --web` for browser OAuth, or set PARSE_API_KEY)."
34
+ )
35
+ _DIAGNOSE = (
36
+ "Something not working? `uv run parse doctor` diagnoses (and `--fix` repairs) "
37
+ "this project's install."
38
+ )
39
+
40
+
41
+ def _rows(group: "click.Group") -> List[Tuple[str, str]]:
42
+ cmds = group.commands
43
+ ordered = [n for n in _ORDER if n in cmds] + sorted(n for n in cmds if n not in _ORDER)
44
+ return [(n, cmds[n].get_short_help_str(limit=80)) for n in ordered]
45
+
46
+
47
+ def _facts(cmd: "click.Command") -> Dict[str, bool]:
48
+ """Per-command auth/init facts, read from attrs set at the command's
49
+ definition. Absent attr → omitted key (never a wrong ``false``)."""
50
+ out: Dict[str, bool] = {}
51
+ for attr in ("requires_auth", "requires_init"):
52
+ v = getattr(cmd, attr, None)
53
+ if v is not None:
54
+ out[attr] = bool(v)
55
+ return out
56
+
57
+
58
+ def render_overview(group: "click.Group", *, as_json: bool = False) -> str:
59
+ rows = _rows(group)
60
+
61
+ if as_json:
62
+ payload: Dict[str, Any] = {
63
+ "workflow": [n for n in _ORDER if n in group.commands],
64
+ "commands": [
65
+ {"name": n, "summary": s, **_facts(group.commands[n])} for n, s in rows
66
+ ],
67
+ "agent_modes": _AGENT_MODES,
68
+ "settings": {
69
+ "env": ["PARSE_API_KEY", "PARSE_API_BASE_URL"],
70
+ "credentials_path": str(CREDENTIALS_PATH),
71
+ "base_url_default": DEFAULT_BASE_URL,
72
+ "auth": _AUTH_HOWTO,
73
+ },
74
+ "diagnose": _DIAGNOSE,
75
+ "pointers": {
76
+ "root_pointer": "parse_apis/AGENTS.md",
77
+ "deep_index": "parse_apis/src/parse_apis/CLAUDE.md",
78
+ },
79
+ }
80
+ return json.dumps(payload, indent=2, sort_keys=True)
81
+
82
+ workflow = [(n, s) for n, s in rows if n in _ORDER]
83
+ other = [(n, s) for n, s in rows if n not in _ORDER]
84
+ lines = [
85
+ "Parse SDK — generate typed Python clients for the APIs your key can call.",
86
+ "",
87
+ "Workflow (run in order):",
88
+ ]
89
+ lines += [f" uv run parse {n:<7} {s}" for n, s in workflow]
90
+ if other:
91
+ lines.append(
92
+ " Other: " + ", ".join(n for n, _ in other) + " (uv run parse <cmd> --help)"
93
+ )
94
+ lines += [
95
+ "",
96
+ "Then: from parse_apis.<slug> import <Root> "
97
+ "(per-slug imports raise ModuleNotFoundError until `uv run parse sync`).",
98
+ "",
99
+ "Authentication:",
100
+ " " + _AUTH_HOWTO,
101
+ f" (saved to {CREDENTIALS_PATH}; default base URL {DEFAULT_BASE_URL}).",
102
+ "",
103
+ _DIAGNOSE,
104
+ "",
105
+ f"Agent mode: {_AGENT_MODES['json']}; {_AGENT_MODES['check']}.",
106
+ "",
107
+ "More: `uv run parse <command> --help`; after sync, "
108
+ "`parse_apis/src/parse_apis/CLAUDE.md` for conventions + the API index.",
109
+ ]
110
+ return "\n".join(lines)
@@ -0,0 +1,157 @@
1
+ """Sample-vs-type reconciliation — the verifier half of codegen.
2
+
3
+ Kept OUT of ``_normalize_schema`` (the naming-consistency pass): a contradicted
4
+ ``resources[*].fields[*].type`` is reconciled against the captured
5
+ ``return_schema.sample`` and floored to the sample-gate's ``Dict[str, Any]`` form
6
+ (copy-on-write, so the caller's stored spec stays raw). ``_normalize_schema``
7
+ calls ``reconcile_resources`` as a single named pass; the complete-time persist
8
+ (``sdk_field_gate``) reuses the SAME path, so build-time and serving-time agree.
9
+
10
+ Imports ``_extract_field_spec``/``_camel`` from ``codegen_v2`` — this module is
11
+ imported lazily (at call time, from inside ``_normalize_schema``), after
12
+ ``codegen_v2`` is fully loaded, so the back-import is cycle-safe.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Dict, List, Optional, Set
17
+
18
+ from parse_sdk import sample_gate as _sample_gate
19
+ from parse_sdk._labels import unwrap_label
20
+ from parse_sdk._runtime import unwrap_scraper_envelope
21
+ from parse_sdk.codegen_v2 import _camel, _extract_field_spec
22
+
23
+
24
+ def _returns_leaf(returns: Any) -> Optional[str]:
25
+ """The leaf resource name an operation ``returns`` names — unwrapping
26
+ ``List[X]`` / ``Optional[X]`` / ``_Paginator[X]`` (repeatedly) down to ``X``;
27
+ None for a non-string label.
28
+
29
+ Used for the EXACT returns-match in sample projection. A substring match
30
+ (``'Game' in 'List[GameSummary]'``) would bleed one resource's payload into
31
+ another and corrupt its key field to ``Any`` — so we compare leaf-to-name."""
32
+ return unwrap_label(returns) if isinstance(returns, str) else None
33
+
34
+
35
+ def _iter_operations(rspec: Dict[str, Any]):
36
+ """Every operation a resource declares — its own ``operations`` block AND
37
+ each ``sub_resources[*].operations`` block — so an op returning ANOTHER
38
+ resource (``Post.comments.list -> List[Comment]``) is not missed."""
39
+ ops = rspec.get("operations")
40
+ if isinstance(ops, dict):
41
+ yield from (op for op in ops.values() if isinstance(op, dict))
42
+ subs = rspec.get("sub_resources")
43
+ if isinstance(subs, dict):
44
+ for sub in subs.values():
45
+ if isinstance(sub, dict) and isinstance(sub.get("operations"), dict):
46
+ yield from (op for op in sub["operations"].values() if isinstance(op, dict))
47
+
48
+
49
+ def project_field_samples(schema: Dict[str, Any]) -> Dict[str, Dict[str, List[Any]]]:
50
+ """``resource_name → field_name → pooled OBSERVED field values``.
51
+
52
+ Buckets every operation (across ALL resources' own + ``sub_resources`` blocks)
53
+ by its EXACT returns-leaf, so a resource is fed the sample of whatever
54
+ operation returns it — including a sub-resource/cross-resource op, not just
55
+ its own block (a resource without its own op but returned by one IS an
56
+ own-endpoint resource per the projection contract). Then: resolve the op's
57
+ endpoint → ``return_schema.sample`` → envelope descent FIRST, then
58
+ ``items_path`` (both branches) → record each declared field's value by its
59
+ WIRE key (``alias_from or field_name``), distinguishing a missing key
60
+ (contributes nothing) from an explicit null. A resource no operation returns
61
+ (truly nested-only / field-type-only) gets no sample → kept verbatim. Pure:
62
+ reads value-types only, never emits the sample into output."""
63
+ resources = schema.get("resources") or {}
64
+ endpoints_by_name = {
65
+ ep["name"]: ep
66
+ for ep in (schema.get("endpoints") or [])
67
+ if isinstance(ep, dict) and isinstance(ep.get("name"), str)
68
+ }
69
+ wire_keys = {
70
+ rname: {fname: (_extract_field_spec(fval)[2] or fname)
71
+ for fname, fval in rspec["fields"].items()}
72
+ for rname, rspec in resources.items()
73
+ if isinstance(rspec, dict) and isinstance(rspec.get("fields"), dict)
74
+ }
75
+ # Match an op's returns-leaf to a resource by CANONICAL (``_camel``) name —
76
+ # resource keys may be snake/lower (``item``) while ``returns`` is the
77
+ # PascalCase form (``Item``); compare both canonicalized, exactly as the
78
+ # original per-resource ``_camel(leaf) == _camel(rname)`` match did.
79
+ # Camel-colliding raw keys ('a_b'/'aB' → 'AB') are SUPPORTED by codegen
80
+ # (the allocator suffixes the second class), but a camel-keyed sample match
81
+ # cannot disambiguate them — first-wins would route BOTH resources' samples
82
+ # into one pool (cross-resource contamination) and starve the loser. Drop
83
+ # the camel route for an ambiguous canonical instead: both resources get no
84
+ # projected sample and are kept verbatim (the no-sample path).
85
+ canon_to_raw: Dict[str, str] = {}
86
+ _ambiguous: Set[str] = set()
87
+ for rname in wire_keys:
88
+ canon = _camel(rname)
89
+ if canon in canon_to_raw or canon in _ambiguous:
90
+ _ambiguous.add(canon)
91
+ canon_to_raw.pop(canon, None)
92
+ continue
93
+ canon_to_raw[canon] = rname
94
+ observed: Dict[str, Dict[str, List[Any]]] = {}
95
+ for owner in resources.values():
96
+ if not isinstance(owner, dict):
97
+ continue
98
+ for op in _iter_operations(owner):
99
+ raw = canon_to_raw.get(_camel(_returns_leaf(op.get("returns")) or ""))
100
+ if raw is None:
101
+ continue # feed only a MODELED resource; exact returns-match → no bleed
102
+ ep = endpoints_by_name.get(op.get("endpoint"))
103
+ if not isinstance(ep, dict):
104
+ continue
105
+ rs = ep.get("return_schema")
106
+ sample = rs.get("sample") if isinstance(rs, dict) else None
107
+ if sample is None:
108
+ continue
109
+ body = unwrap_scraper_envelope(sample)
110
+ pg = op.get("pagination")
111
+ items_path = pg.get("items_path") if isinstance(pg, dict) else None
112
+ if items_path:
113
+ items = body.get(items_path) if isinstance(body, dict) else None
114
+ records = items if isinstance(items, list) else []
115
+ else:
116
+ records = [body] # point lookup: the unwrapped body IS the object
117
+ per_field = observed.setdefault(raw, {})
118
+ for rec in records:
119
+ if not isinstance(rec, dict):
120
+ continue
121
+ for fname, wire in wire_keys[raw].items():
122
+ if wire in rec:
123
+ per_field.setdefault(fname, []).append(rec[wire])
124
+ return {rname: per_field for rname, per_field in observed.items() if per_field}
125
+
126
+
127
+ def reconcile_resources(schema: Dict[str, Any]) -> Dict[str, Any]:
128
+ """Floor every contradicted ``resources[*].fields[*].type`` to the sample-
129
+ gate's ``Dict[str, Any]`` form and return the (copy-on-write) resources dict
130
+ for assignment onto ``schema``. The key field and out-of-scope/CONFIRMED
131
+ fields are never rewritten; both bare-string and FieldSpec-dict field shapes
132
+ are handled. Pure CoW — the caller's stored ``request_flow`` stays raw."""
133
+ observed = project_field_samples(schema)
134
+ resources = dict(schema.get("resources") or {})
135
+ if not observed:
136
+ return resources
137
+ for rname, rspec in resources.items():
138
+ if not isinstance(rspec, dict):
139
+ continue
140
+ obs_r = observed.get(rname)
141
+ if not obs_r:
142
+ continue
143
+ key_field = rspec.get("keyed_by")
144
+ fields = dict(rspec.get("fields") or {})
145
+ changed = False
146
+ for fname, fval in fields.items():
147
+ declared, _enum, _alias = _extract_field_spec(fval)
148
+ verdict = _sample_gate.gate_field(
149
+ declared, obs_r.get(fname), is_key=(fname == key_field))
150
+ if verdict.status == "CONTRADICTED" and verdict.repaired_label:
151
+ fields[fname] = (
152
+ {**fval, "type": verdict.repaired_label}
153
+ if isinstance(fval, dict) else verdict.repaired_label)
154
+ changed = True
155
+ if changed:
156
+ resources[rname] = {**rspec, "fields": fields}
157
+ return resources