diff --git a/.rhiza/docs/TEMPLATE_YML_EXAMPLE.md b/.rhiza/docs/TEMPLATE_YML_EXAMPLE.md new file mode 100644 index 00000000..4f7cfc46 --- /dev/null +++ b/.rhiza/docs/TEMPLATE_YML_EXAMPLE.md @@ -0,0 +1,117 @@ +# `.rhiza/template.yml` Example with `pyproject:` Section + +This document shows a fully-annotated example of a downstream project's +`.rhiza/template.yml` using the optional `pyproject:` section introduced by +the `sync-pyproject` feature. + +--- + +## Full Example + +```yaml +# .rhiza/template.yml +# --------------------------------------------------------------- +# Which rhiza release to sync against +repository: Jebel-Quant/rhiza +ref: v0.9.0 + +# Which template bundles to enable +templates: + - core + - tests + - github + +# --------------------------------------------------------------- +# Fields rhiza controls in pyproject.toml (all optional) +# +# Only the keys declared here are touched during `make sync-pyproject`. +# Everything else in pyproject.toml (name, version, description, authors, +# keywords, dependencies, [dependency-groups], [project.urls]) is +# preserved unchanged. +# --------------------------------------------------------------- +pyproject: + + # Set [project].requires-python + requires-python: ">=3.11" + + # Set [project].license — accepts a plain string (PEP 639) ... + license: "MIT" + # ... or an inline table (PEP 517): + # license: + # text: "MIT" + # license: + # file: "LICENSE" + + # Set [project].readme (file path string) + readme: "README.md" + + # Replace [project].classifiers entirely. + # Rhiza owns this list — it mirrors the requires-python / .python-version + # support matrix. + classifiers: + - "Programming Language :: Python :: 3" + - "Programming Language :: Python :: 3 :: Only" + - "Programming Language :: Python :: 3.11" + - "Programming Language :: Python :: 3.12" + - "Programming Language :: Python :: 3.13" + - "Programming Language :: Python :: 3.14" + - "License :: OSI Approved :: MIT License" + - "Intended Audience :: Developers" + + # Sync entire [tool.*] subsections from rhiza's own pyproject.toml. + # Use dotted TOML paths — the entire subtree at that path is replaced. + tool-sections: + - tool.deptry.package_module_name_map +``` + +--- + +## Supported `pyproject:` Keys + +| Key | TOML path patched | Behaviour | +|-----|-------------------|-----------| +| `requires-python` | `[project].requires-python` | Sets the value; no-op if already matching | +| `classifiers` | `[project].classifiers` | Replaces the list entirely | +| `license` | `[project].license` | Accepts a plain string (`"MIT"`) or a mapping (`{text: "MIT"}`) | +| `readme` | `[project].readme` | Sets the readme file path string | +| `tool-sections` | `[tool.<...>]` (dotted path) | Syncs the subtree from rhiza's own `pyproject.toml` | + +--- + +## Running the Sync + +```bash +# Apply changes +make sync-pyproject + +# Preview without writing +make sync-pyproject DRY_RUN=1 + +# CI check — exits non-zero if changes are needed +make sync-pyproject CHECK=1 +``` + +Or call the script directly: + +```bash +uv run python .rhiza/utils/sync_pyproject.py +uv run python .rhiza/utils/sync_pyproject.py --dry-run +uv run python .rhiza/utils/sync_pyproject.py --check +``` + +--- + +## What is Never Touched + +The following fields are **never modified** by `sync-pyproject` unless you +add them to a supported key above: + +- `name` +- `version` +- `description` +- `authors` +- `keywords` +- `dependencies` +- `[dependency-groups]` +- `[project.urls]` +- Any `[tool.*]` section not listed under `tool-sections` \ No newline at end of file diff --git a/.rhiza/docs/WORKFLOWS.md b/.rhiza/docs/WORKFLOWS.md index 9025fe29..b32d808c 100644 --- a/.rhiza/docs/WORKFLOWS.md +++ b/.rhiza/docs/WORKFLOWS.md @@ -213,6 +213,33 @@ make sync This updates shared configurations while preserving your customizations in `local.mk`. +## `pyproject.toml` Field Synchronization + +Rhiza can non-destructively patch selected fields in your `pyproject.toml` +via the optional `pyproject:` section in `.rhiza/template.yml`. + +Fields rhiza can control (all opt-in): +- `requires-python` — keeps the Python version constraint in sync +- `classifiers` — mirrors the supported Python version matrix +- `tool-sections` — syncs `[tool.*]` subtrees from rhiza's own `pyproject.toml` + +Everything else (`name`, `version`, `description`, `authors`, `dependencies`, +`[dependency-groups]`, `[project.urls]`) is **never touched**. + +```bash +# Apply changes defined in .rhiza/template.yml [pyproject:] section +make sync-pyproject + +# Preview without writing +make sync-pyproject DRY_RUN=1 + +# CI check — exits non-zero if changes are pending +make sync-pyproject CHECK=1 +``` + +See `.rhiza/docs/TEMPLATE_YML_EXAMPLE.md` for a fully-annotated example +of the `pyproject:` section. + ## Troubleshooting ### Environment Out of Sync diff --git a/.rhiza/make.d/releasing.mk b/.rhiza/make.d/releasing.mk index fc6d57a6..2124c1d6 100644 --- a/.rhiza/make.d/releasing.mk +++ b/.rhiza/make.d/releasing.mk @@ -2,7 +2,7 @@ # This file provides targets for version bumping and release management. # Declare phony targets (they don't produce files) -.PHONY: bump release publish release-status pre-bump post-bump pre-release post-release +.PHONY: bump release publish release-status sync-pyproject pre-bump post-bump pre-release post-release # Hook targets (double-colon rules allow multiple definitions) pre-bump:: ; @: @@ -46,5 +46,16 @@ else @printf "${RED}[ERROR] Could not detect forge type (.github/workflows/ or .gitlab-ci.yml not found)${RESET}\n" endif +sync-pyproject: ## sync pyproject.toml fields from .rhiza/template.yml (supports DRY_RUN=1, CHECK=1) + @if [ -f "pyproject.toml" ] && [ -f ".rhiza/template.yml" ]; then \ + $(MAKE) install; \ + _FLAGS=""; \ + if [ -n "$(DRY_RUN)" ]; then _FLAGS="$$_FLAGS --dry-run"; fi; \ + if [ -n "$(CHECK)" ]; then _FLAGS="$$_FLAGS --check"; fi; \ + ${UV_BIN} run python .rhiza/utils/sync_pyproject.py $$_FLAGS; \ + else \ + printf "${YELLOW}[WARN] pyproject.toml or .rhiza/template.yml not found, skipping sync-pyproject${RESET}\n"; \ + fi + diff --git a/.rhiza/requirements/tools.txt b/.rhiza/requirements/tools.txt index 262ffc01..f033622a 100644 --- a/.rhiza/requirements/tools.txt +++ b/.rhiza/requirements/tools.txt @@ -5,3 +5,5 @@ python-dotenv==1.2.1 # for now needed until rhiza-tools is finished typer==0.21.1 ty==0.0.18 + +tomlkit>=0.13,<1.0 diff --git a/.rhiza/template-bundles.yml b/.rhiza/template-bundles.yml index 97a6d105..c8bfb6da 100644 --- a/.rhiza/template-bundles.yml +++ b/.rhiza/template-bundles.yml @@ -48,6 +48,7 @@ bundles: - .rhiza/requirements/README.md - .rhiza/requirements/docs.txt - .rhiza/requirements/tools.txt + - .rhiza/utils/sync_pyproject.py # Root configuration files - Makefile diff --git a/.rhiza/tests/deps/test_sync_pyproject.py b/.rhiza/tests/deps/test_sync_pyproject.py new file mode 100644 index 00000000..6a0b1a81 --- /dev/null +++ b/.rhiza/tests/deps/test_sync_pyproject.py @@ -0,0 +1,505 @@ +"""Tests for .rhiza/utils/sync_pyproject.py. + +This file and its associated tests flow down via a SYNC action from the +jebel-quant/rhiza repository (https://github.com/jebel-quant/rhiza). + +Verifies that sync_pyproject.py: +- Exists and has valid Python syntax +- Is a no-op when no ``pyproject:`` section is present in template.yml +- Correctly patches ``requires-python`` when specified +- Correctly replaces ``classifiers`` when specified +- Leaves ``name``, ``version``, ``description``, ``dependencies`` untouched +- ``--dry-run`` does not modify the file +- ``--check`` exits non-zero when changes would be made + +Security Notes: +- S101 (assert usage): Asserts are appropriate in test code for validating conditions +""" + +from __future__ import annotations + +import ast +import importlib.util +import textwrap +import tomllib +from pathlib import Path + +import pytest + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +SCRIPT_PATH = Path(__file__).parent.parent.parent / "utils" / "sync_pyproject.py" + +_MINIMAL_PYPROJECT = textwrap.dedent("""\ + [project] + name = "my-project" + version = "1.2.3" + description = "A test project" + requires-python = ">=3.10" + dependencies = ["requests>=2.0"] + + [dependency-groups] + dev = ["pytest"] +""") + +_TEMPLATE_NO_PYPROJECT = textwrap.dedent("""\ + repository: Jebel-Quant/rhiza + ref: v0.9.0 + templates: + - core +""") + +_TEMPLATE_WITH_REQUIRES_PYTHON = textwrap.dedent("""\ + repository: Jebel-Quant/rhiza + ref: v0.9.0 + templates: + - core + pyproject: + requires-python: ">=3.11" +""") + +_TEMPLATE_WITH_CLASSIFIERS = textwrap.dedent("""\ + repository: Jebel-Quant/rhiza + ref: v0.9.0 + templates: + - core + pyproject: + classifiers: + - "Programming Language :: Python :: 3" + - "Programming Language :: Python :: 3.11" +""") + +_TEMPLATE_WITH_BOTH = textwrap.dedent("""\ + repository: Jebel-Quant/rhiza + ref: v0.9.0 + templates: + - core + pyproject: + requires-python: ">=3.12" + classifiers: + - "Programming Language :: Python :: 3" + - "Programming Language :: Python :: 3.12" +""") + +_TEMPLATE_WITH_LICENSE_STRING = textwrap.dedent("""\ + repository: Jebel-Quant/rhiza + ref: v0.9.0 + templates: + - core + pyproject: + license: "MIT" +""") + +_TEMPLATE_WITH_LICENSE_TABLE = textwrap.dedent("""\ + repository: Jebel-Quant/rhiza + ref: v0.9.0 + templates: + - core + pyproject: + license: + text: "MIT" +""") + +_TEMPLATE_WITH_README = textwrap.dedent("""\ + repository: Jebel-Quant/rhiza + ref: v0.9.0 + templates: + - core + pyproject: + readme: "README.md" +""") + + +def _make_project(tmp_path: Path, pyproject_content: str, template_content: str) -> Path: + """Create a minimal fake project directory for testing.""" + # Simulate /.rhiza/utils/sync_pyproject.py layout + (tmp_path / ".rhiza" / "utils").mkdir(parents=True) + (tmp_path / ".rhiza" / "template.yml").write_text(template_content, encoding="utf-8") + (tmp_path / "pyproject.toml").write_text(pyproject_content, encoding="utf-8") + + # Place a copy of the real script so imports work from the fake project root + real_script = SCRIPT_PATH.read_text(encoding="utf-8") + (tmp_path / ".rhiza" / "utils" / "sync_pyproject.py").write_text(real_script, encoding="utf-8") + + return tmp_path + + +def _run_sync(tmp_path: Path, argv: list[str] | None = None) -> int: + """Import and run sync_pyproject.main() in the context of *tmp_path*.""" + script = tmp_path / ".rhiza" / "utils" / "sync_pyproject.py" + spec = importlib.util.spec_from_file_location("sync_pyproject_test", script) + assert spec is not None + mod = importlib.util.module_from_spec(spec) + assert spec.loader is not None + + # We need to monkey-patch the module's Path(__file__) lookup. + # The easiest way: temporarily chdir into tmp_path so relative resolution + # works, BUT sync_pyproject uses resolve(), so we patch __file__ directly. + spec.loader.exec_module(mod) # type: ignore[union-attr] + + # Patch _resolve_paths to return our tmp directories + def _patched_resolve_paths(): + return ( + tmp_path, + tmp_path / ".rhiza" / "template.yml", + tmp_path / "pyproject.toml", + ) + + mod._resolve_paths = _patched_resolve_paths # type: ignore[attr-defined] + return mod.main(argv or []) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestScriptExists: + """Verify that the script file exists and is syntactically valid.""" + + def test_script_file_exists(self): + """sync_pyproject.py must exist at .rhiza/utils/sync_pyproject.py.""" + assert SCRIPT_PATH.exists(), f"sync_pyproject.py not found at {SCRIPT_PATH}" + + def test_script_has_valid_syntax(self): + """sync_pyproject.py must be valid Python.""" + source = SCRIPT_PATH.read_text(encoding="utf-8") + try: + ast.parse(source) + except SyntaxError as exc: + pytest.fail(f"sync_pyproject.py has a syntax error: {exc}") + + def test_script_has_module_docstring(self): + """sync_pyproject.py must have a module-level docstring.""" + source = SCRIPT_PATH.read_text(encoding="utf-8") + tree = ast.parse(source) + docstring = ast.get_docstring(tree) + assert docstring, "sync_pyproject.py is missing a module docstring" + + def test_script_mentions_rhiza_sync(self): + """Module docstring must mention jebel-quant/rhiza SYNC origin.""" + source = SCRIPT_PATH.read_text(encoding="utf-8") + assert "jebel-quant/rhiza" in source.lower() or "SYNC" in source, ( + "sync_pyproject.py should note it flows from jebel-quant/rhiza" + ) + + +class TestNoOp: + """No changes made when conditions don't require it.""" + + def test_no_pyproject_section_is_noop(self, tmp_path): + """No pyproject: section → exit 0, file unchanged.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_NO_PYPROJECT) + original = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + + rc = _run_sync(tmp_path) + + assert rc == 0 + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == original + + def test_already_up_to_date_is_noop(self, tmp_path): + """If requires-python already matches, file should not change.""" + pyproject = textwrap.dedent("""\ + [project] + name = "my-project" + version = "1.2.3" + requires-python = ">=3.11" + dependencies = [] + """) + _make_project(tmp_path, pyproject, _TEMPLATE_WITH_REQUIRES_PYTHON) + original = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + + rc = _run_sync(tmp_path) + + assert rc == 0 + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == original + + def test_missing_template_yml_is_noop(self, tmp_path): + """If template.yml doesn't exist, exit 0 without modifying anything.""" + (tmp_path / "pyproject.toml").write_text(_MINIMAL_PYPROJECT, encoding="utf-8") + # No .rhiza/template.yml created + + # Need to create fake script path too + (tmp_path / ".rhiza" / "utils").mkdir(parents=True) + real_script = SCRIPT_PATH.read_text(encoding="utf-8") + (tmp_path / ".rhiza" / "utils" / "sync_pyproject.py").write_text(real_script, encoding="utf-8") + + original = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + rc = _run_sync(tmp_path) + + assert rc == 0 + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == original + + +class TestRequiresPython: + """Tests for patching [project].requires-python.""" + + def test_patches_requires_python(self, tmp_path): + """requires-python should be updated to the template value.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_REQUIRES_PYTHON) + + rc = _run_sync(tmp_path) + + assert rc == 0 + with (tmp_path / "pyproject.toml").open("rb") as f: + result = tomllib.load(f) + assert result["project"]["requires-python"] == ">=3.11" + + def test_preserves_name_version_description(self, tmp_path): + """Patching requires-python must not touch name/version/description.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_REQUIRES_PYTHON) + + _run_sync(tmp_path) + + with (tmp_path / "pyproject.toml").open("rb") as f: + result = tomllib.load(f) + assert result["project"]["name"] == "my-project" + assert result["project"]["version"] == "1.2.3" + assert result["project"]["description"] == "A test project" + + def test_preserves_dependencies(self, tmp_path): + """Patching requires-python must not touch dependencies.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_REQUIRES_PYTHON) + + _run_sync(tmp_path) + + with (tmp_path / "pyproject.toml").open("rb") as f: + result = tomllib.load(f) + assert result["project"]["dependencies"] == ["requests>=2.0"] + + def test_preserves_dependency_groups(self, tmp_path): + """Patching requires-python must not touch [dependency-groups].""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_REQUIRES_PYTHON) + + _run_sync(tmp_path) + + with (tmp_path / "pyproject.toml").open("rb") as f: + result = tomllib.load(f) + assert result["dependency-groups"]["dev"] == ["pytest"] + + +class TestClassifiers: + """Tests for patching [project].classifiers.""" + + def test_replaces_classifiers(self, tmp_path): + """Classifiers list should be replaced with template values.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_CLASSIFIERS) + + rc = _run_sync(tmp_path) + + assert rc == 0 + with (tmp_path / "pyproject.toml").open("rb") as f: + result = tomllib.load(f) + assert result["project"]["classifiers"] == [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + ] + + def test_classifiers_replaces_existing(self, tmp_path): + """Existing classifiers not in the template list should be removed.""" + pyproject = textwrap.dedent("""\ + [project] + name = "my-project" + version = "1.0.0" + requires-python = ">=3.10" + classifiers = [ + "Old Classifier :: Should Be Gone", + ] + dependencies = [] + """) + _make_project(tmp_path, pyproject, _TEMPLATE_WITH_CLASSIFIERS) + + _run_sync(tmp_path) + + with (tmp_path / "pyproject.toml").open("rb") as f: + result = tomllib.load(f) + assert "Old Classifier :: Should Be Gone" not in result["project"]["classifiers"] + assert "Programming Language :: Python :: 3" in result["project"]["classifiers"] + + +class TestDryRun: + """Tests for --dry-run flag.""" + + def test_dry_run_does_not_modify_file(self, tmp_path): + """--dry-run must not write any changes to pyproject.toml.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_REQUIRES_PYTHON) + original = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + + rc = _run_sync(tmp_path, argv=["--dry-run"]) + + assert rc == 0 + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == original + + def test_dry_run_exits_zero(self, tmp_path): + """--dry-run should exit 0 even when there are pending changes.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_REQUIRES_PYTHON) + + rc = _run_sync(tmp_path, argv=["--dry-run"]) + + assert rc == 0 + + +class TestCheck: + """Tests for --check flag.""" + + def test_check_exits_nonzero_when_changes_pending(self, tmp_path): + """--check should exit non-zero when changes would be made.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_REQUIRES_PYTHON) + + rc = _run_sync(tmp_path, argv=["--check"]) + + assert rc != 0 + + def test_check_does_not_modify_file(self, tmp_path): + """--check must not write changes.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_REQUIRES_PYTHON) + original = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + + _run_sync(tmp_path, argv=["--check"]) + + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == original + + def test_check_exits_zero_when_up_to_date(self, tmp_path): + """--check should exit 0 when there are no pending changes.""" + pyproject = textwrap.dedent("""\ + [project] + name = "my-project" + version = "1.2.3" + requires-python = ">=3.11" + dependencies = [] + """) + _make_project(tmp_path, pyproject, _TEMPLATE_WITH_REQUIRES_PYTHON) + + rc = _run_sync(tmp_path, argv=["--check"]) + + assert rc == 0 + + +class TestBothFields: + """Tests when both requires-python and classifiers are in the template.""" + + def test_patches_both_fields(self, tmp_path): + """Both requires-python and classifiers should be updated.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_BOTH) + + rc = _run_sync(tmp_path) + + assert rc == 0 + with (tmp_path / "pyproject.toml").open("rb") as f: + result = tomllib.load(f) + assert result["project"]["requires-python"] == ">=3.12" + assert "Programming Language :: Python :: 3.12" in result["project"]["classifiers"] + + def test_preserves_unrelated_fields(self, tmp_path): + """Patching multiple fields must still preserve name/version/description/deps.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_BOTH) + + _run_sync(tmp_path) + + with (tmp_path / "pyproject.toml").open("rb") as f: + result = tomllib.load(f) + assert result["project"]["name"] == "my-project" + assert result["project"]["version"] == "1.2.3" + assert result["project"]["description"] == "A test project" + assert result["project"]["dependencies"] == ["requests>=2.0"] + + +class TestLicense: + """Tests for patching [project].license.""" + + def test_patches_license_string(self, tmp_path): + """License as a plain string should be written as a string.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_LICENSE_STRING) + + rc = _run_sync(tmp_path) + + assert rc == 0 + with (tmp_path / "pyproject.toml").open("rb") as f: + result = tomllib.load(f) + assert result["project"]["license"] == "MIT" + + def test_patches_license_table(self, tmp_path): + """License as a mapping should be written as an inline table.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_LICENSE_TABLE) + + rc = _run_sync(tmp_path) + + assert rc == 0 + with (tmp_path / "pyproject.toml").open("rb") as f: + result = tomllib.load(f) + assert result["project"]["license"] == {"text": "MIT"} + + def test_license_noop_when_already_matching_string(self, tmp_path): + """License should not change if it already matches the template value.""" + pyproject = textwrap.dedent("""\ + [project] + name = "my-project" + version = "1.0.0" + license = "MIT" + dependencies = [] + """) + _make_project(tmp_path, pyproject, _TEMPLATE_WITH_LICENSE_STRING) + original = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + + rc = _run_sync(tmp_path) + + assert rc == 0 + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == original + + def test_license_preserves_other_fields(self, tmp_path): + """Patching license must not touch name/version/dependencies.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_LICENSE_STRING) + + _run_sync(tmp_path) + + with (tmp_path / "pyproject.toml").open("rb") as f: + result = tomllib.load(f) + assert result["project"]["name"] == "my-project" + assert result["project"]["version"] == "1.2.3" + assert result["project"]["dependencies"] == ["requests>=2.0"] + + +class TestReadme: + """Tests for patching [project].readme.""" + + def test_patches_readme(self, tmp_path): + """Readme should be updated to the template value.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_README) + + rc = _run_sync(tmp_path) + + assert rc == 0 + with (tmp_path / "pyproject.toml").open("rb") as f: + result = tomllib.load(f) + assert result["project"]["readme"] == "README.md" + + def test_readme_noop_when_already_matching(self, tmp_path): + """Readme should not change if it already matches the template value.""" + pyproject = textwrap.dedent("""\ + [project] + name = "my-project" + version = "1.0.0" + readme = "README.md" + dependencies = [] + """) + _make_project(tmp_path, pyproject, _TEMPLATE_WITH_README) + original = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + + rc = _run_sync(tmp_path) + + assert rc == 0 + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == original + + def test_readme_preserves_other_fields(self, tmp_path): + """Patching readme must not touch name/version/dependencies.""" + _make_project(tmp_path, _MINIMAL_PYPROJECT, _TEMPLATE_WITH_README) + + _run_sync(tmp_path) + + with (tmp_path / "pyproject.toml").open("rb") as f: + result = tomllib.load(f) + assert result["project"]["name"] == "my-project" + assert result["project"]["version"] == "1.2.3" + assert result["project"]["dependencies"] == ["requests>=2.0"] diff --git a/.rhiza/utils/sync_pyproject.py b/.rhiza/utils/sync_pyproject.py new file mode 100644 index 00000000..5f19a3df --- /dev/null +++ b/.rhiza/utils/sync_pyproject.py @@ -0,0 +1,386 @@ +"""Sync selected pyproject.toml fields from .rhiza/template.yml. + +This file flows down via a SYNC action from the jebel-quant/rhiza repository +(https://github.com/jebel-quant/rhiza). + +Non-destructively patches the project's pyproject.toml with values declared in +the ``pyproject:`` section of ``.rhiza/template.yml``. All project-specific +fields (``name``, ``version``, ``description``, ``authors``, ``keywords``, +``dependencies``, ``[dependency-groups]``, ``[project.urls]``) are never +touched unless they are explicitly listed in the template. + +Supported ``pyproject:`` keys in template.yml +--------------------------------------------- +requires-python + Sets ``[project].requires-python``. +classifiers + Replaces ``[project].classifiers`` entirely (rhiza owns this list). +license + Sets ``[project].license``. Accepts either a plain string (PEP 639, + e.g. ``"MIT"``) or a mapping (PEP 517, e.g. ``{text: "MIT"}`` or + ``{file: "LICENSE"}``). +readme + Sets ``[project].readme`` (a string file path, e.g. ``"README.md"``). +tool-sections + A list of dotted TOML paths (e.g. ``tool.deptry.package_module_name_map``) + that are synced wholesale from rhiza's own ``pyproject.toml`` into the + downstream project's ``pyproject.toml``. + +Usage +----- + uv run python .rhiza/utils/sync_pyproject.py [--dry-run] [--check] + +Flags +----- +--dry-run Preview changes without writing. +--check Exit non-zero if changes would be made (useful in CI). + +Security Notes +-------------- +- S603 (subprocess without shell=True): Not used in this module. +- No external network calls are made. +""" + +from __future__ import annotations + +import argparse +import difflib +import sys +import textwrap +import tomllib +from pathlib import Path + +import tomlkit +import yaml # pyyaml + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _resolve_paths() -> tuple[Path, Path, Path]: + """Return (repo_root, template_yml, pyproject_toml) paths.""" + # Walk up from this script's location to find the repo root (contains pyproject.toml) + script_dir = Path(__file__).resolve().parent + # This script lives at .rhiza/utils/sync_pyproject.py + # So repo root is two levels up + repo_root = script_dir.parent.parent + template_yml = repo_root / ".rhiza" / "template.yml" + pyproject_toml = repo_root / "pyproject.toml" + return repo_root, template_yml, pyproject_toml + + +def _load_template_yml(template_yml: Path) -> dict: + """Load and return parsed .rhiza/template.yml.""" + with template_yml.open() as f: + return yaml.safe_load(f) or {} + + +def _load_pyproject(pyproject_toml: Path) -> tomlkit.TOMLDocument: + """Load pyproject.toml preserving formatting via tomlkit.""" + with pyproject_toml.open("rb") as f: + return tomlkit.load(f) + + +def _get_nested(doc: dict, dotted_path: str): + """Retrieve a value from a nested dict using a dotted path string. + + Returns ``None`` if any key in the path is missing. + """ + parts = dotted_path.split(".") + current = doc + for part in parts: + if not isinstance(current, dict) or part not in current: + return None + current = current[part] + return current + + +def _set_nested(doc: tomlkit.TOMLDocument, dotted_path: str, value) -> None: + """Set a value in a tomlkit document at the given dotted path. + + Intermediate tables are created as needed using ``tomlkit.table()``. + """ + parts = dotted_path.split(".") + current = doc + for part in parts[:-1]: + if part not in current: + current.add(part, tomlkit.table()) # type: ignore[arg-type] + current = current[part] + current[parts[-1]] = value + + +def _tomlkit_from_raw(value) -> object: + """Convert a plain Python value into the appropriate tomlkit type.""" + if isinstance(value, list): + arr = tomlkit.array() + arr.multiline(True) + for item in value: + arr.append(item) + return arr + if isinstance(value, dict): + tbl = tomlkit.table() + for k, v in value.items(): + tbl.add(k, _tomlkit_from_raw(v)) + return tbl + return value + + +# --------------------------------------------------------------------------- +# Per-field patchers +# --------------------------------------------------------------------------- + + +def _patch_requires_python(project: tomlkit.container.Container, section: dict) -> list[str]: + """Patch ``[project].requires-python`` if declared in *section*.""" + if "requires-python" not in section: + return [] + new_val = str(section["requires-python"]) + old_val = project.get("requires-python") + if old_val == new_val: + return [] + project["requires-python"] = new_val + return [f" requires-python: {old_val!r} → {new_val!r}"] + + +def _patch_classifiers(project: tomlkit.container.Container, section: dict) -> list[str]: + """Patch ``[project].classifiers`` if declared in *section*.""" + if "classifiers" not in section: + return [] + new_classifiers = list(section["classifiers"]) + old_classifiers = list(project.get("classifiers") or []) + if old_classifiers == new_classifiers: + return [] + arr = tomlkit.array() + arr.multiline(True) + for c in new_classifiers: + arr.append(c) + project["classifiers"] = arr + return [" classifiers: replaced list"] + + +def _patch_license(project: tomlkit.container.Container, section: dict) -> list[str]: + """Patch ``[project].license`` if declared in *section*. + + Accepts a plain string (PEP 639) or a mapping (PEP 517 inline table). + """ + if "license" not in section: + return [] + new_license = section["license"] + old_license = project.get("license") + # Normalise for comparison: tomlkit inline tables compare as dicts + old_cmp = dict(old_license) if hasattr(old_license, "items") else old_license + new_cmp = dict(new_license) if isinstance(new_license, dict) else new_license + if old_cmp == new_cmp: + return [] + if isinstance(new_license, dict): + tbl = tomlkit.inline_table() + for k, v in new_license.items(): + tbl.append(k, v) + project["license"] = tbl + else: + project["license"] = str(new_license) + return [f" license: {old_license!r} → {new_license!r}"] + + +def _patch_readme(project: tomlkit.container.Container, section: dict) -> list[str]: + """Patch ``[project].readme`` if declared in *section*.""" + if "readme" not in section: + return [] + new_readme = str(section["readme"]) + old_readme = project.get("readme") + if old_readme == new_readme: + return [] + project["readme"] = new_readme + return [f" readme: {old_readme!r} → {new_readme!r}"] + + +def _patch_tool_sections( + pyproject_doc: tomlkit.TOMLDocument, + section: dict, + rhiza_pyproject: dict | None, +) -> list[str]: + """Sync ``[tool.*]`` subtrees listed under *section[tool-sections]*.""" + if "tool-sections" not in section: + return [] + if rhiza_pyproject is None: + print("[WARN] tool-sections specified but rhiza pyproject.toml not found — skipping.") + return [] + changes: list[str] = [] + for dotted_path in section["tool-sections"]: + rhiza_val = _get_nested(rhiza_pyproject, dotted_path) + if rhiza_val is None: + print(f"[WARN] tool-section '{dotted_path}' not found in rhiza pyproject.toml — skipping.") + continue + if _get_nested(dict(pyproject_doc), dotted_path) != rhiza_val: + _set_nested(pyproject_doc, dotted_path, _tomlkit_from_raw(rhiza_val)) + changes.append(f" tool-section '{dotted_path}': updated") + return changes + + +# --------------------------------------------------------------------------- +# Core patching logic +# --------------------------------------------------------------------------- + + +def _apply_pyproject_section( + pyproject_doc: tomlkit.TOMLDocument, + pyproject_section: dict, + rhiza_pyproject: dict | None, +) -> list[str]: + """Apply the ``pyproject:`` template section to *pyproject_doc* in-place. + + Returns a list of human-readable change descriptions. + """ + if "project" not in pyproject_doc: + pyproject_doc.add("project", tomlkit.table()) + + project = pyproject_doc["project"] + + return [ + *_patch_requires_python(project, pyproject_section), + *_patch_classifiers(project, pyproject_section), + *_patch_license(project, pyproject_section), + *_patch_readme(project, pyproject_section), + *_patch_tool_sections(pyproject_doc, pyproject_section, rhiza_pyproject), + ] + + +# --------------------------------------------------------------------------- +# Rhiza pyproject loader (for tool-sections) +# --------------------------------------------------------------------------- + + +def _load_rhiza_pyproject(repo_root: Path) -> dict | None: + """Load rhiza's own pyproject.toml for ``tool-sections`` resolution. + + Walks up the directory tree looking for a rhiza pyproject.toml located + relative to a ``.rhiza/utils`` directory. If unavailable, returns None. + """ + # This script lives at /.rhiza/utils/sync_pyproject.py + # When running in a downstream project, rhiza files are not present as a + # separate checkout. The ``tool-sections`` values come from rhiza's own + # pyproject.toml (which lives at the root of *this* repo). + # When this script runs inside the rhiza repo itself, that file is the + # repo root pyproject.toml — i.e. the same file we are patching. + # For downstream projects, the rhiza pyproject.toml is not available + # on disk unless it was explicitly placed there. We therefore look in + # the same repo root first (covers both the rhiza repo and the common + # case where the user has copied the script together with pyproject.toml). + candidate = repo_root / "pyproject.toml" + if candidate.exists(): + try: + with candidate.open("rb") as f: + return tomllib.load(f) + except Exception: # nosec B110 - best-effort load; failure means tool-sections are unavailable + return None + return None + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main(argv: list[str] | None = None) -> int: + """Entry point for sync_pyproject. + + Returns: + ------- + int + 0 on success / no changes needed. + 1 on error or when ``--check`` detects pending changes. + """ + parser = argparse.ArgumentParser( + description="Sync selected pyproject.toml fields from .rhiza/template.yml", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=textwrap.dedent("""\ + Examples: + uv run python .rhiza/utils/sync_pyproject.py + uv run python .rhiza/utils/sync_pyproject.py --dry-run + uv run python .rhiza/utils/sync_pyproject.py --check + """), + ) + parser.add_argument("--dry-run", action="store_true", help="Preview changes without writing") + parser.add_argument("--check", action="store_true", help="Exit non-zero if changes would be made") + args = parser.parse_args(argv) + + repo_root, template_yml, pyproject_toml = _resolve_paths() + + # --- Validate required files --- + if not template_yml.exists(): + print(f"[INFO] No .rhiza/template.yml found at {template_yml}, nothing to do.") + return 0 + + if not pyproject_toml.exists(): + print(f"[ERROR] pyproject.toml not found at {pyproject_toml}") + return 1 + + # --- Load template --- + try: + template_data = _load_template_yml(template_yml) + except Exception as exc: + print(f"[ERROR] Failed to load {template_yml}: {exc}") + return 1 + + pyproject_section = template_data.get("pyproject") + if not pyproject_section: + print("[INFO] No pyproject: section in template.yml, nothing to do.") + return 0 + + # --- Load pyproject.toml --- + try: + pyproject_doc = _load_pyproject(pyproject_toml) + except Exception as exc: + print(f"[ERROR] Failed to load {pyproject_toml}: {exc}") + return 1 + + original_text = pyproject_toml.read_text(encoding="utf-8") + + # --- Load rhiza pyproject for tool-sections (best-effort) --- + rhiza_pyproject = _load_rhiza_pyproject(repo_root) if "tool-sections" in pyproject_section else None + + # --- Apply patch --- + changes = _apply_pyproject_section(pyproject_doc, pyproject_section, rhiza_pyproject) + + if not changes: + print("[INFO] pyproject.toml is already up to date.") + return 0 + + # --- Produce new text --- + new_text = tomlkit.dumps(pyproject_doc) + + # --- Show diff --- + diff = list( + difflib.unified_diff( + original_text.splitlines(keepends=True), + new_text.splitlines(keepends=True), + fromfile="pyproject.toml (before)", + tofile="pyproject.toml (after)", + ) + ) + print("[INFO] Changes to pyproject.toml:") + for line in changes: + print(line) + print() + if diff: + print("--- diff ---") + sys.stdout.writelines(diff) + print() + + if args.check: + print("[CHECK] pyproject.toml is NOT up to date. Run sync-pyproject to apply changes.") + return 1 + + if args.dry_run: + print("[DRY-RUN] No changes written.") + return 0 + + # --- Write --- + pyproject_toml.write_text(new_text, encoding="utf-8") + print("[INFO] pyproject.toml updated successfully.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 55ffc2a0..5b6653eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dev = [ "plotly>=6.5.0,<7.0", # Interactive visualization in notebooks (v6.5+ mature features, <7.0 for API stability) "pandas>=3,<3.1", # Data manipulation for notebook examples (v3+ for modern APIs, <3.1 conservative after major release) "pyyaml>=6.0,<7.0", # YAML parsing for validation scripts (v6+ for security fixes, <7.0 for API stability) + "tomlkit>=0.13,<1.0", # TOML read/write with comment preservation for sync_pyproject.py # See docs/DEPENDENCIES.md for detailed version rationale ] @@ -44,3 +45,4 @@ numpy = "numpy" plotly = "plotly" pandas = "pandas" pyyaml = "yaml" +tomlkit = "tomlkit" diff --git a/uv.lock b/uv.lock index b89d4f98..0bce97a4 100644 --- a/uv.lock +++ b/uv.lock @@ -575,6 +575,7 @@ dev = [ { name = "pandas" }, { name = "plotly" }, { name = "pyyaml" }, + { name = "tomlkit" }, ] [package.metadata] @@ -586,6 +587,7 @@ dev = [ { name = "pandas", specifier = ">=3,<3.1" }, { name = "plotly", specifier = ">=6.5.0,<7.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, + { name = "tomlkit", specifier = ">=0.13,<1.0" }, ] [[package]]