diff --git a/autoconf/workspace.py b/autoconf/workspace.py index df22153..7cdeb97 100644 --- a/autoconf/workspace.py +++ b/autoconf/workspace.py @@ -9,44 +9,127 @@ class WorkspaceVersionMismatchError(exc.ConfigException): pass +_BYPASS_ENV_VAR = "PYAUTO_SKIP_WORKSPACE_VERSION_CHECK" + + +def _read_general_yaml(workspace_root): + """ + Return the parsed ``config/general.yaml`` dict for ``workspace_root``. + + Returns an empty dict on any failure (missing file, missing yaml module, + unreadable YAML) so the caller can fall through to legacy ``version.txt`` + handling without crashing the user's script on import. + """ + try: + import yaml + + config_path = workspace_root / "config" / "general.yaml" + with config_path.open("r") as f: + return yaml.safe_load(f) or {} + except Exception: + return {} + + +def _yaml_bypass_set(general_yaml): + return general_yaml.get("version", {}).get("workspace_version_check") is False + + +def _yaml_workspace_version(general_yaml): + value = general_yaml.get("version", {}).get("workspace_version") + if value is None: + return None + return str(value).strip() + + +def _missing_version_warning(workspace_root, library_version): + return ( + f"Cannot verify the workspace at {workspace_root} matches the " + f"installed library version ({library_version}): no " + f"`version.workspace_version` key in config/general.yaml and no " + f"version.txt at the workspace root.\n\n" + f"If you cloned the workspace from `main` rather than a release tag, " + f"set `version.workspace_version_check: False` in " + f"config/general.yaml to silence this warning. The `main` branch " + f"updates more frequently than library releases, so version " + f"mismatches are expected and not actionable for `main`-branch users.\n\n" + f"You can also set the environment variable " + f"{_BYPASS_ENV_VAR}=1 to disable temporarily." + ) + + +def _mismatch_message(workspace_version, library_version, workspace_root): + return ( + f"Workspace version ({workspace_version}) at {workspace_root} does " + f"not match the installed library version ({library_version}).\n\n" + f"This usually means your installed library was upgraded but your " + f"workspace clone is from an older release tag. Re-clone the " + f"workspace at the matching tag:\n\n" + f" git clone --branch {library_version} \n\n" + f"To bypass this check, edit config/general.yaml:\n\n" + f" version:\n" + f" workspace_version_check: False\n\n" + f"IMPORTANT: If you cloned the workspace from `main` rather than a " + f"release tag, you should set `workspace_version_check: False`. The " + f"`main` branch updates much more frequently than library releases, " + f"so version mismatches are expected and not actionable for " + f"`main`-branch users.\n\n" + f"You can also set the environment variable " + f"{_BYPASS_ENV_VAR}=1 to disable temporarily." + ) + + def check_version(library_version, workspace_root=None): """ Verify that the workspace at ``workspace_root`` matches ``library_version``. - Reads ``version.txt`` from ``workspace_root`` (defaults to the current - working directory, which is where users run workspace scripts from). - Raises ``WorkspaceVersionMismatchError`` if the file's version differs - from ``library_version``. If ``version.txt`` does not exist (e.g. an - older workspace clone or one cloned from ``main`` outside a release tag) - a warning is emitted and the check is skipped. + Resolves the workspace version with the following precedence: + + 1. ``config/general.yaml`` — ``version.workspace_version`` key, written by + the release pipeline. Travels with the user's config directory even + when scripts are copy-pasted out of the workspace root. + 2. ``version.txt`` at the workspace root — legacy fallback for clones + that pre-date the YAML key. + + If neither source is found, a warning is emitted and the check is + skipped. If both sources exist but disagree, the YAML value wins + (release pipeline writes both atomically; YAML is the configured + source-of-truth on the user's machine). + + The check can be disabled in two ways: + + * Set ``version.workspace_version_check: False`` in + ``config/general.yaml`` — the recommended path for users on + ``main``-branch workspace clones, where mismatches are expected + because ``main`` updates more frequently than library releases. + * Set ``PYAUTO_SKIP_WORKSPACE_VERSION_CHECK=1`` — intended for + developers running source checkouts where workspace and library + versions intentionally diverge. - Set ``PYAUTO_SKIP_WORKSPACE_VERSION_CHECK=1`` to disable the check - entirely — intended for developers running source checkouts where - workspace and library versions intentionally diverge. + Defaults ``workspace_root`` to the current working directory, which is + where users run workspace scripts from. """ - if os.environ.get("PYAUTO_SKIP_WORKSPACE_VERSION_CHECK") == "1": + if os.environ.get(_BYPASS_ENV_VAR) == "1": return root = Path(workspace_root) if workspace_root else Path.cwd() - version_file = root / "version.txt" - - if not version_file.exists(): - warnings.warn( - f"No version.txt found at {version_file}. Cannot verify that the " - f"workspace matches the installed library version ({library_version}). " - f"If you cloned the workspace from main rather than a release tag, " - f"set PYAUTO_SKIP_WORKSPACE_VERSION_CHECK=1 to silence this warning." - ) + + general_yaml = _read_general_yaml(root) + + if _yaml_bypass_set(general_yaml): return - workspace_version = version_file.read_text().strip() + workspace_version = _yaml_workspace_version(general_yaml) + + if workspace_version is None: + version_file = root / "version.txt" + if version_file.exists(): + workspace_version = version_file.read_text().strip() + + if workspace_version is None or workspace_version == "": + warnings.warn(_missing_version_warning(root, library_version)) + return if workspace_version != library_version: raise WorkspaceVersionMismatchError( - f"Workspace version ({workspace_version}) at {root} does not match " - f"the installed library version ({library_version}). Re-clone the " - f"workspace at the matching tag:\n\n" - f" git clone --branch {library_version} \n\n" - f"Or set PYAUTO_SKIP_WORKSPACE_VERSION_CHECK=1 to override (intended " - f"for source-checkout development)." + _mismatch_message(workspace_version, library_version, root) ) diff --git a/test_autoconf/test_workspace.py b/test_autoconf/test_workspace.py index 1e0f723..e04a89f 100644 --- a/test_autoconf/test_workspace.py +++ b/test_autoconf/test_workspace.py @@ -1,23 +1,34 @@ +import textwrap + import pytest from autoconf.workspace import check_version, WorkspaceVersionMismatchError -def test_match(tmp_path): +def _write_general_yaml(tmp_path, body): + config_dir = tmp_path / "config" + config_dir.mkdir(exist_ok=True) + (config_dir / "general.yaml").write_text(textwrap.dedent(body).lstrip()) + + +def test_match_via_version_txt(tmp_path): (tmp_path / "version.txt").write_text("2026.4.13.6\n") check_version("2026.4.13.6", workspace_root=tmp_path) -def test_mismatch_raises(tmp_path): +def test_mismatch_via_version_txt_raises(tmp_path): (tmp_path / "version.txt").write_text("2026.4.13.6\n") with pytest.raises(WorkspaceVersionMismatchError) as info: check_version("2025.1.1.1", workspace_root=tmp_path) - assert "2026.4.13.6" in str(info.value) - assert "2025.1.1.1" in str(info.value) + msg = str(info.value) + assert "2026.4.13.6" in msg + assert "2025.1.1.1" in msg + assert "workspace_version_check: False" in msg + assert "main" in msg -def test_missing_file_warns(tmp_path): - with pytest.warns(UserWarning, match="No version.txt"): +def test_missing_sources_warns(tmp_path): + with pytest.warns(UserWarning, match="workspace_version_check: False"): check_version("2026.4.13.6", workspace_root=tmp_path) @@ -36,3 +47,95 @@ def test_default_root_is_cwd(tmp_path, monkeypatch): (tmp_path / "version.txt").write_text("2026.4.13.6\n") monkeypatch.chdir(tmp_path) check_version("2026.4.13.6") + + +def test_match_via_general_yaml(tmp_path): + _write_general_yaml( + tmp_path, + """ + version: + workspace_version: 2026.4.13.6 + workspace_version_check: True + """, + ) + check_version("2026.4.13.6", workspace_root=tmp_path) + + +def test_mismatch_via_general_yaml_raises(tmp_path): + _write_general_yaml( + tmp_path, + """ + version: + workspace_version: 2026.4.13.6 + workspace_version_check: True + """, + ) + with pytest.raises(WorkspaceVersionMismatchError): + check_version("2025.1.1.1", workspace_root=tmp_path) + + +def test_yaml_bypass_skips_mismatch(tmp_path): + _write_general_yaml( + tmp_path, + """ + version: + workspace_version: 2026.4.13.6 + workspace_version_check: False + """, + ) + check_version("2025.1.1.1", workspace_root=tmp_path) + + +def test_yaml_bypass_skips_missing_version_key(tmp_path): + _write_general_yaml( + tmp_path, + """ + version: + workspace_version_check: False + """, + ) + check_version("2026.4.13.6", workspace_root=tmp_path) + + +def test_yaml_overrides_version_txt_when_both_present(tmp_path): + _write_general_yaml( + tmp_path, + """ + version: + workspace_version: 2026.4.13.6 + """, + ) + (tmp_path / "version.txt").write_text("2025.1.1.1\n") + check_version("2026.4.13.6", workspace_root=tmp_path) + + +def test_version_txt_used_when_yaml_lacks_workspace_version(tmp_path): + _write_general_yaml( + tmp_path, + """ + version: + python_version_check: True + """, + ) + (tmp_path / "version.txt").write_text("2026.4.13.6\n") + check_version("2026.4.13.6", workspace_root=tmp_path) + + +def test_yaml_without_version_key_falls_through_to_warning(tmp_path): + _write_general_yaml( + tmp_path, + """ + updates: + iterations_per_quick_update: 1 + """, + ) + with pytest.warns(UserWarning): + check_version("2026.4.13.6", workspace_root=tmp_path) + + +def test_unparseable_yaml_falls_back_to_version_txt(tmp_path): + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "general.yaml").write_text("::: not valid yaml :::") + (tmp_path / "version.txt").write_text("2026.4.13.6\n") + check_version("2026.4.13.6", workspace_root=tmp_path)