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/doctor.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""`parse doctor` — diagnose a project's Parse SDK install and explain/fix hiccups.
|
|
2
|
+
|
|
3
|
+
Read-only DETECTION lives here: ``run_diagnostics`` is a pure function over the
|
|
4
|
+
project paths + the resolved ``Config`` + an injectable import-probe, so it is
|
|
5
|
+
unit-testable without a real venv or network. The CLI command
|
|
6
|
+
(``parse_sdk.cli.doctor``) formats the findings, drives ``--fix``, and owns the
|
|
7
|
+
side-effecting remediations (the helpers it already has: ``_ensure_consumer_pyproject``,
|
|
8
|
+
``uv sync``).
|
|
9
|
+
|
|
10
|
+
The single most valuable check is the project-interpreter import probe: a fresh
|
|
11
|
+
``parse init`` can leave ``parse_sdk`` unresolvable in the consumer venv (pre-PyPI,
|
|
12
|
+
or a wrong selected interpreter), and that is exactly what silently kills editor
|
|
13
|
+
autocomplete on the generated modules (they ``from parse_sdk._runtime import …``).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import urllib.parse
|
|
23
|
+
try:
|
|
24
|
+
import tomllib # Python 3.11+
|
|
25
|
+
except ModuleNotFoundError: # 3.10 — tomllib landed in 3.11; tomli is its backport
|
|
26
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
27
|
+
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Callable, Dict, List, Optional
|
|
31
|
+
|
|
32
|
+
from parse_sdk._sync import _find_pin
|
|
33
|
+
from parse_sdk.config import (
|
|
34
|
+
CANONICAL_HOST, Config, _host_is_trusted, _read_credentials_file,
|
|
35
|
+
extra_trusted_hosts,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Finding levels (ordered by severity for sorting/summary).
|
|
39
|
+
OK = "ok"
|
|
40
|
+
WARN = "warn"
|
|
41
|
+
ERROR = "error"
|
|
42
|
+
_SEVERITY = {OK: 0, WARN: 1, ERROR: 2}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Finding:
|
|
47
|
+
"""One diagnostic result. ``kind`` tags how ``parse doctor --fix`` may act:
|
|
48
|
+
``manual`` (show the fix, don't auto-run), ``record_deps`` (re-record the
|
|
49
|
+
consumer pyproject deps/source), ``uv_sync`` (run ``uv sync``), ``sync``
|
|
50
|
+
(needs a network ``parse sync`` regen — shown, never auto-run), ``login``,
|
|
51
|
+
``init``."""
|
|
52
|
+
level: str
|
|
53
|
+
title: str
|
|
54
|
+
detail: str = ""
|
|
55
|
+
fix: str = ""
|
|
56
|
+
kind: str = "manual"
|
|
57
|
+
|
|
58
|
+
def as_dict(self) -> dict:
|
|
59
|
+
return {"level": self.level, "title": self.title,
|
|
60
|
+
"detail": self.detail, "fix": self.fix, "kind": self.kind}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --------------------------------------------------------------------------- #
|
|
64
|
+
# pyproject parsing (self-contained — doctor is imported BY cli, so it must not
|
|
65
|
+
# import back from cli to reach _dep_present; this mirrors that canonicalization)
|
|
66
|
+
# --------------------------------------------------------------------------- #
|
|
67
|
+
|
|
68
|
+
def _canonical(name: str) -> str:
|
|
69
|
+
"""PEP 503 distribution-name canonicalization (``Parse_SDK`` → ``parse-sdk``)."""
|
|
70
|
+
return re.sub(r"[-_.]+", "-", name).lower()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _declared_project_deps(text: str) -> set:
|
|
74
|
+
"""Canonicalized names in ``[project].dependencies`` (empty set on bad TOML)."""
|
|
75
|
+
try:
|
|
76
|
+
deps = tomllib.loads(text).get("project", {}).get("dependencies", [])
|
|
77
|
+
except Exception:
|
|
78
|
+
return set()
|
|
79
|
+
out: set = set()
|
|
80
|
+
if isinstance(deps, list):
|
|
81
|
+
for d in deps:
|
|
82
|
+
if isinstance(d, str):
|
|
83
|
+
m = re.match(r"\s*([A-Za-z0-9][A-Za-z0-9._-]*)", d)
|
|
84
|
+
if m:
|
|
85
|
+
out.add(_canonical(m.group(1)))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _uv_sources(text: str) -> dict:
|
|
90
|
+
try:
|
|
91
|
+
return tomllib.loads(text).get("tool", {}).get("uv", {}).get("sources", {}) or {}
|
|
92
|
+
except Exception:
|
|
93
|
+
return {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _hostname(url: str) -> str:
|
|
97
|
+
try:
|
|
98
|
+
return (urllib.parse.urlsplit(url).hostname or "").rstrip(".")
|
|
99
|
+
except ValueError:
|
|
100
|
+
return ""
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------------------- #
|
|
104
|
+
# the project-interpreter import probe (the autocomplete predictor)
|
|
105
|
+
# --------------------------------------------------------------------------- #
|
|
106
|
+
|
|
107
|
+
_PROBE = (
|
|
108
|
+
"import json,sys\n"
|
|
109
|
+
"r={}\n"
|
|
110
|
+
"for m in ('parse_sdk','parse_apis'):\n"
|
|
111
|
+
" try:\n"
|
|
112
|
+
" __import__(m)\n"
|
|
113
|
+
" r[m]=True\n"
|
|
114
|
+
" except Exception as e:\n"
|
|
115
|
+
" r[m]=type(e).__name__+': '+str(e)\n"
|
|
116
|
+
"sys.stdout.write(json.dumps(r))\n"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _venv_python(project: Path) -> Optional[Path]:
|
|
121
|
+
"""The project's ``.venv`` interpreter (uv's convention), or None.
|
|
122
|
+
|
|
123
|
+
Only ``.venv`` is probed — a differently-named or external interpreter
|
|
124
|
+
(conda, pyenv) is the user's to point their editor at; doctor degrades to a
|
|
125
|
+
WARN rather than guessing wrong."""
|
|
126
|
+
for rel in (
|
|
127
|
+
Path(".venv") / "bin" / "python",
|
|
128
|
+
Path(".venv") / "bin" / "python3",
|
|
129
|
+
Path(".venv") / "Scripts" / "python.exe", # Windows
|
|
130
|
+
):
|
|
131
|
+
p = project / rel
|
|
132
|
+
if p.exists():
|
|
133
|
+
return p
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def probe_project_imports(project: Path) -> Dict[str, object]:
|
|
138
|
+
"""Try ``import parse_sdk`` / ``import parse_apis`` in the project's ``.venv``.
|
|
139
|
+
|
|
140
|
+
Returns ``{"_venv": <str|None>, "parse_sdk": True|errstr, "parse_apis": ...}``;
|
|
141
|
+
``{"_venv": None}`` when no ``.venv`` exists; ``{"_venv": str, "_error": ...}``
|
|
142
|
+
if the probe subprocess itself fails. Bounded so a wedged interpreter can't
|
|
143
|
+
hang the command."""
|
|
144
|
+
py = _venv_python(project)
|
|
145
|
+
if py is None:
|
|
146
|
+
return {"_venv": None}
|
|
147
|
+
try:
|
|
148
|
+
r = subprocess.run([str(py), "-c", _PROBE],
|
|
149
|
+
capture_output=True, text=True, timeout=60)
|
|
150
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
151
|
+
return {"_venv": str(py), "_error": str(e)}
|
|
152
|
+
data: Dict[str, object]
|
|
153
|
+
try:
|
|
154
|
+
data = json.loads(r.stdout or "{}")
|
|
155
|
+
except ValueError:
|
|
156
|
+
data = {"_error": (r.stderr or r.stdout or "probe produced no output").strip()[:300]}
|
|
157
|
+
if not isinstance(data, dict): # defensive: a non-object body is unusable
|
|
158
|
+
data = {"_error": "import probe returned non-object JSON"}
|
|
159
|
+
data["_venv"] = str(py)
|
|
160
|
+
return data
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --------------------------------------------------------------------------- #
|
|
164
|
+
# the checks
|
|
165
|
+
# --------------------------------------------------------------------------- #
|
|
166
|
+
|
|
167
|
+
def run_diagnostics(
|
|
168
|
+
project: Path,
|
|
169
|
+
cfg: Config,
|
|
170
|
+
*,
|
|
171
|
+
version: str,
|
|
172
|
+
probe: Optional[Callable[[Path], Dict[str, object]]] = None,
|
|
173
|
+
) -> List[Finding]:
|
|
174
|
+
"""All diagnostics for ``project``, in display order. Pure except for the
|
|
175
|
+
injectable ``probe`` (defaults to the real ``.venv`` subprocess)."""
|
|
176
|
+
probe = probe or probe_project_imports
|
|
177
|
+
root = project / "parse_apis"
|
|
178
|
+
pkg = root / "src" / "parse_apis"
|
|
179
|
+
consumer = project / "pyproject.toml"
|
|
180
|
+
initialized = (root / "pyproject.toml").exists() and (pkg / "__init__.py").exists()
|
|
181
|
+
|
|
182
|
+
findings: List[Finding] = []
|
|
183
|
+
|
|
184
|
+
# 1. uv on PATH (the documented install flow runs `uv sync` / `uv run parse`).
|
|
185
|
+
if shutil.which("uv") is None:
|
|
186
|
+
findings.append(Finding(
|
|
187
|
+
WARN, "uv is not on PATH",
|
|
188
|
+
"The Parse SDK install flow uses uv (`uv sync`, `uv run parse`).",
|
|
189
|
+
fix="Install uv (https://docs.astral.sh/uv/), or manage the venv with pip yourself."))
|
|
190
|
+
else:
|
|
191
|
+
findings.append(Finding(OK, "uv is installed"))
|
|
192
|
+
|
|
193
|
+
# 2. a consumer project must exist before anything else is meaningful.
|
|
194
|
+
if not consumer.exists():
|
|
195
|
+
findings.append(Finding(
|
|
196
|
+
ERROR, "no pyproject.toml in the project root",
|
|
197
|
+
f"Looked in {project}. The Parse SDK installs into a uv/pip project here.",
|
|
198
|
+
fix="Run `uv init` (or add a pyproject.toml), then `parse init`.", kind="init"))
|
|
199
|
+
return findings
|
|
200
|
+
|
|
201
|
+
# 3. initialized scaffold?
|
|
202
|
+
if not initialized:
|
|
203
|
+
findings.append(Finding(
|
|
204
|
+
ERROR, "parse_apis is not initialized",
|
|
205
|
+
f"No initialized package at {pkg}.",
|
|
206
|
+
fix="Run `parse init` from the project root.", kind="init"))
|
|
207
|
+
findings.extend(_credential_findings(cfg)) # let the user fix both at once
|
|
208
|
+
return findings
|
|
209
|
+
findings.append(Finding(OK, "parse_apis package is initialized"))
|
|
210
|
+
|
|
211
|
+
findings.extend(_consumer_dep_findings(consumer))
|
|
212
|
+
findings.extend(_pin_findings(root, version))
|
|
213
|
+
findings.extend(_import_findings(project, probe))
|
|
214
|
+
findings.extend(_credential_findings(cfg))
|
|
215
|
+
return findings
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _consumer_dep_findings(consumer: Path) -> List[Finding]:
|
|
219
|
+
out: List[Finding] = []
|
|
220
|
+
try:
|
|
221
|
+
text = consumer.read_text()
|
|
222
|
+
except OSError as e:
|
|
223
|
+
return [Finding(WARN, "couldn't read the project pyproject.toml", str(e))]
|
|
224
|
+
declared = _declared_project_deps(text)
|
|
225
|
+
missing = [d for d in ("parse-sdk", "parse-apis") if _canonical(d) not in declared]
|
|
226
|
+
if missing:
|
|
227
|
+
out.append(Finding(
|
|
228
|
+
WARN, f"missing dependency: {', '.join(missing)}",
|
|
229
|
+
"Your project's pyproject.toml doesn't declare these, so `uv sync` won't "
|
|
230
|
+
"install the SDK runtime and/or your typed clients.",
|
|
231
|
+
fix="parse doctor --fix (re-records them), then `uv sync`", kind="record_deps"))
|
|
232
|
+
else:
|
|
233
|
+
out.append(Finding(OK, "parse-sdk + parse-apis are declared dependencies"))
|
|
234
|
+
if not any(_canonical(k) == "parse-apis" for k in _uv_sources(text)):
|
|
235
|
+
out.append(Finding(
|
|
236
|
+
WARN, "no editable source for parse-apis",
|
|
237
|
+
"[tool.uv.sources] doesn't point parse-apis at ./parse_apis, so uv can't "
|
|
238
|
+
"resolve your local generated package (it is never published).",
|
|
239
|
+
fix="parse doctor --fix (adds the editable source), then `uv sync`",
|
|
240
|
+
kind="record_deps"))
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _pin_findings(root: Path, version: str) -> List[Finding]:
|
|
245
|
+
out: List[Finding] = []
|
|
246
|
+
try:
|
|
247
|
+
text = (root / "pyproject.toml").read_text()
|
|
248
|
+
except OSError:
|
|
249
|
+
return out
|
|
250
|
+
m = _find_pin(text)
|
|
251
|
+
if m is None:
|
|
252
|
+
out.append(Finding(
|
|
253
|
+
WARN, "no parse-sdk pin in parse_apis/pyproject.toml",
|
|
254
|
+
"The generated package should pin the exact parse-sdk it was generated against.",
|
|
255
|
+
fix="parse sync (re-stamps the pin)", kind="sync"))
|
|
256
|
+
elif m.group(1) != version:
|
|
257
|
+
out.append(Finding(
|
|
258
|
+
WARN, f"parse-sdk version skew (pinned {m.group(1)}, CLI is {version})",
|
|
259
|
+
"Your generated clients were built against a different parse_sdk runtime "
|
|
260
|
+
"than the one you're running — imports may break or types may drift.",
|
|
261
|
+
fix=f"parse sync (regenerates against {version} and re-stamps the pin)",
|
|
262
|
+
kind="sync"))
|
|
263
|
+
else:
|
|
264
|
+
out.append(Finding(OK, f"parse-sdk pin matches the CLI ({version})"))
|
|
265
|
+
return out
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _import_findings(project: Path, probe: Callable[[Path], Dict[str, object]]) -> List[Finding]:
|
|
269
|
+
data = probe(project)
|
|
270
|
+
venv = data.get("_venv")
|
|
271
|
+
if venv is None:
|
|
272
|
+
return [Finding(
|
|
273
|
+
WARN, "no .venv in the project (couldn't verify autocomplete)",
|
|
274
|
+
"Only ./.venv is probed for whether parse_sdk + parse_apis import. If you "
|
|
275
|
+
"use a different interpreter, ensure it has both installed.",
|
|
276
|
+
fix="uv sync (creates ./.venv and installs the deps)", kind="uv_sync")]
|
|
277
|
+
if "_error" in data and "parse_sdk" not in data:
|
|
278
|
+
return [Finding(
|
|
279
|
+
WARN, "couldn't probe the project interpreter",
|
|
280
|
+
str(data.get("_error")), fix="uv sync", kind="uv_sync")]
|
|
281
|
+
|
|
282
|
+
sdk_ok = data.get("parse_sdk") is True
|
|
283
|
+
apis_ok = data.get("parse_apis") is True
|
|
284
|
+
if sdk_ok and apis_ok:
|
|
285
|
+
return [Finding(
|
|
286
|
+
OK, "project interpreter imports parse_sdk + parse_apis "
|
|
287
|
+
"(editor autocomplete will resolve)")]
|
|
288
|
+
|
|
289
|
+
out: List[Finding] = []
|
|
290
|
+
if not sdk_ok:
|
|
291
|
+
out.append(Finding(
|
|
292
|
+
ERROR, "the project interpreter can't import parse_sdk",
|
|
293
|
+
f"{data.get('parse_sdk')}\n"
|
|
294
|
+
"This is the usual cause of dead editor autocomplete: generated modules do "
|
|
295
|
+
"`from parse_sdk._runtime import …`, which can't resolve when parse_sdk isn't "
|
|
296
|
+
"installed in the selected interpreter.",
|
|
297
|
+
fix="uv sync — if that fails with 'unsatisfiable'/'parse-sdk', the package "
|
|
298
|
+
"isn't on the index yet; install from a local checkout: "
|
|
299
|
+
"`uv add --editable /path/to/Parse-sdk`", kind="uv_sync"))
|
|
300
|
+
if not apis_ok:
|
|
301
|
+
out.append(Finding(
|
|
302
|
+
ERROR, "the project interpreter can't import parse_apis",
|
|
303
|
+
f"{data.get('parse_apis')}\n"
|
|
304
|
+
"Your typed clients aren't installed into the interpreter.",
|
|
305
|
+
fix="uv sync (then `parse sync` if no clients are generated yet)",
|
|
306
|
+
kind="uv_sync"))
|
|
307
|
+
out.append(Finding(
|
|
308
|
+
WARN, "point your editor at the project interpreter",
|
|
309
|
+
f"VSCode: Cmd/Ctrl-Shift-P → 'Python: Select Interpreter' → {venv}, then reload."))
|
|
310
|
+
return out
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _credential_findings(cfg: Config) -> List[Finding]:
|
|
314
|
+
host = _hostname(cfg.base_url)
|
|
315
|
+
cred = "API key" if cfg.api_key else ("web-login token" if cfg.access_token else None)
|
|
316
|
+
if cred:
|
|
317
|
+
if host in extra_trusted_hosts():
|
|
318
|
+
return [Finding(
|
|
319
|
+
WARN, f"{cred} is configured for {cfg.base_url} (opted in via PARSE_EXTRA_TRUSTED_HOSTS)",
|
|
320
|
+
"Trusted only because you opted this host in — fine for staging QA; unset "
|
|
321
|
+
"PARSE_EXTRA_TRUSTED_HOSTS for production.")]
|
|
322
|
+
return [Finding(OK, f"{cred} is configured for {cfg.base_url}")]
|
|
323
|
+
# No credential will be sent. Distinguish "none anywhere" from "withheld by host trust".
|
|
324
|
+
creds = _read_credentials_file()
|
|
325
|
+
_o = creds.get("oauth")
|
|
326
|
+
oauth = _o if isinstance(_o, dict) else {}
|
|
327
|
+
configured = bool(os.environ.get("PARSE_API_KEY") or creds.get("api_key")
|
|
328
|
+
or os.environ.get("PARSE_ACCESS_TOKEN") or oauth.get("access_token"))
|
|
329
|
+
if configured and not _host_is_trusted(cfg.base_url):
|
|
330
|
+
return [Finding(
|
|
331
|
+
ERROR, f"credential withheld — {cfg.base_url} is not a trusted host",
|
|
332
|
+
f"A credential IS configured but won't be sent to {host or cfg.base_url}: it "
|
|
333
|
+
f"rides only to https://{CANONICAL_HOST}, loopback, or a PARSE_EXTRA_TRUSTED_HOSTS host.",
|
|
334
|
+
fix=f"For staging QA: export PARSE_EXTRA_TRUSTED_HOSTS={host} "
|
|
335
|
+
f"(or point base_url at https://{CANONICAL_HOST})")]
|
|
336
|
+
return [Finding(
|
|
337
|
+
WARN, "no credentials configured",
|
|
338
|
+
"Commands that reach Parse (sync, list) need an API key or web login.",
|
|
339
|
+
fix="parse login (API key) · parse login --web (browser)", kind="login")]
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def has_errors(findings: List[Finding]) -> bool:
|
|
343
|
+
return any(f.level == ERROR for f in findings)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def sort_key(f: Finding) -> int:
|
|
347
|
+
"""Sort errors first, then warnings, then OKs (stable within a level)."""
|
|
348
|
+
return -_SEVERITY.get(f.level, 0)
|
parse_sdk/migrate.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Flat-layout migration ownership detection.
|
|
2
|
+
|
|
3
|
+
Before `parse init` creates the `src/` scaffold, it must decide whether an
|
|
4
|
+
existing flat ``parse_apis/`` tree is Parse-owned generated output (safe to move
|
|
5
|
+
aside to ``.parse/legacy-…``) or contains files a user hand-wrote (refuse the
|
|
6
|
+
migration and list them, so we never silently destroy work).
|
|
7
|
+
|
|
8
|
+
The decision is multi-signal on purpose. A single "this looks generated" guess
|
|
9
|
+
is unsafe: a hand-written ``__init__.py`` or a JSON file that merely parses is
|
|
10
|
+
NOT proof of Parse ownership. Each generated artifact carries an explicit
|
|
11
|
+
provenance marker that codegen/docgen stamp at write time:
|
|
12
|
+
|
|
13
|
+
* slug ``__init__.py`` → the codegen banner (:data:`CODEGEN_BANNER`).
|
|
14
|
+
* ``.md`` / ``example.py`` → the docgen banner (``docgen.BANNER_TEXT``).
|
|
15
|
+
* root ``_manifest.json`` → a ``{uuid: slug}`` shape only codegen produces.
|
|
16
|
+
|
|
17
|
+
Anything else under the tree is UNKNOWN and blocks the move. Interpreter debris
|
|
18
|
+
(``__pycache__`` contents, ``*.pyc`` / ``*.pyo``) is ignored as debris — it is
|
|
19
|
+
neither user work to preserve nor a provenance signal.
|
|
20
|
+
|
|
21
|
+
The predicate functions are pure (string/dict in, ``bool`` out) so they can be
|
|
22
|
+
reused and unit-tested in isolation. Classification only ever *reads* metadata
|
|
23
|
+
and never follows symlinks: a symlink pointing into the tree must not be able to
|
|
24
|
+
launder an outside file into "owned" (and following it risks cycles / escaping
|
|
25
|
+
the tree entirely).
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, List
|
|
34
|
+
|
|
35
|
+
from parse_sdk.docgen import BANNER_TEXT as _DOCGEN_BANNER
|
|
36
|
+
|
|
37
|
+
# The marker codegen stamps into every generated slug ``__init__.py``. Kept as a
|
|
38
|
+
# literal here (not imported from codegen) so this detector has no heavy import
|
|
39
|
+
# dependency and the contract is visible at the migration boundary.
|
|
40
|
+
CODEGEN_BANNER = "Auto-generated by `parse sync`. Do not edit by hand."
|
|
41
|
+
|
|
42
|
+
# 8-4-4-4-12 hex, case-insensitive — the canonical UUID string shape codegen
|
|
43
|
+
# uses for manifest keys. Anchored so a value merely *containing* a UUID does
|
|
44
|
+
# not pass.
|
|
45
|
+
_UUID_RE = re.compile(
|
|
46
|
+
r"\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\Z",
|
|
47
|
+
re.IGNORECASE,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Docs / example artifacts docgen owns, by suffix.
|
|
51
|
+
_DOC_SUFFIXES = frozenset({".md"})
|
|
52
|
+
|
|
53
|
+
# Interpreter debris suffixes — bytecode caches, never source.
|
|
54
|
+
_DEBRIS_SUFFIXES = frozenset({".pyc", ".pyo"})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_generated_slug_init(text: str) -> bool:
|
|
58
|
+
"""True iff ``text`` carries the codegen banner for a generated slug
|
|
59
|
+
``__init__.py``. A banner-less ``__init__.py`` (hand-written package marker)
|
|
60
|
+
is intentionally NOT owned — provenance must be explicit."""
|
|
61
|
+
return isinstance(text, str) and CODEGEN_BANNER in text
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_generated_doc(text: str) -> bool:
|
|
65
|
+
"""True iff ``text`` carries the docgen banner (README / index / example
|
|
66
|
+
body). Pure substring presence so it holds for the markdown-comment form
|
|
67
|
+
(``<!-- … -->``) and the python-comment form (``# …``) alike."""
|
|
68
|
+
return isinstance(text, str) and _DOCGEN_BANNER in text
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_generated_manifest(data: Any) -> bool:
|
|
72
|
+
"""True iff ``data`` has the generated manifest shape: a non-empty ``dict``
|
|
73
|
+
mapping UUID-shaped string keys to non-empty string slug values.
|
|
74
|
+
|
|
75
|
+
Rejects the obvious impostors a plain ``json.loads`` would otherwise admit:
|
|
76
|
+
a ``list`` (e.g. ``["reddit"]``), an empty dict, a versioned config dict
|
|
77
|
+
(``{"version": 2, "data": []}``), or any entry whose key is not a UUID or
|
|
78
|
+
whose value is not a non-empty string. Every entry must conform — one bad
|
|
79
|
+
pair disqualifies the whole file (mixed shapes are not Parse output).
|
|
80
|
+
"""
|
|
81
|
+
if not isinstance(data, dict) or not data:
|
|
82
|
+
return False
|
|
83
|
+
for key, value in data.items():
|
|
84
|
+
if not isinstance(key, str) or _UUID_RE.match(key) is None:
|
|
85
|
+
return False
|
|
86
|
+
if not isinstance(value, str) or not value.strip():
|
|
87
|
+
return False
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class FlatLayoutReport:
|
|
93
|
+
"""The classification of an existing flat ``parse_apis/`` tree.
|
|
94
|
+
|
|
95
|
+
``owned`` files are provably Parse-generated (safe to move aside), ``debris``
|
|
96
|
+
is interpreter cache to discard, and ``unknown`` is everything else — any
|
|
97
|
+
non-empty ``unknown`` blocks an automatic migration so user work is never
|
|
98
|
+
clobbered. Paths are absolute and sorted for stable reporting.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
owned: List[Path] = field(default_factory=list)
|
|
102
|
+
unknown: List[Path] = field(default_factory=list)
|
|
103
|
+
debris: List[Path] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def is_fully_owned(self) -> bool:
|
|
107
|
+
"""True iff nothing is unknown — i.e. every non-debris file is provably
|
|
108
|
+
Parse-generated and the tree can be moved aside automatically."""
|
|
109
|
+
return not self.unknown
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _is_debris(path: Path) -> bool:
|
|
113
|
+
"""A bytecode cache file or anything inside a ``__pycache__`` directory."""
|
|
114
|
+
if path.suffix in _DEBRIS_SUFFIXES:
|
|
115
|
+
return True
|
|
116
|
+
return "__pycache__" in path.parts
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_owned_file(path: Path, root: Path) -> bool:
|
|
120
|
+
"""Whether a regular file is a provably Parse-generated artifact.
|
|
121
|
+
|
|
122
|
+
Reads the file's text/JSON to check the provenance marker. Returns ``False``
|
|
123
|
+
for anything unreadable or off-contract rather than guessing — an unknown
|
|
124
|
+
file blocks migration, which is the safe default.
|
|
125
|
+
"""
|
|
126
|
+
# Name/suffix comparisons are case-folded: on case-insensitive filesystems
|
|
127
|
+
# (macOS/APFS, Windows) ``README.MD`` IS ``README.md`` and ``__INIT__.PY``
|
|
128
|
+
# is the same file codegen wrote in lowercase, so a banner-carrying generated
|
|
129
|
+
# file must not be misclassified as unknown merely on suffix casing. This
|
|
130
|
+
# only ever promotes an actually-banner'd file out of "unknown" — it never
|
|
131
|
+
# marks an unbanner'd file owned, so the safety filter's recall is preserved.
|
|
132
|
+
name = path.name.casefold()
|
|
133
|
+
suffix = path.suffix.casefold()
|
|
134
|
+
|
|
135
|
+
# Root-level generated manifest: shape check on parsed JSON.
|
|
136
|
+
if name == "_manifest.json" and path.parent == root:
|
|
137
|
+
try:
|
|
138
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
139
|
+
except (OSError, ValueError):
|
|
140
|
+
return False
|
|
141
|
+
return is_generated_manifest(data)
|
|
142
|
+
|
|
143
|
+
# Slug package initializer: codegen banner required.
|
|
144
|
+
if name == "__init__.py":
|
|
145
|
+
try:
|
|
146
|
+
return is_generated_slug_init(path.read_text(encoding="utf-8"))
|
|
147
|
+
except OSError:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
# Docs / example artifacts: docgen banner required.
|
|
151
|
+
if suffix in _DOC_SUFFIXES or name == "example.py":
|
|
152
|
+
try:
|
|
153
|
+
return is_generated_doc(path.read_text(encoding="utf-8"))
|
|
154
|
+
except OSError:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
return False
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def classify_flat_layout(root: Path) -> FlatLayoutReport:
|
|
161
|
+
"""Walk ``root`` and classify every file as owned / debris / unknown.
|
|
162
|
+
|
|
163
|
+
Directories themselves are not classified (only their files), so an empty
|
|
164
|
+
directory contributes nothing and a nested user package directory is judged
|
|
165
|
+
purely by the files it contains. Symlinks are never followed and never
|
|
166
|
+
counted as owned: a symlink (to a file or a directory, inside or outside the
|
|
167
|
+
tree) is treated as unknown so it can neither launder an external file into
|
|
168
|
+
"owned" nor cause us to walk outside ``root``.
|
|
169
|
+
"""
|
|
170
|
+
root = Path(root)
|
|
171
|
+
report = FlatLayoutReport()
|
|
172
|
+
if not root.exists():
|
|
173
|
+
return report
|
|
174
|
+
|
|
175
|
+
# ``Path.walk`` with ``follow_symlinks=False`` (default) does not descend
|
|
176
|
+
# into symlinked directories; we additionally skip symlinked entries by hand
|
|
177
|
+
# so a symlink is never read or classified as owned.
|
|
178
|
+
for dirpath, dirnames, filenames in _walk(root):
|
|
179
|
+
for fname in filenames:
|
|
180
|
+
fpath = dirpath / fname
|
|
181
|
+
if fpath.is_symlink():
|
|
182
|
+
report.unknown.append(fpath)
|
|
183
|
+
continue
|
|
184
|
+
if _is_debris(fpath):
|
|
185
|
+
report.debris.append(fpath)
|
|
186
|
+
elif _is_owned_file(fpath, root):
|
|
187
|
+
report.owned.append(fpath)
|
|
188
|
+
else:
|
|
189
|
+
report.unknown.append(fpath)
|
|
190
|
+
# Surface symlinked subdirectories as unknown without descending — they
|
|
191
|
+
# are not Parse output and following them risks escaping the tree.
|
|
192
|
+
for dname in list(dirnames):
|
|
193
|
+
dpath = dirpath / dname
|
|
194
|
+
if dpath.is_symlink():
|
|
195
|
+
report.unknown.append(dpath)
|
|
196
|
+
dirnames.remove(dname)
|
|
197
|
+
|
|
198
|
+
report.owned.sort()
|
|
199
|
+
report.unknown.sort()
|
|
200
|
+
report.debris.sort()
|
|
201
|
+
return report
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _walk(root: Path):
|
|
205
|
+
"""``os.walk``-style traversal over ``root`` that does not follow symlinked
|
|
206
|
+
directories, yielding ``(Path, list[str], list[str])`` and allowing the
|
|
207
|
+
caller to prune ``dirnames`` in place. Kept separate so the classification
|
|
208
|
+
logic reads cleanly and the no-follow guarantee lives in one place."""
|
|
209
|
+
import os
|
|
210
|
+
|
|
211
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
|
|
212
|
+
yield Path(dirpath), dirnames, filenames
|