Skip to content

Commit

Permalink
Permissive version string matching x 1.
Browse files Browse the repository at this point in the history
This commit is the first in a commit chain relaxing version string
matching internally performed by @beartype's test suite to permissively
match version strings suffixed by supplementary software-specific
version metadata (e.g., release candidates, alpha releases, beta
releases), en-route to resolving test-time issue #283 kindly submitted
by Gentoo Linux lead @mgorny (Michał Górny), who is also a cat, which is
fairly impressive, considering that even the smartest cat only has a
firm grasp on about 40 human words. Specifically, this commit:

* Defines a new private `beartype._util.text.utiltextversion` submodule.
* Defines a new low-level `convert_str_version_to_tuple()` utility
  function in that submodule resilient against aforementioned metadata.
* Refactors existing logic in our test suite to call that function when
  performing numerical comparisons against competing version strings.

Although reasonably tested, `convert_str_version_to_tuple()` has yet to
be exhaustively tested against all possible edge cases.
(*Unhemmed finale of a final femme fatale!*)
  • Loading branch information
leycec committed Sep 21, 2023
1 parent 5170a2b commit d7ab7ce
Show file tree
Hide file tree
Showing 18 changed files with 195 additions and 47 deletions.
2 changes: 1 addition & 1 deletion beartype/_check/forward/fwdscope.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
make_forwardref_indexable_subtype,
_BeartypeForwardRefIndexableABC,
)
from beartype._util.text.utiltextident import die_unless_identifier
from beartype._util.text.utiltextidentifier import die_unless_identifier
# from sys import modules as sys_modules

# ....................{ SUBCLASSES }....................
Expand Down
2 changes: 1 addition & 1 deletion beartype/_decor/decorcore.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
is_func_python,
)
from beartype._util.py.utilpyversion import IS_PYTHON_AT_LEAST_3_10
# from beartype._util.text.utiltextansi import strip_text_ansi
# from beartype._util.text.utiltextansi import strip_str_ansi
from beartype._util.text.utiltextlabel import label_object_context
from beartype._util.text.utiltextmunge import (
truncate_str,
Expand Down
4 changes: 2 additions & 2 deletions beartype/_decor/error/_util/errorutilcolor.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
COLOR_BLUE,
COLOR_YELLOW,
STYLE_BOLD,
strip_text_ansi,
strip_str_ansi,
)

