parse-sdk 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- parse_sdk/__init__.py +57 -0
- parse_sdk/_labels.py +51 -0
- parse_sdk/_oauth.py +301 -0
- parse_sdk/_project.py +184 -0
- parse_sdk/_runtime.py +2142 -0
- parse_sdk/_sanitize.py +15 -0
- parse_sdk/_sync.py +887 -0
- parse_sdk/checks.py +601 -0
- parse_sdk/cli.py +1667 -0
- parse_sdk/cli_help.py +110 -0
- parse_sdk/codegen_reconcile.py +157 -0
- parse_sdk/codegen_v2.py +3361 -0
- parse_sdk/config.py +349 -0
- parse_sdk/docgen.py +587 -0
- parse_sdk/doctor.py +348 -0
- parse_sdk/migrate.py +212 -0
- parse_sdk/preview.py +588 -0
- parse_sdk/py.typed +0 -0
- parse_sdk/resource_surface.py +196 -0
- parse_sdk/sample_gate.py +245 -0
- parse_sdk/scaffold.py +273 -0
- parse_sdk-0.1.0.dist-info/METADATA +177 -0
- parse_sdk-0.1.0.dist-info/RECORD +26 -0
- parse_sdk-0.1.0.dist-info/WHEEL +4 -0
- parse_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- parse_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
parse_sdk/__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)
|