feat(project): add migrate_project and SCHEMA_VERSION (M2 T2-2) - #171
Conversation
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.
|
Caution The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased. |
|
I ran Input, and what came back out: # Tycoon project config for the analytics team. # BEFORE
# Owner: data-platform@acme.com
name: acme-analytics
version: 1.4.2 # our product release, tracked in CI
database:
# raw lands here from dlt
raw: data/raw.duckdbname: acme-analytics # AFTER
version: 0.2.0
database:
raw: data/raw.duckdb1. Every comment in the file is destroyed. The docstring says "Operates on raw YAML so comments and key ordering are preserved." Key ordering does survive; comments don't — 2. The user's project version gets silently overwritten. Smaller, while you're in here:
Worth confirming: was leaving Good news on the part I was most worried about: the hand-authored |
db-tycoon-stephen
left a comment
There was a problem hiding this comment.
Blocking on the two data-loss issues detailed in the comment above: migrate_project strips every comment from the user's tycoon.yml (contradicting its own docstring), and it overwrites the user's version: with SCHEMA_VERSION, conflating the project version with the schema version.
Happy to re-review as soon as those two are addressed — the rest of the PR, including the idempotency tests, is in good shape.
…ion 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.
…a_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.
|
Pushed fixes for both blocking issues: Comments preserved — switched User version untouched — migration now writes to a new |
|
Both original issues are properly fixed — I re-ran the same case and got 4 of 4 comments preserved including the inline one on The new version guard has two problems, though, and they're both in the comparison itself: if existing_schema_version is None or existing_schema_version < SCHEMA_VERSION:1. It's a string comparison, so it downgrades double-digit versions. Latent until a component hits double digits, but that's exactly the case the guard exists to prevent, so it'd be a shame to leave it armed. 2. A non-string value crashes. YAML parses a bare Plausible for a hand-edited file, and a traceback isn't a great answer to it. Both go away with a parsed comparison — One question, not a blocker: |
db-tycoon-stephen
left a comment
There was a problem hiding this comment.
Original two bugs are fixed — comments and the user's version are both safe now. Holding on the two defects in the new version guard, detailed above: the string comparison downgrades 0.10.0 to 0.2.0, and a float schema_version raises a TypeError.
db-tycoon-stephen
left a comment
There was a problem hiding this comment.
The comment fix and the version / schema_version split are both solid — I re-ran my earlier case and got 4 of 4 comments preserved including the inline one, version: 1.4.2 untouched, still idempotent.
Suggestions inline for the version guard. The short version: rather than fixing the string comparison, change the type so the bug can't be expressed, and make a newer-than-us file an error rather than a silent no-op.
Two things worth raising separately from the suggestions:
save_project undoes the ruamel work. It rebuilds the file from project.model_dump() and writes it with plain yaml.dump, so comments survive tycoon migrate but die on the next tycoon data sources add. Pre-existing, not something this PR introduced — but comment preservation isn't achieved end to end until save_project gets the same treatment. Probably its own issue.
Tests worth adding beyond the existing four: a v1 file migrates and stamps 2; a file at schema_version: 3 raises instead of silently passing; schema_version: 0.2 gives a clear error rather than TypeError; and a round-trip asserting comments actually survive, which nothing covers today.
The int change revises a format you wrote yesterday, so push back if there's a reason for semver I can't see from the diff — the three-outcome logic and the step chain stand either way.
|
|
||
|
|
||
| PROJECT_FILENAME = "tycoon.yml" | ||
| SCHEMA_VERSION = "0.2.0" |
There was a problem hiding this comment.
Making this an int is what actually removes the bug class. The comparison problem isn't really "we used < on strings" — it's that a schema version is being modelled as a dotted semver string, a type with no built-in total ordering. Switch the type and "0.10.0" < "0.2.0" becomes unrepresentable rather than fixed.
There's also no meaningful minor-vs-patch distinction for a config schema: either the shape changed or it didn't. Alembic, Django and Rails all use sequence numbers here for exactly that reason.
| SCHEMA_VERSION = "0.2.0" | |
| SCHEMA_VERSION = 2 # monotonic; bump whenever the tycoon.yml shape changes |
|
|
||
| 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)") |
There was a problem hiding this comment.
Follows from the SCHEMA_VERSION change below. version stays exactly as it is — the user's own project version, never written by tycoon.
| schema_version: str | None = Field(default=None, description="Tycoon schema version (managed by tycoon)") | |
| schema_version: int | None = Field(default=None, description="tycoon.yml schema version (managed by tycoon)") |
| 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 |
There was a problem hiding this comment.
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).
| 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 |
…e versions 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.
|
Pushed the version guard fix. Switched Also filing an issue for |
db-tycoon-stephen
left a comment
There was a problem hiding this comment.
Both issues are resolved — SCHEMA_VERSION = 2 as an int, schema_version: int | None on the model, type validation with a clear message, and a hard refusal on a future version. The string-comparison downgrade and the ScalarFloat TypeError are both gone by construction.
You skipped the _MIGRATIONS step chain, which is fine — that was forward-looking rather than a defect. Worth revisiting when a second migration step actually lands.
Note the bool guard you added in #178 belongs here too (isinstance(True, int) is True, so schema_version: true slips through on this branch), but it's covered downstream so not worth another round-trip.
What this does
T2-1 made
tycoon.ymlacceptmetadata:andruntimes:via defaults. But existing project files never have those keys written to disk — they only get the defaults at load time. T2-2 adds a migration function that writes those defaults into the file so a project is permanently upgraded.Files touched
src/tycoon/project.pySCHEMA_VERSION = "0.2.0"andmigrate_project()tests/test_project.pyTestMigrateProjectclass with 4 testsWhat to review
migrate_project(project_root):metadata:with defaults if the key is absentversiontoSCHEMA_VERSIONTrueif the file was modified,Falseif already up to dateTests added
test_missing_metadata_block_is_writtenmetadata:gets it written in, version bumped, file reloads correctlytest_second_call_is_no_opFalsetest_already_migrated_yml_is_unchangedSCHEMA_VERSIONwithmetadata:present → returnsFalsetest_missing_file_returns_falsetycoon.yml→ returnsFalse, no crashCI checks (run locally)
uv run ruff check src tests— cleanpytest— 690 passed, 3 skippedStack position
Stacks on #169 (T2-1). T2-3 (#94) will follow.
Closes #92
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.Closes #92