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/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