diff --git a/autoconf/workspace.py b/autoconf/workspace.py index 6efdaa6..9a1bb19 100644 --- a/autoconf/workspace.py +++ b/autoconf/workspace.py @@ -1,3 +1,4 @@ +import datetime import os import warnings from pathlib import Path @@ -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): """ @@ -34,8 +40,8 @@ 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() @@ -43,10 +49,11 @@ def _yaml_workspace_version(general_yaml): 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 " @@ -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" @@ -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 "" +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 "" 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. @@ -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)) diff --git a/test_autoconf/test_workspace.py b/test_autoconf/test_workspace.py index e04a89f..5472536 100644 --- a/test_autoconf/test_workspace.py +++ b/test_autoconf/test_workspace.py @@ -1,4 +1,5 @@ import textwrap +import warnings import pytest @@ -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)