parse-sdk 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (30) hide show
  1. parse_sdk-0.2.0/CHANGELOG.md +32 -0
  2. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/PKG-INFO +65 -15
  3. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/README.md +64 -14
  4. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/__init__.py +2 -2
  5. parse_sdk-0.2.0/parse_sdk/_identifiers.py +23 -0
  6. parse_sdk-0.2.0/parse_sdk/_project.py +277 -0
  7. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/_sync.py +131 -53
  8. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/cli.py +779 -279
  9. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/cli_help.py +43 -22
  10. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/config.py +6 -5
  11. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/doctor.py +215 -41
  12. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/preview.py +48 -1
  13. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/sample_gate.py +13 -3
  14. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/scaffold.py +4 -3
  15. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/pyproject.toml +1 -1
  16. parse_sdk-0.1.0/CHANGELOG.md +0 -18
  17. parse_sdk-0.1.0/parse_sdk/_project.py +0 -184
  18. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/.gitignore +0 -0
  19. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/LICENSE +0 -0
  20. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/_labels.py +0 -0
  21. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/_oauth.py +0 -0
  22. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/_runtime.py +0 -0
  23. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/_sanitize.py +0 -0
  24. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/checks.py +0 -0
  25. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/codegen_reconcile.py +0 -0
  26. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/codegen_v2.py +0 -0
  27. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/docgen.py +0 -0
  28. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/migrate.py +0 -0
  29. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/py.typed +0 -0
  30. {parse_sdk-0.1.0 → parse_sdk-0.2.0}/parse_sdk/resource_surface.py +0 -0
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ All notable changes to `parse-sdk` are documented here. This project follows
4
+ [Semantic Versioning](https://semver.org/).
5
+
6
+ ## [0.2.0] — reliable sync and improved CLI workflows
7
+
8
+ - **Reliable reconciliation:** stronger validation and staged promotion make sync
9
+ more predictable when schemas or project configuration change.
10
+ - **Consistent API identity:** account and marketplace workflows now handle API
11
+ selection consistently across `init`, `add`, `remove`, and sync.
12
+ - **Improved CLI automation:** commands provide clearer guidance, predictable
13
+ JSON output and exit behavior, API selectors for `init`, and more focused sync
14
+ results.
15
+ - **Project diagnostics:** `parse doctor` performs read-only inspection with
16
+ fewer assumptions about the local project environment.
17
+ - **Documentation:** refreshed the command reference, SDK-versus-MCP guidance,
18
+ credential setup, and development workflow.
19
+
20
+ ## [0.1.0] — first public release
21
+
22
+ Initial public release of the `parse-sdk` runtime + `parse` CLI: a
23
+ dynamic-yet-typed Python SDK that generates a project-local, fully typed client
24
+ for the Parse APIs your key can call.
25
+
26
+ - **CLI** (`parse`): `init`, `sync`, `login` (API key or browser OAuth via
27
+ `--web`), `doctor` (diagnose / `--fix`), `list`, `whoami`, `help`, `clean`,
28
+ and marketplace `add` / `search` / `remove`, plus `[tool.parse]`-driven
29
+ selective sync.
30
+ - **Typed runtime**: resources, collections, pagination, retries with a
31
+ wall-clock budget, and host-bound authentication.
32
+ - Ships `py.typed`; supports Python 3.10–3.14.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parse-sdk
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Dynamic-yet-typed Python SDK for Parse APIs
5
5
  Project-URL: Homepage, https://parse.bot
6
6
  Project-URL: Changelog, https://pypi.org/project/parse-sdk/#history
@@ -32,11 +32,21 @@ Description-Content-Type: text/markdown
32
32
 
33
33
  Typed Python SDK tooling for [Parse](https://parse.bot) APIs.
34
34
 
35
+ Requires Python 3.10 or newer.
36
+
35
37
  `parse-sdk` is the shared **runtime + CLI**. It generates a project-local
36
38
  `parse_apis` package — a real, installed, editable dependency — that holds the
37
39
  typed client for every API your key can call. Your application code imports from
38
40
  `parse_apis`; `parse-sdk` provides the runtime bases and errors those clients use.
39
41
 
42
+ ## SDK and MCP
43
+
44
+ Use Parse MCP when an agent is discovering or calling APIs ad hoc, or managing
45
+ account-owned server-side variants. Use this SDK for durable, typed Python
46
+ clients committed to an application. Neither requires the other: the SDK can
47
+ use `parse search` and `parse add --marketplace <listing_id>` without creating
48
+ an MCP subscription.
49
+
40
50
  ## Quickstart
41
51
 
42
52
  In a uv-managed project (`uv init` first if you don't have a `pyproject.toml`):
@@ -46,9 +56,10 @@ uv add parse-sdk # installs the runtime + `parse` CLI into your proje
46
56
  uv run parse init # prompts for your API key, then scaffolds + syncs parse_apis
47
57
  ```
48
58
 
49
- Non-interactive (CI, agents, scripts): authenticate first with
50
- `uv run parse login --api-key YOUR_KEY` (add `--base-url …` for a non-default
51
- host), or export `PARSE_API_KEY` then `uv run parse init` runs without prompts.
59
+ Non-interactive (CI, agents, scripts): export `PARSE_API_KEY`, then run
60
+ `uv run parse init`. `parse login --api-key` persists a key, but is a less-safe
61
+ automation fallback because argv can be recorded in process listings and shell
62
+ history.
52
63
 
53
64
  `parse init` is the one-time setup. It:
54
65
 
@@ -74,8 +85,9 @@ except RateLimitError:
74
85
  ...
75
86
  ```
76
87
 
77
- (The exact methods are generated from *your* API's spec — `parse_apis/<slug>/README.md`
78
- and `example.py` document each client's real surface.)
88
+ (The exact methods are generated from *your* API's spec —
89
+ `parse_apis/src/parse_apis/<slug>/README.md` and `example.py` document each
90
+ client's real surface.)
79
91
 
80
92
  ```bash
81
93
  uv run python app.py # run your app in the project environment
@@ -99,16 +111,17 @@ they just `import parse_apis`.
99
111
 
100
112
  ```text
101
113
  parse_apis/
114
+ AGENTS.md CLAUDE.md # committed, byte-identical agent pointers
102
115
  pyproject.toml # committed: name="parse-apis", pinned parse-sdk==X
103
116
  src/parse_apis/
104
117
  __init__.py # committed scaffold — re-exports runtime errors
105
118
  py.typed # committed
106
- reddit/__init__.py # generated (gitignored)
119
+ reddit_com_api/__init__.py # generated (gitignored)
107
120
  _manifest.json # generated (gitignored)
108
121
  AGENTS.md CLAUDE.md # generated cross-API index (gitignored)
109
122
  ```
110
123
 
111
- Commit the three scaffold files; the generated payload is per-key (it reveals your API
124
+ Commit the five scaffold files; the generated payload is per-key (it reveals your API
112
125
  inventory) and stays gitignored. `parse sync` builds the next payload in a staging tree,
113
126
  self-tests it (secret scan, import-model, no-stale-docs), and promotes it with an atomic
114
127
  directory swap — a failed sync never leaves broken or secret-bearing files in your project.
@@ -138,14 +151,51 @@ succeeds on a clean checkout.
138
151
 
139
152
  | Command | What it does |
140
153
  | --- | --- |
141
- | `parse login` | Save API key + base URL to local credentials. No project changes. |
142
- | `parse init` | One-time: ensure creds, scaffold `parse_apis`, record deps + gitignore, first sync. |
143
- | `parse sync` | Regenerate the typed payload into `parse_apis/src/parse_apis` (staged + atomic). |
144
- | `parse clean` | Remove generated payload; keep the committed scaffold. |
154
+ | `parse login` | Save browser OAuth credentials by default, or an API key with `--api-key`. No project changes. |
155
+ | `parse init` | One-time: ensure creds, scaffold `parse_apis`, record deps + gitignore, first sync. Pass slugs to track just those APIs. |
145
156
  | `parse list` | Print the APIs the current key can call (no generation). |
157
+ | `parse sync` | Regenerate the typed payload into `parse_apis/src/parse_apis` (staged + atomic). `--check` is a CI dry run. |
158
+ | `parse add` | Add APIs to the repo's synced set (`--marketplace` for shared canonicals; new entries pin the latest usable release, while re-add preserves existing pin/live state), then reconcile. |
159
+ | `parse remove` | Stop syncing APIs (account or marketplace) and prune their generated clients. |
160
+ | `parse search` | Search the Parse marketplace for ready-made APIs. |
161
+ | `parse clean` | Remove generated payload; keep the committed scaffold. |
146
162
  | `parse whoami` | Show the current key + base URL. |
163
+ | `parse doctor` | Diagnose the project's SDK install; `--fix` applies safe remediations. |
164
+ | `parse help` | Guided getting-started workflow (`--json` for machine-readable orientation). |
147
165
 
148
- Run `parse <cmd> --help` for full per-command help.
166
+ Run `parse <cmd> --help` for full per-command help — every command's help ends
167
+ with copy-pasteable Examples.
168
+
169
+ ## Agents & automation
170
+
171
+ The CLI is built to run headless: prompts only fire on an interactive terminal
172
+ (non-TTY runs fail fast with the exact fix), warnings go to stderr, and stdout
173
+ stays machine-parseable under `--json`.
174
+
175
+ **Machine-readable output.** `parse help --json` is the orientation entrypoint:
176
+ it lists every command with `requires_auth` / `requires_init` facts and (under
177
+ `agent_modes`) which commands accept `--json` — the list is derived from the
178
+ live command registry, so it never goes stale. The `--json` shapes are stable:
179
+
180
+ | Command | Shape |
181
+ | --- | --- |
182
+ | `whoami --json` | object: `base_url`, `api_key_masked`, `key_configured`, `key_sent_to_host`, `web_login`, `auth_method` |
183
+ | `list --json` | array of `{id, slug, name, modeled, endpoints, resources, version}` |
184
+ | `sync` / `add` / `remove` `--json` | object: `written[] {slug, id, root, status, warnings[]}`, `skipped[] {slug, id, reason}`, `removed[]`, `quarantined[] {slug, id, retained, findings[]}`, `failures[]` — on a gate abort, `{failures}` alone with exit 1; under `sync --check`, `failures` carries every finding (non-empty ⟺ exit 1) |
185
+ | `doctor --json` | object: `ok`, `findings[] {level, title, detail, fix, kind}` |
186
+ | `search --json` | array of `{id, slug, name, source_url, endpoint_count, is_authenticated}` |
187
+
188
+ **Exit codes.** `0` — success. `1` — the command ran and detected a problem
189
+ (sync's pre-promotion gate abort, `sync --check` findings, `doctor` error-level
190
+ findings, `init` refusing to migrate a legacy tree with non-Parse files, a lost
191
+ concurrent-promote race). `2` — the command couldn't run: usage errors, an
192
+ uninitialized project, missing/withheld credentials or an untrusted host,
193
+ network failures.
194
+
195
+ **Credentials in automation.** Prefer exporting `PARSE_API_KEY` over passing
196
+ `--api-key` on the command line — argv is visible in process listings and shell
197
+ history. `parse login` (browser OAuth by default; `--web` is explicit) requires an interactive terminal
198
+ and fails fast headless.
149
199
 
150
200
  ## Versioning
151
201
 
@@ -157,8 +207,8 @@ regenerate against the new runtime; `init`/`sync` restamp the pin.
157
207
 
158
208
  | Var | Default | Notes |
159
209
  | --- | --- | --- |
160
- | `PARSE_API_KEY` | – | Auth for real requests (or use `parse login`). |
161
- | `PARSE_API_BASE_URL` | `https://api.parse.bot` | Locally: `http://localhost:8001`. |
210
+ | `PARSE_API_KEY` | – | Auth for real requests (or use `parse login`). Preferred over `--api-key` in automation. |
211
+ | `PARSE_ACCESS_TOKEN` | | OAuth bearer token override (normally managed by interactive `parse login`). |
162
212
 
163
213
  ## Changelog
164
214
 
@@ -2,11 +2,21 @@
2
2
 
3
3
  Typed Python SDK tooling for [Parse](https://parse.bot) APIs.
4
4
 
5
+ Requires Python 3.10 or newer.
6
+
5
7
  `parse-sdk` is the shared **runtime + CLI**. It generates a project-local
6
8
  `parse_apis` package — a real, installed, editable dependency — that holds the
7
9
  typed client for every API your key can call. Your application code imports from
8
10
  `parse_apis`; `parse-sdk` provides the runtime bases and errors those clients use.
9
11
 
12
+ ## SDK and MCP
13
+
14
+ Use Parse MCP when an agent is discovering or calling APIs ad hoc, or managing
15
+ account-owned server-side variants. Use this SDK for durable, typed Python
16
+ clients committed to an application. Neither requires the other: the SDK can
17
+ use `parse search` and `parse add --marketplace <listing_id>` without creating
18
+ an MCP subscription.
19
+
10
20
  ## Quickstart
11
21
 
12
22
  In a uv-managed project (`uv init` first if you don't have a `pyproject.toml`):
@@ -16,9 +26,10 @@ uv add parse-sdk # installs the runtime + `parse` CLI into your proje
16
26
  uv run parse init # prompts for your API key, then scaffolds + syncs parse_apis
17
27
  ```
18
28
 
19
- Non-interactive (CI, agents, scripts): authenticate first with
20
- `uv run parse login --api-key YOUR_KEY` (add `--base-url …` for a non-default
21
- host), or export `PARSE_API_KEY` then `uv run parse init` runs without prompts.
29
+ Non-interactive (CI, agents, scripts): export `PARSE_API_KEY`, then run
30
+ `uv run parse init`. `parse login --api-key` persists a key, but is a less-safe
31
+ automation fallback because argv can be recorded in process listings and shell
32
+ history.
22
33
 
23
34
  `parse init` is the one-time setup. It:
24
35
 
@@ -44,8 +55,9 @@ except RateLimitError:
44
55
  ...
45
56
  ```
46
57
 
47
- (The exact methods are generated from *your* API's spec — `parse_apis/<slug>/README.md`
48
- and `example.py` document each client's real surface.)
58
+ (The exact methods are generated from *your* API's spec —
59
+ `parse_apis/src/parse_apis/<slug>/README.md` and `example.py` document each
60
+ client's real surface.)
49
61
 
50
62
  ```bash
51
63
  uv run python app.py # run your app in the project environment
@@ -69,16 +81,17 @@ they just `import parse_apis`.
69
81
 
70
82
  ```text
71
83
  parse_apis/
84
+ AGENTS.md CLAUDE.md # committed, byte-identical agent pointers
72
85
  pyproject.toml # committed: name="parse-apis", pinned parse-sdk==X
73
86
  src/parse_apis/
74
87
  __init__.py # committed scaffold — re-exports runtime errors
75
88
  py.typed # committed
76
- reddit/__init__.py # generated (gitignored)
89
+ reddit_com_api/__init__.py # generated (gitignored)
77
90
  _manifest.json # generated (gitignored)
78
91
  AGENTS.md CLAUDE.md # generated cross-API index (gitignored)
79
92
  ```
80
93
 
81
- Commit the three scaffold files; the generated payload is per-key (it reveals your API
94
+ Commit the five scaffold files; the generated payload is per-key (it reveals your API
82
95
  inventory) and stays gitignored. `parse sync` builds the next payload in a staging tree,
83
96
  self-tests it (secret scan, import-model, no-stale-docs), and promotes it with an atomic
84
97
  directory swap — a failed sync never leaves broken or secret-bearing files in your project.
@@ -108,14 +121,51 @@ succeeds on a clean checkout.
108
121
 
109
122
  | Command | What it does |
110
123
  | --- | --- |
111
- | `parse login` | Save API key + base URL to local credentials. No project changes. |
112
- | `parse init` | One-time: ensure creds, scaffold `parse_apis`, record deps + gitignore, first sync. |
113
- | `parse sync` | Regenerate the typed payload into `parse_apis/src/parse_apis` (staged + atomic). |
114
- | `parse clean` | Remove generated payload; keep the committed scaffold. |
124
+ | `parse login` | Save browser OAuth credentials by default, or an API key with `--api-key`. No project changes. |
125
+ | `parse init` | One-time: ensure creds, scaffold `parse_apis`, record deps + gitignore, first sync. Pass slugs to track just those APIs. |
115
126
  | `parse list` | Print the APIs the current key can call (no generation). |
127
+ | `parse sync` | Regenerate the typed payload into `parse_apis/src/parse_apis` (staged + atomic). `--check` is a CI dry run. |
128
+ | `parse add` | Add APIs to the repo's synced set (`--marketplace` for shared canonicals; new entries pin the latest usable release, while re-add preserves existing pin/live state), then reconcile. |
129
+ | `parse remove` | Stop syncing APIs (account or marketplace) and prune their generated clients. |
130
+ | `parse search` | Search the Parse marketplace for ready-made APIs. |
131
+ | `parse clean` | Remove generated payload; keep the committed scaffold. |
116
132
  | `parse whoami` | Show the current key + base URL. |
133
+ | `parse doctor` | Diagnose the project's SDK install; `--fix` applies safe remediations. |
134
+ | `parse help` | Guided getting-started workflow (`--json` for machine-readable orientation). |
117
135
 
118
- Run `parse <cmd> --help` for full per-command help.
136
+ Run `parse <cmd> --help` for full per-command help — every command's help ends
137
+ with copy-pasteable Examples.
138
+
139
+ ## Agents & automation
140
+
141
+ The CLI is built to run headless: prompts only fire on an interactive terminal
142
+ (non-TTY runs fail fast with the exact fix), warnings go to stderr, and stdout
143
+ stays machine-parseable under `--json`.
144
+
145
+ **Machine-readable output.** `parse help --json` is the orientation entrypoint:
146
+ it lists every command with `requires_auth` / `requires_init` facts and (under
147
+ `agent_modes`) which commands accept `--json` — the list is derived from the
148
+ live command registry, so it never goes stale. The `--json` shapes are stable:
149
+
150
+ | Command | Shape |
151
+ | --- | --- |
152
+ | `whoami --json` | object: `base_url`, `api_key_masked`, `key_configured`, `key_sent_to_host`, `web_login`, `auth_method` |
153
+ | `list --json` | array of `{id, slug, name, modeled, endpoints, resources, version}` |
154
+ | `sync` / `add` / `remove` `--json` | object: `written[] {slug, id, root, status, warnings[]}`, `skipped[] {slug, id, reason}`, `removed[]`, `quarantined[] {slug, id, retained, findings[]}`, `failures[]` — on a gate abort, `{failures}` alone with exit 1; under `sync --check`, `failures` carries every finding (non-empty ⟺ exit 1) |
155
+ | `doctor --json` | object: `ok`, `findings[] {level, title, detail, fix, kind}` |
156
+ | `search --json` | array of `{id, slug, name, source_url, endpoint_count, is_authenticated}` |
157
+
158
+ **Exit codes.** `0` — success. `1` — the command ran and detected a problem
159
+ (sync's pre-promotion gate abort, `sync --check` findings, `doctor` error-level
160
+ findings, `init` refusing to migrate a legacy tree with non-Parse files, a lost
161
+ concurrent-promote race). `2` — the command couldn't run: usage errors, an
162
+ uninitialized project, missing/withheld credentials or an untrusted host,
163
+ network failures.
164
+
165
+ **Credentials in automation.** Prefer exporting `PARSE_API_KEY` over passing
166
+ `--api-key` on the command line — argv is visible in process listings and shell
167
+ history. `parse login` (browser OAuth by default; `--web` is explicit) requires an interactive terminal
168
+ and fails fast headless.
119
169
 
120
170
  ## Versioning
121
171
 
@@ -127,8 +177,8 @@ regenerate against the new runtime; `init`/`sync` restamp the pin.
127
177
 
128
178
  | Var | Default | Notes |
129
179
  | --- | --- | --- |
130
- | `PARSE_API_KEY` | – | Auth for real requests (or use `parse login`). |
131
- | `PARSE_API_BASE_URL` | `https://api.parse.bot` | Locally: `http://localhost:8001`. |
180
+ | `PARSE_API_KEY` | – | Auth for real requests (or use `parse login`). Preferred over `--api-key` in automation. |
181
+ | `PARSE_ACCESS_TOKEN` | | OAuth bearer token override (normally managed by interactive `parse login`). |
132
182
 
133
183
  ## Changelog
134
184
 
@@ -4,7 +4,7 @@ Typed clients are generated by `parse sync` into a project-local
4
4
  `parse_apis/` package and imported directly:
5
5
 
6
6
  import parse_sdk as parse # errors / runtime helpers
7
- from parse_apis.reddit import Reddit # generated typed client
7
+ from parse_apis.reddit_com_api import Reddit # generated typed client
8
8
 
9
9
  try:
10
10
  reddit = Reddit() # picks up `parse login` creds
@@ -37,7 +37,7 @@ from parse_sdk._runtime import (
37
37
  UpstreamError,
38
38
  )
39
39
 
40
- __version__ = "0.1.0"
40
+ __version__ = "0.2.0"
41
41
  __all__ = [
42
42
  "BaseAPI",
43
43
  "Resource",
@@ -0,0 +1,23 @@
1
+ """Comparison helpers for server-owned API identifiers."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+
7
+ _UUID_RE = re.compile(
8
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
9
+ r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
10
+ )
11
+
12
+
13
+ def _identifier_key(value: str) -> str:
14
+ """Return a comparison key without changing opaque identifier semantics."""
15
+ return value.casefold() if _UUID_RE.fullmatch(value) else value
16
+
17
+
18
+ def _same_identifier(left: object, right: object) -> bool:
19
+ return (
20
+ isinstance(left, str)
21
+ and isinstance(right, str)
22
+ and _identifier_key(left) == _identifier_key(right)
23
+ )
@@ -0,0 +1,277 @@
1
+ """The committed, declarative project config — the ``[tool.parse]`` table in the
2
+ consumer ``pyproject.toml`` that records which APIs this repo wants.
3
+
4
+ This is the DESIRED-state manifest: committed, reproducible across clones/CI,
5
+ and the source of truth ``parse sync`` reconciles the generated tree to. It is
6
+ distinct from ``parse_apis/_manifest.json`` (the GENERATED uuid→slug map, which
7
+ is gitignored and rebuilt every sync).
8
+
9
+ * ``apis`` — the account APIs this repo wants, recorded as *server slugs* (the
10
+ readable, committed form). A ``scraper_id`` (UUID) is also accepted on read (a
11
+ tolerant dual-read), so a hand-written UUID — or any future identifier — still
12
+ resolves and is never mistaken for a missing entry. The key being ABSENT means
13
+ "all your account APIs" (the post-``init`` default); an explicit list
14
+ (including the empty list) means exactly those.
15
+ * ``marketplace`` — locally-downloaded marketplace canonicals, optionally
16
+ pinned to a release. Unknown fields fail closed: generated projects pin the
17
+ SDK version, so silently guessing at a newer config contract is unnecessary.
18
+
19
+ We OWN the ``[tool.parse]`` table: writes regenerate it wholesale from the values
20
+ we just read back, so a hand-edited value (e.g. a slug you added to ``apis``)
21
+ survives, but formatting/comments *inside* the table are normalized. We never
22
+ touch anything outside it.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import re
28
+ try:
29
+ import tomllib # Python 3.11+
30
+ except ModuleNotFoundError: # 3.10 — tomllib landed in 3.11; tomli is its backport
31
+ import tomli as tomllib # type: ignore[no-redef]
32
+
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+ from typing import Dict, List, Optional
36
+
37
+ from parse_sdk._identifiers import _identifier_key
38
+
39
+
40
+ @dataclass
41
+ class MarketplaceEntry:
42
+ """A locally-downloaded marketplace canonical recorded for reproducible
43
+ re-sync. ``scraper_id`` (canonical_scraper_id) is the execution + schema
44
+ primary key; ``version`` pins execution via the ``API-Snapshot-Version``
45
+ header."""
46
+ scraper_id: str
47
+ slug: str
48
+ version: Optional[int] = None
49
+ listing_id: Optional[str] = None
50
+ source_url: Optional[str] = None
51
+
52
+
53
+ @dataclass
54
+ class ProjectConfig:
55
+ # None → the `apis` key is absent → "all your account APIs" (default).
56
+ # [...] → exactly these account APIs as server slugs; a scraper_id (UUID) is
57
+ # also accepted on read ([] = no account APIs).
58
+ apis: Optional[List[str]] = None
59
+ marketplace: List[MarketplaceEntry] = field(default_factory=list)
60
+
61
+ @property
62
+ def all_account_mode(self) -> bool:
63
+ """True when the repo tracks ALL account APIs (apis key absent)."""
64
+ return self.apis is None
65
+
66
+ def account_desired(self, all_account_slugs: List[str]) -> List[str]:
67
+ """The account server-slugs this repo wants, given the full account set."""
68
+ return list(all_account_slugs) if self.apis is None else list(self.apis)
69
+
70
+
71
+ class ProjectConfigError(ValueError):
72
+ """The consumer's committed ``[tool.parse]`` desired state is invalid."""
73
+
74
+
75
+ # --------------------------------------------------------------------------- #
76
+ # read
77
+ # --------------------------------------------------------------------------- #
78
+
79
+ def read_project_config(consumer_pyproject: Path) -> ProjectConfig:
80
+ """Parse ``[tool.parse]`` from the consumer pyproject.
81
+
82
+ A missing file/table means the backward-compatible all-account default.
83
+ Once ``[tool.parse]`` exists, malformed TOML or known fields fail closed:
84
+ silently widening a broken declaration to all-account can prune or generate
85
+ clients the user did not request.
86
+ """
87
+ try:
88
+ data = tomllib.loads(consumer_pyproject.read_text())
89
+ except FileNotFoundError:
90
+ return ProjectConfig()
91
+ except OSError as e:
92
+ raise ProjectConfigError(f"could not read {consumer_pyproject}: {e}") from e
93
+ except tomllib.TOMLDecodeError as e:
94
+ raise ProjectConfigError(f"invalid TOML in {consumer_pyproject}: {e}") from e
95
+
96
+ tool_tbl = data.get("tool") if isinstance(data, dict) else None
97
+ if tool_tbl is None:
98
+ return ProjectConfig()
99
+ if not isinstance(tool_tbl, dict):
100
+ raise ProjectConfigError("[tool] must be a table")
101
+ if "parse" not in tool_tbl:
102
+ return ProjectConfig()
103
+ parse_tbl = tool_tbl.get("parse")
104
+ if not isinstance(parse_tbl, dict):
105
+ raise ProjectConfigError("[tool.parse] must be a table")
106
+
107
+ known_table_fields = {"apis", "marketplace"}
108
+ _reject_unknown_fields(parse_tbl, known_table_fields, "tool.parse")
109
+
110
+ apis: Optional[List[str]] = None
111
+ if "apis" in parse_tbl:
112
+ raw = parse_tbl.get("apis")
113
+ # Entries are server slugs (a scraper_id UUID is also accepted) — both are
114
+ # plain strings, so no narrowing is needed here; the tolerant dual-read in
115
+ # `cli._apply_account_selection` resolves either form.
116
+ if not isinstance(raw, list):
117
+ raise ProjectConfigError("tool.parse.apis must be an array of non-empty strings")
118
+ if any(not isinstance(item, str) or not item for item in raw):
119
+ raise ProjectConfigError("tool.parse.apis must contain only non-empty strings")
120
+ apis = list(raw)
121
+
122
+ marketplace: List[MarketplaceEntry] = []
123
+ if "marketplace" in parse_tbl:
124
+ raw_mkt = parse_tbl.get("marketplace")
125
+ if not isinstance(raw_mkt, list):
126
+ raise ProjectConfigError("tool.parse.marketplace must be an array of tables")
127
+ seen_scraper_ids: Dict[str, tuple[int, Optional[int]]] = {}
128
+ for i, row in enumerate(raw_mkt):
129
+ where = f"tool.parse.marketplace[{i}]"
130
+ if not isinstance(row, dict):
131
+ raise ProjectConfigError(f"{where} must be a table")
132
+ known_row_fields = {"scraper_id", "slug", "version", "listing_id", "source_url"}
133
+ _reject_unknown_fields(row, known_row_fields, where)
134
+ sid, slug = row.get("scraper_id"), row.get("slug")
135
+ if not isinstance(sid, str) or not sid:
136
+ raise ProjectConfigError(f"{where}.scraper_id must be a non-empty string")
137
+ if not isinstance(slug, str) or not slug:
138
+ raise ProjectConfigError(f"{where}.slug must be a non-empty string")
139
+ ver = row.get("version")
140
+ if ver is not None and (
141
+ not isinstance(ver, int) or isinstance(ver, bool) or ver < 1):
142
+ raise ProjectConfigError(f"{where}.version must be a positive integer")
143
+ sid_key = _identifier_key(sid)
144
+ if sid_key in seen_scraper_ids:
145
+ prior_index, prior_version = seen_scraper_ids[sid_key]
146
+ raise ProjectConfigError(
147
+ f"{where}.scraper_id duplicates tool.parse.marketplace"
148
+ f"[{prior_index}] for {sid!r} (versions {prior_version!r} and "
149
+ f"{ver!r}); each canonical scraper_id must be declared once"
150
+ )
151
+ seen_scraper_ids[sid_key] = (i, ver)
152
+ listing_id = row.get("listing_id")
153
+ if listing_id is not None and not isinstance(listing_id, str):
154
+ raise ProjectConfigError(f"{where}.listing_id must be a string")
155
+ source_url = row.get("source_url")
156
+ if source_url is not None and not isinstance(source_url, str):
157
+ raise ProjectConfigError(f"{where}.source_url must be a string")
158
+ marketplace.append(MarketplaceEntry(
159
+ scraper_id=sid,
160
+ slug=slug,
161
+ version=ver,
162
+ listing_id=listing_id,
163
+ source_url=source_url,
164
+ ))
165
+ return ProjectConfig(apis=apis, marketplace=marketplace)
166
+
167
+
168
+ # --------------------------------------------------------------------------- #
169
+ # write (regenerate the table we own)
170
+ # --------------------------------------------------------------------------- #
171
+
172
+ def _toml_str(s: str) -> str:
173
+ """A TOML basic string. Slugs/uuids are tame, but source_url is freer —
174
+ JSON's string escapes are also valid TOML basic-string escapes."""
175
+ # json.dumps handles TOML-compatible escapes for C0 controls, quotes, and
176
+ # backslashes. JSON permits a raw DEL character while TOML does not.
177
+ return json.dumps(s, ensure_ascii=False).replace("\x7f", "\\u007f")
178
+
179
+
180
+ def _reject_unknown_fields(table: dict, known: set, where: str) -> None:
181
+ """Fail closed on fields this pinned SDK does not understand.
182
+
183
+ The CLI owns and regenerates this table. Preserving arbitrary siblings would
184
+ require a complete lossless TOML editor and still could not distinguish a
185
+ future field from a destructive typo such as ``apiss``. A clear upgrade
186
+ error is smaller, deterministic, and cannot widen desired state.
187
+ """
188
+ common_typos = {
189
+ "api": "apis",
190
+ "marketpalce": "marketplace",
191
+ "scrapper_id": "scraper_id",
192
+ "scraperid": "scraper_id",
193
+ "versoin": "version",
194
+ "listng_id": "listing_id",
195
+ "soruce_url": "source_url",
196
+ }
197
+ canonical_by_case = {field.casefold(): field for field in known}
198
+ unknown = sorted(set(table) - known)
199
+ if not unknown:
200
+ return
201
+ details = []
202
+ for key in unknown:
203
+ suggestion = canonical_by_case.get(key.casefold())
204
+ if suggestion is None:
205
+ candidate = common_typos.get(key.casefold())
206
+ suggestion = candidate if candidate in known else None
207
+ details.append(
208
+ f"{where}.{key}" + (f" (did you mean {suggestion!r}?)" if suggestion else "")
209
+ )
210
+ raise ProjectConfigError(
211
+ "unsupported project configuration field(s): " + ", ".join(details)
212
+ + "; this project may require a newer parse-sdk — upgrade it before retrying"
213
+ )
214
+
215
+
216
+ def render_parse_table(config: ProjectConfig) -> str:
217
+ """The full ``[tool.parse]`` block (+ any ``[[tool.parse.marketplace]]``
218
+ sub-tables). Trailing newline included."""
219
+ lines: List[str] = [
220
+ "# Managed by the parse CLI (`parse add` / `parse remove` / `parse sync`).",
221
+ "# Edit `apis` to curate which of your account APIs this repo syncs;",
222
+ "# an absent `apis` key means all of them.",
223
+ "[tool.parse]",
224
+ ]
225
+ if config.apis is not None:
226
+ if config.apis:
227
+ lines.append("apis = [")
228
+ for slug in config.apis:
229
+ lines.append(f" {_toml_str(slug)},")
230
+ lines.append("]")
231
+ else:
232
+ lines.append("apis = []")
233
+ for m in config.marketplace:
234
+ lines.append("")
235
+ lines.append("[[tool.parse.marketplace]]")
236
+ lines.append(f"scraper_id = {_toml_str(m.scraper_id)}")
237
+ lines.append(f"slug = {_toml_str(m.slug)}")
238
+ if m.version is not None:
239
+ lines.append(f"version = {int(m.version)}")
240
+ if m.listing_id:
241
+ lines.append(f"listing_id = {_toml_str(m.listing_id)}")
242
+ if m.source_url:
243
+ lines.append(f"source_url = {_toml_str(m.source_url)}")
244
+ return "\n".join(lines) + "\n"
245
+
246
+
247
+ def _strip_parse_tables(text: str) -> str:
248
+ """Remove the existing ``[tool.parse]`` / ``[tool.parse.*]`` /
249
+ ``[[tool.parse.*]]`` blocks (header line through the line before the next
250
+ table header or EOF), leaving everything else byte-for-byte."""
251
+ out: List[str] = []
252
+ skipping = False
253
+ header_re = re.compile(r"^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$")
254
+ for line in text.splitlines(keepends=True):
255
+ m = header_re.match(line)
256
+ if m:
257
+ name = m.group(1)
258
+ skipping = name == "tool.parse" or name.startswith("tool.parse.")
259
+ if not skipping:
260
+ out.append(line)
261
+ return "".join(out)
262
+
263
+
264
+ def write_project_config(consumer_pyproject: Path, config: ProjectConfig) -> None:
265
+ """Persist ``config`` into the consumer pyproject's ``[tool.parse]`` table,
266
+ regenerating only that table. Raises ValueError if the result wouldn't be
267
+ valid TOML (invariant: we never write a broken pyproject)."""
268
+ original = consumer_pyproject.read_text()
269
+ stripped = _strip_parse_tables(original).rstrip()
270
+ block = render_parse_table(config)
271
+ new = (stripped + "\n\n" + block) if stripped else block
272
+ try:
273
+ tomllib.loads(new)
274
+ except tomllib.TOMLDecodeError as e: # pragma: no cover — defensive invariant
275
+ raise ValueError(f"refusing to write pyproject.toml — the [tool.parse] edit "
276
+ f"would corrupt it ({e}).") from e
277
+ consumer_pyproject.write_text(new)