From 9bc2d8c7e42c8c5d69fa65d9e568f4c48e4678fc Mon Sep 17 00:00:00 2001 From: Jesufemi-O Date: Wed, 29 Jul 2026 17:58:28 +0100 Subject: [PATCH 1/3] feat(t2-4): warn on stale schema_version, add tycoon init --upgrade load_project now emits a UserWarning when tycoon.yml has no schema_version or one older than SCHEMA_VERSION, pointing users to run the migration. tycoon init --upgrade calls migrate_project and prints whether the file was updated or was already current. Closes #96 --- src/tycoon/commands/init.py | 20 ++++++++++++++++++++ src/tycoon/project.py | 12 +++++++++++- tests/test_init.py | 35 ++++++++++++++++++++++++++++++++++- tests/test_project.py | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/tycoon/commands/init.py b/src/tycoon/commands/init.py index b922531..cd550ee 100644 --- a/src/tycoon/commands/init.py +++ b/src/tycoon/commands/init.py @@ -14,6 +14,7 @@ StackConfig, TransformationTool, WarehouseType, + migrate_project, ) from tycoon.scaffolding.templates import ( list_templates, @@ -504,8 +505,27 @@ def init_cmd( ), ), ] = None, + upgrade: Annotated[ + bool, + typer.Option( + "--upgrade", + help="Migrate tycoon.yml to the current schema version and exit.", + ), + ] = False, ) -> None: """Initialize a new tycoon project in the current directory.""" + if upgrade: + target = Path.cwd() + if not (target / "tycoon.yml").exists(): + error("No tycoon.yml found in the current directory. Run 'tycoon init' to create one.") + raise typer.Exit(1) + changed = migrate_project(target) + if changed: + success("tycoon.yml migrated to the current schema version.") + else: + info("tycoon.yml is already up to date.") + raise typer.Exit(0) + if list_templates_flag: templates = list_templates() if not templates: diff --git a/src/tycoon/project.py b/src/tycoon/project.py index 12f165e..6b81a85 100644 --- a/src/tycoon/project.py +++ b/src/tycoon/project.py @@ -398,6 +398,8 @@ def _check_path_field(cls, v: str | None) -> str | None: def load_project(project_root: Path) -> TycoonProject | None: """Load and validate tycoon.yml from the given root. Returns None if not found.""" + import warnings + path = project_root / PROJECT_FILENAME if not path.exists(): return None @@ -405,7 +407,15 @@ def load_project(project_root: Path) -> TycoonProject | None: if raw is None: return TycoonProject() raw = _interpolate_allowed_fields(raw) - return TycoonProject.model_validate(raw) + project = TycoonProject.model_validate(raw) + if project.schema_version is None or project.schema_version < SCHEMA_VERSION: + current = project.schema_version if project.schema_version is not None else "none" + warnings.warn( + f"tycoon.yml is at schema version {current}, current is {SCHEMA_VERSION}. " + "Run 'tycoon init --upgrade' to migrate.", + stacklevel=2, + ) + return project def save_project(project: TycoonProject, project_root: Path) -> None: diff --git a/tests/test_init.py b/tests/test_init.py index 75f0aa6..8631b84 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -5,7 +5,7 @@ import yaml from tycoon.cli import app -from tycoon.project import load_project +from tycoon.project import SCHEMA_VERSION, load_project from tycoon.scaffolding.templates import list_templates, scaffold_blank_project @@ -18,6 +18,7 @@ def test_init_help_exits_zero(self, cli_runner): assert "--template" in result.stdout assert "--name" in result.stdout assert "--list-templates" in result.stdout + assert "--upgrade" in result.stdout def test_init_appears_in_top_level_help(self, cli_runner): result = cli_runner.invoke(app, ["--help"]) @@ -294,3 +295,35 @@ def test_unknown_param_is_warned_but_not_fatal(self, cli_runner, tmp_path, monke assert result.exit_code == 0 # The unknown-param warning goes to stdout via the console helper assert "bogus" in result.stdout.lower() or "unknown parameter" in result.stdout.lower() + + +class TestUpgrade: + """tycoon init --upgrade migrates tycoon.yml to the current schema version.""" + + def test_upgrade_no_tycoon_yml_exits_nonzero(self, cli_runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + result = cli_runner.invoke(app, ["init", "--upgrade"]) + assert result.exit_code != 0 + + def test_upgrade_migrates_outdated_yml(self, cli_runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "tycoon.yml").write_text("name: old-project\nversion: 1.0.0\n") + + result = cli_runner.invoke(app, ["init", "--upgrade"]) + + assert result.exit_code == 0 + p = load_project(tmp_path) + assert p is not None + assert p.schema_version == SCHEMA_VERSION + + def test_upgrade_already_current_reports_up_to_date(self, cli_runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "tycoon.yml").write_text( + f"name: current\nschema_version: {SCHEMA_VERSION}\n" + "metadata:\n backend: duckdb_file\n path: .tycoon/metadata.duckdb\n" + ) + + result = cli_runner.invoke(app, ["init", "--upgrade"]) + + assert result.exit_code == 0 + assert "up to date" in result.stdout diff --git a/tests/test_project.py b/tests/test_project.py index 589b1e2..d7a21f9 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -195,6 +195,38 @@ def test_metadata_field_parses(self, tmp_path): assert p.metadata.path == ".tycoon/custom_meta.duckdb" +class TestSchemaVersionWarning: + """T2-4: load_project emits a warning when schema_version is absent or old.""" + + def test_warns_when_schema_version_absent(self, tmp_path): + import pytest + + (tmp_path / "tycoon.yml").write_text("name: old-project\n") + + with pytest.warns(UserWarning, match="Run 'tycoon init --upgrade'"): + load_project(tmp_path) + + def test_warns_when_schema_version_old(self, tmp_path): + import pytest + + (tmp_path / "tycoon.yml").write_text(f"name: old-project\nschema_version: {SCHEMA_VERSION - 1}\n") + + with pytest.warns(UserWarning, match="Run 'tycoon init --upgrade'"): + load_project(tmp_path) + + def test_no_warning_when_current(self, tmp_path): + import warnings + + (tmp_path / "tycoon.yml").write_text(f"name: current\nschema_version: {SCHEMA_VERSION}\n") + + with warnings.catch_warnings(): + warnings.filterwarnings("error", category=UserWarning) + p = load_project(tmp_path) + + assert p is not None + assert p.schema_version == SCHEMA_VERSION + + class TestMigrateProject: """T2-2: migrate_project writes missing keys and is idempotent.""" From ff5a72ffa44a54cddfdfd01720d5f6dd750cde49 Mon Sep 17 00:00:00 2001 From: Jesufemi-O Date: Fri, 31 Jul 2026 03:51:57 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix(t2-4):=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20move=20warning=20to=20load=5Fconfig,=20fix=20edge?= =?UTF-8?q?=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Warning moved from load_project() to load_config() and switched to Rich console helper, so it renders in-band and does not appear on fresh projects or on tycoon init --upgrade (neither goes through load_config) - load_project() now raises ValueError for schema_version > SCHEMA_VERSION, mirroring migrate_project's three-way split - save_project() stamps schema_version on every write so a load+save round-trip (sources add/remove) silently upgrades the stamp - scaffold_blank_project() and scaffold_from_template() write schema_version on new projects, eliminating the false-alarm on init - migrate_project() checks isinstance(existing, bool) before int to catch schema_version: true - tycoon init --upgrade wraps migrate_project in try/except ValueError for clean error output instead of a typer traceback --- src/tycoon/commands/init.py | 6 +++- src/tycoon/config.py | 15 +++++++-- src/tycoon/project.py | 15 ++++----- src/tycoon/scaffolding/templates.py | 6 +++- tests/test_config.py | 52 ++++++++++++++++++++++++++++- tests/test_init.py | 9 +++++ tests/test_project.py | 35 +++++++------------ 7 files changed, 101 insertions(+), 37 deletions(-) diff --git a/src/tycoon/commands/init.py b/src/tycoon/commands/init.py index cd550ee..c06fc78 100644 --- a/src/tycoon/commands/init.py +++ b/src/tycoon/commands/init.py @@ -519,7 +519,11 @@ def init_cmd( if not (target / "tycoon.yml").exists(): error("No tycoon.yml found in the current directory. Run 'tycoon init' to create one.") raise typer.Exit(1) - changed = migrate_project(target) + try: + changed = migrate_project(target) + except ValueError as exc: + error(str(exc)) + raise typer.Exit(1) from exc if changed: success("tycoon.yml migrated to the current schema version.") else: diff --git a/src/tycoon/config.py b/src/tycoon/config.py index 277e708..6101bdf 100644 --- a/src/tycoon/config.py +++ b/src/tycoon/config.py @@ -8,7 +8,8 @@ from pathlib import Path -from tycoon.project import PROJECT_FILENAME, TycoonProject, load_project +from tycoon.project import PROJECT_FILENAME, SCHEMA_VERSION, TycoonProject, load_project +from tycoon.utils.console import warn as _warn_console # v0.1 defaults (used when no tycoon.yml exists) _DEFAULT_RAW_DB = "data/raw.duckdb" @@ -124,8 +125,18 @@ def load_config() -> TycoonConfig: """Return a TycoonConfig rooted at the nearest project directory. Prefer this over importing _find_project_root across module boundaries. + Emits a console warning when tycoon.yml is at an older schema version. """ - return TycoonConfig(project_root=_find_project_root()) + cfg = TycoonConfig(project_root=_find_project_root()) + if cfg.project is not None: + sv = cfg.project.schema_version + if sv is None or sv < SCHEMA_VERSION: + current = sv if sv is not None else "none" + _warn_console( + f"tycoon.yml is at schema version {current}, current is {SCHEMA_VERSION}. " + "Run 'tycoon init --upgrade' to migrate." + ) + return cfg # Singleton (used by modules not yet migrated to load_config) diff --git a/src/tycoon/project.py b/src/tycoon/project.py index 6b81a85..18c612d 100644 --- a/src/tycoon/project.py +++ b/src/tycoon/project.py @@ -398,8 +398,6 @@ def _check_path_field(cls, v: str | None) -> str | None: def load_project(project_root: Path) -> TycoonProject | None: """Load and validate tycoon.yml from the given root. Returns None if not found.""" - import warnings - path = project_root / PROJECT_FILENAME if not path.exists(): return None @@ -408,12 +406,10 @@ def load_project(project_root: Path) -> TycoonProject | None: return TycoonProject() raw = _interpolate_allowed_fields(raw) project = TycoonProject.model_validate(raw) - if project.schema_version is None or project.schema_version < SCHEMA_VERSION: - current = project.schema_version if project.schema_version is not None else "none" - warnings.warn( - f"tycoon.yml is at schema version {current}, current is {SCHEMA_VERSION}. " - "Run 'tycoon init --upgrade' to migrate.", - stacklevel=2, + if project.schema_version is not None and project.schema_version > SCHEMA_VERSION: + raise ValueError( + f"tycoon.yml schema_version {project.schema_version} is newer than this tycoon supports " + f"({SCHEMA_VERSION}). Upgrade tycoon-cli to use this project." ) return project @@ -431,6 +427,7 @@ def save_project(project: TycoonProject, project_root: Path) -> None: """ path = project_root / PROJECT_FILENAME data = project.model_dump(by_alias=True, exclude_none=True, mode="json") + data["schema_version"] = SCHEMA_VERSION if path.exists(): existing = yaml.safe_load(path.read_text()) if isinstance(existing, dict): @@ -466,7 +463,7 @@ def migrate_project(project_root: Path) -> bool: return False existing = raw.get("schema_version") - if existing is not None and not isinstance(existing, int): + if existing is not None and (isinstance(existing, bool) or 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( diff --git a/src/tycoon/scaffolding/templates.py b/src/tycoon/scaffolding/templates.py index f7441db..8b404c7 100644 --- a/src/tycoon/scaffolding/templates.py +++ b/src/tycoon/scaffolding/templates.py @@ -14,7 +14,7 @@ import typer import yaml -from tycoon.project import StackConfig +from tycoon.project import SCHEMA_VERSION, StackConfig from tycoon.utils.console import info, success, warn @@ -159,6 +159,7 @@ def scaffold_blank_project( project_data: dict = { "name": name, "version": "0.1.0", + "schema_version": SCHEMA_VERSION, "database": { "raw": raw_db_path, "warehouse": warehouse_db_path, @@ -537,6 +538,9 @@ def scaffold_from_template( warn("tycoon.yml already exists, skipping") else: shutil.copy2(src_yml, dst_yml) + _raw = yaml.safe_load(dst_yml.read_text()) or {} + _raw["schema_version"] = SCHEMA_VERSION + dst_yml.write_text(yaml.dump(_raw, default_flow_style=False, sort_keys=False)) success(f"Created tycoon.yml from template '{template_name}'") # Copy any subdirectories from the template (e.g. dbt_project/, rill/) diff --git a/tests/test_config.py b/tests/test_config.py index d06e956..af43fd1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,7 +4,8 @@ from pathlib import Path -from tycoon.config import TycoonConfig +from tycoon.config import TycoonConfig, load_config +from tycoon.project import SCHEMA_VERSION class TestTycoonConfig: @@ -36,3 +37,52 @@ def test_paths_relative_to_project_root(self, tmp_config): assert tmp_config.data_dir == tmp_config.root / "data" assert tmp_config.dbt_project_dir == tmp_config.root / "dbt_project" assert tmp_config.rill_dir == tmp_config.root / "rill" + + +class TestLoadConfigSchemaWarning: + """T2-4: load_config warns via console when tycoon.yml schema_version is stale.""" + + def test_warns_when_schema_version_absent(self, tmp_path, monkeypatch): + (tmp_path / "tycoon.yml").write_text("name: old-project\n") + monkeypatch.chdir(tmp_path) + + calls = [] + monkeypatch.setattr("tycoon.config._warn_console", lambda msg: calls.append(msg)) + + load_config() + + assert len(calls) == 1 + assert "tycoon init --upgrade" in calls[0] + + def test_warns_when_schema_version_old(self, tmp_path, monkeypatch): + (tmp_path / "tycoon.yml").write_text(f"name: old\nschema_version: {SCHEMA_VERSION - 1}\n") + monkeypatch.chdir(tmp_path) + + calls = [] + monkeypatch.setattr("tycoon.config._warn_console", lambda msg: calls.append(msg)) + + load_config() + + assert len(calls) == 1 + + def test_no_warning_when_current(self, tmp_path, monkeypatch): + (tmp_path / "tycoon.yml").write_text(f"name: current\nschema_version: {SCHEMA_VERSION}\n") + monkeypatch.chdir(tmp_path) + + calls = [] + monkeypatch.setattr("tycoon.config._warn_console", lambda msg: calls.append(msg)) + + load_config() + + assert calls == [] + + def test_no_warning_without_tycoon_yml(self, tmp_path, monkeypatch): + (tmp_path / "pyproject.toml").write_text('[project]\nname = "x"\n') + monkeypatch.chdir(tmp_path) + + calls = [] + monkeypatch.setattr("tycoon.config._warn_console", lambda msg: calls.append(msg)) + + load_config() + + assert calls == [] diff --git a/tests/test_init.py b/tests/test_init.py index 8631b84..51f4588 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -327,3 +327,12 @@ def test_upgrade_already_current_reports_up_to_date(self, cli_runner, tmp_path, assert result.exit_code == 0 assert "up to date" in result.stdout + + def test_upgrade_future_schema_version_exits_nonzero_cleanly(self, cli_runner, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + (tmp_path / "tycoon.yml").write_text(f"name: future\nschema_version: {SCHEMA_VERSION + 1}\n") + + result = cli_runner.invoke(app, ["init", "--upgrade"]) + + assert result.exit_code != 0 + assert "newer than this tycoon supports" in result.output diff --git a/tests/test_project.py b/tests/test_project.py index d7a21f9..5757c22 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -195,36 +195,25 @@ def test_metadata_field_parses(self, tmp_path): assert p.metadata.path == ".tycoon/custom_meta.duckdb" -class TestSchemaVersionWarning: - """T2-4: load_project emits a warning when schema_version is absent or old.""" +class TestSchemaVersionEnforcement: + """T2-4: load_project raises on future schema_version; save_project stamps it.""" - def test_warns_when_schema_version_absent(self, tmp_path): + def test_future_schema_version_raises_in_load(self, tmp_path): import pytest - (tmp_path / "tycoon.yml").write_text("name: old-project\n") - - with pytest.warns(UserWarning, match="Run 'tycoon init --upgrade'"): - load_project(tmp_path) + (tmp_path / "tycoon.yml").write_text(f"name: future\nschema_version: {SCHEMA_VERSION + 1}\n") - def test_warns_when_schema_version_old(self, tmp_path): - import pytest - - (tmp_path / "tycoon.yml").write_text(f"name: old-project\nschema_version: {SCHEMA_VERSION - 1}\n") - - with pytest.warns(UserWarning, match="Run 'tycoon init --upgrade'"): + with pytest.raises(ValueError, match="newer than this tycoon supports"): load_project(tmp_path) - def test_no_warning_when_current(self, tmp_path): - import warnings - - (tmp_path / "tycoon.yml").write_text(f"name: current\nschema_version: {SCHEMA_VERSION}\n") - - with warnings.catch_warnings(): - warnings.filterwarnings("error", category=UserWarning) - p = load_project(tmp_path) - + def test_save_project_stamps_schema_version(self, tmp_path): + (tmp_path / "tycoon.yml").write_text("name: old-project\n") + p = load_project(tmp_path) assert p is not None - assert p.schema_version == SCHEMA_VERSION + save_project(p, tmp_path) + reloaded = load_project(tmp_path) + assert reloaded is not None + assert reloaded.schema_version == SCHEMA_VERSION class TestMigrateProject: From 53cebfcb86cb6ef480477c7d92b684bb4e21aade Mon Sep 17 00:00:00 2001 From: Jesufemi-O Date: Sun, 2 Aug 2026 08:55:43 -0400 Subject: [PATCH 3/3] fix(t2-4): move future-schema gate to load_config, preserve schema_version in save_project - load_project no longer raises for schema_version > SCHEMA_VERSION; the enforcement now lives in load_config() alongside the stale-version warning, keeping the module-level singleton and --help import-safe - load_config errors and raises SystemExit(1) for a future schema_version so any data command fails cleanly rather than tracing through typer internals - save_project preserves whatever schema_version is already in the model instead of unconditionally stamping SCHEMA_VERSION; only migrate_project (via tycoon init --upgrade) advances the stamp - Update tests: load_project future-schema test flipped to assert no raise; save_project tests now assert preservation semantics; new test covers the load_config SystemExit path --- src/tycoon/config.py | 8 +++++++- src/tycoon/project.py | 11 +++-------- tests/test_config.py | 21 +++++++++++++++++++++ tests/test_project.py | 29 +++++++++++++++++++++-------- 4 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/tycoon/config.py b/src/tycoon/config.py index 6101bdf..83b5e31 100644 --- a/src/tycoon/config.py +++ b/src/tycoon/config.py @@ -9,7 +9,7 @@ from pathlib import Path from tycoon.project import PROJECT_FILENAME, SCHEMA_VERSION, TycoonProject, load_project -from tycoon.utils.console import warn as _warn_console +from tycoon.utils.console import error as _error_console, warn as _warn_console # v0.1 defaults (used when no tycoon.yml exists) _DEFAULT_RAW_DB = "data/raw.duckdb" @@ -130,6 +130,12 @@ def load_config() -> TycoonConfig: cfg = TycoonConfig(project_root=_find_project_root()) if cfg.project is not None: sv = cfg.project.schema_version + if sv is not None and sv > SCHEMA_VERSION: + _error_console( + f"tycoon.yml schema_version {sv} is newer than this tycoon supports " + f"({SCHEMA_VERSION}). Upgrade tycoon-cli to use this project." + ) + raise SystemExit(1) if sv is None or sv < SCHEMA_VERSION: current = sv if sv is not None else "none" _warn_console( diff --git a/src/tycoon/project.py b/src/tycoon/project.py index 18c612d..6bc1854 100644 --- a/src/tycoon/project.py +++ b/src/tycoon/project.py @@ -405,13 +405,7 @@ def load_project(project_root: Path) -> TycoonProject | None: if raw is None: return TycoonProject() raw = _interpolate_allowed_fields(raw) - project = TycoonProject.model_validate(raw) - if project.schema_version is not None and project.schema_version > SCHEMA_VERSION: - raise ValueError( - f"tycoon.yml schema_version {project.schema_version} is newer than this tycoon supports " - f"({SCHEMA_VERSION}). Upgrade tycoon-cli to use this project." - ) - return project + return TycoonProject.model_validate(raw) def save_project(project: TycoonProject, project_root: Path) -> None: @@ -427,7 +421,8 @@ def save_project(project: TycoonProject, project_root: Path) -> None: """ path = project_root / PROJECT_FILENAME data = project.model_dump(by_alias=True, exclude_none=True, mode="json") - data["schema_version"] = SCHEMA_VERSION + if project.schema_version is not None: + data["schema_version"] = project.schema_version if path.exists(): existing = yaml.safe_load(path.read_text()) if isinstance(existing, dict): diff --git a/tests/test_config.py b/tests/test_config.py index af43fd1..051f2bc 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -86,3 +86,24 @@ def test_no_warning_without_tycoon_yml(self, tmp_path, monkeypatch): load_config() assert calls == [] + + def test_errors_and_exits_when_schema_version_future(self, tmp_path, monkeypatch): + """load_config must error and exit for a schema_version newer than SCHEMA_VERSION. + + The gate lives here (not in load_project) so the import-time singleton + never raises and --help / init --upgrade remain reachable. + """ + import pytest + + (tmp_path / "tycoon.yml").write_text(f"name: future\nschema_version: {SCHEMA_VERSION + 1}\n") + monkeypatch.chdir(tmp_path) + + errors = [] + monkeypatch.setattr("tycoon.config._error_console", lambda msg: errors.append(msg)) + + with pytest.raises(SystemExit) as exc_info: + load_config() + + assert exc_info.value.code == 1 + assert len(errors) == 1 + assert "newer than this tycoon supports" in errors[0] diff --git a/tests/test_project.py b/tests/test_project.py index 5757c22..83d500d 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -196,24 +196,37 @@ def test_metadata_field_parses(self, tmp_path): class TestSchemaVersionEnforcement: - """T2-4: load_project raises on future schema_version; save_project stamps it.""" - - def test_future_schema_version_raises_in_load(self, tmp_path): - import pytest + """T2-4: load_project is permissive; save_project preserves schema_version.""" + def test_future_schema_version_loads_without_raise(self, tmp_path): + """load_project must not raise for a future schema_version — the gate + lives in load_config() so the import-time singleton never trips it.""" (tmp_path / "tycoon.yml").write_text(f"name: future\nschema_version: {SCHEMA_VERSION + 1}\n") + p = load_project(tmp_path) + assert p is not None + assert p.schema_version == SCHEMA_VERSION + 1 - with pytest.raises(ValueError, match="newer than this tycoon supports"): - load_project(tmp_path) + def test_save_project_preserves_schema_version(self, tmp_path): + """save_project preserves whatever schema_version is in the model; + only migrate_project (via init --upgrade) advances the stamp.""" + (tmp_path / "tycoon.yml").write_text(f"name: old\nschema_version: {SCHEMA_VERSION}\n") + p = load_project(tmp_path) + assert p is not None + save_project(p, tmp_path) + reloaded = load_project(tmp_path) + assert reloaded is not None + assert reloaded.schema_version == SCHEMA_VERSION - def test_save_project_stamps_schema_version(self, tmp_path): + def test_save_project_does_not_stamp_when_absent(self, tmp_path): + """A project with no schema_version on disk stays unstamped after save_project.""" (tmp_path / "tycoon.yml").write_text("name: old-project\n") p = load_project(tmp_path) assert p is not None + assert p.schema_version is None save_project(p, tmp_path) reloaded = load_project(tmp_path) assert reloaded is not None - assert reloaded.schema_version == SCHEMA_VERSION + assert reloaded.schema_version is None class TestMigrateProject: