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
2 changes: 2 additions & 0 deletions cli/python/base_setup/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from .ide import reconcile_ide_installs
from .ide import reconcile_ide_settings
from .manifest import BaseManifest, ManifestError, read_manifest
from .pyproject import check_pyproject


app = base_cli.App(name="base_setup")
Expand Down Expand Up @@ -250,6 +251,7 @@ def manifest_checks(default_manifest: BaseManifest, manifest: BaseManifest) -> t
checks.extend(check_ide_installs(effective_manifest))
checks.extend(check_ide_extensions(effective_manifest))
checks.extend(check_ide_settings(effective_manifest))
checks.extend(check_pyproject(effective_manifest))

for artifact, definition in zip(artifacts, definitions, strict=True):
checks.append(check_artifact(effective_manifest.project_name, artifact, definition))
Expand Down
127 changes: 127 additions & 0 deletions cli/python/base_setup/pyproject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
from __future__ import annotations

from pathlib import Path
from typing import Any

from .checks import ArtifactCheck
from .manifest import BaseManifest

try:
import tomllib
except ImportError: # pragma: no cover - exercised only on Python runtimes without tomllib
tomllib = None # type: ignore[assignment]


def check_pyproject(manifest: BaseManifest) -> tuple[ArtifactCheck, ...]:
pyproject_path = manifest.path.parent / "pyproject.toml"
if not pyproject_path.exists():
return ()

data, error = read_pyproject(pyproject_path)
if error is not None:
return (pyproject_readability_warning(pyproject_path, error),)

checks: list[ArtifactCheck] = [pyproject_metadata_check(data)]
if has_dependency_metadata(data):
checks.append(pyproject_dependency_warning())
if has_tool_base(data):
checks.append(pyproject_tool_base_warning())
return tuple(checks)


def read_pyproject(path: Path) -> tuple[dict[str, Any], str | None]:
if tomllib is None:
return {}, "tomllib is not available in this Python runtime"
if not path.is_file():
return {}, "path is not a regular file"
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
except OSError as exc:
return {}, str(exc)
except tomllib.TOMLDecodeError as exc:
return {}, str(exc)
if not isinstance(data, dict):
return {}, "top-level TOML document is not a mapping"
return data, None


def pyproject_metadata_check(data: dict[str, Any]) -> ArtifactCheck:
project_data = data.get("project")
if project_data is None:
message = "pyproject.toml is readable; no [project] metadata table was found."
elif not isinstance(project_data, dict):
return ArtifactCheck(
name="pyproject.toml",
ok=False,
message="pyproject.toml has a [project] table that Base cannot read as a mapping.",
fix="Update [project] to be a TOML table with standard Python project metadata.",
finding_id="BASE-P140",
status="warn",
)
else:
details = pyproject_project_details(project_data)
message = f"pyproject.toml is readable; {details}."
return ArtifactCheck(
name="pyproject.toml",
ok=True,
message=message,
fix="",
finding_id="BASE-P140",
)


def pyproject_project_details(project_data: dict[str, Any]) -> str:
details: list[str] = []
project_name = project_data.get("name")
requires_python = project_data.get("requires-python")
if isinstance(project_name, str) and project_name:
details.append(f"project name '{project_name}'")
if isinstance(requires_python, str) and requires_python:
details.append(f"requires-python '{requires_python}'")
return ", ".join(details) if details else "[project] metadata was found"


def has_dependency_metadata(data: dict[str, Any]) -> bool:
project_data = data.get("project")
if isinstance(project_data, dict):
if "dependencies" in project_data or "optional-dependencies" in project_data:
return True
return "dependency-groups" in data


def has_tool_base(data: dict[str, Any]) -> bool:
tool_data = data.get("tool")
return isinstance(tool_data, dict) and "base" in tool_data


def pyproject_readability_warning(path: Path, error: str) -> ArtifactCheck:
return ArtifactCheck(
name="pyproject.toml",
ok=False,
message=f"{path}: pyproject.toml is not readable TOML: {error}.",
fix="Fix pyproject.toml syntax or remove the file if this is not a Python project.",
finding_id="BASE-P141",
status="warn",
)


def pyproject_dependency_warning() -> ArtifactCheck:
return ArtifactCheck(
name="pyproject dependencies",
ok=False,
message="pyproject.toml declares Python dependency metadata that Base observes but does not reconcile yet.",
fix="Keep Python dependencies managed by Python tooling; use base_manifest.yaml only for Base-owned artifacts.",
finding_id="BASE-P142",
status="warn",
)


