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