diff --git a/singer_sdk/helpers/_compat.py b/singer_sdk/helpers/_compat.py index 2c39f71fb..88bb0d976 100644 --- a/singer_sdk/helpers/_compat.py +++ b/singer_sdk/helpers/_compat.py @@ -1,4 +1,7 @@ """Compatibility helpers.""" +from __future__ import annotations + +import pathlib try: from typing import final @@ -12,4 +15,31 @@ # Running on pre-3.8 Python; use importlib-metadata package import importlib_metadata as metadata # type: ignore -__all__ = ["metadata", "final"] + +__all__ = ["metadata", "get_project_distribution", "final"] + + +# Future: replace with `importlib.metadata.packages_distributions()` introduced in 3.10 +def get_project_distribution(file_path=None) -> metadata.Distribution | None: + """Get project distribution. + + This walks each distribution on `sys.path` looking for one whose installed paths + include the given path. + + Args: + file_path: File path to find distribution for. + + Returns: + A discovered Distribution or None. + """ + for dist in metadata.distributions(): + try: + relative = pathlib.Path(file_path or __file__).relative_to( + dist.locate_file("") + ) + except ValueError: + pass + else: + if dist.files and relative in dist.files: + return dist + return None diff --git a/singer_sdk/plugin_base.py b/singer_sdk/plugin_base.py index feb0f0db3..6294c8f31 100644 --- a/singer_sdk/plugin_base.py +++ b/singer_sdk/plugin_base.py @@ -4,6 +4,7 @@ import json import logging import os +import sys from collections import OrderedDict from pathlib import PurePath from types import MappingProxyType @@ -27,7 +28,7 @@ from singer_sdk.configuration._dict_config import parse_environment_config from singer_sdk.exceptions import ConfigValidationError from singer_sdk.helpers._classproperty import classproperty -from singer_sdk.helpers._compat import metadata +from singer_sdk.helpers._compat import get_project_distribution, metadata from singer_sdk.helpers._secrets import SecretString, is_common_secret_key from singer_sdk.helpers._util import read_json_file from singer_sdk.helpers.capabilities import ( @@ -162,10 +163,16 @@ def plugin_version(cls) -> str: Returns: The package version number. """ - try: - version = metadata.version(cls.name) - except metadata.PackageNotFoundError: - version = "[could not be detected]" + # get the file location of the subclass + path = sys.modules[cls.__module__].__file__ + distribution = get_project_distribution(path) + if distribution: + version = str(distribution.metadata["Version"]) + else: + try: + version = metadata.version(cls.name) + except metadata.PackageNotFoundError: + version = "[could not be detected]" return version @classproperty diff --git a/tests/core/test_helpers.py b/tests/core/test_helpers.py new file mode 100644 index 000000000..af82209fa --- /dev/null +++ b/tests/core/test_helpers.py @@ -0,0 +1,26 @@ +import platform +import site +from pathlib import Path + +import pytest + +from singer_sdk.helpers._compat import get_project_distribution, metadata + + +def test_get_project_distribution(): + """Test `get_project_distribution()`. + + click is a representative example of a distributed package from our dependency tree. + Any similar singer_sdk dependency could be used in stead. + """ + if platform.system() == "Windows": + pytest.xfail("Doesn't pass on windows.") + + site_package_paths = site.getsitepackages() + site_package_path = next( + pth for pth in site_package_paths if Path(pth).parts[-1] == "site-packages" + ) + singer_sdk_dependency_path = Path(site_package_path) / "click" / "__init__.py" + discovered_dst = get_project_distribution(singer_sdk_dependency_path) + assert discovered_dst + assert discovered_dst.name == "click"