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.py
ADDED
|
@@ -0,0 +1,1667 @@
|
|
|
1
|
+
"""`parse` CLI — `sync`, `login`, `whoami`, `list`, `doctor`, `add`, `remove`.
|
|
2
|
+
|
|
3
|
+
`parse sync` is the load-bearing command: hits the `/sdk/schemas`
|
|
4
|
+
endpoint, renders a real `.py` per resource-modeled API into
|
|
5
|
+
`./parse_apis/<slug>/__init__.py`. APIs without a `resources` block are
|
|
6
|
+
skipped with a warning (hard-cutover SDK).
|
|
7
|
+
|
|
8
|
+
A repo curates which APIs it syncs via the declarative `[tool.parse]` table in
|
|
9
|
+
its pyproject (see `parse_sdk._project`): `parse add`/`remove` edit `apis` and
|
|
10
|
+
reconcile; `parse sync` reconciles the generated tree to that committed,
|
|
11
|
+
reproducible set. An absent `apis` key means "all your account APIs".
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import urllib.parse
|
|
24
|
+
try:
|
|
25
|
+
import tomllib # Python 3.11+
|
|
26
|
+
except ModuleNotFoundError: # 3.10 — tomllib landed in 3.11; tomli is its backport
|
|
27
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
28
|
+
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import TYPE_CHECKING, List, Optional, Tuple
|
|
31
|
+
|
|
32
|
+
import click
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
# httpx is imported lazily (function-local in _fetch_schemas + a module
|
|
36
|
+
# __getattr__ for `cli.httpx`): it costs ~32-35 ms to import — ~84% of the
|
|
37
|
+
# CLI's non-interpreter cold start — yet only `sync` uses it for transport.
|
|
38
|
+
# See plan 003. Kept here so the httpx annotations/uses resolve statically.
|
|
39
|
+
import httpx
|
|
40
|
+
|
|
41
|
+
from parse_sdk import __version__, _project, docgen, doctor as doctor_mod, migrate, scaffold
|
|
42
|
+
from parse_sdk.codegen_v2 import CodegenError, render_module, resolve_root_name
|
|
43
|
+
from parse_sdk.scaffold import safe_slug
|
|
44
|
+
from parse_sdk.config import (
|
|
45
|
+
CANONICAL_HOST, CREDENTIALS_PATH, DEFAULT_BASE_URL, Config,
|
|
46
|
+
_host_is_trusted, _read_credentials_file, extra_trusted_hosts, read_oauth,
|
|
47
|
+
resolve, save_oauth_credentials, save_credentials,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Sync engine (staging / gates / atomic promote) lives in parse_sdk._sync; the
|
|
51
|
+
# names are re-exported here because tests and downstream code reach them as
|
|
52
|
+
# ``cli.<name>`` attributes (the extraction is mechanical, not a rename).
|
|
53
|
+
from parse_sdk._sync import ( # noqa: F401 — re-exported surface
|
|
54
|
+
MANIFEST_NAME,
|
|
55
|
+
_find_pin,
|
|
56
|
+
OWNED_SLUG_FILES,
|
|
57
|
+
_assign_slugs,
|
|
58
|
+
_atomic_write_text,
|
|
59
|
+
_carry_skipped,
|
|
60
|
+
_example_drop_reason,
|
|
61
|
+
_load_manifest,
|
|
62
|
+
_safe_console,
|
|
63
|
+
_schema_endpoint_names,
|
|
64
|
+
_schema_modeled,
|
|
65
|
+
_schema_resources,
|
|
66
|
+
_schema_resource_names,
|
|
67
|
+
_slug_dir_differs,
|
|
68
|
+
_staged_check_failures,
|
|
69
|
+
_sweep_stale_stages,
|
|
70
|
+
stage_check_swap,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# The CLI configures no logging, so an engine-side ``logger.warning`` (e.g.
|
|
74
|
+
# codegen's factory-suppression notice) would hit Python's lastResort handler —
|
|
75
|
+
# raw, unstyled stderr on every render, duplicating the slugged CLI warning
|
|
76
|
+
# channel. A NullHandler marks the tree "handled" without consuming records:
|
|
77
|
+
# propagation is unaffected, so library consumers with real logging configs
|
|
78
|
+
# still receive engine warnings.
|
|
79
|
+
logging.getLogger("parse_sdk").addHandler(logging.NullHandler())
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def __getattr__(name: str):
|
|
83
|
+
"""Lazily expose ``httpx`` as a module attribute (PEP 562).
|
|
84
|
+
|
|
85
|
+
httpx is no longer imported at module top (it cost ~32-35 ms on every
|
|
86
|
+
`parse` invocation; only `sync` uses it for transport — see plan 003). Tests
|
|
87
|
+
patch transport via ``monkeypatch.setattr(cli.httpx, "get", ...)``, which
|
|
88
|
+
needs ``cli.httpx`` to resolve to the real module; this returns the
|
|
89
|
+
``sys.modules`` httpx, so a patch on it is seen by the function-local
|
|
90
|
+
``import httpx`` in ``_fetch_schemas``."""
|
|
91
|
+
if name == "httpx":
|
|
92
|
+
import httpx
|
|
93
|
+
return httpx
|
|
94
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _fetch_schemas(cfg: Config) -> list:
|
|
98
|
+
"""Fetch ``/sdk/schemas`` for the resolved config.
|
|
99
|
+
|
|
100
|
+
Takes the whole ``Config`` (not a raw key + url pair) so the key↔host
|
|
101
|
+
binding is structural: ``require_key()`` is the single emit chokepoint and
|
|
102
|
+
a call site can never pair a key with a different host's URL.
|
|
103
|
+
"""
|
|
104
|
+
import httpx # lazy (see plan 003) — sync is the only httpx-transport user
|
|
105
|
+
|
|
106
|
+
# auth_headers() raises a RuntimeError (no credential, or an untrusted host)
|
|
107
|
+
# whose message is already agent-actionable — surface it as a clean colored
|
|
108
|
+
# error + exit(2), matching the other failure branches, instead of letting it
|
|
109
|
+
# escape as a raw Click traceback. Returns X-API-Key or a Bearer token.
|
|
110
|
+
try:
|
|
111
|
+
auth = cfg.auth_headers()
|
|
112
|
+
except RuntimeError as e:
|
|
113
|
+
click.echo(click.style(str(e), fg="red"), err=True)
|
|
114
|
+
click.echo(click.style(
|
|
115
|
+
"Run `uv run parse help` for setup, or `uv run parse doctor` to diagnose.",
|
|
116
|
+
fg="yellow"), err=True)
|
|
117
|
+
sys.exit(2)
|
|
118
|
+
url = f"{cfg.base_url}/sdk/schemas"
|
|
119
|
+
try:
|
|
120
|
+
r = httpx.get(url, headers=auth, timeout=30.0)
|
|
121
|
+
except httpx.HTTPError as e:
|
|
122
|
+
click.echo(click.style(f"Could not reach {cfg.base_url}: {e}", fg="red"), err=True)
|
|
123
|
+
sys.exit(2)
|
|
124
|
+
if r.status_code == 401:
|
|
125
|
+
click.echo(click.style(
|
|
126
|
+
f"Auth failed against {cfg.base_url}. Run `parse login` or set PARSE_API_KEY.",
|
|
127
|
+
fg="red"), err=True)
|
|
128
|
+
sys.exit(2)
|
|
129
|
+
if r.status_code == 403:
|
|
130
|
+
click.echo(click.style(
|
|
131
|
+
f"Your key is valid but not allowed to list schemas on {cfg.base_url} "
|
|
132
|
+
f"(HTTP 403) — check plan/permissions in Parse.", fg="red"), err=True)
|
|
133
|
+
sys.exit(2)
|
|
134
|
+
if r.status_code != 200:
|
|
135
|
+
click.echo(click.style(f"Schema fetch failed [{r.status_code}]: {_safe_console(r.text)}", fg="red"), err=True)
|
|
136
|
+
sys.exit(2)
|
|
137
|
+
try:
|
|
138
|
+
payload = r.json()
|
|
139
|
+
except ValueError: # json.JSONDecodeError ⊂ ValueError — a 200 with a non-JSON body
|
|
140
|
+
click.echo(click.style(
|
|
141
|
+
f"Schema fetch returned a 200 with a non-JSON body from {url} "
|
|
142
|
+
f"(a proxy/gateway page?): {r.text[:200]!r}", fg="red"), err=True)
|
|
143
|
+
sys.exit(2)
|
|
144
|
+
if not isinstance(payload, dict):
|
|
145
|
+
click.echo(click.style(
|
|
146
|
+
f"Schema fetch returned unexpected JSON from {url} "
|
|
147
|
+
f"(expected an object, got {type(payload).__name__})", fg="red"), err=True)
|
|
148
|
+
sys.exit(2)
|
|
149
|
+
schemas = payload.get("schemas", [])
|
|
150
|
+
if not isinstance(schemas, list):
|
|
151
|
+
click.echo(click.style(
|
|
152
|
+
f"Schema fetch returned unexpected JSON from {url} "
|
|
153
|
+
f"(expected 'schemas' to be a list, got {type(schemas).__name__})",
|
|
154
|
+
fg="red"), err=True)
|
|
155
|
+
sys.exit(2)
|
|
156
|
+
# Trust-boundary row gate: one malformed server row (non-dict, missing
|
|
157
|
+
# id/slug) must skip with a warning, never crash the whole command with a
|
|
158
|
+
# raw KeyError — every downstream consumer (sync, init, list) does raw
|
|
159
|
+
# row['id']/row['slug'] derefs. Warnings go to STDERR so machine-readable
|
|
160
|
+
# stdout (--json) stays parseable.
|
|
161
|
+
valid: list = []
|
|
162
|
+
for i, row in enumerate(schemas):
|
|
163
|
+
problem = _valid_schema_row(row)
|
|
164
|
+
if problem is None:
|
|
165
|
+
valid.append(row)
|
|
166
|
+
else:
|
|
167
|
+
click.echo(click.style(
|
|
168
|
+
f" · skipped malformed schema row #{i}: {_safe_console(problem)}",
|
|
169
|
+
fg="yellow", dim=True), err=True)
|
|
170
|
+
return valid
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _valid_schema_row(row: object) -> Optional[str]:
|
|
174
|
+
"""Why a server schema row is unusable, or ``None`` when well-shaped
|
|
175
|
+
(a dict whose ``id`` and ``slug`` are non-empty strings)."""
|
|
176
|
+
if not isinstance(row, dict):
|
|
177
|
+
return f"expected an object, got {type(row).__name__}"
|
|
178
|
+
for field in ("id", "slug"):
|
|
179
|
+
v = row.get(field)
|
|
180
|
+
if not (isinstance(v, str) and v):
|
|
181
|
+
return f"missing or non-string {field!r} (got {type(v).__name__})"
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _http_get_json(cfg: Config, path: str, *, params: Optional[dict] = None,
|
|
186
|
+
with_key: bool = True) -> "Tuple[int, object]":
|
|
187
|
+
"""GET ``{base_url}{path}`` → (status, parsed-json|text). Single transport
|
|
188
|
+
helper for the marketplace/SDK reads beyond ``/sdk/schemas`` (search, detail,
|
|
189
|
+
versioned schema). ``with_key`` attaches X-API-Key through the host gate
|
|
190
|
+
(require_key); public marketplace reads pass ``with_key=False``."""
|
|
191
|
+
import httpx # lazy (see plan 003)
|
|
192
|
+
|
|
193
|
+
headers = {}
|
|
194
|
+
if with_key:
|
|
195
|
+
try:
|
|
196
|
+
headers = cfg.auth_headers()
|
|
197
|
+
except RuntimeError as e:
|
|
198
|
+
click.echo(click.style(str(e), fg="red"), err=True)
|
|
199
|
+
sys.exit(2)
|
|
200
|
+
try:
|
|
201
|
+
r = httpx.get(f"{cfg.base_url}{path}", headers=headers, params=params or {}, timeout=30.0)
|
|
202
|
+
except httpx.HTTPError as e:
|
|
203
|
+
click.echo(click.style(f"Could not reach {cfg.base_url}: {e}", fg="red"), err=True)
|
|
204
|
+
sys.exit(2)
|
|
205
|
+
try:
|
|
206
|
+
return r.status_code, r.json()
|
|
207
|
+
except ValueError:
|
|
208
|
+
return r.status_code, r.text
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _fetch_marketplace_schema(cfg: Config, scraper_id: str,
|
|
212
|
+
version: Optional[int]) -> Optional[dict]:
|
|
213
|
+
"""Fetch a marketplace canonical's SDK schema (optionally version-pinned),
|
|
214
|
+
stamped with ``_pinned_version`` so codegen bakes the execution pin. Returns
|
|
215
|
+
None (with a stderr warning) when the API/version is gone — a stale manifest
|
|
216
|
+
entry must skip, never abort the whole sync."""
|
|
217
|
+
path = f"/sdk/schemas/{urllib.parse.quote(scraper_id)}"
|
|
218
|
+
params = {"version": version} if version is not None else None
|
|
219
|
+
status, body = _http_get_json(cfg, path, params=params)
|
|
220
|
+
if status == 200 and isinstance(body, dict):
|
|
221
|
+
schema = dict(body)
|
|
222
|
+
if version is not None:
|
|
223
|
+
schema["_pinned_version"] = version
|
|
224
|
+
return schema
|
|
225
|
+
detail = body.get("detail") if isinstance(body, dict) else _safe_console(body)
|
|
226
|
+
click.echo(click.style(
|
|
227
|
+
f" · skipping marketplace API {scraper_id}"
|
|
228
|
+
f"{f' @v{version}' if version else ''} [{status}]: {_safe_console(detail)}",
|
|
229
|
+
fg="yellow", dim=True), err=True)
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _fetch_marketplace_schemas(cfg: Config,
|
|
234
|
+
entries: "List[_project.MarketplaceEntry]") -> List[dict]:
|
|
235
|
+
"""All resolvable marketplace schemas for the repo's recorded entries."""
|
|
236
|
+
out: List[dict] = []
|
|
237
|
+
for e in entries:
|
|
238
|
+
s = _fetch_marketplace_schema(cfg, e.scraper_id, e.version)
|
|
239
|
+
if s is not None:
|
|
240
|
+
out.append(s)
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# Committed scaffold members inside src/parse_apis that `parse clean` / a payload
|
|
245
|
+
# clean must never remove (mirrors scaffold.write_scaffold's package files).
|
|
246
|
+
_SCAFFOLD_PKG_FILES = frozenset({"__init__.py", "py.typed"})
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _render_modules(schemas: List[dict], base_url: str,
|
|
250
|
+
*, quiet: bool = False) -> Tuple[List[tuple], List[dict]]:
|
|
251
|
+
"""Render every schema to source, returning (rendered, skipped).
|
|
252
|
+
|
|
253
|
+
``rendered`` is a list of ``(schema, source)``; ``skipped`` is the schemas
|
|
254
|
+
that produced no module, each carrying a ``_skip_reason``. A CodegenError
|
|
255
|
+
means a list op reached codegen with no (or an unknown) pagination block —
|
|
256
|
+
codegen refuses to guess the response shape. Skip that ONE API with a loud
|
|
257
|
+
warning (its raw HTTP endpoints still work) instead of crashing the whole
|
|
258
|
+
sync for every other API. ``quiet`` suppresses the stdout skip line (the
|
|
259
|
+
``--json`` path carries the reason in the result struct instead); error
|
|
260
|
+
output on stderr is never suppressed.
|
|
261
|
+
"""
|
|
262
|
+
rendered: List[tuple] = []
|
|
263
|
+
skipped: List[dict] = []
|
|
264
|
+
for s in schemas:
|
|
265
|
+
try:
|
|
266
|
+
src = render_module(s, base_url=base_url)
|
|
267
|
+
except CodegenError as e:
|
|
268
|
+
if not quiet:
|
|
269
|
+
click.echo(click.style(
|
|
270
|
+
f" · skipped {_safe_console(s.get('slug'))} ({_safe_console(s.get('id'))}): {_safe_console(e)}",
|
|
271
|
+
fg="yellow", dim=True,
|
|
272
|
+
))
|
|
273
|
+
skipped.append({**s, "_skip_reason": f"codegen refused: {e}"})
|
|
274
|
+
continue
|
|
275
|
+
except Exception as e: # noqa: BLE001 — one row must never kill the fleet
|
|
276
|
+
# Anything other than CodegenError is an ENGINE bug (CodegenError
|
|
277
|
+
# blames the spec and tells the user to fix it in Parse — wrong
|
|
278
|
+
# advice for a crash). Distinct wording, stderr, same skip path.
|
|
279
|
+
click.echo(click.style(
|
|
280
|
+
f" · codegen crashed on {_safe_console(s.get('slug'))} "
|
|
281
|
+
f"({_safe_console(s.get('id'))}): {_safe_console(e)} — engine bug, "
|
|
282
|
+
f"please report; API skipped this sync",
|
|
283
|
+
fg="red"), err=True)
|
|
284
|
+
skipped.append({**s, "_skip_reason": f"codegen crashed: {e}"})
|
|
285
|
+
continue
|
|
286
|
+
if src is None:
|
|
287
|
+
skipped.append({**s, "_skip_reason": "un-modeled (no `resources` in spec)"})
|
|
288
|
+
continue
|
|
289
|
+
rendered.append((s, src))
|
|
290
|
+
return rendered, skipped
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _clean_payload(pkg: Path) -> List[Path]:
|
|
294
|
+
"""Remove generated payload from an installed ``src/parse_apis`` package while
|
|
295
|
+
preserving the committed scaffold (``__init__.py``, ``py.typed``).
|
|
296
|
+
|
|
297
|
+
Generated payload = slug directories + ``_manifest.json`` + ``AGENTS.md`` +
|
|
298
|
+
``CLAUDE.md`` (anything that is not a scaffold file). Returns removed paths.
|
|
299
|
+
This is the payload-only operation behind ``parse clean`` and ``parse sync
|
|
300
|
+
--clean`` — it never touches the package root or scaffold (the old
|
|
301
|
+
``--clean`` did ``shutil.rmtree`` on the whole target, a data-loss hazard).
|
|
302
|
+
"""
|
|
303
|
+
removed: List[Path] = []
|
|
304
|
+
if not pkg.is_dir():
|
|
305
|
+
return removed
|
|
306
|
+
for p in sorted(pkg.iterdir()):
|
|
307
|
+
if p.name in _SCAFFOLD_PKG_FILES:
|
|
308
|
+
continue
|
|
309
|
+
if p.is_dir() and not p.is_symlink():
|
|
310
|
+
shutil.rmtree(p)
|
|
311
|
+
else:
|
|
312
|
+
p.unlink()
|
|
313
|
+
removed.append(p)
|
|
314
|
+
return removed
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]},
|
|
318
|
+
epilog="Run `parse help` for a guided getting-started workflow.")
|
|
319
|
+
@click.version_option(__version__, prog_name="parse")
|
|
320
|
+
def cli() -> None:
|
|
321
|
+
"""Parse SDK CLI. Run `parse init` once to set up the project's parse_apis
|
|
322
|
+
package, then `parse sync` to refresh your typed clients."""
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@cli.command("help")
|
|
326
|
+
@click.option("--json", "as_json", is_flag=True,
|
|
327
|
+
help="Machine-readable orientation (stable, extensible shape).")
|
|
328
|
+
def help_cmd(as_json: bool) -> None:
|
|
329
|
+
"""Guided getting-started workflow: which commands to run, when, and how to authenticate."""
|
|
330
|
+
from parse_sdk import cli_help
|
|
331
|
+
click.echo(cli_help.render_overview(cli, as_json=as_json))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _login_web(base_url: Optional[str]) -> None:
|
|
335
|
+
"""Run the browser OAuth flow against the resolved base URL and persist the
|
|
336
|
+
token. Loopback redirect → the captured token is host-gated on save."""
|
|
337
|
+
from parse_sdk import _oauth
|
|
338
|
+
|
|
339
|
+
target = (base_url or resolve().base_url).rstrip("/")
|
|
340
|
+
# Refuse an untrusted host UP FRONT — before opening a browser, registering a
|
|
341
|
+
# client, or running any OAuth round-trip — so nothing is opened or persisted
|
|
342
|
+
# for a host whose token save_oauth_credentials would reject anyway. (The save
|
|
343
|
+
# gate still fires as defense-in-depth; this just fails fast and cleanly.)
|
|
344
|
+
if not _host_is_trusted(target):
|
|
345
|
+
click.echo(click.style(
|
|
346
|
+
f"Refusing to log in against {target!r} — it is not a trusted Parse "
|
|
347
|
+
f"API host. Use https://{CANONICAL_HOST} (the default), a loopback URL, "
|
|
348
|
+
f"or an opted-in PARSE_EXTRA_TRUSTED_HOSTS host.", fg="red"), err=True)
|
|
349
|
+
sys.exit(2)
|
|
350
|
+
try:
|
|
351
|
+
tokens = _oauth.run_web_login(target)
|
|
352
|
+
except RuntimeError as e:
|
|
353
|
+
click.echo(click.style(str(e), fg="red"), err=True)
|
|
354
|
+
sys.exit(2)
|
|
355
|
+
except Exception as e: # noqa: BLE001 — network/JSON/transport failures
|
|
356
|
+
click.echo(click.style(f"Web login failed against {target}: {e}", fg="red"), err=True)
|
|
357
|
+
sys.exit(2)
|
|
358
|
+
try:
|
|
359
|
+
saved = save_oauth_credentials(
|
|
360
|
+
access_token=tokens.access_token, refresh_token=tokens.refresh_token,
|
|
361
|
+
expires_at=tokens.expires_at, client_id=tokens.client_id, base_url=target)
|
|
362
|
+
except RuntimeError as e:
|
|
363
|
+
click.echo(click.style(str(e), fg="red"), err=True)
|
|
364
|
+
sys.exit(2)
|
|
365
|
+
click.echo(click.style("✓ logged in via browser", fg="green"))
|
|
366
|
+
click.echo(f" saved token to {CREDENTIALS_PATH}")
|
|
367
|
+
click.echo(f" base_url: {saved}")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _ensure_fresh_token() -> None:
|
|
371
|
+
"""Refresh a stored OAuth token that is at/near expiry, persisting the new
|
|
372
|
+
one — a no-op for API-key users and for non-expiring/absent tokens. Called at
|
|
373
|
+
the top of networked CLI commands so `parse sync` keeps working past the 1h
|
|
374
|
+
access-token lifetime. Best-effort: a failed refresh leaves the old token
|
|
375
|
+
(the command then surfaces a clean 401)."""
|
|
376
|
+
from parse_sdk import _oauth
|
|
377
|
+
import time as _t
|
|
378
|
+
|
|
379
|
+
oauth = read_oauth()
|
|
380
|
+
refresh = oauth.get("refresh_token")
|
|
381
|
+
if not (isinstance(refresh, str) and refresh):
|
|
382
|
+
return
|
|
383
|
+
if not _oauth.is_expiring(oauth.get("expires_at"), now=int(_t.time())):
|
|
384
|
+
return
|
|
385
|
+
cfg = resolve()
|
|
386
|
+
# Refuse to send the (long-lived, high-value) refresh token to an untrusted
|
|
387
|
+
# host. cfg.base_url comes from PARSE_API_BASE_URL first, so an injected host
|
|
388
|
+
# would otherwise receive the refresh token via the discovery/token call
|
|
389
|
+
# before save_oauth_credentials' trust check ever fires (and the best-effort
|
|
390
|
+
# `except` would swallow it silently). Gate BEFORE any network call.
|
|
391
|
+
if not _host_is_trusted(cfg.base_url):
|
|
392
|
+
return
|
|
393
|
+
try:
|
|
394
|
+
meta = _oauth.discover(cfg.base_url)
|
|
395
|
+
token_ep = meta.get("token_endpoint")
|
|
396
|
+
if not isinstance(token_ep, str):
|
|
397
|
+
return
|
|
398
|
+
ts = _oauth.refresh_access_token(token_ep, refresh_token=refresh,
|
|
399
|
+
client_id=oauth.get("client_id"))
|
|
400
|
+
save_oauth_credentials(
|
|
401
|
+
access_token=ts.access_token, refresh_token=ts.refresh_token,
|
|
402
|
+
expires_at=ts.expires_at, client_id=ts.client_id, base_url=cfg.base_url)
|
|
403
|
+
except Exception: # noqa: BLE001 — refresh is best-effort; old token still tried
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _net_resolve() -> Config:
|
|
408
|
+
"""resolve() for a command that makes authenticated network calls — refreshes
|
|
409
|
+
an expiring web-login token first so the request carries a live Bearer."""
|
|
410
|
+
_ensure_fresh_token()
|
|
411
|
+
return resolve()
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _prompt_key_or_exit() -> str:
|
|
415
|
+
"""Interactive API-key prompt that is SAFE non-interactively: when stdin is
|
|
416
|
+
not a TTY (agents / CI), emit the actionable setup message and exit 2 instead
|
|
417
|
+
of a getpass that hangs or aborts opaquely."""
|
|
418
|
+
if not sys.stdin.isatty():
|
|
419
|
+
click.echo(click.style(
|
|
420
|
+
"No Parse API key found. Set PARSE_API_KEY, run `uv run parse login` "
|
|
421
|
+
"(or `uv run parse login --web`), or pass `--api-key`. "
|
|
422
|
+
"Run `uv run parse help` for setup.", fg="red"), err=True)
|
|
423
|
+
sys.exit(2)
|
|
424
|
+
return click.prompt("Parse API key", hide_input=True)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _stdin_is_interactive() -> bool:
|
|
428
|
+
"""True when a human is at the terminal to complete a browser flow / answer a
|
|
429
|
+
prompt. False under CI/agents (or a closed stdin) so callers fail fast with
|
|
430
|
+
guidance instead of hanging on a callback that never arrives."""
|
|
431
|
+
try:
|
|
432
|
+
return sys.stdin.isatty()
|
|
433
|
+
except (ValueError, OSError):
|
|
434
|
+
return False
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@cli.command()
|
|
438
|
+
@click.option("--api-key", help="Log in with an API key instead of the browser. "
|
|
439
|
+
"Saved to ~/.config/parse/credentials.")
|
|
440
|
+
@click.option("--web/--no-web", "web", default=None,
|
|
441
|
+
help="Browser (OAuth) login [default], or --no-web to use an API key.")
|
|
442
|
+
@click.option("--base-url", help=f"Override the Parse base URL (default: {DEFAULT_BASE_URL}).")
|
|
443
|
+
def login(api_key: Optional[str], web: Optional[bool], base_url: Optional[str]) -> None:
|
|
444
|
+
"""Stash credentials so future commands work without env vars.
|
|
445
|
+
|
|
446
|
+
Default: open the browser, log in to Parse, and store the resulting OAuth
|
|
447
|
+
token (auto-refreshed). Use --api-key (or --no-web) to log in with an API key
|
|
448
|
+
instead.
|
|
449
|
+
"""
|
|
450
|
+
if web is True and api_key:
|
|
451
|
+
click.echo(click.style("--web and --api-key are mutually exclusive.", fg="red"), err=True)
|
|
452
|
+
sys.exit(2)
|
|
453
|
+
# Browser OAuth is the default; an explicit --api-key or --no-web switches to
|
|
454
|
+
# API-key login (which prompts when no key value is supplied).
|
|
455
|
+
want_web = web if web is not None else (api_key is None)
|
|
456
|
+
# The browser flow needs a human at the terminal; default to it only when one
|
|
457
|
+
# is present (explicit --web, or an interactive TTY). Non-interactively
|
|
458
|
+
# (CI/agents) fall through to the key path, which fails fast with guidance
|
|
459
|
+
# rather than hanging on a callback that never arrives.
|
|
460
|
+
if want_web and (web is True or _stdin_is_interactive()):
|
|
461
|
+
_login_web(base_url)
|
|
462
|
+
return
|
|
463
|
+
if not api_key:
|
|
464
|
+
api_key = _prompt_key_or_exit()
|
|
465
|
+
try:
|
|
466
|
+
saved_base_url = save_credentials(api_key=api_key, base_url=base_url)
|
|
467
|
+
except RuntimeError as e:
|
|
468
|
+
click.echo(click.style(str(e), fg="red"), err=True)
|
|
469
|
+
sys.exit(2)
|
|
470
|
+
click.echo(f"Saved credentials to {CREDENTIALS_PATH}")
|
|
471
|
+
click.echo(f" base_url: {saved_base_url}")
|
|
472
|
+
# Transparency: if this host is trusted ONLY because the user opted it in via
|
|
473
|
+
# PARSE_EXTRA_TRUSTED_HOSTS (not the canonical prod host / loopback), say so —
|
|
474
|
+
# the key now rides to a non-prod host, which is intended for staging QA only.
|
|
475
|
+
saved_host = (urllib.parse.urlsplit(saved_base_url).hostname or "").rstrip(".")
|
|
476
|
+
if saved_host in extra_trusted_hosts():
|
|
477
|
+
click.echo(click.style(
|
|
478
|
+
f" note: {saved_host} is trusted only via PARSE_EXTRA_TRUSTED_HOSTS "
|
|
479
|
+
f"(staging/preview QA). Unset it for production.", fg="yellow"))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _mask_key(key: str) -> str:
|
|
483
|
+
# Non-overlapping mask: prefix+suffix only when they can't reconstruct the
|
|
484
|
+
# key (an 8-char key under [:6]…[-4:] leaked ALL its characters).
|
|
485
|
+
return f"{key[:4]}…{key[-4:]}" if len(key) >= 12 else "<set, hidden>"
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@cli.command()
|
|
489
|
+
@click.option("--json", "as_json", is_flag=True,
|
|
490
|
+
help="Machine-readable output (stable shape; agents should prefer this).")
|
|
491
|
+
def whoami(as_json: bool) -> None:
|
|
492
|
+
"""Show which key + base URL the SDK is currently configured with."""
|
|
493
|
+
cfg = resolve()
|
|
494
|
+
key_configured = bool(
|
|
495
|
+
cfg.api_key
|
|
496
|
+
or os.environ.get("PARSE_API_KEY")
|
|
497
|
+
or _read_credentials_file().get("api_key")
|
|
498
|
+
)
|
|
499
|
+
web_token_stored = bool(read_oauth().get("access_token"))
|
|
500
|
+
# The credential that would actually ride to this host: key first, else token.
|
|
501
|
+
auth_method = "api_key" if cfg.api_key else ("oauth" if cfg.access_token else None)
|
|
502
|
+
if as_json:
|
|
503
|
+
# Stable JSON shape (documented in README): base_url, the masked key
|
|
504
|
+
# (null when unset/withheld), whether ANY key is configured, whether a
|
|
505
|
+
# web-login token is stored, and which credential rides to this host.
|
|
506
|
+
click.echo(json.dumps({
|
|
507
|
+
"base_url": cfg.base_url,
|
|
508
|
+
"api_key_masked": _mask_key(cfg.api_key) if cfg.api_key else None,
|
|
509
|
+
"key_configured": key_configured,
|
|
510
|
+
"key_sent_to_host": bool(cfg.api_key),
|
|
511
|
+
"web_login": web_token_stored,
|
|
512
|
+
"auth_method": auth_method,
|
|
513
|
+
}, indent=2, sort_keys=True))
|
|
514
|
+
return
|
|
515
|
+
key_summary = _mask_key(cfg.api_key) if cfg.api_key else "<unset>"
|
|
516
|
+
click.echo(f"base_url: {cfg.base_url}")
|
|
517
|
+
click.echo(f"api_key: {key_summary}")
|
|
518
|
+
if web_token_stored:
|
|
519
|
+
click.echo(f"web login: {'active (token rides to this host)' if cfg.access_token else 'stored (withheld on this host)'}")
|
|
520
|
+
if not cfg.api_key and not cfg.access_token and (key_configured or web_token_stored):
|
|
521
|
+
click.echo(click.style(
|
|
522
|
+
" a credential IS configured but withheld: this base_url's host is not "
|
|
523
|
+
"trusted to receive it (credentials ride only to https://api.parse.bot, "
|
|
524
|
+
"loopback, or a host in PARSE_EXTRA_TRUSTED_HOSTS). Unset "
|
|
525
|
+
"PARSE_API_BASE_URL, run `parse login`, or — for staging QA — set "
|
|
526
|
+
"PARSE_EXTRA_TRUSTED_HOSTS.", fg="yellow"))
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
@cli.command("list")
|
|
530
|
+
@click.option("--json", "as_json", is_flag=True,
|
|
531
|
+
help="Machine-readable output (stable shape; agents should prefer this).")
|
|
532
|
+
@click.option("--long", "long_", is_flag=True,
|
|
533
|
+
help="Verbose per-API blocks (name, endpoint names, resources).")
|
|
534
|
+
def list_cmd(as_json: bool, long_: bool) -> None:
|
|
535
|
+
"""Print the APIs the current key can call (without generating modules)."""
|
|
536
|
+
cfg = _net_resolve()
|
|
537
|
+
schemas = _fetch_schemas(cfg)
|
|
538
|
+
if as_json:
|
|
539
|
+
# Stable JSON shape (documented in README) — json.dumps escapes any
|
|
540
|
+
# control characters, so no console sanitization is needed here.
|
|
541
|
+
click.echo(json.dumps([{
|
|
542
|
+
"id": s.get("id"),
|
|
543
|
+
"slug": s.get("slug"),
|
|
544
|
+
"name": s.get("name"),
|
|
545
|
+
"modeled": _schema_modeled(s),
|
|
546
|
+
"endpoints": _schema_endpoint_names(s),
|
|
547
|
+
"resources": _schema_resource_names(s),
|
|
548
|
+
} for s in schemas], indent=2, sort_keys=True))
|
|
549
|
+
return
|
|
550
|
+
if not schemas:
|
|
551
|
+
click.echo("No APIs found for this key.")
|
|
552
|
+
return
|
|
553
|
+
if long_:
|
|
554
|
+
for s in schemas:
|
|
555
|
+
modeled = _schema_modeled(s)
|
|
556
|
+
marker = click.style("[SDK]", fg="green") if modeled else click.style("[raw]", fg="yellow", dim=True)
|
|
557
|
+
click.echo(f" {marker} {click.style(_safe_console(s['slug']), fg='cyan')} {_safe_console(s['id'])}")
|
|
558
|
+
click.echo(f" name: {_safe_console(s.get('name'))}")
|
|
559
|
+
eps = ", ".join(_safe_console(name) for name in _schema_endpoint_names(s))
|
|
560
|
+
click.echo(f" endpoints: {eps or '(none)'}")
|
|
561
|
+
if modeled:
|
|
562
|
+
res = ", ".join(_safe_console(k) for k in _schema_resource_names(s))
|
|
563
|
+
click.echo(f" resources: {res}")
|
|
564
|
+
return
|
|
565
|
+
# Compact default: one sorted row per API + a summary line. The example
|
|
566
|
+
# column is SHIPPED-ness — the same render_example + drop-reason
|
|
567
|
+
# chokepoints sync runs — not payload presence: the live example-less
|
|
568
|
+
# APIs were DROPS of present examples, which a presence column would
|
|
569
|
+
# show as ✓ on exactly the motivating cases.
|
|
570
|
+
with_examples = 0
|
|
571
|
+
rows = []
|
|
572
|
+
for s in sorted(schemas, key=lambda x: str(x.get("slug") or "")):
|
|
573
|
+
# The per-row computation reaches codegen (render_example /
|
|
574
|
+
# resolve_root_name normalize the schema) — keep it total: one
|
|
575
|
+
# malformed spec row degrades to a stderr skip, never a crash that
|
|
576
|
+
# blanks the listing for every healthy API (same per-row isolation
|
|
577
|
+
# as _render_modules).
|
|
578
|
+
try:
|
|
579
|
+
ex = docgen.render_example(s, slug=safe_slug(str(s.get("slug"))))
|
|
580
|
+
shipped = ex is not None and _example_drop_reason(ex) is None
|
|
581
|
+
root = resolve_root_name(s)
|
|
582
|
+
except Exception as e: # noqa: BLE001 — one row must never kill the list
|
|
583
|
+
click.echo(click.style(
|
|
584
|
+
f" · skipped {_safe_console(s.get('slug'))} "
|
|
585
|
+
f"({_safe_console(s.get('id'))}): malformed spec "
|
|
586
|
+
f"({_safe_console(e)})", fg="yellow", dim=True), err=True)
|
|
587
|
+
continue
|
|
588
|
+
with_examples += 1 if shipped else 0
|
|
589
|
+
rows.append((s, root, shipped))
|
|
590
|
+
for s, root, shipped in rows:
|
|
591
|
+
n_eps = len(_schema_endpoint_names(s))
|
|
592
|
+
mark = "example ✓" if shipped else "example –"
|
|
593
|
+
click.echo(
|
|
594
|
+
f" {click.style(_safe_console(s['slug']), fg='cyan')} · "
|
|
595
|
+
f"{_safe_console(root)} · "
|
|
596
|
+
f"{n_eps} endpoint{'s' if n_eps != 1 else ''} · {mark}")
|
|
597
|
+
click.echo(
|
|
598
|
+
f"{len(rows)} API{'s' if len(rows) != 1 else ''} · "
|
|
599
|
+
f"{with_examples} with examples")
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _resolve_account_slug(selector: str, schemas: List[dict]) -> Optional[str]:
|
|
603
|
+
"""Map a user selector to an account API's SERVER slug, or None.
|
|
604
|
+
|
|
605
|
+
Tries exact server-slug, case-insensitive server-slug, exact name, then
|
|
606
|
+
case-insensitive name. Server slugs (``zillow_com_api``) are the stable
|
|
607
|
+
handle recorded in ``[tool.parse].apis`` — the assigned on-disk dir slug may
|
|
608
|
+
be sanitized/de-duped, but the server slug is what `parse list` shows.
|
|
609
|
+
"""
|
|
610
|
+
by_slug = {s["slug"]: s for s in schemas if isinstance(s.get("slug"), str)}
|
|
611
|
+
if selector in by_slug:
|
|
612
|
+
return selector
|
|
613
|
+
fold = {k.casefold(): k for k in by_slug}
|
|
614
|
+
if selector.casefold() in fold:
|
|
615
|
+
return fold[selector.casefold()]
|
|
616
|
+
for s in schemas:
|
|
617
|
+
if isinstance(s.get("name"), str) and s["name"] == selector:
|
|
618
|
+
return s.get("slug")
|
|
619
|
+
for s in schemas:
|
|
620
|
+
if isinstance(s.get("name"), str) and s["name"].casefold() == selector.casefold():
|
|
621
|
+
return s.get("slug")
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _apply_account_selection(schemas: List[dict],
|
|
626
|
+
pcfg: "_project.ProjectConfig") -> Tuple[List[dict], List[str]]:
|
|
627
|
+
"""(selected schemas, missing declared slugs). ``apis is None`` ⇒ all account
|
|
628
|
+
schemas (the default), no missing."""
|
|
629
|
+
if pcfg.apis is None:
|
|
630
|
+
return list(schemas), []
|
|
631
|
+
by_slug = {s["slug"]: s for s in schemas if isinstance(s.get("slug"), str)}
|
|
632
|
+
selected: List[dict] = []
|
|
633
|
+
missing: List[str] = []
|
|
634
|
+
for slug in pcfg.apis:
|
|
635
|
+
if slug in by_slug:
|
|
636
|
+
selected.append(by_slug[slug])
|
|
637
|
+
else:
|
|
638
|
+
missing.append(slug)
|
|
639
|
+
return selected, missing
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _installed_account_slugs(project: Path, schemas: List[dict]) -> List[str]:
|
|
643
|
+
"""Server slugs of the account APIs CURRENTLY generated in this repo.
|
|
644
|
+
|
|
645
|
+
Used to snapshot the set the first time `add`/`remove` switches a repo from
|
|
646
|
+
all-account mode to an explicit list, so nothing already present is dropped.
|
|
647
|
+
Order follows the server response (stable) for a deterministic written list.
|
|
648
|
+
"""
|
|
649
|
+
manifest = _load_manifest(project / "parse_apis" / "src" / "parse_apis")
|
|
650
|
+
installed_uuids = set(manifest.keys())
|
|
651
|
+
return [s["slug"] for s in schemas
|
|
652
|
+
if isinstance(s.get("slug"), str) and s.get("id") in installed_uuids]
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _warn_missing_selection(missing: List[str]) -> None:
|
|
656
|
+
"""Stderr warning per declared-but-absent account slug (stdout stays clean
|
|
657
|
+
for --json consumers)."""
|
|
658
|
+
for slug in missing:
|
|
659
|
+
click.echo(click.style(
|
|
660
|
+
f" · declared API {slug!r} is not in your account — skipping "
|
|
661
|
+
f"(run `parse list` for available slugs)", fg="yellow", dim=True), err=True)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _gather_desired_schemas(
|
|
665
|
+
cfg: Config, account_schemas: List[dict], pcfg: "_project.ProjectConfig",
|
|
666
|
+
) -> Tuple[List[dict], List[str]]:
|
|
667
|
+
"""The full schema set this repo should generate: the account selection
|
|
668
|
+
(`[tool.parse].apis`) PLUS every recorded `[[tool.parse.marketplace]]`
|
|
669
|
+
canonical (version-pinned, `_pinned_version`-stamped). Returns
|
|
670
|
+
(schemas, account_missing). Marketplace fetch failures skip with a warning
|
|
671
|
+
inside `_fetch_marketplace_schemas`."""
|
|
672
|
+
selected, missing = _apply_account_selection(account_schemas, pcfg)
|
|
673
|
+
_warn_missing_selection(missing)
|
|
674
|
+
if pcfg.marketplace:
|
|
675
|
+
selected = selected + _fetch_marketplace_schemas(cfg, pcfg.marketplace)
|
|
676
|
+
return selected, missing
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
@cli.command()
|
|
680
|
+
@click.option(
|
|
681
|
+
"--clean", is_flag=True,
|
|
682
|
+
help="Remove generated payload before regenerating (keeps the committed scaffold).",
|
|
683
|
+
)
|
|
684
|
+
@click.option(
|
|
685
|
+
"--check", "check", is_flag=True,
|
|
686
|
+
help="CI dry run: fetch, render, and run every promotion gate with NO "
|
|
687
|
+
"promote — print per-slug diff markers and findings, then exit 1 on "
|
|
688
|
+
"any finding (including would-be-quarantined APIs and a missing "
|
|
689
|
+
"parse-sdk pin). Requires an initialized project, same as a real sync.",
|
|
690
|
+
)
|
|
691
|
+
@click.option(
|
|
692
|
+
"--json", "as_json", is_flag=True,
|
|
693
|
+
help="Machine-readable result on stdout (stable shape; agents should "
|
|
694
|
+
"prefer this). Composes with --check. Errors still go to stderr.",
|
|
695
|
+
)
|
|
696
|
+
def sync(clean: bool, check: bool, as_json: bool) -> None:
|
|
697
|
+
"""Fetch your APIs' schemas and write typed Python modules.
|
|
698
|
+
|
|
699
|
+
Target is the initialized project's `parse_apis/src/parse_apis` package
|
|
700
|
+
(run `parse init` first). Generated payload is staged, self-tested, and
|
|
701
|
+
promoted atomically — a failed sync never leaves broken or secret-bearing
|
|
702
|
+
files in your project.
|
|
703
|
+
|
|
704
|
+
APIs without a `resources` block (the agent didn't model them yet)
|
|
705
|
+
are skipped with a warning — they still work via raw HTTP through
|
|
706
|
+
the executor.
|
|
707
|
+
"""
|
|
708
|
+
# Init precondition BEFORE any network round-trip; reuse the resolved
|
|
709
|
+
# paths for the reconcile + the staged swap so the check runs exactly once.
|
|
710
|
+
project, root, pkg = _require_generated_package()
|
|
711
|
+
cfg = _net_resolve()
|
|
712
|
+
schemas = _fetch_schemas(cfg)
|
|
713
|
+
if not schemas:
|
|
714
|
+
if as_json:
|
|
715
|
+
click.echo(json.dumps({"written": [], "skipped": [], "removed": [],
|
|
716
|
+
"quarantined": [], "failures": []},
|
|
717
|
+
indent=2, sort_keys=True))
|
|
718
|
+
else:
|
|
719
|
+
click.echo("No APIs found for this key.")
|
|
720
|
+
return
|
|
721
|
+
# Reconcile to the repo's declared set (`[tool.parse].apis` + marketplace):
|
|
722
|
+
# an absent apis key means all account APIs (unchanged behavior); an explicit
|
|
723
|
+
# list narrows it; recorded marketplace canonicals are added version-pinned.
|
|
724
|
+
consumer = project / "pyproject.toml"
|
|
725
|
+
pcfg = (_project.read_project_config(consumer) if consumer.exists()
|
|
726
|
+
else _project.ProjectConfig())
|
|
727
|
+
selected, _missing = _gather_desired_schemas(cfg, schemas, pcfg)
|
|
728
|
+
# Wipe-guard: refuse when the repo DECLARES a curated set (account slugs
|
|
729
|
+
# and/or marketplace canonicals) but every one is unresolvable. `pcfg.apis`
|
|
730
|
+
# alone misses the marketplace-only case (apis == [] / None with
|
|
731
|
+
# `[[tool.parse.marketplace]]` entries that all fail to resolve), which would
|
|
732
|
+
# otherwise prune every generated client. `declared_any` covers both axes.
|
|
733
|
+
declared_any = bool(pcfg.apis) or bool(pcfg.marketplace)
|
|
734
|
+
if declared_any and not selected:
|
|
735
|
+
click.echo(click.style(
|
|
736
|
+
"Refusing to sync: every API in [tool.parse] is unresolvable "
|
|
737
|
+
"(missing from your account / marketplace), which would prune all "
|
|
738
|
+
"generated clients. Fix the list (`parse list` shows valid slugs) or "
|
|
739
|
+
"remove the [tool.parse] table to track all account APIs.", fg="red"), err=True)
|
|
740
|
+
sys.exit(2)
|
|
741
|
+
_sync_installed(cfg, selected, clean=clean, check=check, as_json=as_json,
|
|
742
|
+
_pkg_paths=(project, root, pkg))
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
@cli.command()
|
|
746
|
+
def clean() -> None:
|
|
747
|
+
"""Remove generated payload from parse_apis, keeping the committed scaffold.
|
|
748
|
+
|
|
749
|
+
Payload-only — replaces the old destructive `parse sync --clean` (which
|
|
750
|
+
`rmtree`'d the whole tree). Never touches `__init__.py`, `py.typed`, or the
|
|
751
|
+
package `pyproject.toml`.
|
|
752
|
+
"""
|
|
753
|
+
project = _find_project_root()
|
|
754
|
+
root = project / "parse_apis"
|
|
755
|
+
pkg = root / "src" / "parse_apis"
|
|
756
|
+
if not (pkg / "__init__.py").exists():
|
|
757
|
+
hint = _interrupted_promote_hint(root)
|
|
758
|
+
click.echo(click.style(
|
|
759
|
+
"No initialized `parse_apis` package found (searched this directory "
|
|
760
|
+
"and its parents). Run `parse init` first.",
|
|
761
|
+
fg="red"), err=True)
|
|
762
|
+
if hint:
|
|
763
|
+
click.echo(click.style(hint, fg="yellow"), err=True)
|
|
764
|
+
sys.exit(2)
|
|
765
|
+
_guard_target_project(project, command="clean")
|
|
766
|
+
removed = _clean_payload(pkg)
|
|
767
|
+
if removed:
|
|
768
|
+
for p in removed:
|
|
769
|
+
click.echo(click.style(f" · removed {p.name}", fg="yellow", dim=True))
|
|
770
|
+
click.echo(
|
|
771
|
+
f"✓ cleaned {len(removed)} generated item"
|
|
772
|
+
f"{'s' if len(removed) != 1 else ''} (scaffold kept)")
|
|
773
|
+
else:
|
|
774
|
+
click.echo("Nothing to clean — no generated payload.")
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def _require_initialized_consumer(command: str) -> Tuple[Path, Path]:
|
|
778
|
+
"""(project root, consumer pyproject) for a command that mutates the repo's
|
|
779
|
+
declared API set — exits 2 with the init hint when not initialized."""
|
|
780
|
+
project = _find_project_root()
|
|
781
|
+
consumer = project / "pyproject.toml"
|
|
782
|
+
pkg_init = project / "parse_apis" / "src" / "parse_apis" / "__init__.py"
|
|
783
|
+
if not (consumer.exists() and pkg_init.exists()):
|
|
784
|
+
click.echo(click.style(
|
|
785
|
+
"No initialized parse_apis project here (searched this directory and "
|
|
786
|
+
"its parents). Run `parse init` first.", fg="red"), err=True)
|
|
787
|
+
sys.exit(2)
|
|
788
|
+
_guard_target_project(project, command=command)
|
|
789
|
+
return project, consumer
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _require_generated_package(command: str = "sync") -> Tuple[Path, Path, Path]:
|
|
793
|
+
"""(project, ``parse_apis`` root, ``src/parse_apis`` pkg) for a command that
|
|
794
|
+
operates on the GENERATED payload. Exits 2 with the init hint — plus the
|
|
795
|
+
interrupted-promote recovery hint when a half-promoted tree is detected — and
|
|
796
|
+
the disjoint-env guard. Distinct from ``_require_initialized_consumer``, which
|
|
797
|
+
gates repo-mutating commands on the CONSUMER pyproject; this checks the
|
|
798
|
+
generated scaffold (``parse_apis/pyproject.toml`` + the package ``__init__``).
|
|
799
|
+
"""
|
|
800
|
+
project = _find_project_root()
|
|
801
|
+
root = project / "parse_apis"
|
|
802
|
+
pkg = root / "src" / "parse_apis"
|
|
803
|
+
if not ((root / "pyproject.toml").exists() and (pkg / "__init__.py").exists()):
|
|
804
|
+
hint = _interrupted_promote_hint(root)
|
|
805
|
+
click.echo(click.style(
|
|
806
|
+
"No initialized `parse_apis` package found (searched this directory "
|
|
807
|
+
"and its parents). Run `parse init` first "
|
|
808
|
+
"(expected parse_apis/pyproject.toml and parse_apis/src/parse_apis/__init__.py). "
|
|
809
|
+
"Run `uv run parse doctor` to diagnose.",
|
|
810
|
+
fg="red"), err=True)
|
|
811
|
+
if hint:
|
|
812
|
+
click.echo(click.style(hint, fg="yellow"), err=True)
|
|
813
|
+
sys.exit(2)
|
|
814
|
+
_guard_target_project(project, command=command)
|
|
815
|
+
return project, root, pkg
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
_UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
|
819
|
+
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _host_of(url: str) -> str:
|
|
823
|
+
try:
|
|
824
|
+
h = (urllib.parse.urlsplit(url if "//" in url else f"//{url}").hostname or "").lower()
|
|
825
|
+
except ValueError:
|
|
826
|
+
return ""
|
|
827
|
+
return h[4:] if h.startswith("www.") else h
|
|
828
|
+
|
|
829
|
+
|
|
830
|
+
def _marketplace_detail(cfg: Config, listing_id: str) -> Optional[dict]:
|
|
831
|
+
status, body = _http_get_json(cfg, f"/marketplace/apis/{urllib.parse.quote(listing_id)}",
|
|
832
|
+
with_key=False)
|
|
833
|
+
return body if status == 200 and isinstance(body, dict) else None
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _marketplace_search(cfg: Config, query: str, *, limit: int = 25) -> List[dict]:
|
|
837
|
+
status, body = _http_get_json(cfg, "/marketplace/apis",
|
|
838
|
+
params={"q": query, "limit": limit}, with_key=False)
|
|
839
|
+
if status != 200 or not isinstance(body, dict):
|
|
840
|
+
return []
|
|
841
|
+
items = body.get("items")
|
|
842
|
+
return [it for it in items if isinstance(it, dict)] if isinstance(items, list) else []
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _resolve_marketplace_selector(cfg: Config, selector: str) -> Optional[dict]:
|
|
846
|
+
"""Resolve a listing-id / URL / slug / name to a marketplace detail dict
|
|
847
|
+
(carrying canonical_scraper_id, latest_version, slug, name, source_url,
|
|
848
|
+
is_authenticated), or None after printing the reason.
|
|
849
|
+
|
|
850
|
+
Ambiguity is fail-LOUD, never auto-picked (a broad term matches dozens):
|
|
851
|
+
prints the candidates and returns None so the caller exits non-zero.
|
|
852
|
+
"""
|
|
853
|
+
# 1. Direct listing id.
|
|
854
|
+
if _UUID_RE.match(selector):
|
|
855
|
+
detail = _marketplace_detail(cfg, selector)
|
|
856
|
+
if detail:
|
|
857
|
+
return detail
|
|
858
|
+
click.echo(click.style(f"No marketplace API with id {selector}.", fg="red"), err=True)
|
|
859
|
+
return None
|
|
860
|
+
|
|
861
|
+
# 2. Search, then disambiguate by exact slug / host / single result.
|
|
862
|
+
items = _marketplace_search(cfg, selector)
|
|
863
|
+
if not items:
|
|
864
|
+
click.echo(click.style(
|
|
865
|
+
f"No marketplace APIs match {selector!r}. Try `parse search {selector}`.",
|
|
866
|
+
fg="red"), err=True)
|
|
867
|
+
return None
|
|
868
|
+
sel_fold = selector.casefold()
|
|
869
|
+
sel_host = _host_of(selector)
|
|
870
|
+
exact = [it for it in items
|
|
871
|
+
if str(it.get("slug", "")).casefold() == sel_fold
|
|
872
|
+
or str(it.get("name", "")).casefold() == sel_fold
|
|
873
|
+
or (sel_host and _host_of(str(it.get("source_url", ""))) == sel_host)]
|
|
874
|
+
chosen = None
|
|
875
|
+
if len(exact) == 1:
|
|
876
|
+
chosen = exact[0]
|
|
877
|
+
elif len(items) == 1:
|
|
878
|
+
chosen = items[0]
|
|
879
|
+
if chosen is None:
|
|
880
|
+
click.echo(click.style(
|
|
881
|
+
f"{selector!r} matches {len(items)} marketplace APIs — pick one by "
|
|
882
|
+
f"listing id or exact slug:", fg="yellow"), err=True)
|
|
883
|
+
for it in items[:15]:
|
|
884
|
+
click.echo(click.style(
|
|
885
|
+
f" {it.get('slug')} · {it.get('name')} · "
|
|
886
|
+
f"{it.get('source_url')} · {it.get('id')}", dim=True), err=True)
|
|
887
|
+
return None
|
|
888
|
+
detail = _marketplace_detail(cfg, str(chosen.get("id")))
|
|
889
|
+
if detail is None:
|
|
890
|
+
click.echo(click.style(
|
|
891
|
+
f"Couldn't load detail for {chosen.get('slug')} ({chosen.get('id')}).",
|
|
892
|
+
fg="red"), err=True)
|
|
893
|
+
return detail
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def _add_marketplace(cfg: Config, project: Path, consumer: Path,
|
|
897
|
+
pcfg: "_project.ProjectConfig", selectors: Tuple[str, ...],
|
|
898
|
+
version: Optional[int], as_json: bool) -> None:
|
|
899
|
+
"""Resolve + record marketplace canonicals (version-pinned), then reconcile."""
|
|
900
|
+
if version is not None and len(selectors) != 1:
|
|
901
|
+
click.echo(click.style(
|
|
902
|
+
"--version pins a single API — pass exactly one selector with --version.",
|
|
903
|
+
fg="red"), err=True)
|
|
904
|
+
sys.exit(2)
|
|
905
|
+
# Reject non-positive / non-int (and bool, which is an int subclass) versions
|
|
906
|
+
# up front — otherwise `--version 0`/`-1` silently degraded to "no pin" with
|
|
907
|
+
# no signal that the explicit request was dropped.
|
|
908
|
+
if version is not None and (not isinstance(version, int) or isinstance(version, bool) or version < 1):
|
|
909
|
+
click.echo(click.style(
|
|
910
|
+
f"--version must be a positive integer (got {version!r}).", fg="red"), err=True)
|
|
911
|
+
sys.exit(2)
|
|
912
|
+
|
|
913
|
+
# Resolve + validate ALL selectors first, accumulating the entries and their
|
|
914
|
+
# user-facing output. Nothing is printed and nothing is persisted until every
|
|
915
|
+
# selector succeeds — otherwise a later failure (sys.exit) would leave the user
|
|
916
|
+
# staring at an "Added …" line for an entry that was never written to
|
|
917
|
+
# pyproject.toml.
|
|
918
|
+
by_sid = {m.scraper_id: m for m in pcfg.marketplace}
|
|
919
|
+
added: List[str] = []
|
|
920
|
+
deferred_msgs: List[str] = []
|
|
921
|
+
for sel in selectors:
|
|
922
|
+
detail = _resolve_marketplace_selector(cfg, sel)
|
|
923
|
+
if detail is None:
|
|
924
|
+
sys.exit(2)
|
|
925
|
+
sid = detail.get("canonical_scraper_id")
|
|
926
|
+
if not isinstance(sid, str) or not sid:
|
|
927
|
+
click.echo(click.style(
|
|
928
|
+
f"{sel!r} has no canonical scraper id — can't add.", fg="red"), err=True)
|
|
929
|
+
sys.exit(2)
|
|
930
|
+
pin = version if version is not None else detail.get("latest_version")
|
|
931
|
+
pin = pin if isinstance(pin, int) and pin >= 1 else None
|
|
932
|
+
entry = _project.MarketplaceEntry(
|
|
933
|
+
scraper_id=sid,
|
|
934
|
+
# Sanitize to the import-safe form codegen will use on disk (listing
|
|
935
|
+
# slugs use dashes: `zillow-com-api` → `zillow_com_api`).
|
|
936
|
+
slug=safe_slug(str(detail.get("slug") or sel)),
|
|
937
|
+
version=pin,
|
|
938
|
+
listing_id=str(detail.get("id")) if detail.get("id") else None,
|
|
939
|
+
source_url=str(detail.get("source_url")) if detail.get("source_url") else None,
|
|
940
|
+
)
|
|
941
|
+
by_sid[sid] = entry # idempotent: re-adding updates the pin
|
|
942
|
+
added.append(entry.slug)
|
|
943
|
+
deferred_msgs.append(click.style(
|
|
944
|
+
f"Added marketplace API {entry.slug}"
|
|
945
|
+
f"{f' @v{pin}' if pin else ' (latest — no release to pin)'} "
|
|
946
|
+
f"— shared canonical, pinned and won't change under you.", fg="cyan"))
|
|
947
|
+
if detail.get("is_authenticated"):
|
|
948
|
+
deferred_msgs.append(click.style(
|
|
949
|
+
" ⚠ this API requires authentication; calls need credentials/login "
|
|
950
|
+
"and may not work with a plain API key.", fg="yellow"))
|
|
951
|
+
deferred_msgs.append(click.style(
|
|
952
|
+
" To customize it, subscribe + swap to your own clone in the dashboard, "
|
|
953
|
+
"then `parse add` your clone.", dim=True))
|
|
954
|
+
|
|
955
|
+
# Every selector resolved — persist first, then announce, so the "Added …"
|
|
956
|
+
# output only ever appears for entries that actually made it to disk.
|
|
957
|
+
pcfg.marketplace = list(by_sid.values())
|
|
958
|
+
_project.write_project_config(consumer, pcfg)
|
|
959
|
+
for msg in deferred_msgs:
|
|
960
|
+
click.echo(msg)
|
|
961
|
+
|
|
962
|
+
account_schemas = _fetch_schemas(cfg)
|
|
963
|
+
selected, _missing = _gather_desired_schemas(cfg, account_schemas, pcfg)
|
|
964
|
+
if not selected:
|
|
965
|
+
click.echo(click.style(
|
|
966
|
+
"Nothing to generate — the added marketplace API(s) returned no "
|
|
967
|
+
"resolvable schema.", fg="red"), err=True)
|
|
968
|
+
sys.exit(2)
|
|
969
|
+
_sync_installed(cfg, selected, clean=False, as_json=as_json)
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
@cli.command()
|
|
973
|
+
@click.argument("query", nargs=-1, required=True)
|
|
974
|
+
@click.option("--limit", type=int, default=15, show_default=True, help="Max results.")
|
|
975
|
+
@click.option("--json", "as_json", is_flag=True, help="Machine-readable results on stdout.")
|
|
976
|
+
def search(query: Tuple[str, ...], limit: int, as_json: bool) -> None:
|
|
977
|
+
"""Search the Parse marketplace for ready-made APIs.
|
|
978
|
+
|
|
979
|
+
Prints matches with their slug, name, source site, endpoint count, and listing
|
|
980
|
+
id. Add one with `parse add --marketplace <listing-id|slug>`.
|
|
981
|
+
"""
|
|
982
|
+
cfg = _net_resolve()
|
|
983
|
+
q = " ".join(query).strip()
|
|
984
|
+
items = _marketplace_search(cfg, q, limit=max(1, min(limit, 50)))
|
|
985
|
+
if as_json:
|
|
986
|
+
click.echo(json.dumps([{
|
|
987
|
+
"id": it.get("id"), "slug": it.get("slug"), "name": it.get("name"),
|
|
988
|
+
"source_url": it.get("source_url"),
|
|
989
|
+
"endpoint_count": it.get("endpoint_count"),
|
|
990
|
+
"is_authenticated": it.get("is_authenticated"),
|
|
991
|
+
} for it in items], indent=2, sort_keys=True))
|
|
992
|
+
return
|
|
993
|
+
if not items:
|
|
994
|
+
click.echo(f"No marketplace APIs match {q!r}.")
|
|
995
|
+
return
|
|
996
|
+
for it in items:
|
|
997
|
+
n = it.get("endpoint_count")
|
|
998
|
+
auth = " 🔒" if it.get("is_authenticated") else ""
|
|
999
|
+
click.echo(
|
|
1000
|
+
f" {click.style(_safe_console(it.get('slug')), fg='cyan')}{auth} · "
|
|
1001
|
+
f"{_safe_console(it.get('name'))} · {_safe_console(it.get('source_url'))} · "
|
|
1002
|
+
f"{n if isinstance(n, int) else '?'} endpoint(s)")
|
|
1003
|
+
click.echo(click.style(f" add: parse add --marketplace {_safe_console(it.get('id'))}", dim=True))
|
|
1004
|
+
click.echo(f"{len(items)} result{'s' if len(items) != 1 else ''} · "
|
|
1005
|
+
f"add one with `parse add --marketplace <listing-id|slug>`")
|
|
1006
|
+
|
|
1007
|
+
|
|
1008
|
+
@cli.command()
|
|
1009
|
+
@click.argument("selectors", nargs=-1, required=True)
|
|
1010
|
+
@click.option("--marketplace", "-m", "is_marketplace", is_flag=True,
|
|
1011
|
+
help="Treat selectors as MARKETPLACE APIs (listing id / URL / name) to "
|
|
1012
|
+
"download locally, version-pinned. `parse search` finds them.")
|
|
1013
|
+
@click.option("--version", "version", type=int, default=None,
|
|
1014
|
+
help="Pin marketplace API(s) to this release version (default: latest at add time). "
|
|
1015
|
+
"Only valid with --marketplace and a single selector.")
|
|
1016
|
+
@click.option("--json", "as_json", is_flag=True, help="Machine-readable sync result on stdout.")
|
|
1017
|
+
def add(selectors: Tuple[str, ...], is_marketplace: bool, version: Optional[int],
|
|
1018
|
+
as_json: bool) -> None:
|
|
1019
|
+
"""Add APIs to this repo's synced set, then reconcile.
|
|
1020
|
+
|
|
1021
|
+
Account APIs (default): appends server slugs / names to [tool.parse].apis —
|
|
1022
|
+
switching the repo from "all account APIs" to an explicit, reproducible list
|
|
1023
|
+
the first time (snapshotting what is already installed so nothing is dropped).
|
|
1024
|
+
|
|
1025
|
+
Marketplace APIs (--marketplace): downloads a shared canonical's typed client
|
|
1026
|
+
locally, version-pinned, recording it under [[tool.parse.marketplace]]. No
|
|
1027
|
+
subscription is created — the pinned canonical won't change under you; to
|
|
1028
|
+
customize it, subscribe + swap to your clone in the dashboard.
|
|
1029
|
+
"""
|
|
1030
|
+
cfg = _net_resolve()
|
|
1031
|
+
project, consumer = _require_initialized_consumer("add")
|
|
1032
|
+
pcfg = _project.read_project_config(consumer)
|
|
1033
|
+
|
|
1034
|
+
if is_marketplace:
|
|
1035
|
+
_add_marketplace(cfg, project, consumer, pcfg, selectors, version, as_json)
|
|
1036
|
+
return
|
|
1037
|
+
if version is not None:
|
|
1038
|
+
click.echo(click.style("--version is only valid with --marketplace.", fg="red"), err=True)
|
|
1039
|
+
sys.exit(2)
|
|
1040
|
+
|
|
1041
|
+
schemas = _fetch_schemas(cfg)
|
|
1042
|
+
resolved: List[str] = []
|
|
1043
|
+
unknown: List[str] = []
|
|
1044
|
+
for sel in selectors:
|
|
1045
|
+
slug = _resolve_account_slug(sel, schemas)
|
|
1046
|
+
(resolved.append(slug) if slug else unknown.append(sel))
|
|
1047
|
+
if unknown:
|
|
1048
|
+
click.echo(click.style(
|
|
1049
|
+
f"Not found in your account APIs: {', '.join(unknown)}. Run "
|
|
1050
|
+
f"`parse list` for valid slugs, or `parse add --marketplace {unknown[0]}` "
|
|
1051
|
+
f"to add a shared marketplace API.", fg="red"), err=True)
|
|
1052
|
+
sys.exit(2)
|
|
1053
|
+
|
|
1054
|
+
switched = pcfg.apis is None
|
|
1055
|
+
base = _installed_account_slugs(project, schemas) if switched else list(pcfg.apis or [])
|
|
1056
|
+
new_apis = list(base)
|
|
1057
|
+
for slug in resolved:
|
|
1058
|
+
if slug not in new_apis:
|
|
1059
|
+
new_apis.append(slug)
|
|
1060
|
+
pcfg.apis = new_apis
|
|
1061
|
+
_project.write_project_config(consumer, pcfg)
|
|
1062
|
+
if switched:
|
|
1063
|
+
click.echo(click.style(
|
|
1064
|
+
f"Switched this repo to an explicit API list (was: all account APIs). "
|
|
1065
|
+
f"Now tracking {len(new_apis)}: {', '.join(new_apis)}", fg="cyan"))
|
|
1066
|
+
else:
|
|
1067
|
+
click.echo(f"Added {', '.join(resolved)} — now tracking {len(new_apis)} API(s).")
|
|
1068
|
+
|
|
1069
|
+
selected, _missing = _gather_desired_schemas(cfg, schemas, pcfg)
|
|
1070
|
+
_sync_installed(cfg, selected, clean=False, as_json=as_json)
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
@cli.command()
|
|
1074
|
+
@click.argument("selectors", nargs=-1, required=True)
|
|
1075
|
+
@click.option("--json", "as_json", is_flag=True, help="Machine-readable sync result on stdout.")
|
|
1076
|
+
def remove(selectors: Tuple[str, ...], as_json: bool) -> None:
|
|
1077
|
+
"""Stop syncing APIs in this repo: drop them from [tool.parse] and reconcile
|
|
1078
|
+
(their generated clients are pruned).
|
|
1079
|
+
|
|
1080
|
+
Removes both account APIs (from `apis`) and marketplace APIs (matched by
|
|
1081
|
+
slug / scraper id / listing id). If the repo currently tracks all account
|
|
1082
|
+
APIs, this switches it to an explicit list of everything-installed-minus-removed.
|
|
1083
|
+
"""
|
|
1084
|
+
cfg = _net_resolve()
|
|
1085
|
+
project, consumer = _require_initialized_consumer("remove")
|
|
1086
|
+
schemas = _fetch_schemas(cfg)
|
|
1087
|
+
pcfg = _project.read_project_config(consumer)
|
|
1088
|
+
|
|
1089
|
+
# Resolve to a server slug when possible, else take the literal (so a stale
|
|
1090
|
+
# entry no longer in the account can still be removed).
|
|
1091
|
+
targets = {(_resolve_account_slug(sel, schemas) or sel) for sel in selectors}
|
|
1092
|
+
base = _installed_account_slugs(project, schemas) if pcfg.apis is None else list(pcfg.apis)
|
|
1093
|
+
new_apis = [s for s in base if s not in targets]
|
|
1094
|
+
removed = [s for s in base if s in targets]
|
|
1095
|
+
|
|
1096
|
+
# Marketplace entries match on slug, scraper_id, or listing_id.
|
|
1097
|
+
kept_mkt, removed_mkt = [], []
|
|
1098
|
+
for m in pcfg.marketplace:
|
|
1099
|
+
if targets & {m.slug, m.scraper_id, m.listing_id or ""}:
|
|
1100
|
+
removed_mkt.append(m.slug)
|
|
1101
|
+
else:
|
|
1102
|
+
kept_mkt.append(m)
|
|
1103
|
+
|
|
1104
|
+
if not removed and not removed_mkt:
|
|
1105
|
+
click.echo(f"Nothing to remove — none of [{', '.join(selectors)}] are tracked.")
|
|
1106
|
+
return
|
|
1107
|
+
pcfg.apis = new_apis
|
|
1108
|
+
pcfg.marketplace = kept_mkt
|
|
1109
|
+
_project.write_project_config(consumer, pcfg)
|
|
1110
|
+
all_removed = removed + removed_mkt
|
|
1111
|
+
click.echo(f"Removed {', '.join(all_removed)} — now tracking "
|
|
1112
|
+
f"{len(new_apis)} account + {len(kept_mkt)} marketplace API(s).")
|
|
1113
|
+
|
|
1114
|
+
selected, _missing = _gather_desired_schemas(cfg, schemas, pcfg)
|
|
1115
|
+
# Wipe-guard (parity with sync): if APIs are still declared (account or
|
|
1116
|
+
# marketplace) but NONE resolve, refuse rather than prune every generated
|
|
1117
|
+
# client. The config edit above stands (it reflects the remove intent); a
|
|
1118
|
+
# later `parse sync` reconciles once the stale entries are fixed. Removing
|
|
1119
|
+
# everything is an intentional empty repo, handled below.
|
|
1120
|
+
declared_any = bool(pcfg.apis) or bool(pcfg.marketplace)
|
|
1121
|
+
if declared_any and not selected:
|
|
1122
|
+
click.echo(click.style(
|
|
1123
|
+
"Not pruning: the remaining declared APIs are all unresolvable (missing "
|
|
1124
|
+
"from your account / marketplace), so syncing would wipe every generated "
|
|
1125
|
+
"client. Fix the stale entries (`parse list` shows valid slugs) or remove "
|
|
1126
|
+
"the [tool.parse] table to track all account APIs, then `parse sync`.",
|
|
1127
|
+
fg="red"), err=True)
|
|
1128
|
+
sys.exit(2)
|
|
1129
|
+
if not declared_any:
|
|
1130
|
+
# Removing the LAST tracked API leaves an empty explicit selection. Do
|
|
1131
|
+
# NOT `_sync_installed(cfg, [], ...)` — that swaps in a scaffold-only
|
|
1132
|
+
# package and wipes every generated client (the old branch
|
|
1133
|
+
# only *commented* "switch back rather than wipe-sync" but had no return).
|
|
1134
|
+
# The config edit stands; the existing generated clients are left in
|
|
1135
|
+
# place. To intentionally clear them, delete the [tool.parse] table (or
|
|
1136
|
+
# the package) — `parse sync` then reconciles to all-account.
|
|
1137
|
+
click.echo(click.style(
|
|
1138
|
+
" · this repo now tracks no APIs in [tool.parse]. Existing generated "
|
|
1139
|
+
"clients were left untouched (no prune). Remove the [tool.parse] table "
|
|
1140
|
+
"to track all account APIs again, then `parse sync`.",
|
|
1141
|
+
fg="yellow", dim=True))
|
|
1142
|
+
return
|
|
1143
|
+
_sync_installed(cfg, selected, clean=False, as_json=as_json)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _unique_legacy_dir(project: Path) -> Path:
|
|
1147
|
+
"""A fresh `.parse/legacy-parse_apis[-N]` path to archive an old flat tree."""
|
|
1148
|
+
base = project / ".parse"
|
|
1149
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
1150
|
+
n = 0
|
|
1151
|
+
while True:
|
|
1152
|
+
candidate = base / ("legacy-parse_apis" if n == 0 else f"legacy-parse_apis-{n}")
|
|
1153
|
+
if not candidate.exists():
|
|
1154
|
+
return candidate
|
|
1155
|
+
n += 1
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
def _append_gitignore(project: Path) -> None:
|
|
1159
|
+
"""Append the deny-by-default ignore block to ``.gitignore`` once (idempotent).
|
|
1160
|
+
|
|
1161
|
+
The guard keys on the managed-block marker (its header line), not on a single
|
|
1162
|
+
ignore pattern: a stray ``parse_apis/...`` line elsewhere must not suppress
|
|
1163
|
+
writing the block, which includes the ``!``-re-includes for the committed
|
|
1164
|
+
scaffold (skipping those would over-ignore — leaving the scaffold untracked).
|
|
1165
|
+
"""
|
|
1166
|
+
gi = project / ".gitignore"
|
|
1167
|
+
block = scaffold.render_gitignore_block()
|
|
1168
|
+
marker = block.splitlines()[0]
|
|
1169
|
+
existing = gi.read_text() if gi.exists() else ""
|
|
1170
|
+
if marker in existing:
|
|
1171
|
+
return
|
|
1172
|
+
prefix = existing if existing.endswith("\n") or not existing else existing + "\n"
|
|
1173
|
+
gi.write_text(prefix + ("\n" if existing else "") + block)
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _interrupted_promote_hint(root: Path) -> Optional[str]:
|
|
1177
|
+
"""Recovery hint for a sync that crashed between the two promote renames:
|
|
1178
|
+
the live package dir is gone, but its full prior contents sit in a
|
|
1179
|
+
``src/.parse_apis.old-*`` sibling (deliberately never swept — it can hold
|
|
1180
|
+
the user's only live copy). Without this, the not-initialized error says
|
|
1181
|
+
'Run `parse init` first', which mis-diagnoses the state and silently
|
|
1182
|
+
orphans the one artifact kept for recovery."""
|
|
1183
|
+
if not (root / "pyproject.toml").exists():
|
|
1184
|
+
return None
|
|
1185
|
+
# `src` can be absent independently of pyproject.toml (manual deletion, a
|
|
1186
|
+
# half-built scaffold). Guard before globbing: this runs in error-recovery
|
|
1187
|
+
# paths (clean/sync) where the tree is already broken, and pathlib's glob
|
|
1188
|
+
# over a missing base is version-dependent (3.13 rewrote the globber) — an
|
|
1189
|
+
# explicit precondition keeps the hint a clean None instead of risking a
|
|
1190
|
+
# traceback that buries the diagnostic.
|
|
1191
|
+
src = root / "src"
|
|
1192
|
+
if not src.is_dir():
|
|
1193
|
+
return None
|
|
1194
|
+
olds = sorted(src.glob(".parse_apis.old-*"))
|
|
1195
|
+
if not olds:
|
|
1196
|
+
return None
|
|
1197
|
+
d = olds[-1]
|
|
1198
|
+
return (
|
|
1199
|
+
f"A previous `parse sync` was interrupted mid-promote; your prior "
|
|
1200
|
+
f"generated payload is intact at {d}.\n"
|
|
1201
|
+
f"Restore it with: mv {d} {root / 'src' / 'parse_apis'}\n"
|
|
1202
|
+
f"or re-run `parse init` to rebuild the scaffold (then delete {d})."
|
|
1203
|
+
)
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def _env_implied_project_root() -> Optional[Path]:
|
|
1207
|
+
"""The project root implied by the RUNNING interpreter's environment, or
|
|
1208
|
+
None when the interpreter isn't a project venv.
|
|
1209
|
+
|
|
1210
|
+
``uv run --project X parse …`` selects X's environment while the process
|
|
1211
|
+
CWD stays wherever the shell was — the one signal that survives that split
|
|
1212
|
+
is the interpreter itself: a project venv lives at ``<root>/.venv``.
|
|
1213
|
+
``VIRTUAL_ENV`` is honored first (matches uv's own resolution); a venv not
|
|
1214
|
+
named ``.venv`` (pipx, ``uv tool``, global installs) yields None — no
|
|
1215
|
+
guard, current behavior.
|
|
1216
|
+
"""
|
|
1217
|
+
venv = os.environ.get("VIRTUAL_ENV") or sys.prefix
|
|
1218
|
+
p = Path(venv)
|
|
1219
|
+
if p.name == ".venv" and p.is_dir():
|
|
1220
|
+
return p.parent.resolve()
|
|
1221
|
+
return None
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _guard_target_project(target: Path, *, command: str) -> None:
|
|
1225
|
+
"""Exit 2 when the target project and the env-implied root are DISJOINT.
|
|
1226
|
+
|
|
1227
|
+
The wrong-target shape this blocks: a user runs
|
|
1228
|
+
``uv run --project X parse init`` from an unrelated repo — init targets
|
|
1229
|
+
CWD, which qualifies on its own (it has a pyproject.toml), and silently
|
|
1230
|
+
mutates the WRONG project (archives its flat ``parse_apis/``, edits its
|
|
1231
|
+
tracked pyproject/.gitignore). Containment, not equality: a target that IS
|
|
1232
|
+
the env root or a DESCENDANT of it is allowed — uv workspaces share one
|
|
1233
|
+
root ``.venv``, so member-directory work under the workspace root is a
|
|
1234
|
+
legitimate, working flow. No interactive confirm by design: agents and
|
|
1235
|
+
scripts auto-confirm, so a prompt is friction without protection; the
|
|
1236
|
+
hard disjoint-gate is the protection.
|
|
1237
|
+
"""
|
|
1238
|
+
env_root = _env_implied_project_root()
|
|
1239
|
+
if env_root is None:
|
|
1240
|
+
return
|
|
1241
|
+
resolved = target.resolve()
|
|
1242
|
+
if resolved == env_root or env_root in resolved.parents:
|
|
1243
|
+
return
|
|
1244
|
+
click.echo(click.style(
|
|
1245
|
+
f"Refusing `parse {command}`: it would act on {resolved} (resolved "
|
|
1246
|
+
f"from your working directory), but this `parse` runs from the "
|
|
1247
|
+
f"environment of {env_root}.", fg="red"), err=True)
|
|
1248
|
+
click.echo(click.style(
|
|
1249
|
+
f" Either `cd {env_root}` and re-run, or run `parse {command}` "
|
|
1250
|
+
f"without `--project` from inside the project you intend to modify.",
|
|
1251
|
+
fg="yellow"), err=True)
|
|
1252
|
+
sys.exit(2)
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def _find_project_root() -> Path:
|
|
1256
|
+
"""Nearest ancestor (cwd included) holding an initialized ``parse_apis``
|
|
1257
|
+
package. cwd-only lookup made agents in subdirectories fail mysteriously;
|
|
1258
|
+
``init`` STAYS cwd-bound (you init exactly where the project root should
|
|
1259
|
+
be)."""
|
|
1260
|
+
for cand in (Path.cwd(), *Path.cwd().parents):
|
|
1261
|
+
if (cand / "parse_apis" / "src" / "parse_apis" / "__init__.py").exists():
|
|
1262
|
+
return cand
|
|
1263
|
+
return Path.cwd() # callers' existing not-initialized error then names init
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def _project_dependencies_pos(text: str) -> Optional[int]:
|
|
1267
|
+
"""Index just after the opening ``[`` of ``dependencies = [`` *inside the
|
|
1268
|
+
``[project]`` table*, or None if absent.
|
|
1269
|
+
|
|
1270
|
+
Anchored to ``[project]`` so a ``dependencies = [`` under any other table
|
|
1271
|
+
(e.g. a ``[tool.*]`` section) is never targeted — the prior unanchored
|
|
1272
|
+
``re.search`` matched the first occurrence anywhere in the file, which could
|
|
1273
|
+
splice deps into the wrong table.
|
|
1274
|
+
"""
|
|
1275
|
+
proj = re.search(r"(?m)^\[project\]\s*$", text)
|
|
1276
|
+
if not proj:
|
|
1277
|
+
return None
|
|
1278
|
+
rest = text[proj.end():]
|
|
1279
|
+
nxt = re.search(r"(?m)^\[", rest) # start of the next table, if any
|
|
1280
|
+
table = rest if nxt is None else rest[:nxt.start()]
|
|
1281
|
+
dm = re.search(r"dependencies\s*=\s*\[", table)
|
|
1282
|
+
if not dm:
|
|
1283
|
+
return None
|
|
1284
|
+
return proj.end() + dm.end()
|
|
1285
|
+
|
|
1286
|
+
|
|
1287
|
+
def _dep_present(text: str, dep: str) -> bool:
|
|
1288
|
+
"""True if ``dep`` is already a declared ``[project]`` dependency.
|
|
1289
|
+
|
|
1290
|
+
Decided from the PARSE, not the raw text: the prior whole-file regex
|
|
1291
|
+
counted a quoted name ANYWHERE (e.g. a comment ``# we removed
|
|
1292
|
+
"parse-sdk" while debugging``) as present, silently skipping the insert —
|
|
1293
|
+
and ``still_missing`` re-ran the same regex, so the gap passed its own
|
|
1294
|
+
verification. Distribution names are PEP-503-canonicalized on both sides
|
|
1295
|
+
(``Parse_SDK>=0.3`` still counts; ``my-parse-sdk`` never does).
|
|
1296
|
+
Unparseable TOML → False: the insert then runs, and the final
|
|
1297
|
+
``tomllib.loads`` invariant fails loud instead of silently passing."""
|
|
1298
|
+
try:
|
|
1299
|
+
deps = tomllib.loads(text).get("project", {}).get("dependencies", [])
|
|
1300
|
+
except Exception:
|
|
1301
|
+
return False
|
|
1302
|
+
if not isinstance(deps, list):
|
|
1303
|
+
return False
|
|
1304
|
+
declared = set()
|
|
1305
|
+
for d in deps:
|
|
1306
|
+
if isinstance(d, str):
|
|
1307
|
+
m = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", d)
|
|
1308
|
+
if m:
|
|
1309
|
+
declared.add(re.sub(r"[-_.]+", "-", m.group(1)).lower())
|
|
1310
|
+
return re.sub(r"[-_.]+", "-", dep).lower() in declared
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _ensure_consumer_pyproject(path: Path) -> None:
|
|
1314
|
+
"""Record ``parse-sdk`` + ``parse-apis`` as deps and ``parse-apis`` as an
|
|
1315
|
+
editable path source — WITHOUT a ``parse-sdk`` path source (that
|
|
1316
|
+
path is machine-specific). Idempotent.
|
|
1317
|
+
|
|
1318
|
+
Corruption-proof: new dependency entries are inserted immediately
|
|
1319
|
+
after the array's opening ``[``, each on its own line ending in a comma. That
|
|
1320
|
+
yields valid TOML for EVERY array shape — empty, single-line, multiline,
|
|
1321
|
+
PEP-508 extras (``foo[extra]``), inline ``#`` comments, trailing commas —
|
|
1322
|
+
because a ``"name",`` prefix is always a legal first array element regardless
|
|
1323
|
+
of what follows. (The prior regex that spliced before a non-greedy ``]`` and
|
|
1324
|
+
stripped ``inner`` corrupted all of those shapes, including ``uv add`` output.)
|
|
1325
|
+
"""
|
|
1326
|
+
text = path.read_text()
|
|
1327
|
+
new = text
|
|
1328
|
+
|
|
1329
|
+
want = [d for d in ("parse-sdk", "parse-apis") if not _dep_present(new, d)]
|
|
1330
|
+
if want:
|
|
1331
|
+
dep_at = _project_dependencies_pos(new)
|
|
1332
|
+
if dep_at is not None:
|
|
1333
|
+
insert = "".join(f'\n "{d}",' for d in want)
|
|
1334
|
+
new = new[:dep_at] + insert + new[dep_at:]
|
|
1335
|
+
elif re.search(r"(?m)^\[project\]\s*$", new):
|
|
1336
|
+
# SAME anchor as _project_dependencies_pos — both must agree on what
|
|
1337
|
+
# a [project] table is, or a header like `[project] # x` routes
|
|
1338
|
+
# here while the pos-finder said None and we'd write a duplicate
|
|
1339
|
+
# `dependencies` key.
|
|
1340
|
+
joined = ", ".join(f'"{d}"' for d in want)
|
|
1341
|
+
new = re.sub(r"(?m)^(\[project\]\s*\n)",
|
|
1342
|
+
lambda mm: mm.group(1) + f"dependencies = [{joined}]\n",
|
|
1343
|
+
new, count=1)
|
|
1344
|
+
else:
|
|
1345
|
+
raise click.ClickException(
|
|
1346
|
+
"pyproject.toml has no [project] table, so the parse-sdk/parse-apis "
|
|
1347
|
+
"dependencies can't be recorded. Add a [project] table with `name` "
|
|
1348
|
+
"and `version` (or create the project with `uv init`) and re-run "
|
|
1349
|
+
"`parse init`."
|
|
1350
|
+
)
|
|
1351
|
+
|
|
1352
|
+
if "[tool.uv.sources]" not in new:
|
|
1353
|
+
new = new.rstrip() + (
|
|
1354
|
+
'\n\n[tool.uv.sources]\n'
|
|
1355
|
+
'parse-apis = { path = "parse_apis", editable = true }\n')
|
|
1356
|
+
elif not re.search(r"parse-apis\s*=\s*\{", new):
|
|
1357
|
+
new = re.sub(r"(\[tool\.uv\.sources\]\s*\n)",
|
|
1358
|
+
lambda mm: mm.group(1) + 'parse-apis = { path = "parse_apis", editable = true }\n',
|
|
1359
|
+
new, count=1)
|
|
1360
|
+
|
|
1361
|
+
try:
|
|
1362
|
+
tomllib.loads(new) # invariant: parse init never writes invalid TOML
|
|
1363
|
+
except tomllib.TOMLDecodeError as e:
|
|
1364
|
+
raise click.ClickException(
|
|
1365
|
+
f"refusing to write pyproject.toml — the edit would corrupt it ({e}). "
|
|
1366
|
+
"Add parse-sdk and parse-apis to [project].dependencies manually."
|
|
1367
|
+
)
|
|
1368
|
+
still_missing = [d for d in ("parse-sdk", "parse-apis") if not _dep_present(new, d)]
|
|
1369
|
+
if still_missing:
|
|
1370
|
+
raise click.ClickException(
|
|
1371
|
+
f"could not record {', '.join(still_missing)} in pyproject.toml "
|
|
1372
|
+
"([project].dependencies not found or unparseable) — add them manually "
|
|
1373
|
+
"and re-run `parse init`."
|
|
1374
|
+
)
|
|
1375
|
+
if new != text:
|
|
1376
|
+
_atomic_write_text(path, new)
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def _finalize_install(project: Path) -> None:
|
|
1380
|
+
"""Install the freshly-declared editable ``parse-apis`` so the project is
|
|
1381
|
+
usable immediately — not only on the user's next ``uv run``.
|
|
1382
|
+
|
|
1383
|
+
Without this, ``parse init`` leaves ``parse-apis`` *declared* in pyproject but
|
|
1384
|
+
*not installed*; an activated-venv ``python app.py`` right after init would
|
|
1385
|
+
``ModuleNotFoundError``. Best-effort: if ``uv`` is absent or sync fails, print
|
|
1386
|
+
the exact remediation instead of a misleading "installed" message — never
|
|
1387
|
+
fatal (the scaffold + deps are already written).
|
|
1388
|
+
"""
|
|
1389
|
+
uv = shutil.which("uv")
|
|
1390
|
+
if uv is None:
|
|
1391
|
+
click.echo(click.style(
|
|
1392
|
+
" Next: run `uv sync` (or `pip install -e ./parse_apis`) to install the package.",
|
|
1393
|
+
fg="yellow"))
|
|
1394
|
+
return
|
|
1395
|
+
# Bound both subprocesses: capture_output hides any progress, so a hung `uv`
|
|
1396
|
+
# (e.g. an unreachable registry that never times out the TCP connect) would
|
|
1397
|
+
# otherwise block the CLI forever with no user-visible signal.
|
|
1398
|
+
try:
|
|
1399
|
+
synced = subprocess.run([uv, "sync"], cwd=str(project),
|
|
1400
|
+
capture_output=True, text=True, timeout=300)
|
|
1401
|
+
except subprocess.TimeoutExpired:
|
|
1402
|
+
click.echo(click.style(
|
|
1403
|
+
" `uv sync` timed out — run `uv sync` manually to finish.", fg="yellow"))
|
|
1404
|
+
return
|
|
1405
|
+
if synced.returncode != 0:
|
|
1406
|
+
stderr = synced.stderr.strip()
|
|
1407
|
+
tail = (stderr.splitlines() or ["uv sync failed"])[-1]
|
|
1408
|
+
click.echo(click.style(" Couldn't auto-install parse_apis — run `uv sync` to finish:", fg="yellow"))
|
|
1409
|
+
click.echo(click.style(f" {_safe_console(tail)}", fg="yellow", dim=True))
|
|
1410
|
+
# Targeted hint for the pre-PyPI case: parse-sdk isn't on an index yet,
|
|
1411
|
+
# so uv can't resolve the `parse-sdk==X` pin and reports the dep tree as
|
|
1412
|
+
# unsatisfiable. The fix is to install parse-sdk from a local checkout.
|
|
1413
|
+
low = stderr.lower()
|
|
1414
|
+
if "parse-sdk" in low and ("unsatisfiable" in low or "no solution" in low
|
|
1415
|
+
or "was not found" in low or "not found in the" in low):
|
|
1416
|
+
click.echo(click.style(
|
|
1417
|
+
" parse-sdk isn't resolvable here yet. If it isn't published to "
|
|
1418
|
+
"your index, install it from a local checkout:\n"
|
|
1419
|
+
" uv add --editable /path/to/Parse-sdk\n"
|
|
1420
|
+
" then re-run `uv sync`. Run `parse doctor` to re-check.",
|
|
1421
|
+
fg="yellow"))
|
|
1422
|
+
return
|
|
1423
|
+
try:
|
|
1424
|
+
verify = subprocess.run(
|
|
1425
|
+
[uv, "run", "python", "-c", "import parse_apis"],
|
|
1426
|
+
cwd=str(project), capture_output=True, text=True, timeout=120)
|
|
1427
|
+
except subprocess.TimeoutExpired:
|
|
1428
|
+
click.echo(click.style(
|
|
1429
|
+
" parse_apis installed but import verification timed out — check your environment.",
|
|
1430
|
+
fg="yellow"))
|
|
1431
|
+
return
|
|
1432
|
+
if verify.returncode != 0:
|
|
1433
|
+
tail = (verify.stderr.strip().splitlines() or ["import failed"])[-1]
|
|
1434
|
+
click.echo(click.style(
|
|
1435
|
+
" parse_apis installed but import verification failed:", fg="yellow"))
|
|
1436
|
+
click.echo(click.style(f" {tail}", fg="yellow", dim=True))
|
|
1437
|
+
click.echo(click.style(
|
|
1438
|
+
" Reproduce with: uv run python -c \"import parse_apis\"", fg="yellow"))
|
|
1439
|
+
else:
|
|
1440
|
+
click.echo(click.style("✓ installed and verified `import parse_apis`", fg="green"))
|
|
1441
|
+
|
|
1442
|
+
|
|
1443
|
+
@cli.command()
|
|
1444
|
+
@click.option("--api-key", help="Parse API key. Saved to ~/.config/parse/credentials.")
|
|
1445
|
+
@click.option("--base-url", help=f"Override the Parse base URL (default: {DEFAULT_BASE_URL}).")
|
|
1446
|
+
def init(api_key: Optional[str], base_url: Optional[str]) -> None:
|
|
1447
|
+
"""Initialize parse_apis in this project: scaffold, deps, gitignore, first sync.
|
|
1448
|
+
|
|
1449
|
+
Detects and migrates (or refuses) a legacy flat `parse_apis/` layout, writes
|
|
1450
|
+
the committed scaffold, records the editable `parse-apis` dependency and the
|
|
1451
|
+
deny-by-default gitignore, ensures credentials, then runs the first sync.
|
|
1452
|
+
One-shot non-interactive setup: pass `--api-key` (and optionally `--base-url`).
|
|
1453
|
+
"""
|
|
1454
|
+
project = Path.cwd()
|
|
1455
|
+
consumer = project / "pyproject.toml"
|
|
1456
|
+
if not consumer.exists():
|
|
1457
|
+
click.echo(click.style(
|
|
1458
|
+
"No pyproject.toml in the current directory. Run `parse init` from your "
|
|
1459
|
+
"project root (a uv/pip project with a pyproject.toml).", fg="red"), err=True)
|
|
1460
|
+
sys.exit(2)
|
|
1461
|
+
_guard_target_project(project, command="init")
|
|
1462
|
+
# Name the mutation target FIRST — init edits this project's pyproject/
|
|
1463
|
+
# .gitignore and may archive a flat parse_apis/, so the user must see
|
|
1464
|
+
# which absolute root that is before any of it happens.
|
|
1465
|
+
click.echo(f"Initializing parse_apis in {click.style(str(project.resolve()), fg='cyan')}")
|
|
1466
|
+
|
|
1467
|
+
old = project / "parse_apis"
|
|
1468
|
+
# Legacy flat layout: parse_apis/ exists but is not the new scaffold.
|
|
1469
|
+
if old.exists() and not (old / "pyproject.toml").exists() and not (old / "src").exists():
|
|
1470
|
+
report = migrate.classify_flat_layout(old)
|
|
1471
|
+
if not report.is_fully_owned:
|
|
1472
|
+
click.echo(click.style(
|
|
1473
|
+
"Refusing to migrate: parse_apis/ contains files Parse did not generate.",
|
|
1474
|
+
fg="red"), err=True)
|
|
1475
|
+
for p in report.unknown:
|
|
1476
|
+
click.echo(click.style(f" · {p.relative_to(project)}", fg="red"), err=True)
|
|
1477
|
+
click.echo(click.style(
|
|
1478
|
+
" Move or remove these, then re-run `parse init`.", fg="yellow"), err=True)
|
|
1479
|
+
sys.exit(1)
|
|
1480
|
+
legacy = _unique_legacy_dir(project)
|
|
1481
|
+
click.echo(click.style(
|
|
1482
|
+
f" · archiving legacy flat parse_apis/ → {legacy.relative_to(project)}",
|
|
1483
|
+
fg="yellow", dim=True))
|
|
1484
|
+
shutil.move(str(old), str(legacy))
|
|
1485
|
+
|
|
1486
|
+
if api_key:
|
|
1487
|
+
save_credentials(api_key=api_key, base_url=base_url)
|
|
1488
|
+
cfg = _net_resolve()
|
|
1489
|
+
if not cfg.api_key:
|
|
1490
|
+
api_key = _prompt_key_or_exit()
|
|
1491
|
+
save_credentials(api_key=api_key, base_url=base_url)
|
|
1492
|
+
cfg = resolve()
|
|
1493
|
+
|
|
1494
|
+
scaffold.write_scaffold(project, __version__)
|
|
1495
|
+
_append_gitignore(project)
|
|
1496
|
+
_ensure_consumer_pyproject(consumer)
|
|
1497
|
+
click.echo(click.style(
|
|
1498
|
+
"✓ scaffolded parse_apis/ and recorded the editable dependency", fg="green"))
|
|
1499
|
+
|
|
1500
|
+
schemas = _fetch_schemas(cfg)
|
|
1501
|
+
# Respect a committed [tool.parse] (clone + `parse init` reproduces the
|
|
1502
|
+
# curated set incl. version-pinned marketplace canonicals); absent apis ⇒
|
|
1503
|
+
# all account APIs. Marketplace entries can exist even with no account APIs.
|
|
1504
|
+
pcfg = _project.read_project_config(consumer)
|
|
1505
|
+
selected, _missing = _gather_desired_schemas(cfg, schemas, pcfg)
|
|
1506
|
+
declared_any = bool(pcfg.apis) or bool(pcfg.marketplace)
|
|
1507
|
+
if declared_any and not selected:
|
|
1508
|
+
# The repo DECLARES a curated set (account slugs and/or marketplace
|
|
1509
|
+
# canonicals) but every one is unresolvable (stale/typo'd, a key without
|
|
1510
|
+
# those APIs, or marketplace fetch failures). Scaffolding an empty package
|
|
1511
|
+
# — or pruning every generated client on a re-init — is never what the
|
|
1512
|
+
# user wants; refuse with an actionable error, parity with `sync`.
|
|
1513
|
+
click.echo(click.style(
|
|
1514
|
+
"Refusing to sync: every API in [tool.parse] is unresolvable "
|
|
1515
|
+
"(missing from your account / marketplace), which would leave "
|
|
1516
|
+
"parse_apis with no generated clients. Fix the list (`parse list` "
|
|
1517
|
+
"shows valid slugs) or remove the [tool.parse] table to track all "
|
|
1518
|
+
"account APIs, then `parse sync`.", fg="red"), err=True)
|
|
1519
|
+
sys.exit(2)
|
|
1520
|
+
if selected:
|
|
1521
|
+
_sync_installed(cfg, selected, clean=False)
|
|
1522
|
+
else:
|
|
1523
|
+
click.echo("No APIs for this key yet — run `parse sync` once you add APIs in Parse.")
|
|
1524
|
+
|
|
1525
|
+
# Actually install the editable package + verify, so the project is usable now.
|
|
1526
|
+
_finalize_install(project)
|
|
1527
|
+
|
|
1528
|
+
|
|
1529
|
+
_DOCTOR_ICON = {doctor_mod.OK: ("✓", "green"),
|
|
1530
|
+
doctor_mod.WARN: ("⚠", "yellow"),
|
|
1531
|
+
doctor_mod.ERROR: ("✗", "red")}
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def _print_findings(findings: List["doctor_mod.Finding"]) -> None:
|
|
1535
|
+
"""Render findings to the terminal, most-severe first."""
|
|
1536
|
+
for f in sorted(findings, key=doctor_mod.sort_key):
|
|
1537
|
+
icon, color = _DOCTOR_ICON.get(f.level, ("·", "white"))
|
|
1538
|
+
click.echo(f"{click.style(icon, fg=color)} {f.title}")
|
|
1539
|
+
if f.detail:
|
|
1540
|
+
for line in f.detail.splitlines():
|
|
1541
|
+
click.echo(click.style(f" {_safe_console(line)}", dim=True))
|
|
1542
|
+
if f.fix:
|
|
1543
|
+
click.echo(click.style(f" → {f.fix}", fg="cyan"))
|
|
1544
|
+
|
|
1545
|
+
|
|
1546
|
+
def _apply_doctor_fixes(project: Path, cfg, findings: List["doctor_mod.Finding"]) -> None:
|
|
1547
|
+
"""Run the safe, offline-ish auto-remediations the findings ask for: re-record
|
|
1548
|
+
the consumer pyproject deps/source, then `uv sync`. Network regens (`parse
|
|
1549
|
+
sync`) are deliberately NOT auto-run — they're shown as the fix instead."""
|
|
1550
|
+
kinds = {f.kind for f in findings if f.level != doctor_mod.OK}
|
|
1551
|
+
consumer = project / "pyproject.toml"
|
|
1552
|
+
if "record_deps" in kinds and consumer.exists():
|
|
1553
|
+
try:
|
|
1554
|
+
_ensure_consumer_pyproject(consumer)
|
|
1555
|
+
click.echo(click.style(" · recorded parse-sdk + parse-apis deps / editable source", fg="green"))
|
|
1556
|
+
except (click.ClickException, OSError) as e:
|
|
1557
|
+
click.echo(click.style(f" · couldn't record deps: {e}", fg="red"), err=True)
|
|
1558
|
+
if kinds & {"record_deps", "uv_sync"}:
|
|
1559
|
+
uv = shutil.which("uv")
|
|
1560
|
+
if uv is None:
|
|
1561
|
+
click.echo(click.style(" · skipped `uv sync` — uv not on PATH", fg="yellow"))
|
|
1562
|
+
else:
|
|
1563
|
+
try:
|
|
1564
|
+
r = subprocess.run([uv, "sync"], cwd=str(project),
|
|
1565
|
+
capture_output=True, text=True, timeout=300)
|
|
1566
|
+
if r.returncode == 0:
|
|
1567
|
+
click.echo(click.style(" · ran `uv sync`", fg="green"))
|
|
1568
|
+
else:
|
|
1569
|
+
tail = (r.stderr.strip().splitlines() or ["uv sync failed"])[-1]
|
|
1570
|
+
click.echo(click.style(f" · `uv sync` failed: {_safe_console(tail)}", fg="red"), err=True)
|
|
1571
|
+
except subprocess.TimeoutExpired:
|
|
1572
|
+
click.echo(click.style(" · `uv sync` timed out", fg="red"), err=True)
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
@cli.command()
|
|
1576
|
+
@click.option("--fix", "do_fix", is_flag=True,
|
|
1577
|
+
help="Attempt safe auto-remediation (record deps + `uv sync`), then re-check.")
|
|
1578
|
+
@click.option("--json", "as_json", is_flag=True,
|
|
1579
|
+
help="Machine-readable findings (stable shape; agents should prefer this).")
|
|
1580
|
+
def doctor(do_fix: bool, as_json: bool) -> None:
|
|
1581
|
+
"""Diagnose this project's Parse SDK install and explain (or fix) problems.
|
|
1582
|
+
|
|
1583
|
+
Checks uv, the parse_apis scaffold, your pyproject deps + editable source,
|
|
1584
|
+
parse-sdk version skew, whether the project interpreter can import
|
|
1585
|
+
parse_sdk + parse_apis (the usual cause of dead editor autocomplete), and
|
|
1586
|
+
credentials/host-trust. Exits 1 if any error-level problem remains.
|
|
1587
|
+
"""
|
|
1588
|
+
project = _find_project_root()
|
|
1589
|
+
cfg = resolve()
|
|
1590
|
+
findings = doctor_mod.run_diagnostics(project, cfg, version=__version__)
|
|
1591
|
+
if do_fix and any(f.kind in ("record_deps", "uv_sync") for f in findings
|
|
1592
|
+
if f.level != doctor_mod.OK):
|
|
1593
|
+
if not as_json:
|
|
1594
|
+
click.echo("Applying fixes…")
|
|
1595
|
+
_apply_doctor_fixes(project, cfg, findings)
|
|
1596
|
+
# Re-resolve + re-check so the report reflects the post-fix state.
|
|
1597
|
+
cfg = resolve()
|
|
1598
|
+
findings = doctor_mod.run_diagnostics(project, cfg, version=__version__)
|
|
1599
|
+
if as_json:
|
|
1600
|
+
click.echo(json.dumps({
|
|
1601
|
+
"ok": not doctor_mod.has_errors(findings),
|
|
1602
|
+
"findings": [f.as_dict() for f in findings],
|
|
1603
|
+
}, indent=2, sort_keys=True))
|
|
1604
|
+
else:
|
|
1605
|
+
_print_findings(findings)
|
|
1606
|
+
n_err = sum(1 for f in findings if f.level == doctor_mod.ERROR)
|
|
1607
|
+
n_warn = sum(1 for f in findings if f.level == doctor_mod.WARN)
|
|
1608
|
+
auto_fixable = any(f.kind in ("record_deps", "uv_sync") for f in findings
|
|
1609
|
+
if f.level != doctor_mod.OK)
|
|
1610
|
+
if n_err or n_warn:
|
|
1611
|
+
summary = (f"\n{n_err} error{'s' if n_err != 1 else ''}, "
|
|
1612
|
+
f"{n_warn} warning{'s' if n_warn != 1 else ''}")
|
|
1613
|
+
if auto_fixable and not do_fix:
|
|
1614
|
+
summary += " — re-run with `parse doctor --fix` to auto-remediate."
|
|
1615
|
+
click.echo(summary)
|
|
1616
|
+
else:
|
|
1617
|
+
click.echo(click.style("\n✓ all checks passed", fg="green"))
|
|
1618
|
+
if doctor_mod.has_errors(findings):
|
|
1619
|
+
sys.exit(1)
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def _sync_installed(cfg, schemas: List[dict], *, clean: bool,
|
|
1623
|
+
check: bool = False, as_json: bool = False,
|
|
1624
|
+
_pkg_paths: "Optional[Tuple[Path, Path, Path]]" = None) -> None:
|
|
1625
|
+
"""Default sync: build the COMPLETE next package beside the live one,
|
|
1626
|
+
self-test it, then promote with an atomic directory swap. Refuses if the
|
|
1627
|
+
project isn't initialized.
|
|
1628
|
+
|
|
1629
|
+
Atomicity: the live package is never mutated in
|
|
1630
|
+
place. The next package — committed scaffold + freshly generated slugs +
|
|
1631
|
+
retained present-but-skipped slugs — is built on the same filesystem and
|
|
1632
|
+
swapped via two ``os.replace`` calls with rollback, so a failure (mid-build,
|
|
1633
|
+
a failed check, or an interrupted swap) leaves the live tree exactly as it
|
|
1634
|
+
was. Orphaned slugs (uuids dropped from the server) are simply not carried
|
|
1635
|
+
into the next package — that IS the prune. ``--clean`` drops the
|
|
1636
|
+
carried slugs too, rebuilding the payload from scratch — and, unlike the old
|
|
1637
|
+
upfront ``rmtree``, only after the new payload is proven good.
|
|
1638
|
+
"""
|
|
1639
|
+
# Init precondition + interrupted-promote hint + disjoint-env guard. `sync`
|
|
1640
|
+
# already resolved these before the network and threads them in to
|
|
1641
|
+
# avoid re-running the check; other callers resolve here.
|
|
1642
|
+
project, root, pkg = _pkg_paths or _require_generated_package()
|
|
1643
|
+
|
|
1644
|
+
rendered, skipped = _render_modules(schemas, cfg.base_url, quiet=as_json)
|
|
1645
|
+
stage_check_swap(cfg, schemas, rendered, skipped,
|
|
1646
|
+
project=project, root=root, pkg=pkg, clean=clean,
|
|
1647
|
+
check=check, as_json=as_json)
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
# Per-command auth/init facts for `parse help --json` (read via getattr, omitted
|
|
1651
|
+
# when unset — never a silent false). A NEW command adds its row here or its facts
|
|
1652
|
+
# are honestly omitted from the orientation JSON. Verified against each body:
|
|
1653
|
+
# search = PUBLIC marketplace read (no key); remove fetches account schemas (needs
|
|
1654
|
+
# key); init CREATES the project (auth yes, init no); clean is local (init yes).
|
|
1655
|
+
for _cmd, _req_auth, _req_init in (
|
|
1656
|
+
(login, False, False), (init, True, False), (list_cmd, True, False),
|
|
1657
|
+
(sync, True, True), (whoami, False, False), (clean, False, True),
|
|
1658
|
+
(doctor, False, False), (add, True, True), (search, False, False),
|
|
1659
|
+
(remove, True, True),
|
|
1660
|
+
):
|
|
1661
|
+
setattr(_cmd, "requires_auth", _req_auth)
|
|
1662
|
+
setattr(_cmd, "requires_init", _req_init)
|
|
1663
|
+
del _cmd, _req_auth, _req_init
|
|
1664
|
+
|
|
1665
|
+
|
|
1666
|
+
if __name__ == "__main__":
|
|
1667
|
+
cli()
|