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
135 changes: 109 additions & 26 deletions autoconf/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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} <workspace-repo-url>\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} <workspace-repo-url>\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)
)
115 changes: 109 additions & 6 deletions test_autoconf/test_workspace.py
Original file line number Diff line number Diff line change
@@ -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)


Expand All @@ -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)
Loading