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
185 changes: 120 additions & 65 deletions autoconf/workspace.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import datetime
import os
import warnings
from pathlib import Path
Expand All @@ -11,6 +12,11 @@ class WorkspaceVersionMismatchError(exc.ConfigException):

_BYPASS_ENV_VAR = "PYAUTO_SKIP_WORKSPACE_VERSION_CHECK"

# The installed library may legitimately run ahead of a workspace clone —
# releases are frequent, `git pull`s are not. Only warn once the gap is large
# enough to suggest the clone is genuinely stale.
_STALENESS_WINDOW_DAYS = 30


def _read_general_yaml(workspace_root):
"""
Expand All @@ -34,19 +40,20 @@ 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")
def _yaml_version_value(general_yaml, key):
value = general_yaml.get("version", {}).get(key)
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"Cannot verify the workspace at {workspace_root} is compatible with "
f"the installed library version ({library_version}): no "
f"`version.minimum_library_version` or `version.workspace_version` "
f"key in config/general.yaml and no version.txt at the workspace "
f"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 "
Expand All @@ -64,6 +71,17 @@ def _parse_version(version_string):
return None


def _version_date(parsed_version):
"""
The ``(year, month, day)`` prefix of a parsed date-version as a
``datetime.date``, or None when the prefix is not a valid date.
"""
try:
return datetime.date(parsed_version[0], parsed_version[1], parsed_version[2])
except (IndexError, TypeError, ValueError):
return None


def _library_name_from_workspace(workspace_root):
name = workspace_root.name
suffix = "_workspace"
Expand All @@ -72,79 +90,91 @@ def _library_name_from_workspace(workspace_root):
return None


def _update_library_block(library_name, target_version):
package = library_name if library_name else "<library>"
def _bypass_block():
return (
f"Your workspace is newer than your installed library. Update the "
f"library to match:\n\n"
f" pip install --upgrade {package}=={target_version}"
f"To bypass this check, edit config/general.yaml:\n\n"
f" version:\n"
f" workspace_version_check: False\n\n"
f"You can also set the environment variable "
f"{_BYPASS_ENV_VAR}=1 to disable temporarily."
)


def _update_workspace_block(workspace_root):
def _below_floor_message(floor_version, library_version, workspace_root):
package = _library_name_from_workspace(workspace_root) or "<library>"
return (
f"Your installed library is newer than your workspace clone. Pull "
f"the latest workspace `main`:\n\n"
f" cd {workspace_root} && git pull origin main"
f"The installed library version ({library_version}) is older than the "
f"minimum version this workspace requires ({floor_version}), so its "
f"scripts may use API your install does not have. Update the "
f"library:\n\n"
f" pip install --upgrade {package}\n\n"
f"If the newest release on PyPI is still older than {floor_version}, "
f"this workspace clone tracks code that has not been released yet — "
f"either wait for the next release, or check out the workspace state "
f"matching your installed version:\n\n"
f" cd {workspace_root} && git checkout {library_version}\n\n"
f"IMPORTANT: If you cloned the workspace from `main` rather than a "
f"release tag, `main` may require a newer library than the latest "
f"release provides.\n\n"
f"{_bypass_block()}"
)


def _mismatch_message(workspace_version, library_version, workspace_root):
library_name = _library_name_from_workspace(workspace_root)
ws_parsed = _parse_version(workspace_version)
lib_parsed = _parse_version(library_version)

if ws_parsed is not None and lib_parsed is not None and ws_parsed > lib_parsed:
advice = _update_library_block(library_name, workspace_version)
elif ws_parsed is not None and lib_parsed is not None and lib_parsed > ws_parsed:
advice = _update_workspace_block(workspace_root)
else:
advice = (
f"{_update_library_block(library_name, workspace_version)}\n\n"
f"Or, if your workspace is the side that is out of date:\n\n"
f"{_update_workspace_block(workspace_root)}"
)
def _stale_workspace_message(floor_version, library_version, workspace_root):
return (
f"The workspace at {workspace_root} records library version "
f"{floor_version}, but the installed library is {library_version} — "
f"more than {_STALENESS_WINDOW_DAYS} days newer. The workspace "
f"examples and configs may lag the installed API. Pull the latest "
f"workspace:\n\n"
f" cd {workspace_root} && git pull origin main\n\n"
f"{_bypass_block()}"
)


def _unparseable_mismatch_message(floor_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"{advice}\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."
f"The workspace at {workspace_root} records library version "
f"{floor_version}, which does not match the installed library "
f"version ({library_version}); the two cannot be compared as date "
f"versions, so compatibility is unverified. This is expected for "
f"development installs.\n\n"
f"{_bypass_block()}"
)


def check_version(library_version, workspace_root=None):
"""
Verify that the workspace at ``workspace_root`` matches ``library_version``.

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).
Verify that the installed library is new enough for the workspace at
``workspace_root``.

The workspace records the **minimum library version** its scripts
require, resolved with the following precedence:

1. ``config/general.yaml`` — ``version.minimum_library_version``, bumped
deliberately when workspace scripts start depending on new API.
2. ``config/general.yaml`` — ``version.workspace_version``, the legacy
exact release stamp, reinterpreted as a floor so older clones keep
working against newer libraries.
3. ``version.txt`` at the workspace root — legacy fallback for clones
that pre-date the YAML keys.

An installed library **older** than the floor raises
``WorkspaceVersionMismatchError`` — the workspace's scripts may use API
the install does not have. An installed library **newer** than the floor
passes; if it is more than ``_STALENESS_WINDOW_DAYS`` days newer (by
date-version comparison) a warning suggests pulling the workspace.
Versions that cannot be parsed as date versions (e.g. development
installs) warn on inequality rather than raising.

If no floor source is found, a warning is emitted and the check is
skipped.

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.
``main``-branch workspace clones.
* Set ``PYAUTO_SKIP_WORKSPACE_VERSION_CHECK=1`` — intended for
developers running source checkouts where workspace and library
versions intentionally diverge.
Expand All @@ -162,18 +192,43 @@ def check_version(library_version, workspace_root=None):
if _yaml_bypass_set(general_yaml):
return

workspace_version = _yaml_workspace_version(general_yaml)
floor_version = _yaml_version_value(general_yaml, "minimum_library_version")

if floor_version is None:
floor_version = _yaml_version_value(general_yaml, "workspace_version")

if workspace_version is None:
if floor_version is None:
version_file = root / "version.txt"
if version_file.exists():
workspace_version = version_file.read_text().strip()
floor_version = version_file.read_text().strip()

if workspace_version is None or workspace_version == "":
if floor_version is None or floor_version == "":
warnings.warn(_missing_version_warning(root, library_version))
return

if workspace_version != library_version:
if floor_version == library_version:
return

floor_parsed = _parse_version(floor_version)
library_parsed = _parse_version(library_version)

if floor_parsed is None or library_parsed is None:
warnings.warn(
_unparseable_mismatch_message(floor_version, library_version, root)
)
return

if library_parsed < floor_parsed:
raise WorkspaceVersionMismatchError(
_mismatch_message(workspace_version, library_version, root)
_below_floor_message(floor_version, library_version, root)
)

floor_date = _version_date(floor_parsed)
library_date = _version_date(library_parsed)

if (
floor_date is not None
and library_date is not None
and (library_date - floor_date).days > _STALENESS_WINDOW_DAYS
):
warnings.warn(_stale_workspace_message(floor_version, library_version, root))
57 changes: 57 additions & 0 deletions test_autoconf/test_workspace.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import textwrap
import warnings

import pytest

Expand Down Expand Up @@ -139,3 +140,59 @@ def test_unparseable_yaml_falls_back_to_version_txt(tmp_path):
(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)


def test_newer_library_within_window_passes_silently(tmp_path):
(tmp_path / "version.txt").write_text("2026.4.13.6\n")
with warnings.catch_warnings():
warnings.simplefilter("error")
check_version("2026.4.20.1", workspace_root=tmp_path)


def test_newer_library_beyond_window_warns_stale(tmp_path):
(tmp_path / "version.txt").write_text("2026.4.13.6\n")
with pytest.warns(UserWarning, match="git pull"):
check_version("2026.6.1.1", workspace_root=tmp_path)


def test_minimum_library_version_key_is_the_floor(tmp_path):
_write_general_yaml(
tmp_path,
"""
version:
minimum_library_version: 2026.4.13.6
workspace_version: 2027.1.1.1
""",
)
with warnings.catch_warnings():
warnings.simplefilter("error")
check_version("2026.4.20.1", workspace_root=tmp_path)


def test_below_minimum_library_version_raises_with_upgrade_advice(tmp_path):
workspace_root = tmp_path / "autolens_workspace"
workspace_root.mkdir()
_write_general_yaml(
workspace_root,
"""
version:
minimum_library_version: 2026.4.13.6
""",
)
with pytest.raises(WorkspaceVersionMismatchError) as info:
check_version("2025.1.1.1", workspace_root=workspace_root)
msg = str(info.value)
assert "pip install --upgrade autolens" in msg
assert "==" not in msg


def test_unparseable_library_version_warns_not_raises(tmp_path):
(tmp_path / "version.txt").write_text("2026.4.13.6\n")
with pytest.warns(UserWarning, match="cannot be compared"):
check_version("1.0.dev0", workspace_root=tmp_path)


def test_unparseable_workspace_record_warns_not_raises(tmp_path):
(tmp_path / "version.txt").write_text("not-a-version\n")
with pytest.warns(UserWarning, match="cannot be compared"):
check_version("2026.4.13.6", workspace_root=tmp_path)
Loading