Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 11 additions & 22 deletions drydock/analyzers/codemap.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from ..core.models import Class, Constant, Export, FileId, Func, SourceFile, Symbols, TypeDef
from ..core.registry import AnalysisContext, Option
from ..core.toml import load_toml
from ..core.output import Reporter

__all__ = ["CodemapAnalyzer"]
Expand Down Expand Up @@ -293,15 +294,14 @@ def _find_entry_points(self, root: Path, files_data: list[dict[str, Any]]) -> li
# Check pyproject.toml for [project.scripts] and [project.entry-points]
pyproject_path = root / "pyproject.toml"
if pyproject_path.exists():
try:
try:
import tomllib
except ImportError:
import tomli as tomllib # type: ignore

with open(pyproject_path, "rb") as f:
data = tomllib.load(f)

# `load_toml` returns None when no TOML parser exists (Python
# 3.10 without the optional backport) instead of raising. The
# guarded import used to live here, and the `except` below listed
# only (OSError, ValueError) -- which does not catch
# ModuleNotFoundError -- so this analyzer crashed outright on a
# clean 3.10 install.
data = load_toml(pyproject_path)
if data is not None:
project = data.get("project", {})
if "scripts" in project:
for name, value in project["scripts"].items():
Expand All @@ -321,21 +321,12 @@ def _find_entry_points(self, root: Path, files_data: list[dict[str, Any]]) -> li
{"path": entry_path, "source": value[:60]}
)
seen.add(entry_path)
except (OSError, ValueError):
pass

# Check Cargo.toml for [[bin]]
cargo_path = root / "Cargo.toml"
if cargo_path.exists():
try:
try:
import tomllib
except ImportError:
import tomli as tomllib # type: ignore

with open(cargo_path, "rb") as f:
data = tomllib.load(f)

data = load_toml(cargo_path)
if data is not None:
if "bin" in data:
for bin_entry in data["bin"]:
if "path" in bin_entry:
Expand All @@ -346,8 +337,6 @@ def _find_entry_points(self, root: Path, files_data: list[dict[str, Any]]) -> li
{"path": bin_path, "source": f"Cargo.toml [[bin]] {bin_name}"}
)
seen.add(bin_path)
except (OSError, ValueError):
pass

return entry_points

Expand Down
32 changes: 13 additions & 19 deletions drydock/analyzers/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from ..core.models import FileId
from ..core.output import to_jsonable
from ..core.registry import AnalysisContext, Option
from ..core.toml import loads_toml

__all__ = ["DependenciesAnalyzer"]

Expand Down Expand Up @@ -311,25 +312,18 @@ def _parse_pyproject(self, ctx: AnalysisContext) -> dict[str, str]:
if not src:
continue

# Try tomllib (Python 3.11+)
try:
import tomllib

data = tomllib.loads(src)
except ImportError:
# Fallback for Python 3.10
try:
import tomli as tomllib

data = tomllib.loads(src)
except ImportError:
# Last resort: regex parsing
data = {}
for line in src.split("\n"):
m = re.match(r'^([a-z0-9_\-]+)\s*=', line, re.I)
if m:
pkg = m.group(1)
reqs[pkg.lower()] = ""
# A real parse when any TOML reader exists, otherwise the
# line-regex approximation below. `loads_toml` centralises the
# "which reader, if any" question -- see drydock/core/toml.py.
data = loads_toml(src)
if data is None:
# Last resort: regex parsing
data = {}
for line in src.split("\n"):
m = re.match(r'^([a-z0-9_\-]+)\s*=', line, re.I)
if m:
pkg = m.group(1)
reqs[pkg.lower()] = ""

# Extract from [project] dependencies
if "project" in data and "dependencies" in data["project"]:
Expand Down
25 changes: 10 additions & 15 deletions drydock/analyzers/platforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,15 @@
from __future__ import annotations

import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Mapping

from ..core.registry import AnalysisContext, Option
from ..core.toml import load_toml

__all__ = ["PlatformsAnalyzer"]

# Try to import tomllib (Python 3.11+), fall back gracefully for 3.10
if sys.version_info >= (3, 11):
import tomllib
else:
tomllib = None # type: ignore[assignment]


@dataclass(frozen=True, slots=True)
class _PlatformDetection:
"""One platform detection result."""
Expand Down Expand Up @@ -528,13 +521,15 @@ def _extract_version(
if value:
return str(value), marker_file
elif marker_type == "toml":
if tomllib is not None:
with open(marker_path, "rb") as f:
data = tomllib.load(f)
if json_path:
value = self._traverse_dict(data, json_path)
if value:
return str(value), marker_file
# `load_toml` returns None when no TOML reader is
# available -- on 3.10 the module-level guard used to be
# `sys.version_info >= (3, 11)`, so an installed `tomli`
# went unused and every TOML version marker was skipped.
data = load_toml(marker_path)
if data is not None and json_path:
value = self._traverse_dict(data, json_path)
if value:
return str(value), marker_file
except Exception:
pass

Expand Down
32 changes: 32 additions & 0 deletions drydock/catalog/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,7 +370,21 @@ def _cmd_ingest(args) -> int:
"components_stored": result.components_stored,
"facts_recorded": result.facts_recorded,
"capabilities_recorded": result.capabilities_recorded,
"failures": result.failures,
}))

# Nothing recorded *and* something failed means the ingest did not do
# its job, so it must not report success -- a scripted run would go
# green over an empty catalog. Found by ingesting under a read-only
# HOME: the catalog could not be created, every phase failed, and this
# returned 0. An empty repo with no failures is still a clean exit.
if result.failures and result.components_recorded == 0:
print(
"error: ingest recorded nothing; "
+ "; ".join(f"{phase}: {err}" for phase, err in result.failures.items()),
file=sys.stderr,
)
return EXIT_ERROR
return EXIT_OK
except Exception as exc:
print(f"error: ingest failed: {exc}", file=sys.stderr)
Expand Down Expand Up @@ -592,6 +606,21 @@ def _cmd_diagram(args) -> int:
catalog.close()


def _unwritable_catalog_message(catalog, exc: Exception) -> str:
"""Say what to do, not just which errno.

The default catalog lives under $HOME, which is not writable in a lot of
real environments -- CI runners, containers run as a non-root user, locked
down workstations. A bare "Permission denied: '/rohome/.drydock'" does not
tell anyone that `--catalog` and `DRYDOCK_CATALOG` exist.
"""
return (
f"error: cannot use the catalog at {catalog.root}: {exc}\n"
"hint: choose a writable location with --catalog PATH "
"or the DRYDOCK_CATALOG environment variable"
)


def _cmd_stats(args) -> int:
"""Execute the stats subcommand."""
catalog = Catalog(args.catalog or None)
Expand All @@ -602,6 +631,9 @@ def _cmd_stats(args) -> int:
else:
_emit(args, _format_stats(stats))
return EXIT_OK
except PermissionError as exc:
print(_unwritable_catalog_message(catalog, exc), file=sys.stderr)
return EXIT_ERROR
except Exception as exc:
print(f"error: {exc}", file=sys.stderr)
return EXIT_ERROR
Expand Down
82 changes: 82 additions & 0 deletions drydock/core/toml.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""Reading TOML without requiring a TOML reader.

`tomllib` entered the stdlib in 3.11, and this package supports 3.10 with no
dependencies. So on 3.10 there may be no TOML parser at all, and every caller
has to cope with that.

Each caller coping separately is how this broke: the guarded import was
duplicated at four sites, and at one of them the surrounding `except` clause
listed `(OSError, ValueError)` — which does not catch `ModuleNotFoundError`.
`drydock codemap` therefore crashed outright on a clean Python 3.10 install.
CI never saw it, because CI installs the `dev` extra, which pulls in `tomli`.

One function, one place to get it right. A missing parser is not an error here:
TOML files are read for enrichment (entry points, platform versions, declared
dependencies), and the correct behaviour without a reader is to return nothing
and carry on.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any

__all__ = ["load_toml", "loads_toml", "toml_available"]

_READER: Any = None
_CHECKED = False


def _reader() -> Any:
global _READER, _CHECKED
if not _CHECKED:
_CHECKED = True
try:
import tomllib as _t # Python 3.11+
except ModuleNotFoundError:
try:
import tomli as _t # 3.10 backport, optional
except ModuleNotFoundError:
_t = None
_READER = _t
return _READER


def toml_available() -> bool:
"""Whether any TOML parser is importable in this environment."""
return _reader() is not None


def load_toml(path: Path | str) -> dict[str, Any] | None:
"""Parse a TOML file, or return None.

Returns None when there is no parser available, the file is missing, or it
is malformed. Never raises -- callers read TOML to enrich a result, and a
missing enrichment must not sink an analysis.
"""
reader = _reader()
if reader is None:
return None
try:
with open(path, "rb") as handle:
data = reader.load(handle)
except (OSError, ValueError, AttributeError):
return None
return data if isinstance(data, dict) else None


def loads_toml(source: str) -> dict[str, Any] | None:
"""Parse TOML already held as text, or return None.

Same contract as `load_toml` for callers that read the file themselves
(the walker hands analyzers decoded source, so re-reading from disk would
be a second read and a second chance to disagree about encoding).
"""
reader = _reader()
if reader is None:
return None
try:
data = reader.loads(source)
except (ValueError, AttributeError, TypeError):
return None
return data if isinstance(data, dict) else None
41 changes: 41 additions & 0 deletions tests/container/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Cold-environment smoke tests

`pytest` runs inside a developer environment that already has everything. These
run the new-user command sequence inside a container that has nothing, which is
where a stranger's first ten minutes actually happen.

They are not part of the pytest suite — they need Docker, and they install the
package for real rather than importing it from the working tree.

```sh
for img in python:3.10-slim python:3.13-slim python:3.13-alpine; do
docker run --rm -v "$PWD:/src:ro" -v "$PWD/tests/container:/ctest:ro" \
-e HOME=/tmp/home -w /tmp "$img" \
sh -c 'mkdir -p /tmp/home && sh /ctest/smoke.sh'
done
```

Non-root, which is how most CI runners and hardened containers execute:

```sh
docker run --rm -u 1001:1001 -v "$PWD:/src:ro" -v "$PWD/tests/container:/ctest:ro" \
-e HOME=/tmp/home -w /tmp python:3.13-slim \
sh -c 'mkdir -p /tmp/home && python -m pip install --user --quiet /src && \
export PATH=$HOME/.local/bin:$PATH && sh /ctest/smoke.sh'
```

## What this has caught

Both of these were green in CI and green locally.

- **`codemap` crashed on a clean 3.10.** `tomllib` arrived in 3.11 and the
guarded fallback import was duplicated at several call sites; at one of them
the surrounding `except` listed `(OSError, ValueError)`, which does not catch
`ModuleNotFoundError`. CI missed it because CI installs the `dev` extra,
which pulls in `tomli`. Now behind `drydock/core/toml.py`.
- **`catalog ingest` exited 0 after recording nothing** when `$HOME` was not
writable — a scripted run went green over an empty catalog.

Note the second one needs a *non-root* container: root ignores directory mode
bits, so a read-only `$HOME` is not read-only for root and the check passes
without testing anything.
30 changes: 30 additions & 0 deletions tests/container/smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#!/bin/sh
# Runs inside the container. Every check is a stranger's first ten minutes.
set -u
pass=0; fail=0
chk() { # name, command...
n="$1"; shift
if out=$("$@" 2>&1); then printf ' ok %s\n' "$n"; pass=$((pass+1))
else printf ' FAIL %s :: %s\n' "$n" "$(printf '%s' "$out" | tail -1 | cut -c1-58)"; fail=$((fail+1)); fi
}
cd /src
python -m pip install --quiet . >/dev/null 2>&1 || { echo " FAIL install"; exit 1; }
echo " (installed; git present: $(command -v git >/dev/null 2>&1 && echo yes || echo NO))"
chk "drydock --version" drydock --version
chk "drydock list" drydock list
chk "structure" drydock structure /src -o /dev/null
chk "codemap" drydock codemap /src -o /dev/null
chk "architecture" drydock architecture /src -o /dev/null
chk "security" drydock security /src -o /dev/null
chk "boundaries" drydock boundaries /src -o /dev/null
chk "interfaces" drydock interfaces /src -o /dev/null
chk "context" drydock context /src -o /dev/null
chk "extract --list-seams" drydock extract /src --list-seams -o /dev/null
chk "catalog stats" drydock catalog stats -o /dev/null
chk "catalog ingest" drydock catalog ingest /src -o /dev/null
chk "catalog search" drydock catalog search parser -o /dev/null
# git analyzer must degrade gracefully when git is absent
if drydock git /src -o /dev/null >/dev/null 2>&1; then printf ' ok git (repo analysed)\n'; pass=$((pass+1))
else printf ' ok git (declined cleanly, exit %s)\n' "$?"; pass=$((pass+1)); fi
echo " -> $pass passed, $fail failed"
[ "$fail" -eq 0 ]
Loading
Loading