diff --git a/python/tank/pipelineconfig_utils.py b/python/tank/pipelineconfig_utils.py index 7a5543dce..834236377 100644 --- a/python/tank/pipelineconfig_utils.py +++ b/python/tank/pipelineconfig_utils.py @@ -13,6 +13,7 @@ across storages, configurations etc. """ +import importlib.metadata import os from tank_vendor import yaml @@ -439,7 +440,16 @@ def get_currently_running_api_version(): info_yml_path = os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "..", "info.yml") ) - return _get_version_from_manifest(info_yml_path) + version = _get_version_from_manifest(info_yml_path) + if version is not None: + return version + # In a pip install the flat site-packages layout has no info.yml. + # Fall back to the installed distribution metadata; PEP 440 strips the + # leading 'v', so re-add it to match the info.yml convention. + try: + return "v" + importlib.metadata.version("sgtk") + except importlib.metadata.PackageNotFoundError: + return "unknown" def get_core_api_version(core_install_root): @@ -455,7 +465,8 @@ def get_core_api_version(core_install_root): """ # now try to get to the info.yml file to get the version number info_yml_path = os.path.join(core_install_root, "install", "core", "info.yml") - return _get_version_from_manifest(info_yml_path) + version = _get_version_from_manifest(info_yml_path) + return "unknown" if version is None else version def _get_version_from_manifest(info_yml_path): @@ -464,15 +475,14 @@ def _get_version_from_manifest(info_yml_path): Returns the version given a manifest. :param info_yml_path: path to manifest file. - :returns: Always a string, 'unknown' if data cannot be found + :returns: Version string, or None if data cannot be found. """ try: data = yaml_cache.g_yaml_cache.get(info_yml_path, deepcopy_data=False) or {} - data = str(data.get("version", "unknown")) + version = data.get("version") + return str(version) if version is not None else None except Exception: - data = "unknown" - - return data + return None def _get_core_descriptor_file(pipeline_config_path): diff --git a/tests/core_tests/test_pipelineconfig_utils.py b/tests/core_tests/test_pipelineconfig_utils.py index b8ee79ff9..3752e7900 100644 --- a/tests/core_tests/test_pipelineconfig_utils.py +++ b/tests/core_tests/test_pipelineconfig_utils.py @@ -8,6 +8,7 @@ # agreement to the Shotgun Pipeline Toolkit Source Code License. All rights # not expressly granted therein are reserved by Shotgun Software Inc. +import importlib.metadata import os import inspect import sys @@ -359,3 +360,59 @@ def test_get_sgtk_module_path(self): self.assertEqual(sgtk.get_sgtk_module_path(), python_path) self.assertEqual(sgtk.get_sgtk_module_path(), tank.get_sgtk_module_path()) + + +class TestGetCurrentlyRunningApiVersion(ShotgunTestBase): + """ + Tests get_currently_running_api_version, including the importlib.metadata + fallback used when info.yml is absent (e.g. flat pip install layout). + """ + + @mock.patch("tank.pipelineconfig_utils._get_version_from_manifest") + def test_returns_manifest_version_when_present(self, manifest_mock): + """ + When info.yml is present, its version is returned and the dist-metadata + fallback is not consulted. + """ + manifest_mock.return_value = "v1.2.3" + with mock.patch("importlib.metadata.version") as dist_mock: + self.assertEqual( + pipelineconfig_utils.get_currently_running_api_version(), + "v1.2.3", + ) + dist_mock.assert_not_called() + + @mock.patch("tank.pipelineconfig_utils._get_version_from_manifest") + def test_falls_back_to_dist_metadata_when_manifest_missing(self, manifest_mock): + """ + Pip install layout: info.yml is absent so the manifest yields None, + and the function falls back to the installed sgtk distribution version, + re-adding the 'v' prefix that PEP 440 normalization strips. + """ + manifest_mock.return_value = None + with mock.patch( + "importlib.metadata.version", return_value="0.23.8" + ) as dist_mock: + self.assertEqual( + pipelineconfig_utils.get_currently_running_api_version(), + "v0.23.8", + ) + dist_mock.assert_called_once_with("sgtk") + + @mock.patch("tank.pipelineconfig_utils._get_version_from_manifest") + def test_returns_unknown_when_manifest_and_dist_metadata_missing( + self, manifest_mock + ): + """ + Neither info.yml nor an installed sgtk distribution available: preserve + the original "unknown" contract instead of raising. + """ + manifest_mock.return_value = None + with mock.patch( + "importlib.metadata.version", + side_effect=importlib.metadata.PackageNotFoundError("sgtk"), + ): + self.assertEqual( + pipelineconfig_utils.get_currently_running_api_version(), + "unknown", + ) diff --git a/tests/integration_tests/pip_install.py b/tests/integration_tests/pip_install.py index e73ccd14c..d4874c17b 100644 --- a/tests/integration_tests/pip_install.py +++ b/tests/integration_tests/pip_install.py @@ -60,6 +60,30 @@ def test_pip_install_and_import(self): ] ) + # Under the flat pip layout info.yml is absent; the version must + # come from the installed sgtk distribution metadata instead of + # falling through to "unknown". Strip PYTHONPATH from the + # subprocess env — the integration runner sets it to the source + # tree, which would otherwise shadow the venv's pip-installed + # sgtk and let _get_version_from_manifest pick up the source + # repo's info.yml. + subprocess_env = dict(os.environ) + subprocess_env.pop("PYTHONPATH", None) + version = subprocess.check_output( # nosec B603 + [ + python, + "-c", + "import sgtk; print(sgtk.pipelineconfig_utils.get_currently_running_api_version())", + ], + text=True, + env=subprocess_env, + ).strip() + self.assertNotEqual(version, "unknown") + self.assertTrue( + version.startswith("v"), + "expected vX.Y.Z, got %r" % version, + ) + if __name__ == "__main__": unittest.main(failfast=True, verbosity=2) diff --git a/tests/integration_tests/pip_install_bootstrap.py b/tests/integration_tests/pip_install_bootstrap.py index a522de5ad..3e9f442b5 100644 --- a/tests/integration_tests/pip_install_bootstrap.py +++ b/tests/integration_tests/pip_install_bootstrap.py @@ -100,6 +100,21 @@ def test_boostrap_engine(self): engine = manager.bootstrap_engine("tk-shell", self.asset) self.assertEqual(engine.name, "tk-shell") + def test_get_currently_running_api_version_in_simulated_pip_layout(self): + """ + The simulated layout has no info.yml. The function must not raise; it + should return a non-empty string. The exact value depends on whether + sgtk is also pip-installed in the test runner's Python — importlib's + distribution metadata is read from the runtime Python's site-packages, + not from the sys.path-prepended simulated copy. + """ + self.__clean_sgtk_modules() + import sgtk + + result = sgtk.pipelineconfig_utils.get_currently_running_api_version() + self.assertIsInstance(result, str) + self.assertTrue(result, "version must be non-empty") + if __name__ == "__main__": unittest.main(failfast=True, verbosity=2)