diff --git a/pyproject.toml b/pyproject.toml index 4659aa5..da13c09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "httpx==0.28.1", "pyyaml==6.0.3", "pydantic==2.13.3", + "ruamel-yaml==0.19.1", ] [project.optional-dependencies] diff --git a/src/tycoon/project.py b/src/tycoon/project.py index f6c214c..697c113 100644 --- a/src/tycoon/project.py +++ b/src/tycoon/project.py @@ -323,6 +323,7 @@ class TycoonProject(BaseModel): name: str = Field(default="my-project", description="Project name") version: str = Field(default="0.1.0", description="Project version") + schema_version: int | None = Field(default=None, description="Tycoon schema version (managed by tycoon)") database: DatabaseConfig = Field(default_factory=DatabaseConfig) sources: dict[str, SourceConfig] = Field(default_factory=dict, description="Registered data sources") dbt_project_dir: str = Field(default="dbt_project", description="Path to dbt project") @@ -392,6 +393,7 @@ def _check_path_field(cls, v: str | None) -> str | None: PROJECT_FILENAME = "tycoon.yml" +SCHEMA_VERSION = 2 def load_project(project_root: Path) -> TycoonProject | None: @@ -426,3 +428,57 @@ def save_project(project: TycoonProject, project_root: Path) -> None: if existing_meta is not None and isinstance(data.get("stack"), dict): data["stack"]["ingestion_metadata"] = existing_meta path.write_text(yaml.dump(data, default_flow_style=False, sort_keys=False)) + + +def migrate_project(project_root: Path) -> bool: + """Upgrade tycoon.yml to SCHEMA_VERSION in place. + + Adds ``metadata:`` with defaults if the key is absent, then stamps + ``schema_version`` when it is absent or older than SCHEMA_VERSION. + The user's ``version`` field is never touched. Writes back only when + a change is needed. Returns True if the file was modified, False if + already up to date (idempotent). Comments and blank lines are + preserved via ruamel.yaml. + """ + from ruamel.yaml import YAML + + path = project_root / PROJECT_FILENAME + if not path.exists(): + return False + + ryaml = YAML() + ryaml.preserve_quotes = True + + with path.open() as f: + raw = ryaml.load(f) + + if not isinstance(raw, dict): + return False + + existing = raw.get("schema_version") + if existing is not None and not isinstance(existing, int): + raise ValueError( + f"tycoon.yml schema_version must be an integer, got {type(existing).__name__}: {existing!r}" + ) + if existing is not None and existing > SCHEMA_VERSION: + raise ValueError( + f"tycoon.yml schema_version {existing} is newer than this tycoon supports ({SCHEMA_VERSION}). " + "Upgrade tycoon-cli to use this project." + ) + + changed = False + + if "metadata" not in raw: + defaults = MetadataConfig() + raw["metadata"] = {"backend": defaults.backend, "path": defaults.path} + changed = True + + if existing is None or existing < SCHEMA_VERSION: + raw["schema_version"] = SCHEMA_VERSION + changed = True + + if changed: + with path.open("w") as f: + ryaml.dump(raw, f) + + return changed diff --git a/tests/test_project.py b/tests/test_project.py index 709a34d..6d8a926 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -2,7 +2,15 @@ from __future__ import annotations -from tycoon.project import DatabaseConfig, SourceConfig, TycoonProject, load_project, save_project +from tycoon.project import ( + DatabaseConfig, + SCHEMA_VERSION, + SourceConfig, + TycoonProject, + load_project, + migrate_project, + save_project, +) class TestTycoonProject: @@ -185,3 +193,85 @@ def test_metadata_field_parses(self, tmp_path): assert p is not None assert p.metadata.backend == "duckdb_file" assert p.metadata.path == ".tycoon/custom_meta.duckdb" + + +class TestMigrateProject: + """T2-2: migrate_project writes missing keys and is idempotent.""" + + def test_missing_metadata_block_is_written(self, tmp_path): + """A yml without metadata: gets it added and schema_version stamped.""" + (tmp_path / "tycoon.yml").write_text("name: old-project\nversion: 1.4.2\n") + + modified = migrate_project(tmp_path) + + assert modified is True + p = load_project(tmp_path) + assert p is not None + assert p.metadata.backend == "duckdb_file" + assert p.metadata.path == ".tycoon/metadata.duckdb" + assert p.schema_version == SCHEMA_VERSION + + def test_user_version_not_overwritten(self, tmp_path): + """migrate_project never touches the user's version field.""" + (tmp_path / "tycoon.yml").write_text("name: old-project\nversion: 1.4.2\n") + + migrate_project(tmp_path) + p = load_project(tmp_path) + + assert p is not None + assert p.version == "1.4.2" + + def test_comments_preserved(self, tmp_path): + """Comments and blank lines survive the ruamel.yaml round-trip.""" + original = "# Project config\nname: acme\n\n# owner: data-platform@acme.com\nversion: 1.0.0\n" + (tmp_path / "tycoon.yml").write_text(original) + + migrate_project(tmp_path) + result = (tmp_path / "tycoon.yml").read_text() + + assert "# Project config" in result + assert "# owner: data-platform@acme.com" in result + + def test_second_call_is_no_op(self, tmp_path): + """Running migrate_project twice returns False on the second call.""" + (tmp_path / "tycoon.yml").write_text("name: old-project\nversion: 0.1.0\n") + + migrate_project(tmp_path) + modified_again = migrate_project(tmp_path) + + assert modified_again is False + + def test_already_migrated_yml_is_unchanged(self, tmp_path): + """A yml that already has metadata: and schema_version is left alone.""" + (tmp_path / "tycoon.yml").write_text( + f"name: current-project\nschema_version: {SCHEMA_VERSION}\n" + "metadata:\n backend: duckdb_file\n path: .tycoon/metadata.duckdb\n" + ) + + modified = migrate_project(tmp_path) + + assert modified is False + + def test_future_schema_version_raises(self, tmp_path): + """A yml with schema_version newer than SCHEMA_VERSION raises ValueError.""" + import pytest + + (tmp_path / "tycoon.yml").write_text( + f"name: future-project\nschema_version: {SCHEMA_VERSION + 1}\n" + ) + + with pytest.raises(ValueError, match="newer than this tycoon supports"): + migrate_project(tmp_path) + + def test_non_integer_schema_version_raises(self, tmp_path): + """A float schema_version (e.g. 0.2 unquoted in YAML) raises ValueError.""" + import pytest + + (tmp_path / "tycoon.yml").write_text("name: bad-project\nschema_version: 0.2\n") + + with pytest.raises(ValueError, match="must be an integer"): + migrate_project(tmp_path) + + def test_missing_file_returns_false(self, tmp_path): + """migrate_project on a directory with no tycoon.yml returns False.""" + assert migrate_project(tmp_path) is False diff --git a/uv.lock b/uv.lock index b3ee75e..74f4279 100644 --- a/uv.lock +++ b/uv.lock @@ -225,6 +225,7 @@ dependencies = [ { name = "pydantic" }, { name = "pyyaml" }, { name = "rich" }, + { name = "ruamel-yaml" }, { name = "typer" }, ] @@ -255,6 +256,7 @@ requires-dist = [ { name = "pydantic", specifier = "==2.13.3" }, { name = "pyyaml", specifier = "==6.0.3" }, { name = "rich", specifier = "==15.0.0" }, + { name = "ruamel-yaml", specifier = "==0.19.1" }, { name = "typer", specifier = "==0.25.0" }, ] provides-extras = ["docs"] @@ -1507,6 +1509,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768 }, ] +[[package]] +name = "ruamel-yaml" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102 }, +] + [[package]] name = "ruff" version = "0.15.20"