# ....................{ COLOURIZERS }....................
Expand Down Expand Up @@ -180,7 +180,7 @@ def strip_text_ansi_if_configured(text: str, conf: BeartypeConf) -> str:
return (
# This string with all ANSI stripped when this configuration instructs
# this function to either...
strip_text_ansi(text)
strip_str_ansi(text)
if (
# Unconditionally strip all ANSI from this string *OR*...
# Conditionally strip all ANSI from this string only when standard
Expand Down
2 changes: 1 addition & 1 deletion beartype/_util/cls/utilclsmake.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
TypeException,
)
from beartype._data.kind.datakinddict import DICT_EMPTY
from beartype._util.text.utiltextident import die_unless_identifier
from beartype._util.text.utiltextidentifier import die_unless_identifier

# ....................{ MAKERS }....................
def make_type(
Expand Down
10 changes: 5 additions & 5 deletions beartype/_util/module/utilmodtest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
'''

# ....................{ IMPORTS }....................
from beartype.meta import _convert_version_str_to_tuple
from beartype.roar._roarexc import _BeartypeUtilModuleException
from beartype._data.hint.datahinttyping import TypeException
from beartype._util.text.utiltextident import die_unless_identifier
from beartype._util.text.utiltextidentifier import die_unless_identifier
from beartype._util.text.utiltextversion import convert_str_version_to_tuple
from importlib.metadata import version as get_module_version # type: ignore[attr-defined]

# ....................{ RAISERS }....................
Expand Down Expand Up @@ -47,7 +47,7 @@ def die_unless_module_attr_name(
Raises
----------
:exc:`exception_cls`
exception_cls
If either:
* This name is *not* a string.
Expand Down Expand Up @@ -185,8 +185,8 @@ def is_module_version_at_least(module_name: str, version_minimum: str) -> bool:
version_actual = get_module_version(module_name)

# Tuples of version parts parsed from version strings.
version_actual_parts = _convert_version_str_to_tuple(version_actual)
version_minimum_parts = _convert_version_str_to_tuple(version_minimum)
version_actual_parts = convert_str_version_to_tuple(version_actual)
version_minimum_parts = convert_str_version_to_tuple(version_minimum)

# Return true only if this module's version satisfies this minimum.
return version_actual_parts >= version_minimum_parts
Expand Down
4 changes: 2 additions & 2 deletions beartype/_util/text/utiltextansi.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
'''

# ....................{ TESTERS }....................
def is_text_ansi(text: str) -> bool:
def is_str_ansi(text: str) -> bool:
'''
:data:`True` only if the passed text contains one or more ANSI escape
sequences.
Expand All @@ -73,7 +73,7 @@ def is_text_ansi(text: str) -> bool:
return _ANSI_REGEX.search(text) is not None

# ....................{ STRIPPERS }....................
def strip_text_ansi(text: str) -> str:
def strip_str_ansi(text: str) -> str:
'''
Strip *all* ANSI escape sequences from the passed string.
Expand Down
5 changes: 3 additions & 2 deletions beartype/_util/text/utiltextget.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
# See "LICENSE" for further details.

'''
**Beartype string getting utilities** (i.e., callables getting substrings of
passed strings, typically prefixes and suffixes satisfying various conditions).
Project-wide **string getters** (i.e., low-level callables slicing and returning
substrings out of arbitrary strings, typically to acquire prefixes and suffixes
satisfying various conditions).
This private submodule is *not* intended for importation by downstream callers.
'''
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
# See "LICENSE" for further details.

'''
Project-wide **Python identifier** (i.e., attribute, callable, class, module,
or variable name) utilities.
Project-wide **Python identifier utilities** (i.e., low-level callables handling
unqualified and qualified attribute, callable, class, module, and variable
names).
This private submodule is *not* intended for importation by downstream callers.
'''
Expand Down
2 changes: 0 additions & 2 deletions beartype/_util/text/utiltextmagic.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@
This private submodule is *not* intended for importation by downstream callers.
'''

# ....................{ IMPORTS }....................

# ....................{ CODE ~ indent }....................
CODE_INDENT_1 = ' '
'''
Expand Down
138 changes: 138 additions & 0 deletions beartype/_util/text/utiltextversion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
# --------------------( LICENSE )--------------------
# Copyright (c) 2014-2023 Beartype authors.
# See "LICENSE" for further details.

'''
Project-wide **version string utilities** (i.e., low-level callables handling
human-readable ``.``-delimited version strings).
This private submodule is *not* intended for importation by downstream callers.
'''

# ....................{ IMPORTS }....................
from beartype.roar._roarexc import _BeartypeUtilTextVersionException
from beartype.typing import Tuple
from re import compile as re_compile

# ....................{ CONVERTERS }....................
#FIXME: Unit test us up, please.
def convert_str_version_to_tuple(version: str) -> Tuple[int, ...]:
'''
Convert the passed human-readable ``.``-delimited version string into a
machine-readable version tuple of corresponding integers, suitable for
efficient comparison against other such version tuples via standard rich
comparison operators (e.g., ``<``, ``==``).
Caveats
----------
**This converter strictly requires each ``.``-delimited substring of this
string to be a non-negative integer.** The exception is the last
``.``-prefixed substring of this string, which this converter permits to
*not* be a non-negative integer. Specifically, that last substring:
* *Must* be prefixed by a non-negative integer.
* *May* be followed by any other arbitrary characters, which this converter
silently ignores as supplementary software-specific version metadata
(e.g., release candidates, alpha releases, beta releases). Since that
metadata does *not* cleanly generalize to all possible use cases, that
metadata *cannot* be safely converted into a non-negative integer.
For example, this converter:
* Converts the valid version string ``"1.26.0"`` to ``(1, 26, 0)``.
* Converts the valid version string ``"1.26.0rc1"`` to ``(1, 26, 0)`` by
simply ignoring the non-numeric suffix ``"rc1"``.
* Raises an exception for the invalid version string ``"1.26.rc1"``.
Parameters
----------
text : str
Version string to be converted.
Returns
----------
Tuple[int, ...]
Machine-readable version tuple of corresponding integers.
Raises
----------
_BeartypeUtilTextVersionException
If this string is syntactically invalid as a version.
'''
assert isinstance(version, str), f'{repr(version)} not version string.'

# List of either:
# * If this version contains one or more "." delimiters, all "."-delimited
# version components split from this version.
# * If this version contains *NO* "." delimiters, the 1-list "[version,]".
version_substrs = version.split('.')

# 0-based index of the last version component in this list.
version_substr_index_last = len(version_substrs) - 1

# List of all version components to be returned as a tuple.
version_list = []

# For the 0-based index of each "."-delimited version component of this
# version string and that component...
for version_substr_index, version_substr in enumerate(version_substrs):
# Attempt to...
try:
# Coerce this version component into an integer.
version_part = int(version_substr)

# If this component is negative, raise an exception.
if version_part < 0:
raise _BeartypeUtilTextVersionException(
f'Version {repr(version)} syntactically invalid '
f'(i.e., version component {repr(version_substr)} negative).'
)
# Else, this component is non-negative.
# If doing so raises a "ValueError", this version component is *NOT*
# syntactically valid as an integer. In this case...
except ValueError as exception:
# If the 0-based index of this version component is that of the last
# version component in this list, this is *NOT* the last version
# component. In this case, this component is syntactically invalid.
# Raise an exception.
if version_substr_index != version_substr_index_last:
raise _BeartypeUtilTextVersionException(
f'Version {repr(version)} syntactically invalid '
f'(i.e., version component {repr(version_substr)} '
f'not an integer).'
) from exception
# Else, this is the last version component. In this case, reduce
# this component to its non-negative integer prefix.

# Match result if this component is prefixed by a non-negative
# integer *OR* "None" otherwise (i.e., if this component is
# syntactically invalid).
version_substr_match = _VERSION_SUBSTR_LAST_REGEX.match(
version_substr)

# If this component is syntactically invalid, raise an exception.
if version_substr_match is None:
raise _BeartypeUtilTextVersionException(
f'Version {repr(version)} syntactically invalid '
f'(i.e., version component {repr(version_substr)} '
f'not an integer).'
) from exception
# Else, this component is syntactically valid.

# Non-negative integer prefixing this component.
version_part = int(version_substr_match.group(1))

# Append this version component to this list.
version_list.append(version_part)

# Return this list coerced into a tuple.
return tuple(version_list)

# ....................{ PRIVATE ~ constants }....................
_VERSION_SUBSTR_LAST_REGEX = re_compile(r'([0-9]+).+')
'''
Compiled regular expression matching the non-negative integer prefixing the last
``.``-delimited version component in a version string (e.g., ``"5"`` in the
version string ``"5rc27"``).
'''
2 changes: 1 addition & 1 deletion beartype/claw/_pkg/_clawpkgmake.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
Optional,
)
from beartype._conf.confcls import BeartypeConf
from beartype._util.text.utiltextident import die_unless_identifier
from beartype._util.text.utiltextidentifier import die_unless_identifier
from collections.abc import Iterable as IterableABC

# ....................{ PRIVATE ~ factories }....................
Expand Down
1 change: 1 addition & 0 deletions beartype/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ def _convert_version_str_to_tuple(version_str: str): # -> _Tuple[int, ...]:
machine-readable version tuple of corresponding integers.
'''
assert isinstance(version_str, str), f'"{version_str}" not version string.'

return tuple(int(version_part) for version_part in version_str.split('.'))


Expand Down
21 changes: 16 additions & 5 deletions beartype/roar/_roarexc.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
# See "LICENSE" for further details.

'''
**Beartype exception hierarchy.**
This private submodule publishes a hierarchy of both public and private
:mod:`beartype`-specific exceptions raised at decoration, call, and usage time.
Beartype **exception hierarchy** (i.e., public and private exception subclasses
raised at decoration, call, and usage time by :mod:`beartype`).
This private submodule is *not* intended for importation by downstream callers.
'''
Expand Down Expand Up @@ -1395,7 +1393,20 @@ class _BeartypeUtilTextIdentifierException(_BeartypeUtilTextException):
Beartype **Python identifier utility exception.**
This exception is raised by private functions of the private
:mod:`beartype._util.text.utiltextident` submodule on fatal edge cases.
:mod:`beartype._util.text.utiltextidentifier` submodule on fatal edge cases.
This exception denotes a critical internal issue and should thus *never* be
raised -- let alone allowed to percolate up the call stack to end users.
'''

pass


class _BeartypeUtilTextVersionException(_BeartypeUtilTextException):
'''
Beartype **Python version utility exception.**
This exception is raised by private functions of the private
:mod:`beartype._util.text.utiltextversion` submodule on fatal edge cases.
This exception denotes a critical internal issue and should thus *never* be
raised -- let alone allowed to percolate up the call stack to end users.
'''
Expand Down
6 changes: 2 additions & 4 deletions beartype/roar/_roarwarn.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
# See "LICENSE" for further details.

'''
**Beartype warning hierarchy.**
This private submodule publishes a hierarchy of both public and private
:mod:`beartype`-specific warnings emitted at decoration, call, and usage time.
Beartype **warning hierarchy** (i.e., public and private warning subclasses
emitted at decoration, call, and usage time by :mod:`beartype`).
This private submodule is *not* intended for importation by downstream callers.
'''
Expand Down
16 changes: 8 additions & 8 deletions beartype_test/a00_unit/a20_util/text/test_utiltextansi.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,33 +20,33 @@
# ....................{ TESTS ~ tester }....................
def test_is_text_ansi() -> None:
'''
Test the :func:`beartype._util.text.utiltextansi.is_text_ansi` tester.
Test the :func:`beartype._util.text.utiltextansi.is_str_ansi` tester.
'''

# Defer test-specific imports.
from beartype._util.text.utiltextansi import is_text_ansi
from beartype._util.text.utiltextansi import is_str_ansi

# Assert that this tester returns false for a string containing *NO* ANSI.
assert is_text_ansi('The sea-blooms and the oozy woods which wear') is False
assert is_str_ansi('The sea-blooms and the oozy woods which wear') is False

# Assert that this tester returns true for a string containing ANSI.
assert is_text_ansi('The sapless foliage of the ocean, know\033[92m') is (
assert is_str_ansi('The sapless foliage of the ocean, know\033[92m') is (
True)

# ....................{ TESTS ~ stripper }....................
def test_strip_text_ansi() -> None:
'''
Test the :func:`beartype._util.text.utiltextansi.strip_text_ansi` stripper.
Test the :func:`beartype._util.text.utiltextansi.strip_str_ansi` stripper.
'''

# Defer test-specific imports.
from beartype._util.text.utiltextansi import strip_text_ansi
from beartype._util.text.utiltextansi import strip_str_ansi

# String containing *NO* ANSI.
THY_VOICE = 'and suddenly grow gray with fear,'

# Assert that this stripper preserves strings containing *NO* ANSI as is.
assert strip_text_ansi(THY_VOICE) == THY_VOICE
assert strip_str_ansi(THY_VOICE) == THY_VOICE

# Assert that this stripper strips *ALL* ANSI from strings containing ANSI.
assert strip_text_ansi(f'\033[31m{THY_VOICE}\033[92m') == THY_VOICE
assert strip_str_ansi(f'\033[31m{THY_VOICE}\033[92m') == THY_VOICE

0 comments on commit d7ab7ce

Please sign in to comment.