diff --git a/SWEET_python/__init__.py b/SWEET_python/__init__.py index e69de29..c5b5cf6 100644 --- a/SWEET_python/__init__.py +++ b/SWEET_python/__init__.py @@ -0,0 +1,81 @@ +"""SWEET_python: the waste model shared by the Climate TRACE pipeline and WasteMAP. + +WHAT ``__version__`` IS, AND WHY IT IS A SHA +-------------------------------------------- +It is the commit this copy was installed from -- not a hand-maintained number. + +SWEET never ships on its own. It reaches the world only inside a Climate TRACE run or a +WasteMAP deploy, and both of those already have identities of their own (a ``run_id`` and +a ``wastemap/YYYY.MM.DD`` tag). A ``MAJOR.MINOR`` maintained by hand here would be a +second, weaker name for a commit that is already recorded by sha in every run manifest, +and it would need bumping on every model change by the same person who has to remember +the tag. So there is no SWEET version number. There is a build stamp. + +WHAT IT IS FOR. The live DST endpoints (``/sdst``, ``/adst``, ``/cdst``) call this +package at request time, which makes them the one place in the system where SWEET's +behaviour is user-visible with no run and no manifest behind it. Until now nothing could +answer "which model is the live site running" -- the API knew the *ref* it asked for, and +the ref could be a lie, because the images fell back to ``main`` when it did not resolve. +A process that imports this can now log what it actually got. + +HOW IT RESOLVES, in order, inventing nothing: + +1. ``SWEET_GIT_SHA``, which the image build sets from the ref it actually installed. +2. A ``.git`` directory beside the package, for an editable checkout. +3. ``"unknown"`` -- an honest answer, and the one a reader must be able to distinguish + from a real sha. ``VERSION_SOURCE`` says which of the three it was. + +Same shape, deliberately, as the Climate TRACE pipeline's +``results_tables.resolve_git_shas``: env first, then a repository read, then a recorded +absence. + +See ``VERSIONING.md`` in RMI_Climate_TRACE_Waste_Methane for the whole scheme. +""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path +from typing import Optional + +__all__ = ["__version__", "VERSION_SOURCE", "version_info"] + + +def _sha_from_git(repo_dir: Path) -> Optional[str]: + if not (repo_dir / ".git").exists(): + return None + try: + out = subprocess.run( + ["git", "-C", str(repo_dir), "rev-parse", "HEAD"], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except Exception: + return None + return (out.stdout or "").strip() or None + + +def _resolve_version() -> "tuple[str, str]": + sha = (os.environ.get("SWEET_GIT_SHA") or "").strip() + if sha: + return sha, "env" + sha = _sha_from_git(Path(__file__).resolve().parents[1]) + if sha: + return sha, "git" + return "unknown", "unavailable" + + +__version__, VERSION_SOURCE = _resolve_version() + + +def version_info() -> dict: + """``{"sweet_version": ..., "sweet_version_source": ...}``, for a startup log line. + + A dict rather than a string so a caller logging it cannot drop the source: a bare + ``"unknown"`` in a log reads as a missing feature, while ``unknown (unavailable)`` + reads as what it is -- an install that cannot say what it is. + """ + return {"sweet_version": __version__, "sweet_version_source": VERSION_SOURCE} diff --git a/setup.py b/setup.py index df0619b..45eacaa 100644 --- a/setup.py +++ b/setup.py @@ -16,6 +16,12 @@ setup( name="SWEET_python", + # Decoration, not a version. SWEET never ships on its own -- it reaches the world + # only inside a Climate TRACE run or a WasteMAP deploy, each of which has an + # identity already -- so there is no hand-maintained number here to keep honest. + # What a caller should read is SWEET_python.__version__, which is the commit this + # copy was installed from. See the package docstring, and VERSIONING.md in + # RMI_Climate_TRACE_Waste_Methane. version="0.1", packages=find_packages(), include_package_data=True, diff --git a/tests/test_version_stamp.py b/tests/test_version_stamp.py new file mode 100644 index 0000000..f6f4770 --- /dev/null +++ b/tests/test_version_stamp.py @@ -0,0 +1,92 @@ +"""``SWEET_python.__version__`` is the commit this copy came from, or it says it cannot tell. + +There is no hand-maintained SWEET version; see the package docstring for why. What there +is has one job: let a process that imported this package say which model it is running. +The live DST endpoints are the reason -- they call SWEET at request time, so they are the +one place where its behaviour ships with no run manifest behind it. + +The rule these tests exist to hold: it never invents a value. A wrong sha in a log is +worse than none, because a reader believes it. +""" + +from __future__ import annotations + +import importlib +import subprocess + +import pytest + +import SWEET_python + + +def _reimport(monkeypatch, **env): + for key, value in env.items(): + if value is None: + monkeypatch.delenv(key, raising=False) + else: + monkeypatch.setenv(key, value) + return importlib.reload(SWEET_python) + + +def test_an_env_sha_wins(monkeypatch): + """The image build knows the ref it actually installed and passes it in. That beats + reading a repository, because the image holds source, not a repository.""" + mod = _reimport(monkeypatch, SWEET_GIT_SHA="a" * 40) + + assert mod.__version__ == "a" * 40 + assert mod.VERSION_SOURCE == "env" + + +def test_a_blank_env_sha_does_not_count(monkeypatch): + """An unset variable arrives as an empty string often enough that it has to be + handled: a deploy that forgot to pass one must fall through, not stamp ''.""" + mod = _reimport(monkeypatch, SWEET_GIT_SHA=" ") + + assert mod.__version__ != "" + assert mod.VERSION_SOURCE in {"git", "unavailable"} + + +def test_an_editable_checkout_reads_its_own_git(monkeypatch): + mod = _reimport(monkeypatch, SWEET_GIT_SHA=None) + + if mod.VERSION_SOURCE == "unavailable": + pytest.skip("no .git beside the package; nothing to compare against") + + head = subprocess.run( + ["git", "rev-parse", "HEAD"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + assert mod.__version__ == head + assert mod.VERSION_SOURCE == "git" + + +def test_an_unreadable_install_says_unknown_rather_than_guessing(monkeypatch): + monkeypatch.setattr(SWEET_python, "_sha_from_git", lambda _: None) + monkeypatch.delenv("SWEET_GIT_SHA", raising=False) + + version, source = SWEET_python._resolve_version() + + assert version == "unknown" + assert source == "unavailable" + + +def test_version_info_carries_the_source(monkeypatch): + """A bare "unknown" in a log reads as a missing feature. "unknown (unavailable)" + reads as an install that cannot say what it is, which is the true statement.""" + info = SWEET_python.version_info() + + assert set(info) == {"sweet_version", "sweet_version_source"} + assert info["sweet_version"] == SWEET_python.__version__ + assert info["sweet_version_source"] == SWEET_python.VERSION_SOURCE + + +def test_importing_the_package_costs_no_subprocess_without_a_git_dir(monkeypatch): + """The `.git` existence check comes first on purpose. In the container there is no + repository, and SWEET is imported by every multiprocessing worker.""" + calls = [] + monkeypatch.setattr(subprocess, "run", lambda *a, **k: calls.append(a)) + + assert SWEET_python._sha_from_git(pytest.importorskip("pathlib").Path("/nonexistent")) is None + assert calls == []