From 4e931e8646546f73a7ec942628b45df8b9e44857 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 18 Dec 2025 15:59:31 +0000 Subject: [PATCH 1/2] fix(utils): normalize minversion LooseVersion [skip ci] --- astropy/utils/introspection.py | 97 +++++++++++++++++++++-- astropy/utils/tests/test_introspection.py | 44 ++++++++-- 2 files changed, 131 insertions(+), 10 deletions(-) diff --git a/astropy/utils/introspection.py b/astropy/utils/introspection.py index 3e784f9fc340..feaea8d5a249 100644 --- a/astropy/utils/introspection.py +++ b/astropy/utils/introspection.py @@ -6,6 +6,8 @@ import inspect import types import importlib +import re +import warnings from distutils.version import LooseVersion @@ -88,6 +90,64 @@ def resolve_name(name, *additional_parts): return ret +_TAG_RULES = ( + ('post', 2), + ('dev', 1), + ('rc', -1), + ('beta', -2), + ('b', -2), + ('alpha', -3), + ('a', -3), +) + +_TAG_PATTERNS = { + name: re.compile(rf'[-_.]*{name}(\d*)$') for name, _ in _TAG_RULES +} + + +def _normalize_for_loose_version(version_string): + """Normalize a version string for ``LooseVersion`` comparisons.""" + text = str(version_string or '').strip() + + if '!' in text: + text = text.split('!', 1)[1] + + if '+' in text: + text = text.split('+', 1)[0] + + match = re.match(r'^(\d+(?:\.\d+)*)', text) + release_str = match.group(1) if match else '' + remainder = text[len(release_str):] + + release_parts = [int(part) for part in release_str.split('.') if part] + while len(release_parts) < 3: + release_parts.append(0) + release_parts = release_parts[:3] + + tag_code = 0 + tag_number = 0 + + tail = remainder.lower() + for name, code in _TAG_RULES: + match = _TAG_PATTERNS[name].search(tail) + if match: + digits = match.group(1) + tag_number = int(digits) if digits else 0 + tag_code = code + break + + normalized_code = tag_code + 3 + components = release_parts + [normalized_code, tag_number] + return '.'.join(str(value) for value in components) + + +def _requires_normalization(value): + lower = str(value or '').lower() + if '!' in lower or '+' in lower: + return True + return any(name in lower for name, _ in _TAG_RULES) + + def minversion(module, version, inclusive=True, version_path='__version__'): """ Returns `True` if the specified Python module satisfies a minimum version @@ -139,10 +199,34 @@ def minversion(module, version, inclusive=True, version_path='__version__'): else: have_version = resolve_name(module.__name__, version_path) - if inclusive: - return LooseVersion(have_version) >= LooseVersion(version) - else: - return LooseVersion(have_version) > LooseVersion(version) + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + left = LooseVersion(have_version) + right = LooseVersion(version) + + comparator = (lambda a, b: a >= b) if inclusive else (lambda a, b: a > b) + + needs_normalized = ( + _requires_normalization(have_version) or + _requires_normalization(version) + ) + + try: + direct_result = comparator(left, right) + except TypeError: + needs_normalized = True + direct_result = None + + if needs_normalized: + with warnings.catch_warnings(): + warnings.simplefilter('ignore', DeprecationWarning) + normalized_have = LooseVersion( + _normalize_for_loose_version(have_version)) + normalized_need = LooseVersion( + _normalize_for_loose_version(version)) + return comparator(normalized_have, normalized_need) + + return direct_result def find_current_module(depth=1, finddiff=False): @@ -313,7 +397,10 @@ def find_mod_objs(modname, onlylocals=False): if onlylocals: if onlylocals is True: onlylocals = [modname] - valids = [any(fqn.startswith(nm) for nm in onlylocals) for fqn in fqnames] + valids = [ + any(fqn.startswith(nm) for nm in onlylocals) + for fqn in fqnames + ] localnames = [e for i, e in enumerate(localnames) if valids[i]] fqnames = [e for i, e in enumerate(fqnames) if valids[i]] objs = [e for i, e in enumerate(objs) if valids[i]] diff --git a/astropy/utils/tests/test_introspection.py b/astropy/utils/tests/test_introspection.py index d8262d4581a9..803467128448 100644 --- a/astropy/utils/tests/test_introspection.py +++ b/astropy/utils/tests/test_introspection.py @@ -2,12 +2,13 @@ # namedtuple is needed for find_mod_objs so it can have a non-local module from collections import namedtuple +from types import ModuleType import pytest from .. import introspection from ..introspection import (find_current_module, find_mod_objs, - isinstancemethod, minversion) + minversion) def test_pkg_finder(): @@ -34,7 +35,8 @@ def test_find_current_mod(): assert find_current_module(0, True).__name__ == thismodnm assert find_current_module(0, [introspection]).__name__ == thismodnm - assert find_current_module(0, ['astropy.utils.introspection']).__name__ == thismodnm + assert find_current_module( + 0, ['astropy.utils.introspection']).__name__ == thismodnm with pytest.raises(ImportError): find_current_module(0, ['faddfdsasewrweriopunjlfiurrhujnkflgwhu']) @@ -63,13 +65,45 @@ def test_find_mod_objs(): assert namedtuple not in objs +def _module_with_version(version): + module = ModuleType(str("test_module")) + module.__version__ = version + return module + + def test_minversion(): - from types import ModuleType - test_module = ModuleType(str("test_module")) - test_module.__version__ = '0.12.2' + test_module = _module_with_version('0.12.2') good_versions = ['0.12', '0.12.1', '0.12.0.dev'] bad_versions = ['1', '1.2rc1'] for version in good_versions: assert minversion(test_module, version) for version in bad_versions: assert not minversion(test_module, version) + + +def test_minversion_dev_comparisons(): + assert minversion(_module_with_version('1.14.3'), '1.14dev') + assert not minversion(_module_with_version('1.14'), '1.14dev') + + +@pytest.mark.parametrize( + 'lower,higher', + [ + ('1.14a1', '1.14b1'), + ('1.14b1', '1.14rc1'), + ('1.14rc1', '1.14'), + ] +) +def test_minversion_prerelease_order(lower, higher): + assert not minversion(_module_with_version(lower), higher) + assert minversion(_module_with_version(higher), lower) + + +def test_minversion_post_release_order(): + assert not minversion(_module_with_version('1.14'), '1.14.post1') + assert minversion(_module_with_version('1.14.1'), '1.14.post1') + + +def test_minversion_ignores_epoch_and_local(): + assert minversion(_module_with_version('2!1.14.0+g123'), '1.14') + assert minversion(_module_with_version('1.14'), '2!1.14.0+local') From 4358578150833418cd541c4c7da54597f9696073 Mon Sep 17 00:00:00 2001 From: Casey Brooks Date: Thu, 18 Dec 2025 16:11:01 +0000 Subject: [PATCH 2/2] fix(utils): retain full release segments [skip ci] --- astropy/utils/introspection.py | 12 ++++++++---- astropy/utils/tests/test_introspection.py | 7 +++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/astropy/utils/introspection.py b/astropy/utils/introspection.py index feaea8d5a249..f5264dd5396f 100644 --- a/astropy/utils/introspection.py +++ b/astropy/utils/introspection.py @@ -104,6 +104,8 @@ def resolve_name(name, *additional_parts): name: re.compile(rf'[-_.]*{name}(\d*)$') for name, _ in _TAG_RULES } +_TAG_SENTINEL = 0 + def _normalize_for_loose_version(version_string): """Normalize a version string for ``LooseVersion`` comparisons.""" @@ -120,9 +122,11 @@ def _normalize_for_loose_version(version_string): remainder = text[len(release_str):] release_parts = [int(part) for part in release_str.split('.') if part] - while len(release_parts) < 3: - release_parts.append(0) - release_parts = release_parts[:3] + if not release_parts: + release_parts = [0] + while len(release_parts) > 1 and release_parts[-1] == 0: + release_parts.pop() + release_parts = [value + 1 for value in release_parts] tag_code = 0 tag_number = 0 @@ -137,7 +141,7 @@ def _normalize_for_loose_version(version_string): break normalized_code = tag_code + 3 - components = release_parts + [normalized_code, tag_number] + components = release_parts + [_TAG_SENTINEL, normalized_code, tag_number] return '.'.join(str(value) for value in components) diff --git a/astropy/utils/tests/test_introspection.py b/astropy/utils/tests/test_introspection.py index 803467128448..4f1cb3a929e7 100644 --- a/astropy/utils/tests/test_introspection.py +++ b/astropy/utils/tests/test_introspection.py @@ -86,6 +86,13 @@ def test_minversion_dev_comparisons(): assert not minversion(_module_with_version('1.14'), '1.14dev') +def test_minversion_multi_component_dev_ordering(): + lower = _module_with_version('1.2.3.4.dev1') + higher_version = '1.2.3.5.dev1' + assert not minversion(lower, higher_version) + assert minversion(_module_with_version(higher_version), '1.2.3.4.dev1') + + @pytest.mark.parametrize( 'lower,higher', [