From 718e6aae1c153f9baf5c7624b41adc7a066a2ead Mon Sep 17 00:00:00 2001 From: Jesufemi-O Date: Mon, 27 Jul 2026 23:37:38 +0100 Subject: [PATCH 1/4] feat(project): add migrate_project and SCHEMA_VERSION (M2 T2-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writes metadata: defaults and bumps version to 0.2.0 in existing tycoon.yml files. Operates on raw YAML so comments and ordering are preserved. Idempotent — second call returns False with no file write. --- src/tycoon/project.py | 37 +++++++++++++++++++++++++++++++ tests/test_project.py | 51 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/tycoon/project.py b/src/tycoon/project.py index f6c214c..22d0608 100644 --- a/src/tycoon/project.py +++ b/src/tycoon/project.py @@ -392,6 +392,7 @@ def _check_path_field(cls, v: str | None) -> str | None: PROJECT_FILENAME = "tycoon.yml" +SCHEMA_VERSION = "0.2.0" def load_project(project_root: Path) -> TycoonProject | None: @@ -426,3 +427,39 @@ 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. + + Operates on raw YAML so comments and key ordering are preserved. + Adds ``metadata:`` with defaults if the key is absent, then bumps + ``version`` to SCHEMA_VERSION. Writes back only when a change is + needed. Returns True if the file was modified, False if it was + already up to date (idempotent). + """ + path = project_root / PROJECT_FILENAME + if not path.exists(): + return False + + raw = yaml.safe_load(path.read_text()) + if not isinstance(raw, dict): + return False + + changed = False + + if "metadata" not in raw: + raw["metadata"] = { + "backend": MetadataConfig().backend, + "path": MetadataConfig().path, + } + changed = True + + if raw.get("version") != SCHEMA_VERSION: + raw["version"] = SCHEMA_VERSION + changed = True + + if changed: + path.write_text(yaml.dump(raw, default_flow_style=False, sort_keys=False)) + + return changed diff --git a/tests/test_project.py b/tests/test_project.py index 709a34d..d3c5e0f 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,44 @@ 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 version bumped on first call.""" + (tmp_path / "tycoon.yml").write_text("name: old-project\nversion: 0.1.0\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.version == SCHEMA_VERSION + + 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 the current version is left alone.""" + (tmp_path / "tycoon.yml").write_text( + f"name: current-project\nversion: {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_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 From 57fa8487d32e18c30a98880c87f5b031d5c960f6 Mon Sep 17 00:00:00 2001 From: Jesufemi-O Date: Tue, 28 Jul 2026 21:48:34 +0100 Subject: [PATCH 2/4] fix(migrate_project): consolidate MetadataConfig defaults, guard version bump Instantiate MetadataConfig once rather than twice. Guard the version bump so a file already at a future schema version is not written back down to SCHEMA_VERSION. Update docstring to accurately state that comments are not preserved through the yaml round-trip. --- src/tycoon/project.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/tycoon/project.py b/src/tycoon/project.py index 22d0608..035c3ed 100644 --- a/src/tycoon/project.py +++ b/src/tycoon/project.py @@ -432,11 +432,13 @@ def save_project(project: TycoonProject, project_root: Path) -> None: def migrate_project(project_root: Path) -> bool: """Upgrade tycoon.yml to SCHEMA_VERSION in place. - Operates on raw YAML so comments and key ordering are preserved. Adds ``metadata:`` with defaults if the key is absent, then bumps - ``version`` to SCHEMA_VERSION. Writes back only when a change is - needed. Returns True if the file was modified, False if it was - already up to date (idempotent). + ``version`` to SCHEMA_VERSION when the existing value is absent or + older. Writes back only when a change is needed. Returns True if the + file was modified, False if it was already up to date (idempotent). + + Note: round-trips through yaml.safe_load / yaml.dump, so comments + in the file are not preserved. """ path = project_root / PROJECT_FILENAME if not path.exists(): @@ -449,13 +451,12 @@ def migrate_project(project_root: Path) -> bool: changed = False if "metadata" not in raw: - raw["metadata"] = { - "backend": MetadataConfig().backend, - "path": MetadataConfig().path, - } + defaults = MetadataConfig() + raw["metadata"] = {"backend": defaults.backend, "path": defaults.path} changed = True - if raw.get("version") != SCHEMA_VERSION: + existing_version = raw.get("version") + if existing_version is None or existing_version < SCHEMA_VERSION: raw["version"] = SCHEMA_VERSION changed = True From 748e91629b34b9ac1302275d8b866b58a177a5d4 Mon Sep 17 00:00:00 2001 From: Jesufemi-O Date: Wed, 29 Jul 2026 01:57:45 +0100 Subject: [PATCH 3/4] fix(migrate_project): use ruamel.yaml to preserve comments, add schema_version field Switch migrate_project from yaml.safe_load/yaml.dump to ruamel.yaml so comments and blank lines survive the round-trip. Add schema_version as a separate field on TycoonProject so the user's version field is never touched by migration. Add ruamel-yaml==0.19.1 as a runtime dependency. Adds two new tests: comments_preserved and user_version_not_overwritten. --- pyproject.toml | 1 + src/tycoon/project.py | 32 ++++++++++++++++++++------------ tests/test_project.py | 31 ++++++++++++++++++++++++++----- uv.lock | 11 +++++++++++ 4 files changed, 58 insertions(+), 17 deletions(-) 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 035c3ed..3d3033c 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: str | 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") @@ -432,19 +433,25 @@ def save_project(project: TycoonProject, project_root: Path) -> None: 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 bumps - ``version`` to SCHEMA_VERSION when the existing value is absent or - older. Writes back only when a change is needed. Returns True if the - file was modified, False if it was already up to date (idempotent). - - Note: round-trips through yaml.safe_load / yaml.dump, so comments - in the file are not preserved. + 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 - raw = yaml.safe_load(path.read_text()) + ryaml = YAML() + ryaml.preserve_quotes = True + + with path.open() as f: + raw = ryaml.load(f) + if not isinstance(raw, dict): return False @@ -455,12 +462,13 @@ def migrate_project(project_root: Path) -> bool: raw["metadata"] = {"backend": defaults.backend, "path": defaults.path} changed = True - existing_version = raw.get("version") - if existing_version is None or existing_version < SCHEMA_VERSION: - raw["version"] = SCHEMA_VERSION + existing_schema_version = raw.get("schema_version") + if existing_schema_version is None or existing_schema_version < SCHEMA_VERSION: + raw["schema_version"] = SCHEMA_VERSION changed = True if changed: - path.write_text(yaml.dump(raw, default_flow_style=False, sort_keys=False)) + 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 d3c5e0f..42bd0f7 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -199,8 +199,8 @@ 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 version bumped on first call.""" - (tmp_path / "tycoon.yml").write_text("name: old-project\nversion: 0.1.0\n") + """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) @@ -209,7 +209,28 @@ def test_missing_metadata_block_is_written(self, tmp_path): assert p is not None assert p.metadata.backend == "duckdb_file" assert p.metadata.path == ".tycoon/metadata.duckdb" - assert p.version == SCHEMA_VERSION + 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.""" @@ -221,9 +242,9 @@ def test_second_call_is_no_op(self, tmp_path): assert modified_again is False def test_already_migrated_yml_is_unchanged(self, tmp_path): - """A yml that already has metadata: and the current version is left alone.""" + """A yml that already has metadata: and schema_version is left alone.""" (tmp_path / "tycoon.yml").write_text( - f"name: current-project\nversion: {SCHEMA_VERSION}\n" + f"name: current-project\nschema_version: {SCHEMA_VERSION}\n" "metadata:\n backend: duckdb_file\n path: .tycoon/metadata.duckdb\n" ) 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" From 54b58a538bf0e34cb46931f8aa04cc10415f7394 Mon Sep 17 00:00:00 2001 From: Jesufemi-O Date: Wed, 29 Jul 2026 17:49:48 +0100 Subject: [PATCH 4/4] fix(schema_version): switch to integer, validate type, error on future versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change SCHEMA_VERSION from a semver string to an integer (2) so version comparisons are unambiguous — string comparison would incorrectly treat "0.10.0" < "0.2.0" as True. Change schema_version field type to int | None. Add type guard before comparison: a float schema_version (e.g. 0.2 written unquoted in YAML) now raises ValueError with a clear message rather than a TypeError. A schema_version newer than SCHEMA_VERSION also raises rather than silently passing. Add two new tests covering both error paths. --- src/tycoon/project.py | 18 ++++++++++++++---- tests/test_project.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/tycoon/project.py b/src/tycoon/project.py index 3d3033c..697c113 100644 --- a/src/tycoon/project.py +++ b/src/tycoon/project.py @@ -323,7 +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: str | None = Field(default=None, description="Tycoon schema version (managed by tycoon)") + 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") @@ -393,7 +393,7 @@ def _check_path_field(cls, v: str | None) -> str | None: PROJECT_FILENAME = "tycoon.yml" -SCHEMA_VERSION = "0.2.0" +SCHEMA_VERSION = 2 def load_project(project_root: Path) -> TycoonProject | None: @@ -455,6 +455,17 @@ def migrate_project(project_root: Path) -> bool: 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: @@ -462,8 +473,7 @@ def migrate_project(project_root: Path) -> bool: raw["metadata"] = {"backend": defaults.backend, "path": defaults.path} changed = True - existing_schema_version = raw.get("schema_version") - if existing_schema_version is None or existing_schema_version < SCHEMA_VERSION: + if existing is None or existing < SCHEMA_VERSION: raw["schema_version"] = SCHEMA_VERSION changed = True diff --git a/tests/test_project.py b/tests/test_project.py index 42bd0f7..6d8a926 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -252,6 +252,26 @@ def test_already_migrated_yml_is_unchanged(self, 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