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/config.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Config resolution.
|
|
2
|
+
|
|
3
|
+
Resolution order, highest first:
|
|
4
|
+
1. explicit kwarg to the generated root client (`Reddit(api_key=...)`)
|
|
5
|
+
2. environment variable
|
|
6
|
+
3. credentials file (~/.config/parse/credentials)
|
|
7
|
+
4. baked-in default
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ipaddress
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import tempfile
|
|
16
|
+
import urllib.parse
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://api.parse.bot"
|
|
22
|
+
CANONICAL_HOST = "api.parse.bot"
|
|
23
|
+
_TRUSTED_HOSTS = frozenset({
|
|
24
|
+
"api.parse.bot", # canonical production host (https); opt others in via PARSE_EXTRA_TRUSTED_HOSTS.
|
|
25
|
+
# Explicit allowlist, NOT `*.parse.bot` minus a denylist: fail-closed on unknown
|
|
26
|
+
# subdomains. Source of truth = backend k8s IngressRoutes; to add a future SDK-facing
|
|
27
|
+
# host, add it here (https-only, exact match — no wildcard).
|
|
28
|
+
})
|
|
29
|
+
# Opt-in escape hatch for staging/preview QA. Empty by default, so prod behavior
|
|
30
|
+
# is UNCHANGED — a user must explicitly set PARSE_EXTRA_TRUSTED_HOSTS (comma- or
|
|
31
|
+
# space-separated hostnames) to let the API key ride to those hosts. Still
|
|
32
|
+
# https-only in _host_is_trusted (the key never travels plaintext to a non-loopback
|
|
33
|
+
# host), so the worst a typo does is widen trust to another https host the user named.
|
|
34
|
+
_EXTRA_TRUSTED_HOSTS_ENV = "PARSE_EXTRA_TRUSTED_HOSTS"
|
|
35
|
+
CREDENTIALS_PATH = Path.home() / ".config" / "parse" / "credentials"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def extra_trusted_hosts() -> frozenset:
|
|
39
|
+
"""Hosts the user has explicitly opted into trusting via
|
|
40
|
+
``PARSE_EXTRA_TRUSTED_HOSTS`` (comma/space separated). Lowercased and
|
|
41
|
+
trailing-dot-stripped to match ``_host_is_trusted``'s normalization. Empty
|
|
42
|
+
set when the env var is unset/blank — the fail-closed default.
|
|
43
|
+
|
|
44
|
+
Read fresh from the environment on every call (not cached at import) so a
|
|
45
|
+
test/monkeypatch toggling the env var takes effect, and so a long-lived
|
|
46
|
+
process picks up an env change without restart."""
|
|
47
|
+
raw = os.environ.get(_EXTRA_TRUSTED_HOSTS_ENV, "")
|
|
48
|
+
return frozenset(
|
|
49
|
+
h.strip().lower().rstrip(".")
|
|
50
|
+
for h in raw.replace(",", " ").split()
|
|
51
|
+
if h.strip()
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _host_is_trusted(url: str) -> bool:
|
|
56
|
+
"""SOLE arbiter of whether Config.api_key may be non-None for a base_url.
|
|
57
|
+
|
|
58
|
+
Package-internal by convention, but intentionally imported by runtime sink
|
|
59
|
+
code and tests so every key-bearing path shares this exact predicate.
|
|
60
|
+
|
|
61
|
+
Self-contained and fail-closed, so it is the FULL local invariant for a
|
|
62
|
+
hand-built Config too (not only resolve()'s output). Trusts a host ONLY when:
|
|
63
|
+
- it is ASCII (C5: the matched host then IS the host httpx connects to —
|
|
64
|
+
non-ASCII / IDNA-confusable hosts are rejected here, not normalized), AND
|
|
65
|
+
- the scheme is http(s) (C2: no ftp/file/gopher, even on loopback), AND
|
|
66
|
+
- it is the canonical host over https, OR a user-opted-in extra host over
|
|
67
|
+
https (PARSE_EXTRA_TRUSTED_HOSTS — empty by default), OR syntactic loopback.
|
|
68
|
+
Pure function over urlsplit().hostname — NO DNS.
|
|
69
|
+
|
|
70
|
+
Invariant (corrected, C5): when this returns True, the ASCII host it matched
|
|
71
|
+
denotes the SAME DNS host httpx connects to (verified across case-fold /
|
|
72
|
+
trailing-dot / userinfo). This is NOT a byte-identity claim over all inputs —
|
|
73
|
+
a non-ASCII host is rejected here while httpx would IDNA-normalize it; that is
|
|
74
|
+
the safe (withhold) direction.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
parts = urllib.parse.urlsplit(url)
|
|
78
|
+
host = parts.hostname # None for malformed authority; already lowercased
|
|
79
|
+
except ValueError:
|
|
80
|
+
# urlsplit() (and .hostname) raise ValueError on a malformed bracketed
|
|
81
|
+
# IPv6 authority (e.g. "http://[fe80::1"). This predicate is the sole,
|
|
82
|
+
# self-contained trust arbiter — it must be TOTAL and fail closed
|
|
83
|
+
# (return False), never raise an exception its four call sites
|
|
84
|
+
# (resolve / require_key / _request / save_credentials) don't expect.
|
|
85
|
+
return False
|
|
86
|
+
if host is None:
|
|
87
|
+
return False
|
|
88
|
+
if not host.isascii(): # C5: reject confusables; keep gate-host == httpx-host
|
|
89
|
+
return False
|
|
90
|
+
if host.endswith("."): # root-anchored FQDN == same DNS host
|
|
91
|
+
host = host[:-1]
|
|
92
|
+
if parts.scheme not in ("http", "https"): # C2: no non-http(s), even on loopback
|
|
93
|
+
return False
|
|
94
|
+
if parts.scheme == "https" and host in _TRUSTED_HOSTS:
|
|
95
|
+
return True
|
|
96
|
+
if parts.scheme == "https" and host in extra_trusted_hosts():
|
|
97
|
+
# User-opted-in staging/preview host (PARSE_EXTRA_TRUSTED_HOSTS).
|
|
98
|
+
# https-only — same plaintext-leak guard as the canonical host.
|
|
99
|
+
return True
|
|
100
|
+
if host == "localhost":
|
|
101
|
+
return True
|
|
102
|
+
try:
|
|
103
|
+
return ipaddress.ip_address(host).is_loopback # 127.0.0.0/8, ::1
|
|
104
|
+
except ValueError:
|
|
105
|
+
return False # a name (incl. 127.0.0.1.evil.tld) is not loopback
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _validate_base_url(url: str) -> str:
|
|
109
|
+
"""Structurally validate a resolved base_url before the API key is attached
|
|
110
|
+
to requests against it.
|
|
111
|
+
|
|
112
|
+
The X-API-Key header is sent on every sync/runtime call to this host, so a
|
|
113
|
+
base_url sourced from env/credentials-file/--base-url must at least be a
|
|
114
|
+
real http(s) URL with a host and no embedded credentials. We deliberately do
|
|
115
|
+
NOT mandate https: the suite and local proxies use ``http://`` endpoints, so
|
|
116
|
+
forcing https would break legitimate non-TLS targets. Callers who need TLS
|
|
117
|
+
enforcement can check the scheme themselves.
|
|
118
|
+
"""
|
|
119
|
+
parts = urllib.parse.urlsplit(url)
|
|
120
|
+
if parts.scheme not in ("http", "https"):
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
f"Parse base_url must be an http(s) URL; got scheme {parts.scheme!r} in {url!r}."
|
|
123
|
+
)
|
|
124
|
+
if not parts.hostname:
|
|
125
|
+
raise RuntimeError(f"Parse base_url has no host: {url!r}.")
|
|
126
|
+
if parts.username or parts.password:
|
|
127
|
+
raise RuntimeError(
|
|
128
|
+
"Parse base_url must not embed credentials (user:pass@host); "
|
|
129
|
+
"set the API key via PARSE_API_KEY or `parse login` instead."
|
|
130
|
+
)
|
|
131
|
+
return url
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class Config:
|
|
136
|
+
# repr=False: a Config travels through tracebacks / logged exception context,
|
|
137
|
+
# and the secrets must never ride along in its repr.
|
|
138
|
+
api_key: Optional[str] = field(repr=False)
|
|
139
|
+
base_url: str
|
|
140
|
+
# OAuth access token from `parse login --web` (host-gated exactly like
|
|
141
|
+
# api_key). Used as ``Authorization: Bearer`` when no api_key is present.
|
|
142
|
+
access_token: Optional[str] = field(default=None, repr=False)
|
|
143
|
+
|
|
144
|
+
def require_key(self) -> str:
|
|
145
|
+
# Type-invariant, not a runtime re-check: resolve() is the sole writer
|
|
146
|
+
# that gates the key, but a hand-built Config(api_key=k, base_url=evil)
|
|
147
|
+
# must withhold too — this is the gate for any Config not produced by
|
|
148
|
+
# resolve(). The predicate is self-contained (rejects non-http(s) and
|
|
149
|
+
# non-ASCII hosts), so this check is the full local invariant.
|
|
150
|
+
trusted = _host_is_trusted(self.base_url)
|
|
151
|
+
if self.api_key and trusted:
|
|
152
|
+
return self.api_key
|
|
153
|
+
if not trusted:
|
|
154
|
+
raise RuntimeError(
|
|
155
|
+
f"Parse API requests to {self.base_url!r} cannot carry an API key — "
|
|
156
|
+
f"it is not a trusted Parse API host. Point base_url at "
|
|
157
|
+
f"https://{CANONICAL_HOST} (the default) or a loopback URL for local dev."
|
|
158
|
+
)
|
|
159
|
+
raise RuntimeError(
|
|
160
|
+
"No Parse API key found. Set PARSE_API_KEY, run `parse login`, "
|
|
161
|
+
"or pass api_key= to the generated root client."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def auth_headers(self) -> dict:
|
|
165
|
+
"""The auth header dict to attach to a Parse request: ``X-API-Key`` when
|
|
166
|
+
an api_key is set, else ``Authorization: Bearer`` from a web-login token.
|
|
167
|
+
Raises RuntimeError (untrusted host, or no credential) — the single
|
|
168
|
+
chokepoint both the CLI transport and the runtime sink use, so the
|
|
169
|
+
key↔host / token↔host binding holds everywhere.
|
|
170
|
+
"""
|
|
171
|
+
trusted = _host_is_trusted(self.base_url)
|
|
172
|
+
if not trusted:
|
|
173
|
+
raise RuntimeError(
|
|
174
|
+
f"Parse API requests to {self.base_url!r} cannot carry credentials — "
|
|
175
|
+
f"it is not a trusted Parse API host. Point base_url at "
|
|
176
|
+
f"https://{CANONICAL_HOST} (the default) or a loopback URL for local dev."
|
|
177
|
+
)
|
|
178
|
+
if self.api_key:
|
|
179
|
+
return {"X-API-Key": self.api_key}
|
|
180
|
+
if self.access_token:
|
|
181
|
+
return {"Authorization": f"Bearer {self.access_token}"}
|
|
182
|
+
raise RuntimeError(
|
|
183
|
+
"No Parse credentials found. Run `parse login` (API key) or "
|
|
184
|
+
"`parse login --web` (browser), or set PARSE_API_KEY."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _read_credentials_file() -> dict:
|
|
189
|
+
# Single fd for stat + chmod + read: O_NOFOLLOW refuses a symlinked creds
|
|
190
|
+
# file outright (save_credentials never writes one) and fstat/fchmod
|
|
191
|
+
# operate on the OPEN fd — no path re-lookup, so the check-then-chmod
|
|
192
|
+
# window of the old is_symlink()/chmod() pair (TOCTOU) is unrepresentable.
|
|
193
|
+
try:
|
|
194
|
+
fd = os.open(CREDENTIALS_PATH, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
|
|
195
|
+
except OSError: # missing, unreadable, or ELOOP (symlink under O_NOFOLLOW)
|
|
196
|
+
return {}
|
|
197
|
+
try:
|
|
198
|
+
with os.fdopen(fd, "r") as f:
|
|
199
|
+
# Re-tighten a pre-existing loose file at the single read
|
|
200
|
+
# chokepoint (save_credentials already writes 0600).
|
|
201
|
+
if os.fstat(f.fileno()).st_mode & 0o077:
|
|
202
|
+
try:
|
|
203
|
+
os.fchmod(f.fileno(), 0o600)
|
|
204
|
+
except (OSError, AttributeError):
|
|
205
|
+
pass
|
|
206
|
+
return json.loads(f.read())
|
|
207
|
+
except Exception:
|
|
208
|
+
return {}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def resolve(
|
|
212
|
+
api_key: Optional[str] = None,
|
|
213
|
+
base_url: Optional[str] = None,
|
|
214
|
+
) -> Config:
|
|
215
|
+
creds = _read_credentials_file()
|
|
216
|
+
key = api_key or os.environ.get("PARSE_API_KEY") or creds.get("api_key")
|
|
217
|
+
url = base_url or os.environ.get("PARSE_API_BASE_URL") or creds.get("base_url") or DEFAULT_BASE_URL
|
|
218
|
+
if not isinstance(url, str):
|
|
219
|
+
# A hand-edited credentials file can carry a non-string base_url
|
|
220
|
+
# (e.g. ``"base_url": 123``); surface a clear error instead of an opaque
|
|
221
|
+
# AttributeError from the rstrip/validate below.
|
|
222
|
+
raise RuntimeError(
|
|
223
|
+
f"Parse base_url must be a string; got {type(url).__name__} ({url!r}). "
|
|
224
|
+
"Check ~/.config/parse/credentials and the PARSE_API_BASE_URL env var."
|
|
225
|
+
)
|
|
226
|
+
url = _validate_base_url(url.rstrip("/")) # runs FIRST: rejects userinfo / non-http(s) / hostless
|
|
227
|
+
# OAuth access token from `parse login --web` (env override first, then the
|
|
228
|
+
# creds file's `oauth` block). Gated by the SAME host predicate as the key.
|
|
229
|
+
_o = creds.get("oauth")
|
|
230
|
+
oauth = _o if isinstance(_o, dict) else {}
|
|
231
|
+
token = os.environ.get("PARSE_ACCESS_TOKEN") or oauth.get("access_token")
|
|
232
|
+
token = token if isinstance(token, str) and token else None
|
|
233
|
+
# Gate the SECRETS, not the URL: base_url is always preserved (raw HTTP against
|
|
234
|
+
# any structurally-valid host keeps working), but key/token only ride to the
|
|
235
|
+
# canonical prod host (https), loopback, or an opted-in extra host. This makes
|
|
236
|
+
# "a Config carrying a real credential for an untrusted host" unrepresentable.
|
|
237
|
+
trusted = _host_is_trusted(url)
|
|
238
|
+
return Config(
|
|
239
|
+
api_key=(key if trusted else None),
|
|
240
|
+
base_url=url,
|
|
241
|
+
access_token=(token if trusted else None),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def read_oauth() -> dict:
|
|
246
|
+
"""The stored ``oauth`` block ({access_token, refresh_token, expires_at,
|
|
247
|
+
client_id}) from the credentials file, or {} — used by the refresh path."""
|
|
248
|
+
creds = _read_credentials_file()
|
|
249
|
+
oauth = creds.get("oauth")
|
|
250
|
+
return oauth if isinstance(oauth, dict) else {}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def save_oauth_credentials(
|
|
254
|
+
*, access_token: str, refresh_token: Optional[str], expires_at: Optional[int],
|
|
255
|
+
client_id: Optional[str], base_url: str,
|
|
256
|
+
) -> str:
|
|
257
|
+
"""Persist an `oauth` block to the credentials file (0600, atomic, no symlink
|
|
258
|
+
follow — same writer as save_credentials). Refuses an untrusted host, so a
|
|
259
|
+
web-login token is never stored against a host it would then leak to. Returns
|
|
260
|
+
the saved base_url."""
|
|
261
|
+
base_url = base_url.rstrip("/")
|
|
262
|
+
_validate_base_url(base_url)
|
|
263
|
+
if not _host_is_trusted(base_url):
|
|
264
|
+
raise RuntimeError(
|
|
265
|
+
f"Refusing to save web-login credentials for {base_url!r} — it is not a "
|
|
266
|
+
f"trusted Parse API host. Use https://{CANONICAL_HOST} (the default), a "
|
|
267
|
+
f"loopback URL, or an opted-in PARSE_EXTRA_TRUSTED_HOSTS host."
|
|
268
|
+
)
|
|
269
|
+
current = _read_credentials_file()
|
|
270
|
+
current["base_url"] = base_url
|
|
271
|
+
current["oauth"] = {
|
|
272
|
+
"access_token": access_token,
|
|
273
|
+
"refresh_token": refresh_token,
|
|
274
|
+
"expires_at": expires_at,
|
|
275
|
+
"client_id": client_id,
|
|
276
|
+
}
|
|
277
|
+
_write_credentials_dict(current)
|
|
278
|
+
return base_url
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def save_credentials(api_key: Optional[str] = None, base_url: Optional[str] = None) -> str:
|
|
282
|
+
"""Persist key/url to the credentials file with 0600 perms — atomically and
|
|
283
|
+
without ever writing through a symlink at the target path. Returns the
|
|
284
|
+
base_url now stored in the file (so callers can echo what was SAVED, not what
|
|
285
|
+
a later env-var override would resolve to).
|
|
286
|
+
|
|
287
|
+
The file holds a plaintext API key, so it must never be world-readable, even
|
|
288
|
+
momentarily. We write to a fresh 0600 temp file in the same directory
|
|
289
|
+
(``mkstemp`` uses ``O_CREAT|O_EXCL``, so there is no pre-existing inode to
|
|
290
|
+
follow) and then ``os.replace`` it over the target. ``os.replace`` swaps the
|
|
291
|
+
NAME: a symlink planted at ``CREDENTIALS_PATH`` is replaced, never written
|
|
292
|
+
through — closing both the world-readable write window and the symlink-follow
|
|
293
|
+
redirect.
|
|
294
|
+
|
|
295
|
+
Write-path host gate: the EFFECTIVE merged base_url (the file's, when the
|
|
296
|
+
caller passes none) must be a trusted host on EVERY write — raising before
|
|
297
|
+
anything is created or modified. A persisted key paired with an untrusted
|
|
298
|
+
host is unrepresentable; saving a key over a pre-existing poisoned file
|
|
299
|
+
refuses instead of arming it.
|
|
300
|
+
"""
|
|
301
|
+
current = _read_credentials_file()
|
|
302
|
+
if api_key is not None:
|
|
303
|
+
current["api_key"] = api_key
|
|
304
|
+
if base_url is not None:
|
|
305
|
+
current["base_url"] = base_url.rstrip("/")
|
|
306
|
+
effective = current.get("base_url", DEFAULT_BASE_URL)
|
|
307
|
+
if not isinstance(effective, str):
|
|
308
|
+
raise RuntimeError(
|
|
309
|
+
f"Parse base_url must be a string; got {type(effective).__name__} "
|
|
310
|
+
f"({effective!r}). Check ~/.config/parse/credentials."
|
|
311
|
+
)
|
|
312
|
+
_validate_base_url(effective)
|
|
313
|
+
if not _host_is_trusted(effective):
|
|
314
|
+
raise RuntimeError(
|
|
315
|
+
f"Refusing to save credentials for {effective!r} — it is not a trusted "
|
|
316
|
+
f"Parse API host, so the saved key would leak there on every request. "
|
|
317
|
+
f"Use https://{CANONICAL_HOST} (the default) or a loopback URL for local dev."
|
|
318
|
+
)
|
|
319
|
+
_write_credentials_dict(current)
|
|
320
|
+
return current.get("base_url", DEFAULT_BASE_URL)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _write_credentials_dict(data: dict) -> None:
|
|
324
|
+
"""Atomically write the credentials dict at 0600 without following a symlink
|
|
325
|
+
at the target (mkstemp uses O_CREAT|O_EXCL; os.replace swaps the NAME, so a
|
|
326
|
+
planted symlink is replaced, never written through). Shared by
|
|
327
|
+
save_credentials + save_oauth_credentials so the secret-handling is identical."""
|
|
328
|
+
parent = CREDENTIALS_PATH.parent
|
|
329
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
330
|
+
try:
|
|
331
|
+
parent.chmod(0o700)
|
|
332
|
+
except OSError:
|
|
333
|
+
pass
|
|
334
|
+
payload = json.dumps(data, indent=2)
|
|
335
|
+
fd, tmp = tempfile.mkstemp(dir=parent, prefix=".credentials.", suffix=".tmp")
|
|
336
|
+
try:
|
|
337
|
+
os.write(fd, payload.encode("utf-8"))
|
|
338
|
+
os.close(fd)
|
|
339
|
+
os.replace(tmp, CREDENTIALS_PATH)
|
|
340
|
+
except BaseException:
|
|
341
|
+
try:
|
|
342
|
+
os.close(fd)
|
|
343
|
+
except OSError:
|
|
344
|
+
pass
|
|
345
|
+
try:
|
|
346
|
+
os.unlink(tmp)
|
|
347
|
+
except OSError:
|
|
348
|
+
pass
|
|
349
|
+
raise
|