diff --git a/src/tycoon/commands/init.py b/src/tycoon/commands/init.py index b922531..c06fc78 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,31 @@ 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) + 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: + 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/config.py b/src/tycoon/config.py index 277e708..83b5e31 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 error as _error_console, warn as _warn_console # v0.1 defaults (used when no tycoon.yml exists) _DEFAULT_RAW_DB = "data/raw.duckdb" @@ -124,8 +125,24 @@ 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 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( + 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 12f165e..6bc1854 100644 --- a/src/tycoon/project.py +++ b/src/tycoon/project.py @@ -421,6 +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") + 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): @@ -456,7 +458,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..051f2bc 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,73 @@ 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 == [] + + 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_init.py b/tests/test_init.py index 75f0aa6..51f4588 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,44 @@ 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 + + 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 589b1e2..83d500d 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -195,6 +195,40 @@ def test_metadata_field_parses(self, tmp_path): assert p.metadata.path == ".tycoon/custom_meta.duckdb" +class TestSchemaVersionEnforcement: + """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 + + 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_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 is None + + class TestMigrateProject: """T2-2: migrate_project writes missing keys and is idempotent."""