def pyproject_tool_base_warning() -> ArtifactCheck:
return ArtifactCheck(
name="pyproject [tool.base]",
ok=False,
message="pyproject.toml contains unsupported [tool.base] configuration.",
fix="Move Base configuration to base_manifest.yaml; [tool.base] is not supported yet.",
finding_id="BASE-P143",
status="warn",
)
78 changes: 78 additions & 0 deletions cli/python/base_setup/tests/test_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,84 @@ def test_doctor_manifest_reports_required_ports_with_finding_ids(self) -> None:
"Start the service that should listen on 127.0.0.1:5432.",
)

def test_manifest_checks_include_same_directory_pyproject(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
manifest_path = root / "base_manifest.yaml"
manifest_path.write_text("project:\n name: demo\nartifacts: []\n", encoding="utf-8")
(root / "pyproject.toml").write_text(
"[project]\nname = \"demo-python\"\nrequires-python = \">=3.11\"\n",
encoding="utf-8",
)
default_manifest = BaseManifest(
path=Path("default_manifest.yaml"),
project_name="base-defaults",
brewfile=None,
artifacts=(),
)
manifest = read_manifest(manifest_path)

checks = engine.manifest_checks(default_manifest, manifest)

self.assertEqual([check.finding_id for check in checks], ["BASE-P140"])
self.assertIn("demo-python", checks[0].message)


def test_check_json_includes_pyproject_warnings_without_failure(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
manifest_path = root / "base_manifest.yaml"
manifest_path.write_text("project:\n name: demo\nartifacts: []\n", encoding="utf-8")
(root / "pyproject.toml").write_text(
"[project]\nname = \"demo-python\"\ndependencies = [\"requests\"]\n",
encoding="utf-8",
)
default_manifest = BaseManifest(
path=Path("default_manifest.yaml"),
project_name="base-defaults",
brewfile=None,
artifacts=(),
)
manifest = read_manifest(manifest_path)

with redirect_stdout(io.StringIO()) as stdout:
status = engine.check_manifest(
fake_context(),
default_manifest,
manifest,
output_format="json",
)

checks = json.loads(stdout.getvalue())
self.assertEqual(status, 0)
self.assertEqual([check["name"] for check in checks], ["pyproject.toml", "pyproject dependencies"])
self.assertTrue(checks[0]["ok"])
self.assertFalse(checks[1]["ok"])
self.assertIn("does not reconcile yet", checks[1]["message"])


def test_doctor_json_reports_pyproject_warnings_without_failure(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
manifest_path = root / "base_manifest.yaml"
manifest_path.write_text("project:\n name: demo\nartifacts: []\n", encoding="utf-8")
(root / "pyproject.toml").write_text("[tool.base]\ncommand = \"pytest\"\n", encoding="utf-8")
default_manifest = BaseManifest(
path=Path("default_manifest.yaml"),
project_name="base-defaults",
brewfile=None,
artifacts=(),
)
manifest = read_manifest(manifest_path)

with redirect_stdout(io.StringIO()) as stdout:
status = engine.doctor_manifest(default_manifest, manifest, output_format="json")

findings = json.loads(stdout.getvalue())
self.assertEqual(status, 0)
self.assertEqual([finding["id"] for finding in findings], ["BASE-P140", "BASE-P143"])
self.assertEqual([finding["status"] for finding in findings], ["ok", "warn"])



class IdeDiagnosticsTests(unittest.TestCase):
Expand Down
121 changes: 121 additions & 0 deletions cli/python/base_setup/tests/test_pyproject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
from __future__ import annotations

import tempfile
import unittest
from pathlib import Path

from base_setup.manifest import BaseManifest
from base_setup.pyproject import check_pyproject


def manifest_at(path: Path) -> BaseManifest:
return BaseManifest(
path=path,
project_name="demo",
brewfile=None,
artifacts=(),
)


class PyprojectDiagnosticsTests(unittest.TestCase):
def test_missing_pyproject_produces_no_findings(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
manifest = manifest_at(Path(tmpdir) / "base_manifest.yaml")

checks = check_pyproject(manifest)

self.assertEqual(checks, ())

def test_valid_project_metadata_reports_name_and_requires_python(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "pyproject.toml").write_text(
"\n".join(
[
"[project]",
'name = "demo-python"',
'requires-python = ">=3.11"',
]
),
encoding="utf-8",
)
manifest = manifest_at(root / "base_manifest.yaml")

checks = check_pyproject(manifest)

self.assertEqual(len(checks), 1)
self.assertEqual(checks[0].finding_id, "BASE-P140")
self.assertTrue(checks[0].ok)
self.assertEqual(checks[0].status, "")
self.assertIn("demo-python", checks[0].message)
self.assertIn(">=3.11", checks[0].message)

def test_malformed_pyproject_warns_without_failing(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "pyproject.toml").write_text("[project\n", encoding="utf-8")
manifest = manifest_at(root / "base_manifest.yaml")

checks = check_pyproject(manifest)

self.assertEqual(len(checks), 1)
self.assertEqual(checks[0].finding_id, "BASE-P141")
self.assertFalse(checks[0].ok)
self.assertEqual(checks[0].status, "warn")
self.assertIn("not readable TOML", checks[0].message)

def test_dependency_metadata_warns_without_listing_values(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "pyproject.toml").write_text(
"\n".join(
[
"[project]",
'name = "demo-python"',
'dependencies = ["requests @ https://user:secret@example.invalid/pkg.whl"]',
"",
"[project.optional-dependencies]",
'dev = ["pytest"]',
"",
"[dependency-groups]",
'lint = ["ruff"]',
]
),
encoding="utf-8",
)
manifest = manifest_at(root / "base_manifest.yaml")

checks = check_pyproject(manifest)

self.assertEqual([check.finding_id for check in checks], ["BASE-P140", "BASE-P142"])
dependency_check = checks[1]
self.assertFalse(dependency_check.ok)
self.assertEqual(dependency_check.status, "warn")
self.assertIn("dependency metadata", dependency_check.message)
self.assertNotIn("secret", dependency_check.message)
self.assertNotIn("example.invalid", dependency_check.message)

def test_tool_base_warns_as_unsupported(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
(root / "pyproject.toml").write_text(
"\n".join(
[
"[project]",
'name = "demo-python"',
"",
"[tool.base]",
'command = "pytest"',
]
),
encoding="utf-8",
)
manifest = manifest_at(root / "base_manifest.yaml")

checks = check_pyproject(manifest)

self.assertEqual([check.finding_id for check in checks], ["BASE-P140", "BASE-P143"])
tool_base_check = checks[1]
self.assertFalse(tool_base_check.ok)
self.assertEqual(tool_base_check.status, "warn")
self.assertIn("[tool.base]", tool_base_check.message)
11 changes: 11 additions & 0 deletions docs/doctor-findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ part of the doctor workflow.
| `BASE-P130` | Homebrew unavailable for IDE app checks |
| `BASE-P131` | IDE app install status |
| `BASE-P132` | IDE CLI PATH status |
| `BASE-P140` | `pyproject.toml` presence and metadata summary |
| `BASE-P141` | `pyproject.toml` readability |
| `BASE-P142` | `pyproject.toml` dependency metadata observed but not reconciled |
| `BASE-P143` | Unsupported `[tool.base]` configuration |

`BASE-P050` is the stable project virtual-environment readiness finding. The
Bash setup/check path reports detailed venv health messages when a project venv
Expand All @@ -89,6 +93,13 @@ project discovery currently verifies that the expected project venv Python path
exists. The finding should be treated as the project-venv readiness contract,
not as a guarantee that every project dependency import succeeds.

`BASE-P140` through `BASE-P143` are read-only `pyproject.toml` diagnostics.
Base only inspects the `pyproject.toml` file beside the active
`base_manifest.yaml`. These findings do not make `pyproject.toml` a Base
configuration source and do not cause Base to install Python dependencies.
Warnings in this range should guide users toward a valid Python project file
without failing the Base manifest check by themselves.

## Health Findings

| ID | Finding |
Expand Down
15 changes: 15 additions & 0 deletions docs/python-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,21 @@ The implementation should reject duplicate requirements only when it can do so
without guessing. Exact duplicate strings can be de-duplicated; semantically
overlapping requirement ranges should be left to pip.

## Relationship To `pyproject.toml`

Base observes a same-directory `pyproject.toml` during project diagnostics when
one exists beside `base_manifest.yaml`. This diagnostic support is read-only:
Base reports whether the file is readable, summarizes standard `[project]`
metadata, and warns when Python dependency metadata or unsupported `[tool.base]`
configuration is present.

`base_manifest.yaml` remains the Base source of truth. Base does not install
packages from `[project].dependencies`, does not execute build backend hooks,
and does not treat `[tool.base]` as an alternate manifest.

Future uv-managed Python support should use an explicit `python:` manifest
contract, tracked separately from the first read-only diagnostics slice.

## Non-Goals

The structured Python section should not turn Base into a Python packaging
Expand Down
Loading
Loading