Repository: meltano/sdk
Pull request #2180 (2024-01-25 18:05:44)

fix: Limit supported Python versions in `--about` output to existing ones
Closes https://github.com/meltano/sdk/issues/2177


<!-- readthedocs-preview meltano-sdk start -->
----
📚 Documentation preview 📚: https://meltano-sdk--2180.org.readthedocs.build/en/2180/

<!-- readthedocs-preview meltano-sdk end -->

codspeed-hq[bot]: ## [CodSpeed Performance Report](https://codspeed.io/meltano/sdk/branches/2177-bug-supported-python-versions-from-about-go-up-to-316)

### Merging #2180 will **not alter performance**

<sub>Comparing <code>2177-bug-supported-python-versions-from-about-go-up-to-316</code> (5c70e37) with <code>main</code> (47609eb)</sub>



### Summary

`✅ 6` untouched benchmarks





codecov[bot]: ## [Codecov](https://app.codecov.io/gh/meltano/sdk/pull/2180?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) Report
Attention: `5 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`47609eb`)](https://app.codecov.io/gh/meltano/sdk/commit/47609ebe3b995dc92e6c48c6f995005414603fcd?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) 88.39% compared to head [(`c0e1d39`)](https://app.codecov.io/gh/meltano/sdk/pull/2180?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) 88.38%.

| [Files](https://app.codecov.io/gh/meltano/sdk/pull/2180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) | Patch % | Lines |
|---|---|---|
| [singer\_sdk/about.py](https://app.codecov.io/gh/meltano/sdk/pull/2180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano#diff-c2luZ2VyX3Nkay9hYm91dC5weQ==) | 84.00% | [2 Missing and 2 partials :warning: ](https://app.codecov.io/gh/meltano/sdk/pull/2180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) |
| [singer\_sdk/plugin\_base.py](https://app.codecov.io/gh/meltano/sdk/pull/2180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano#diff-c2luZ2VyX3Nkay9wbHVnaW5fYmFzZS5weQ==) | 0.00% | [1 Missing :warning: ](https://app.codecov.io/gh/meltano/sdk/pull/2180?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2180      +/-   ##
==========================================
- Coverage   88.39%   88.38%   -0.01%     
==========================================
  Files          54       54              
  Lines        4689     4711      +22     
  Branches      955      961       +6     
==========================================
+ Hits         4145     4164      +19     
- Misses        386      387       +1     
- Partials      158      160       +2     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/meltano/sdk/pull/2180?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano).


Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 8b1d0e15..abc09835 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -187,6 +187,7 @@ exclude_also = [
     '''if (t\.)?TYPE_CHECKING:''',
 ]
 fail_under = 82
+show_missing = true
 
 [tool.mypy]
 exclude = "tests"
diff --git a/singer_sdk/about.py b/singer_sdk/about.py
index e5bbf60d..5585f55d 100644
--- a/singer_sdk/about.py
+++ b/singer_sdk/about.py
@@ -9,6 +9,9 @@ import typing as t
 from collections import OrderedDict
 from textwrap import dedent
 
+from packaging.specifiers import SpecifierSet
+from packaging.version import Version
+
 if t.TYPE_CHECKING:
     from singer_sdk.helpers.capabilities import CapabilitiesEnum
 
@@ -19,6 +22,38 @@ __all__ = [
     "MarkdownFormatter",
 ]
 
+# Keep these in sync with the supported Python versions in pyproject.toml
+_PY_MIN_VERSION = 8
+_PY_MAX_VERSION = 12
+
+
+def _get_min_version(specifiers: SpecifierSet) -> int:
+    min_version: list[int] = []
+    for specifier in specifiers:
+        if specifier.operator == ">=":
+            min_version.append(Version(specifier.version).minor)
+        if specifier.operator == ">":
+            min_version.append(Version(specifier.version).minor + 1)
+    return min(min_version, default=_PY_MIN_VERSION)
+
+
+def _get_max_version(specifiers: SpecifierSet) -> int:
+    max_version: list[int] = []
+    for specifier in specifiers:
+        if specifier.operator == "<=":
+            max_version.append(Version(specifier.version).minor)
+        if specifier.operator == "<":
+            max_version.append(Version(specifier.version).minor - 1)
+    return max(max_version, default=_PY_MAX_VERSION)
+
+
+def get_supported_pythons(requires_python: str) -> t.Generator[str, None, None]:
+    specifiers = SpecifierSet(requires_python)
+    min_version = _get_min_version(specifiers)
+    max_version = _get_max_version(specifiers)
+
+    yield from specifiers.filter(f"3.{v}" for v in range(min_version, max_version + 1))
+
 
 @dataclasses.dataclass
 class AboutInfo:
diff --git a/singer_sdk/plugin_base.py b/singer_sdk/plugin_base.py
index 271e248c..fc1ce941 100644
--- a/singer_sdk/plugin_base.py
+++ b/singer_sdk/plugin_base.py
@@ -14,7 +14,6 @@ from types import MappingProxyType
 
 import click
 from jsonschema import Draft7Validator
-from packaging.specifiers import SpecifierSet
 
 from singer_sdk import about, metrics
 from singer_sdk.cli import plugin_cli
@@ -36,30 +35,6 @@ from singer_sdk.mapper import PluginMapper
 from singer_sdk.typing import extend_validator_with_defaults
 
 SDK_PACKAGE_NAME = "singer_sdk"
-CHECK_SUPPORTED_PYTHON_VERSIONS = (
-    # unsupported versions
-    "2.7",
-    "3.0",
-    "3.1",
-    "3.2",
-    "3.3",
-    "3.4",
-    "3.5",
-    "3.6",
-    "3.7",
-    # current supported versions
-    "3.8",
-    "3.9",
-    "3.10",
-    "3.11",
-    "3.12",
-    # future supported versions
-    "3.13",
-    "3.14",
-    "3.15",
-    "3.16",
-)
-
 
 JSONSchemaValidator = extend_validator_with_defaults(Draft7Validator)
 
@@ -300,12 +275,7 @@ class PluginBase(metaclass=abc.ABCMeta):
         except metadata.PackageNotFoundError:
             return None
 
-        reported_python_versions = SpecifierSet(package_metadata["Requires-Python"])
-        return [
-            version
-            for version in CHECK_SUPPORTED_PYTHON_VERSIONS
-            if version in reported_python_versions
-        ]
+        return list(about.get_supported_pythons(package_metadata["Requires-Python"]))
 
     @classmethod
     def get_plugin_version(cls) -> str:


Test patch:
diff --git a/tests/core/test_about.py b/tests/core/test_about.py
index cd458cea..41507722 100644
--- a/tests/core/test_about.py
+++ b/tests/core/test_about.py
@@ -3,11 +3,19 @@
 from __future__ import annotations
 
 import typing as t
+from importlib import metadata
 
 import pytest
 
-from singer_sdk.about import AboutFormatter, AboutInfo
+from singer_sdk.about import (
+    _PY_MAX_VERSION,
+    _PY_MIN_VERSION,
+    AboutFormatter,
+    AboutInfo,
+    get_supported_pythons,
+)
 from singer_sdk.helpers.capabilities import TapCapabilities
+from singer_sdk.plugin_base import SDK_PACKAGE_NAME
 
 if t.TYPE_CHECKING:
     from pathlib import Path
@@ -72,3 +80,26 @@ def test_about_format(
     output = formatter.format_about(about_info)
     snapshot_name = f"{about_format}.snap.{_format_to_extension[about_format]}"
     snapshot.assert_match(output, snapshot_name)
+
+
+def test_get_supported_pythons_sdk():
+    package_metadata = metadata.metadata(SDK_PACKAGE_NAME)
+    requires_python = package_metadata["Requires-Python"]
+
+    supported_pythons = list(get_supported_pythons(requires_python))
+    assert supported_pythons[0] == f"3.{_PY_MIN_VERSION}"
+    assert supported_pythons[-1] == f"3.{_PY_MAX_VERSION}"
+
+
+@pytest.mark.parametrize(
+    "specifiers,expected",
+    [
+        (">=3.7,<3.12", ["3.7", "3.8", "3.9", "3.10", "3.11"]),
+        (">=3.7", ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]),
+        (">3.7", ["3.8", "3.9", "3.10", "3.11", "3.12"]),
+        (">3.7,<=3.11", ["3.8", "3.9", "3.10", "3.11"]),
+    ],
+)
+def test_get_supported_pythons(specifiers: str, expected: list[str]):
+    supported_pythons = list(get_supported_pythons(specifiers))
+    assert supported_pythons == expected
diff --git a/tests/core/test_plugin_base.py b/tests/core/test_plugin_base.py
index 7d1021c1..0ea87f03 100644
--- a/tests/core/test_plugin_base.py
+++ b/tests/core/test_plugin_base.py
@@ -5,7 +5,7 @@ from unittest import mock
 
 import pytest
 
-from singer_sdk.plugin_base import MapperNotInitialized, PluginBase
+from singer_sdk.plugin_base import SDK_PACKAGE_NAME, MapperNotInitialized, PluginBase
 from singer_sdk.typing import IntegerType, PropertiesList, Property, StringType
 
 
@@ -53,3 +53,8 @@ def test_mapper_not_initialized():
     )
     with pytest.raises(MapperNotInitialized):
         _ = plugin.mapper
+
+
+def test_supported_python_versions():
+    """Test that supported python versions are correctly parsed."""
+    assert PluginBase._get_supported_python_versions(SDK_PACKAGE_NAME)