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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
56 changes: 56 additions & 0 deletions src/tycoon/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Comment on lines +433 to +484

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the substantive one. Three changes rolled together:

Three outcomes instead of two. Today a file written by a newer tycoon falls through and returns False, which the caller reads as "already up to date" — so the CLI proceeds against a schema this build doesn't understand. That's the safety gap that worries me more than the downgrade bug. Older → migrate; equal → no-op; newer → refuse loudly.

A step chain instead of inline if blocks. Right now migrate does exactly one thing, but SCHEMA_VERSION exists precisely because there will be more, and step two must run only for files below it. Adding v3 then becomes writing _to_v3 and bumping the constant, with no change to the driver.

raw.get("schema_version", 1) treats an unstamped file as v1, so existing projects flow through the same path rather than needing a None branch. isinstance(current, bool) comes first because bool subclasses int, so schema_version: true would otherwise sail through.

ProjectMigrationError subclasses RuntimeError to match IngestionError / ScheduleError; the command layer catches it and turns it into error(...) + typer.Exit(1).

Suggested change
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
changed = False
if "metadata" not in raw:
defaults = MetadataConfig()
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:
raw["schema_version"] = SCHEMA_VERSION
changed = True
if changed:
with path.open("w") as f:
ryaml.dump(raw, f)
return changed
class ProjectMigrationError(RuntimeError):
"""tycoon.yml cannot be migrated by this build."""
def _to_v2(raw: dict) -> None:
"""v1 -> v2: the metadata: block became explicit."""
defaults = MetadataConfig()
raw.setdefault("metadata", {"backend": defaults.backend, "path": defaults.path})
_MIGRATIONS = {2: _to_v2}
def migrate_project(project_root: Path) -> bool:
"""Upgrade tycoon.yml to SCHEMA_VERSION in place.
Applies each migration step between the file's ``schema_version`` and
SCHEMA_VERSION, then stamps the new version. The user's ``version``
field is never touched. Comments and blank lines are preserved via
ruamel.yaml. Returns True if the file was modified.
Raises ProjectMigrationError if the file was written by a newer
tycoon, or if ``schema_version`` is not an integer.
"""
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
current = raw.get("schema_version", 1) # unstamped == the original shape
if isinstance(current, bool) or not isinstance(current, int):
raise ProjectMigrationError(f"{path}: schema_version must be an integer, got {current!r}")
if current > SCHEMA_VERSION:
raise ProjectMigrationError(
f"{path} was written by a newer tycoon (schema {current}); this build "
f"understands up to {SCHEMA_VERSION}. Upgrade tycoon to open this project."
)
if current == SCHEMA_VERSION:
return False
for step in range(current + 1, SCHEMA_VERSION + 1):
_MIGRATIONS[step](raw)
raw["schema_version"] = SCHEMA_VERSION
with path.open("w") as f:
ryaml.dump(raw, f)
return True

92 changes: 91 additions & 1 deletion tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
11 changes: 11 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.