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
24 changes: 24 additions & 0 deletions src/tycoon/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
StackConfig,
TransformationTool,
WarehouseType,
migrate_project,
)
from tycoon.scaffolding.templates import (
list_templates,
Expand Down Expand Up @@ -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:

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.

Small one, but it undercuts the feature: this command nags about the very thing it's about to fix.

$ tycoon init --upgrade
UserWarning: tycoon.yml is at schema version none, current is 2. Run 'tycoon init --upgrade' to migrate.
OK tycoon.yml migrated to the current schema version.

Cause is upstream of this block: config.py builds the TycoonConfig singleton at module import, which calls load_project() before any command body runs. So the warning fires on every command whether or not it touches the project. Suppressing it for the upgrade path, or moving the check out of load_project and into the commands that act on the project, both fix it.

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:
Expand Down
21 changes: 19 additions & 2 deletions src/tycoon/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion src/tycoon/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 5 additions & 1 deletion src/tycoon/scaffolding/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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/)
Expand Down
73 changes: 72 additions & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
44 changes: 43 additions & 1 deletion tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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"])
Expand Down Expand Up @@ -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
34 changes: 34 additions & 0 deletions tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down