diff --git a/.github/workflows/Test.yml b/.github/workflows/Test.yml index 133852cd..d60d39d2 100644 --- a/.github/workflows/Test.yml +++ b/.github/workflows/Test.yml @@ -69,6 +69,21 @@ jobs: - name: Build wheel and sdist packages run: python -m build --wheel --sdist --outdir dist + - name: OMPython version + id: ompython_version + shell: bash + run: | + V=$(python -c " + import OMPython + v = OMPython.__version__ + assert v != 'unknown', 'Version resolution failed' + print(v) + ") + echo "Resolved OMPython version: $V" + echo "version=$V" >> "$GITHUB_OUTPUT" + echo "### OMPython Version" >> "$GITHUB_STEP_SUMMARY" + echo "\`$V\`" >> "$GITHUB_STEP_SUMMARY" + - name: Check twine run: python -m twine check dist/* diff --git a/OMPython/__init__.py b/OMPython/__init__.py index 848421c5..3a81235a 100644 --- a/OMPython/__init__.py +++ b/OMPython/__init__.py @@ -9,8 +9,11 @@ omc.sendExpression("getVersion()") ``` + """ +from OMPython._version import __version__, get_version + from OMPython.model_execution import ( ModelExecutionConfig, ModelExecutionRun, @@ -77,6 +80,9 @@ # global names imported if import 'from OMPython import *' is used __all__ = [ + '__version__', + 'get_version', + 'doe_get_solutions', 'LinearizationResult', diff --git a/OMPython/_version.py b/OMPython/_version.py new file mode 100644 index 00000000..cebacd10 --- /dev/null +++ b/OMPython/_version.py @@ -0,0 +1,51 @@ +"""Version information for OMPython. + +Resolves the installed package version via importlib.metadata when +OMPython is installed (pip install, editable install, etc.), and falls +back to reading pyproject.toml directly when running from a source +tree with no installed metadata available. +""" + +import sys +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +_FALLBACK_VERSION = "unknown" + + +def _read_version_from_pyproject() -> str: + """Return the package version from the pyproject.toml file. + + Raises: + FileNotFoundError: if pyproject.toml cannot be found. + KeyError: if the version is not statically defined (e.g. dynamic versioning is used). + """ + pyproject_path = Path(__file__).resolve().parents[1] / "pyproject.toml" + with open(pyproject_path, "rb") as pyproject_file: + pyproject = tomllib.load(pyproject_file) + return pyproject["project"]["version"] + + +def _resolve_version() -> str: + try: + return version(__package__ or "OMPython") + except PackageNotFoundError: + pass + + try: + return _read_version_from_pyproject() + except (FileNotFoundError, KeyError): + return _FALLBACK_VERSION + + +def get_version() -> str: + """Return the OMPython version string.""" + return __version__ + + +__version__ = _resolve_version() diff --git a/pyproject.toml b/pyproject.toml index 4b36b74b..ddff139c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "psutil", "pyparsing", "pyzmq", + "tomli; python_version < '3.11'", ] [tool.setuptools] diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 00000000..a9f9271f --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,67 @@ +"""Tests for OMPython._version.""" + +import importlib.metadata +from OMPython import _version + + +def test_version_module_resolves_installed_metadata(): + """When OMPython is installed, __version__ should come from + importlib.metadata and never fall back to 'unknown'.""" + assert _version.__version__ != "unknown" + assert isinstance(_version.__version__, str) + assert _version.__version__ != "" + + +def test_get_version_matches_module_version(): + """get_version() should return the same value as __version__.""" + assert _version.get_version() == _version.__version__ + + +def test_installed_metadata_matches_importlib(monkeypatch=None): + """Sanity check: the resolved version matches what importlib.metadata + reports directly, confirming the installed-metadata path is being used.""" + expected = importlib.metadata.version(_version.__package__ or "OMPython") + assert _version.__version__ == expected + + +def test_resolve_version_falls_back_when_package_not_found(monkeypatch): + """If importlib.metadata.version() raises PackageNotFoundError, + _resolve_version() should fall back to reading pyproject.toml.""" + + def raise_not_found(_name): + raise importlib.metadata.PackageNotFoundError + + monkeypatch.setattr(_version, "version", raise_not_found) + + result = _version._resolve_version() + + # Falls back to reading pyproject.toml; should not silently return + # "unknown" unless pyproject.toml is genuinely missing/malformed. + assert result != "unknown" + assert isinstance(result, str) + + +def test_resolve_version_returns_unknown_if_pyproject_unreadable(monkeypatch): + """If both importlib.metadata and the pyproject.toml fallback fail, + _resolve_version() should return 'unknown' rather than raising.""" + + def raise_not_found(_name): + raise importlib.metadata.PackageNotFoundError + + def raise_file_not_found(): + raise FileNotFoundError + + monkeypatch.setattr(_version, "version", raise_not_found) + monkeypatch.setattr(_version, "_read_version_from_pyproject", raise_file_not_found) + + result = _version._resolve_version() + + assert result == "unknown" + + +def test_read_version_from_pyproject_returns_string(): + """_read_version_from_pyproject() should return a non-empty string + when pyproject.toml is present and well-formed.""" + result = _version._read_version_from_pyproject() + assert isinstance(result, str) + assert result != ""