parse-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
parse_sdk/cli.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()