Skip to content

feat(project): add migrate_project and SCHEMA_VERSION (M2 T2-2) - #171

Merged
db-tycoon-stephen merged 4 commits into
feat/m2-t2-1from
feat/m2-t2-2
Jul 31, 2026
Merged

feat(project): add migrate_project and SCHEMA_VERSION (M2 T2-2)#171
db-tycoon-stephen merged 4 commits into
feat/m2-t2-1from
feat/m2-t2-2

Conversation

@JesuFemi-O

@JesuFemi-O JesuFemi-O commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

What this does

T2-1 made tycoon.yml accept metadata: and runtimes: 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

File What changed
src/tycoon/project.py Added SCHEMA_VERSION = "0.2.0" and migrate_project()
tests/test_project.py Added TestMigrateProject class with 4 tests

What to review

migrate_project(project_root):

  • Reads raw YAML (not through Pydantic) — preserves comments and key ordering
  • Adds metadata: with defaults if the key is absent
  • Bumps version to SCHEMA_VERSION
  • Writes back only when a change was made
  • Returns True if the file was modified, False if already up to date

Tests added

Test What it covers
test_missing_metadata_block_is_written yml without metadata: gets it written in, version bumped, file reloads correctly
test_second_call_is_no_op Running migrate twice → second call returns False
test_already_migrated_yml_is_unchanged yml already at SCHEMA_VERSION with metadata: present → returns False
test_missing_file_returns_false No tycoon.yml → returns False, no crash

CI checks (run locally)

  • uv run ruff check src tests — clean
  • pytest — 690 passed, 3 skipped

Stack position

Stacks on #169 (T2-1). T2-3 (#94) will follow.

Closes #92


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Closes #92

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.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@db-tycoon-stephen

Copy link
Copy Markdown
Contributor

I ran migrate_project against a representative tycoon.yml. Two bugs, both of which destroy user data, and both of which matter more than usual because the next M2 task puts this in front of users.

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.duckdb
name: acme-analytics                                   # AFTER
version: 0.2.0
database:
  raw: data/raw.duckdb

1. 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 — yaml.safe_load discards them and yaml.dump can't restore them. Four of four gone above, blank lines collapsed too. ruamel.yaml with preserve_quotes=True round-trips comments properly. If we'd rather not take that dependency, the docstring needs to lose the claim and the command needs to warn before writing.

2. The user's project version gets silently overwritten. TycoonProject.version is declared Field(default="0.1.0", description="Project version") — that's the user's version of their project. migrate_project stamps SCHEMA_VERSION into that same key, so 1.4.2 became 0.2.0 above. These are two different concepts sharing one field. Either add a separate schema_version: key, or repurpose version explicitly as the schema version and update its description and default to match.

Smaller, while you're in here:

  • MetadataConfig() is instantiated twice in the same block; once into a local reads better.
  • The version bump is unconditional, so a file already at a future 0.3.0 gets written back down to 0.2.0. Probably want to only stamp when the existing value is older or absent.

Worth confirming: was leaving runtimes: out of the migration deliberate? It defaults to {} so there's nothing useful to stamp, which is a fine answer — just want to make sure it wasn't an oversight given #169 added both keys together.

Good news on the part I was most worried about: the hand-authored ${FIVETRAN_API_SECRET} env-refs survive intact, so this doesn't reintroduce the #60 regression.

@db-tycoon-stephen db-tycoon-stephen left a comment

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.

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.
@JesuFemi-O

Copy link
Copy Markdown
Contributor Author

Pushed fixes for both blocking issues:

Comments preserved — switched migrate_project from yaml.safe_load/yaml.dump to ruamel.yaml (added as ruamel-yaml==0.19.1 in the runtime deps). Comments and blank lines now survive the round-trip. Added test_comments_preserved to verify.

User version untouched — migration now writes to a new schema_version field on TycoonProject rather than the user's version field. version: 1.4.2 stays version: 1.4.2. Added test_user_version_not_overwritten to confirm.

@db-tycoon-stephen

Copy link
Copy Markdown
Contributor

Both original issues are properly fixed — I re-ran the same case and got 4 of 4 comments preserved including the inline one on version:, blank lines intact, version: 1.4.2 untouched, and still idempotent. The separate schema_version field is the right call.

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. "0.10.0" < "0.2.0" is True — lexicographic, "1" sorts before "2". Verified against this branch:

existing 0.1.0   -> schema_version: 0.2.0    ✓
existing 0.10.0  -> schema_version: 0.2.0    ← downgraded
existing 1.0.0   -> schema_version: 1.0.0    ✓

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 schema_version: 0.2 as a float, and the comparison raises rather than erroring cleanly:

TypeError: '<' not supported between instances of 'ScalarFloat' and 'str'

Plausible for a hand-edited file, and a traceback isn't a great answer to it.

Both go away with a parsed comparison — packaging.version.Version if you're happy taking the dep (it's already transitive via dbt), otherwise coercing to str and comparing tuple(int(p) for p in v.split(".")) is enough here.

One question, not a blocker: TycoonProject.schema_version defaults to None, so a project created by tycoon init starts out unstamped and looks unmigrated until someone runs migrate. Is stamping it at init part of the wiring task, or should the default be SCHEMA_VERSION?

@db-tycoon-stephen db-tycoon-stephen left a comment

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.

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 db-tycoon-stephen left a comment

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.

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.

Comment thread src/tycoon/project.py Outdated


PROJECT_FILENAME = "tycoon.yml"
SCHEMA_VERSION = "0.2.0"

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.

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.

Suggested change
SCHEMA_VERSION = "0.2.0"
SCHEMA_VERSION = 2 # monotonic; bump whenever the tycoon.yml shape changes

Comment thread src/tycoon/project.py Outdated

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)")

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.

Follows from the SCHEMA_VERSION change below. version stays exactly as it is — the user's own project version, never written by tycoon.

Suggested change
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)")

Comment thread src/tycoon/project.py
Comment on lines +433 to +474
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

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

…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.
@JesuFemi-O

Copy link
Copy Markdown
Contributor Author

Pushed the version guard fix. Switched SCHEMA_VERSION from "0.2.0" to 2 (integer) and schema_version field type to int | None — eliminates both the lexicographic comparison bug and the float TypeError in one move. Added type validation before the comparison: a non-integer schema_version raises ValueError with a clear message, and a value newer than SCHEMA_VERSION raises rather than silently passing. Two new tests cover both paths.

Also filing an issue for save_project stripping comments via plain yaml.dump — agreed that's out of scope for this PR.

@db-tycoon-stephen db-tycoon-stephen left a comment

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.

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.

@db-tycoon-stephen
db-tycoon-stephen merged commit fb7b4b8 into feat/m2-t2-1 Jul 31, 2026
9 checks passed
@db-tycoon-stephen
db-tycoon-stephen deleted the feat/m2-t2-2 branch July 31, 2026 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants