parse-sdk 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
parse_sdk/__init__.py ADDED
@@ -0,0 +1,57 @@
1
+ """Parse SDK engine — errors, runtime helpers, and the `parse` CLI.
2
+
3
+ Typed clients are generated by `parse sync` into a project-local
4
+ `parse_apis/` package and imported directly:
5
+
6
+ import parse_sdk as parse # errors / runtime helpers
7
+ from parse_apis.reddit import Reddit # generated typed client
8
+
9
+ try:
10
+ reddit = Reddit() # picks up `parse login` creds
11
+ for post in reddit.subreddit("smallbusiness").search_posts(query="hiring", limit=10):
12
+ print(post.title)
13
+ except parse.RateLimitError:
14
+ ...
15
+
16
+ `parse_apis` is a real on-disk namespace package (pyright-resolvable, no
17
+ import magic). `parse_sdk` no longer resolves generated submodules.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from parse_sdk._runtime import (
23
+ AuthError,
24
+ BadRequestError,
25
+ BaseAPI,
26
+ Collection,
27
+ ForbiddenError,
28
+ NotFoundError,
29
+ PaginationError,
30
+ PaginationLimitError,
31
+ Paginator,
32
+ ParseError,
33
+ QuotaExceededError,
34
+ RateLimitError,
35
+ Resource,
36
+ RequestMeta,
37
+ UpstreamError,
38
+ )
39
+
40
+ __version__ = "0.1.0"
41
+ __all__ = [
42
+ "BaseAPI",
43
+ "Resource",
44
+ "Collection",
45
+ "Paginator",
46
+ "RequestMeta",
47
+ "ParseError",
48
+ "AuthError",
49
+ "ForbiddenError",
50
+ "NotFoundError",
51
+ "BadRequestError",
52
+ "QuotaExceededError",
53
+ "RateLimitError",
54
+ "UpstreamError",
55
+ "PaginationError",
56
+ "PaginationLimitError",
57
+ ]
parse_sdk/_labels.py ADDED
@@ -0,0 +1,51 @@
1
+ """Shared parsing for the closed type-label grammar codegen emits
2
+ (``str`` | ``List[X]`` | ``Optional[X]`` | ``Dict[K, V]`` | ``_Paginator[X]`` |
3
+ unions). Single source: ``codegen_reconcile``, ``sample_gate``, and the
4
+ runtime previously each hand-rolled a splitter/unwrapper and drifted
5
+ (``Paginator[`` vs ``_Paginator[``). Stdlib-only — ``_runtime`` imports it."""
6
+ from __future__ import annotations
7
+
8
+ from typing import List, Optional
9
+
10
+ _WRAPPERS = ("List[", "Optional[", "_Paginator[")
11
+
12
+
13
+ def strip_wrapper(s: str, prefix: str) -> Optional[str]:
14
+ """The inner of a single ``<prefix>[...]`` layer, or ``None`` if ``s`` is not
15
+ that shape: ``strip_wrapper('Optional[X]', 'Optional[')`` → ``'X'``. The
16
+ one-layer primitive that ``unwrap_label`` loops over and ``sample_gate``
17
+ applies to a known prefix — single source for the ``s[len(prefix):-1]``
18
+ slice so the grammar parsers cannot drift."""
19
+ if s.startswith(prefix) and s.endswith("]"):
20
+ return s[len(prefix):-1].strip()
21
+ return None
22
+
23
+
24
+ def split_top(s: str) -> List[str]:
25
+ """Split on commas at bracket-depth 0: ``'str, Dict[a, b]'`` →
26
+ ``['str', 'Dict[a, b]']``."""
27
+ parts, depth, start = [], 0, 0
28
+ for i, ch in enumerate(s):
29
+ if ch == "[":
30
+ depth += 1
31
+ elif ch == "]":
32
+ depth -= 1
33
+ elif ch == "," and depth == 0:
34
+ parts.append(s[start:i].strip())
35
+ start = i + 1
36
+ parts.append(s[start:].strip())
37
+ return parts
38
+
39
+
40
+ def unwrap_label(s: str, *, max_depth: int = 5) -> str:
41
+ """Strip wrapper layers down to the leaf: ``'List[Optional[X]]'`` → ``'X'``."""
42
+ s = s.strip()
43
+ for _ in range(max_depth):
44
+ for pfx in _WRAPPERS:
45
+ inner = strip_wrapper(s, pfx)
46
+ if inner is not None:
47
+ s = inner
48
+ break
49
+ else:
50
+ return s
51
+ return s
parse_sdk/_oauth.py ADDED
@@ -0,0 +1,301 @@
1
+ """Browser (loopback) OAuth 2.1 + PKCE login for `parse login --web`.
2
+
3
+ Reuses the backend's existing OAuth endpoints (the same ones the MCP server
4
+ advertises): dynamic client registration → /oauth/authorize (browser consent on
5
+ the Parse frontend) → loopback redirect with ?code → /oauth/token exchange. The
6
+ minted token (aud=parse-mcp) authorizes /sdk + /scraper once the executor accepts
7
+ it (backend Parse#666).
8
+
9
+ The pure, I/O-free pieces (PKCE, URL build, token-response parse, expiry math)
10
+ are split out so they unit-test without a browser, a socket, or the network; the
11
+ orchestration (`run_web_login`) wires them to a real loopback listener + the
12
+ system browser. Loopback redirects are the only ones the backend allowlists for
13
+ http, and the credential-store host gate accepts loopback — so a token captured
14
+ on 127.0.0.1 can be persisted and then used against the configured Parse host.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import base64
19
+ import hashlib
20
+ import http.server
21
+ import secrets
22
+ import threading
23
+ import time
24
+ import urllib.parse
25
+ import webbrowser
26
+ from dataclasses import dataclass
27
+ from typing import Optional, Tuple
28
+
29
+ import httpx
30
+
31
+ # Default scope the backend advertises (scopes_supported: ["mcp"]).
32
+ _SCOPE = "mcp"
33
+ _REGISTER_TIMEOUT = 30.0
34
+ _TOKEN_TIMEOUT = 30.0
35
+ # How long to wait for the human to finish the browser consent before giving up.
36
+ _CALLBACK_WAIT_SECONDS = 300.0
37
+
38
+
39
+ @dataclass
40
+ class TokenSet:
41
+ access_token: str
42
+ refresh_token: Optional[str]
43
+ expires_at: Optional[int] # unix seconds; None when the server omits expires_in
44
+ client_id: Optional[str] = None
45
+
46
+
47
+ # --------------------------------------------------------------------------- #
48
+ # pure helpers (unit-tested without I/O)
49
+ # --------------------------------------------------------------------------- #
50
+
51
+ def generate_pkce() -> Tuple[str, str]:
52
+ """(code_verifier, code_challenge) for PKCE S256 — the exact transform the
53
+ backend verifies (urlsafe-b64 of sha256(verifier), '=' stripped)."""
54
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
55
+ challenge = base64.urlsafe_b64encode(
56
+ hashlib.sha256(verifier.encode()).digest()
57
+ ).rstrip(b"=").decode()
58
+ return verifier, challenge
59
+
60
+
61
+ def build_authorize_url(
62
+ authorize_endpoint: str, *, client_id: str, redirect_uri: str,
63
+ code_challenge: str, state: str, scope: str = _SCOPE,
64
+ ) -> str:
65
+ """The full /oauth/authorize URL to open in the browser."""
66
+ q = urllib.parse.urlencode({
67
+ "response_type": "code",
68
+ "client_id": client_id,
69
+ "redirect_uri": redirect_uri,
70
+ "code_challenge": code_challenge,
71
+ "code_challenge_method": "S256",
72
+ "state": state,
73
+ "scope": scope,
74
+ })
75
+ sep = "&" if "?" in authorize_endpoint else "?"
76
+ return f"{authorize_endpoint}{sep}{q}"
77
+
78
+
79
+ def parse_token_response(payload: object, *, client_id: Optional[str],
80
+ now: int) -> TokenSet:
81
+ """Validate + normalize an /oauth/token JSON response into a TokenSet.
82
+
83
+ Raises ValueError on a missing/blank access_token (so a malformed exchange is
84
+ a clean error, not a TokenSet carrying None). expires_at is derived from
85
+ expires_in relative to ``now`` (caller passes time.time() — keeps this pure)."""
86
+ if not isinstance(payload, dict):
87
+ raise ValueError("token endpoint returned a non-object response")
88
+ access = payload.get("access_token")
89
+ if not isinstance(access, str) or not access:
90
+ raise ValueError("token endpoint response had no access_token")
91
+ refresh = payload.get("refresh_token")
92
+ expires_in = payload.get("expires_in")
93
+ expires_at = (now + int(expires_in)) if isinstance(expires_in, (int, float)) else None
94
+ return TokenSet(
95
+ access_token=access,
96
+ refresh_token=refresh if isinstance(refresh, str) and refresh else None,
97
+ expires_at=expires_at,
98
+ client_id=client_id,
99
+ )
100
+
101
+
102
+ def is_expiring(expires_at: Optional[int], *, now: int, skew: int = 60) -> bool:
103
+ """True when a token is absent-of-expiry-safe to keep, or within ``skew`` of
104
+ expiry. None expiry → never proactively refreshed (treat as long-lived)."""
105
+ if expires_at is None:
106
+ return False
107
+ return now >= (expires_at - skew)
108
+
109
+
110
+ # --------------------------------------------------------------------------- #
111
+ # transport (thin; mocked in tests)
112
+ # --------------------------------------------------------------------------- #
113
+
114
+ def discover(base_url: str) -> dict:
115
+ """Fetch the authorization-server metadata (RFC 8414). Returns the endpoint
116
+ map; raises httpx.HTTPError / ValueError on failure."""
117
+ r = httpx.get(f"{base_url}/.well-known/oauth-authorization-server",
118
+ timeout=_REGISTER_TIMEOUT)
119
+ r.raise_for_status()
120
+ data = r.json()
121
+ if not isinstance(data, dict):
122
+ raise ValueError("discovery returned a non-object")
123
+ return data
124
+
125
+
126
+ def register_client(register_endpoint: str, redirect_uri: str,
127
+ *, client_name: str = "Parse CLI") -> str:
128
+ """Dynamic client registration (RFC 7591) → client_id. The backend requires a
129
+ client whose redirect_uris include the exact loopback URI; registrations are
130
+ short-lived, so the CLI registers per-login."""
131
+ r = httpx.post(register_endpoint,
132
+ json={"client_name": client_name, "redirect_uris": [redirect_uri]},
133
+ timeout=_REGISTER_TIMEOUT)
134
+ r.raise_for_status()
135
+ data = r.json()
136
+ cid = data.get("client_id") if isinstance(data, dict) else None
137
+ if not isinstance(cid, str) or not cid:
138
+ raise ValueError("registration response had no client_id")
139
+ return cid
140
+
141
+
142
+ def exchange_code(token_endpoint: str, *, code: str, code_verifier: str,
143
+ redirect_uri: str, client_id: str) -> TokenSet:
144
+ r = httpx.post(token_endpoint, data={
145
+ "grant_type": "authorization_code", "code": code,
146
+ "code_verifier": code_verifier, "redirect_uri": redirect_uri,
147
+ "client_id": client_id,
148
+ }, timeout=_TOKEN_TIMEOUT)
149
+ r.raise_for_status()
150
+ return parse_token_response(r.json(), client_id=client_id, now=int(time.time()))
151
+
152
+
153
+ def refresh_access_token(token_endpoint: str, *, refresh_token: str,
154
+ client_id: Optional[str]) -> TokenSet:
155
+ data = {"grant_type": "refresh_token", "refresh_token": refresh_token}
156
+ if client_id:
157
+ data["client_id"] = client_id
158
+ r = httpx.post(token_endpoint, data=data, timeout=_TOKEN_TIMEOUT)
159
+ r.raise_for_status()
160
+ ts = parse_token_response(r.json(), client_id=client_id, now=int(time.time()))
161
+ # Some servers omit a rotated refresh token on refresh — keep the old one.
162
+ if ts.refresh_token is None:
163
+ ts.refresh_token = refresh_token
164
+ return ts
165
+
166
+
167
+ # --------------------------------------------------------------------------- #
168
+ # loopback callback server
169
+ # --------------------------------------------------------------------------- #
170
+
171
+ class _CallbackResult:
172
+ code: Optional[str] = None
173
+ state: Optional[str] = None
174
+ error: Optional[str] = None
175
+ # True once the loopback callback actually fired (a GET reached the handler),
176
+ # distinct from `handle_request()` returning on its socket timeout. Without
177
+ # this an abandoned login (browser closed) is indistinguishable from a
178
+ # successful-but-codeless callback, surfacing a misleading "no code" error.
179
+ handled: bool = False
180
+
181
+
182
+ def _make_handler(result: _CallbackResult, expected_state: str):
183
+ class _Handler(http.server.BaseHTTPRequestHandler):
184
+ def do_GET(self): # noqa: N802 — http.server API
185
+ # RFC 8252 §8.3: reject any callback whose Host header is not the
186
+ # loopback interface — belt-and-suspenders against DNS-rebinding a
187
+ # malicious page onto the ephemeral callback port. The genuine
188
+ # browser redirect always carries Host: 127.0.0.1[:port] / localhost.
189
+ host = (self.headers.get("Host") or "").rsplit(":", 1)[0].strip("[]")
190
+ if host not in ("127.0.0.1", "localhost", "::1"):
191
+ self.send_response(400)
192
+ self.end_headers()
193
+ return
194
+ result.handled = True
195
+ parsed = urllib.parse.urlsplit(self.path)
196
+ qs = urllib.parse.parse_qs(parsed.query)
197
+ err = qs.get("error", [None])[0]
198
+ code = qs.get("code", [None])[0]
199
+ state = qs.get("state", [None])[0]
200
+ ok = False
201
+ if err:
202
+ result.error = err
203
+ elif not code:
204
+ result.error = "no authorization code in callback"
205
+ elif state != expected_state:
206
+ result.error = "state mismatch (possible CSRF) — login aborted"
207
+ else:
208
+ result.code, result.state, ok = code, state, True
209
+ body = (b"<html><body><h2>Parse login "
210
+ + (b"complete" if ok else b"failed")
211
+ + b"</h2>You can close this tab and return to the terminal.</body></html>")
212
+ self.send_response(200)
213
+ self.send_header("Content-Type", "text/html")
214
+ self.send_header("Content-Length", str(len(body)))
215
+ self.end_headers()
216
+ self.wfile.write(body)
217
+
218
+ def log_message(self, format, *args): # noqa: A002 — match base signature; silence access log
219
+ return
220
+
221
+ return _Handler
222
+
223
+
224
+ def run_web_login(base_url: str, *, open_browser: bool = True) -> TokenSet:
225
+ """Drive the full loopback PKCE login against ``base_url`` and return a
226
+ TokenSet. Raises RuntimeError with an actionable message on any failure.
227
+
228
+ Requires a browser + the hosted consent page (the backend's /oauth/approve
229
+ needs a logged-in Parse session), so this is inherently interactive — the
230
+ pure/transport helpers above are what the unit tests exercise.
231
+ """
232
+ meta = discover(base_url)
233
+ authorize_ep = meta.get("authorization_endpoint")
234
+ token_ep = meta.get("token_endpoint")
235
+ register_ep = meta.get("registration_endpoint")
236
+ if not (isinstance(authorize_ep, str) and isinstance(token_ep, str)):
237
+ raise RuntimeError(f"{base_url} did not advertise OAuth authorize/token endpoints.")
238
+
239
+ # Bind a loopback listener on an ephemeral port FIRST so the redirect_uri is
240
+ # exact at registration time (the backend allowlists http://127.0.0.1[:port]).
241
+ result = _CallbackResult()
242
+ state = secrets.token_urlsafe(24)
243
+ server = http.server.HTTPServer(("127.0.0.1", 0), _make_handler(result, state))
244
+ port = server.server_address[1]
245
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
246
+
247
+ try:
248
+ if not isinstance(register_ep, str):
249
+ raise RuntimeError(f"{base_url} did not advertise a registration endpoint.")
250
+ client_id = register_client(register_ep, redirect_uri)
251
+ verifier, challenge = generate_pkce()
252
+ url = build_authorize_url(authorize_ep, client_id=client_id,
253
+ redirect_uri=redirect_uri, code_challenge=challenge,
254
+ state=state)
255
+
256
+ print("Opening your browser to log in to Parse…")
257
+ print(f" If it doesn't open, visit:\n {url}")
258
+ if open_browser:
259
+ try:
260
+ webbrowser.open(url)
261
+ except Exception:
262
+ pass # the printed URL is the fallback
263
+
264
+ # Serve exactly one request (the redirect), bounded by a wait timeout so a
265
+ # closed/abandoned browser doesn't hang the CLI forever. `server.timeout`
266
+ # makes `handle_request()` return on its socket select timeout when no
267
+ # request arrives — so `done` is set whether the callback fired or it
268
+ # timed out. The authoritative timeout signal is therefore `result.handled`
269
+ # (set inside the handler), NOT `done.wait()` (which all but always
270
+ # returns True here once the inner socket timeout elapses).
271
+ server.timeout = _CALLBACK_WAIT_SECONDS
272
+ done = threading.Event()
273
+
274
+ def _serve():
275
+ # Keep serving until a real callback is handled, bounded by the same
276
+ # wall-clock deadline — a rejected/stray GET (Host check, prefetch)
277
+ # must NOT consume the one login slot and abort the genuine redirect.
278
+ deadline = time.monotonic() + _CALLBACK_WAIT_SECONDS
279
+ while not result.handled and time.monotonic() < deadline:
280
+ server.handle_request()
281
+ done.set()
282
+
283
+ threading.Thread(target=_serve, daemon=True).start()
284
+ # +5 is a small slack over the socket timeout so the worker thread has
285
+ # exited cleanly; if even that elapses with the thread still alive, treat
286
+ # it as a timeout too.
287
+ done.wait(_CALLBACK_WAIT_SECONDS + 5)
288
+ if not result.handled:
289
+ raise RuntimeError(
290
+ f"login timed out after {int(_CALLBACK_WAIT_SECONDS)}s — no "
291
+ f"callback was received. Did you complete the login in the "
292
+ f"browser? Re-run `parse login --web` to try again.")
293
+ if result.error:
294
+ raise RuntimeError(f"login failed: {result.error}")
295
+ if not result.code:
296
+ raise RuntimeError("login did not return an authorization code.")
297
+
298
+ return exchange_code(token_ep, code=result.code, code_verifier=verifier,
299
+ redirect_uri=redirect_uri, client_id=client_id)
300
+ finally:
301
+ server.server_close()
parse_sdk/_project.py ADDED
@@ -0,0 +1,184 @@
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`` — account API *server slugs* this repo wants. The key being ABSENT
10
+ means "all your account APIs" (the post-``init`` default); an explicit list
11
+ (including the empty list) means exactly those.
12
+ * ``marketplace`` — locally-downloaded marketplace canonicals, each pinned to a
13
+ version (wired in the marketplace-add slice). Read-tolerant here so an older
14
+ CLI never trips over a newer field.
15
+
16
+ We OWN the ``[tool.parse]`` table: writes regenerate it wholesale from the values
17
+ we just read back, so a hand-edited value (e.g. a slug you added to ``apis``)
18
+ survives, but formatting/comments *inside* the table are normalized. We never
19
+ touch anything outside it.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import re
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 dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import List, Optional
32
+
33
+
34
+ @dataclass
35
+ class MarketplaceEntry:
36
+ """A locally-downloaded marketplace canonical recorded for reproducible
37
+ re-sync. ``scraper_id`` (canonical_scraper_id) is the execution + schema
38
+ primary key; ``version`` pins execution via the ``API-Snapshot-Version``
39
+ header."""
40
+ scraper_id: str
41
+ slug: str
42
+ version: Optional[int] = None
43
+ listing_id: Optional[str] = None
44
+ source_url: Optional[str] = None
45
+
46
+
47
+ @dataclass
48
+ class ProjectConfig:
49
+ # None → the `apis` key is absent → "all your account APIs" (default).
50
+ # [...] → exactly these account server slugs ([] = no account APIs).
51
+ apis: Optional[List[str]] = None
52
+ marketplace: List[MarketplaceEntry] = field(default_factory=list)
53
+
54
+ @property
55
+ def all_account_mode(self) -> bool:
56
+ """True when the repo tracks ALL account APIs (apis key absent)."""
57
+ return self.apis is None
58
+
59
+ def account_desired(self, all_account_slugs: List[str]) -> List[str]:
60
+ """The account server-slugs this repo wants, given the full account set."""
61
+ return list(all_account_slugs) if self.apis is None else list(self.apis)
62
+
63
+
64
+ # --------------------------------------------------------------------------- #
65
+ # read
66
+ # --------------------------------------------------------------------------- #
67
+
68
+ def read_project_config(consumer_pyproject: Path) -> ProjectConfig:
69
+ """Parse ``[tool.parse]`` from the consumer pyproject. Total over a missing
70
+ file / malformed TOML / off-type fields → a permissive default (all-account,
71
+ no marketplace), never a raise: a hand-broken config must degrade to the
72
+ safe default, not crash ``parse sync``."""
73
+ try:
74
+ data = tomllib.loads(consumer_pyproject.read_text())
75
+ except (OSError, tomllib.TOMLDecodeError):
76
+ return ProjectConfig()
77
+ parse_tbl = (((data.get("tool") or {}).get("parse")) or {}) if isinstance(data, dict) else {}
78
+ if not isinstance(parse_tbl, dict):
79
+ return ProjectConfig()
80
+
81
+ apis: Optional[List[str]] = None
82
+ if "apis" in parse_tbl:
83
+ raw = parse_tbl.get("apis")
84
+ # An off-type `apis` (string, number) degrades to all-account rather than
85
+ # silently reconciling to an empty/garbage set that would prune the repo.
86
+ if isinstance(raw, list):
87
+ str_items = [s for s in raw if isinstance(s, str)]
88
+ # If the list contained any non-string entries, treat the whole value as
89
+ # malformed and fall back to all-account rather than silently producing a
90
+ # narrower (or empty) set the user never intended.
91
+ apis = str_items if len(str_items) == len(raw) else None
92
+ else:
93
+ apis = None
94
+
95
+ marketplace: List[MarketplaceEntry] = []
96
+ raw_mkt = parse_tbl.get("marketplace")
97
+ if isinstance(raw_mkt, list):
98
+ for row in raw_mkt:
99
+ if not isinstance(row, dict):
100
+ continue
101
+ sid, slug = row.get("scraper_id"), row.get("slug")
102
+ if isinstance(sid, str) and sid and isinstance(slug, str) and slug:
103
+ ver = row.get("version")
104
+ marketplace.append(MarketplaceEntry(
105
+ scraper_id=sid, slug=slug,
106
+ version=ver if isinstance(ver, int) else None,
107
+ listing_id=row.get("listing_id") if isinstance(row.get("listing_id"), str) else None,
108
+ source_url=row.get("source_url") if isinstance(row.get("source_url"), str) else None,
109
+ ))
110
+ return ProjectConfig(apis=apis, marketplace=marketplace)
111
+
112
+
113
+ # --------------------------------------------------------------------------- #
114
+ # write (regenerate the table we own)
115
+ # --------------------------------------------------------------------------- #
116
+
117
+ def _toml_str(s: str) -> str:
118
+ """A TOML basic string. Slugs/uuids are tame, but source_url is freer —
119
+ escape backslash and quote so the emitted table always re-parses."""
120
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
121
+
122
+
123
+ def render_parse_table(config: ProjectConfig) -> str:
124
+ """The full ``[tool.parse]`` block (+ any ``[[tool.parse.marketplace]]``
125
+ sub-tables). Trailing newline included."""
126
+ lines: List[str] = [
127
+ "# Managed by the parse CLI (`parse add` / `parse remove` / `parse sync`).",
128
+ "# Edit `apis` to curate which of your account APIs this repo syncs;",
129
+ "# an absent `apis` key means all of them.",
130
+ "[tool.parse]",
131
+ ]
132
+ if config.apis is not None:
133
+ if config.apis:
134
+ lines.append("apis = [")
135
+ for slug in config.apis:
136
+ lines.append(f" {_toml_str(slug)},")
137
+ lines.append("]")
138
+ else:
139
+ lines.append("apis = []")
140
+ for m in config.marketplace:
141
+ lines.append("")
142
+ lines.append("[[tool.parse.marketplace]]")
143
+ lines.append(f"scraper_id = {_toml_str(m.scraper_id)}")
144
+ lines.append(f"slug = {_toml_str(m.slug)}")
145
+ if m.version is not None:
146
+ lines.append(f"version = {int(m.version)}")
147
+ if m.listing_id:
148
+ lines.append(f"listing_id = {_toml_str(m.listing_id)}")
149
+ if m.source_url:
150
+ lines.append(f"source_url = {_toml_str(m.source_url)}")
151
+ return "\n".join(lines) + "\n"
152
+
153
+
154
+ def _strip_parse_tables(text: str) -> str:
155
+ """Remove the existing ``[tool.parse]`` / ``[tool.parse.*]`` /
156
+ ``[[tool.parse.*]]`` blocks (header line through the line before the next
157
+ table header or EOF), leaving everything else byte-for-byte."""
158
+ out: List[str] = []
159
+ skipping = False
160
+ header_re = re.compile(r"^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*(?:#.*)?$")
161
+ for line in text.splitlines(keepends=True):
162
+ m = header_re.match(line)
163
+ if m:
164
+ name = m.group(1)
165
+ skipping = name == "tool.parse" or name.startswith("tool.parse.")
166
+ if not skipping:
167
+ out.append(line)
168
+ return "".join(out)
169
+
170
+
171
+ def write_project_config(consumer_pyproject: Path, config: ProjectConfig) -> None:
172
+ """Persist ``config`` into the consumer pyproject's ``[tool.parse]`` table,
173
+ regenerating only that table. Raises ValueError if the result wouldn't be
174
+ valid TOML (invariant: we never write a broken pyproject)."""
175
+ original = consumer_pyproject.read_text()
176
+ stripped = _strip_parse_tables(original).rstrip()
177
+ block = render_parse_table(config)
178
+ new = (stripped + "\n\n" + block) if stripped else block
179
+ try:
180
+ tomllib.loads(new)
181
+ except tomllib.TOMLDecodeError as e: # pragma: no cover — defensive invariant
182
+ raise ValueError(f"refusing to write pyproject.toml — the [tool.parse] edit "
183
+ f"would corrupt it ({e}).") from e
184
+ consumer_pyproject.write_text(new)