diff --git a/mesonbuild/interpreter/interpreter.py b/mesonbuild/interpreter/interpreter.py index a69b50a7d157..41235b37b540 100644 --- a/mesonbuild/interpreter/interpreter.py +++ b/mesonbuild/interpreter/interpreter.py @@ -496,7 +496,7 @@ def process_new_values(self, invalues: T.List[T.Union[TYPE_var, ExecutableSerial def handle_meson_version(self, pv: str, location: mparser.BaseNode) -> None: if not mesonlib.version_compare(coredata.stable_version, pv): raise InterpreterException.from_node(f'Meson version is {coredata.version} but project requires {pv}', node=location) - mesonlib.project_meson_versions[self.subproject] = pv + mesonlib.project_meson_versions[self.subproject] = mesonlib.version_check_to_range([pv]) def handle_meson_version_from_ast(self) -> None: if not self.ast.lines: diff --git a/mesonbuild/interpreter/primitives/string.py b/mesonbuild/interpreter/primitives/string.py index 2adc58d34019..0145cce69bf4 100644 --- a/mesonbuild/interpreter/primitives/string.py +++ b/mesonbuild/interpreter/primitives/string.py @@ -8,7 +8,7 @@ import typing as T from ... import mlog -from ...mesonlib import version_compare_many, underscorify +from ...mesonlib import version_check_to_range, version_compare_many, underscorify from ...interpreterbase import ( InterpreterObject, MesonOperator, @@ -204,20 +204,19 @@ class MesonVersionStringHolder(StringHolder): @InterpreterObject.method('version_compare') @typed_pos_args('str.version_compare', varargs=str, min_varargs=1) def version_compare_method(self, args: T.Tuple[T.List[str]], kwargs: TYPE_kwargs) -> bool: - unsupported = [] + unsupported = False for constraint in args[0]: - if not constraint.strip().startswith('>'): - unsupported.append('non-upper-bounds (> or >=) constraints') + if constraint.strip().startswith('!'): + unsupported = True if len(args[0]) > 1: FeatureNew.single_use('meson.version().version_compare() with multiple arguments', '1.10.0', self.subproject, 'From 1.8.0 - 1.9.* it failed to match str.version_compare', location=self.current_node) - unsupported.append('multiple arguments') - else: - self.interpreter.tmp_meson_version = args[0][0] if unsupported: - mlog.debug('meson.version().version_compare() with', ' or '.join(unsupported), + mlog.debug('meson.version().version_compare() with != constraints', 'does not support overriding minimum meson_version checks.') + else: + self.interpreter.tmp_meson_version = version_check_to_range(args[0]) return version_compare_many(self.held_object, args[0])[0] diff --git a/mesonbuild/interpreterbase/decorators.py b/mesonbuild/interpreterbase/decorators.py index 967892e30225..2d41b1dbd394 100644 --- a/mesonbuild/interpreterbase/decorators.py +++ b/mesonbuild/interpreterbase/decorators.py @@ -603,6 +603,8 @@ def emit_feature_change(values: T.Dict[_T, T.Union[str, T.Tuple[str, str]]], fea return inner +MesonVersionTarget = T.Optional[T.Union[mesonlib.Range[mesonlib.Version], mesonlib.NoProjectVersion]] + # This cannot be a dataclass due to https://github.com/python/mypy/issues/5374 class FeatureCheckBase(metaclass=abc.ABCMeta): "Base class for feature version checks" @@ -617,21 +619,21 @@ def __init__(self, feature_name: str, feature_version: str, extra_message: str = self.extra_message = extra_message @staticmethod - def get_target_version(subproject: str) -> T.Union[str, mesonlib.NoProjectVersion]: + def get_target_version(subproject: str) -> MesonVersionTarget: # Don't do any checks if project() has not been parsed yet if subproject not in mesonlib.project_meson_versions: - return '' + return None return mesonlib.project_meson_versions[subproject] @staticmethod @abc.abstractmethod - def check_version(target_version: T.Union[str, mesonlib.NoProjectVersion], feature_version: str) -> bool: + def check_version(target_version: MesonVersionTarget, feature_version: str) -> bool: pass def use(self, subproject: 'SubProject', location: T.Optional['mparser.BaseNode'] = None) -> None: tv = self.get_target_version(subproject) # No target version - if tv == '' and not self.unconditional: + if tv is None and not self.unconditional: return # Target version is new enough, don't warn if self.check_version(tv, self.feature_version) and not self.emit_notice: @@ -674,15 +676,15 @@ def report(cls, subproject: str) -> None: if '\n' in warning_str: mlog.warning(warning_str) - def log_usage_warning(self, tv: T.Union[str, mesonlib.NoProjectVersion], location: T.Optional['mparser.BaseNode']) -> None: + def log_usage_warning(self, tv: MesonVersionTarget, location: T.Optional['mparser.BaseNode']) -> None: raise InterpreterException('log_usage_warning not implemented') @staticmethod - def get_warning_str_prefix(tv: T.Union[str, mesonlib.NoProjectVersion]) -> str: + def get_warning_str_prefix(tv: MesonVersionTarget) -> str: raise InterpreterException('get_warning_str_prefix not implemented') @staticmethod - def get_notice_str_prefix(tv: T.Union[str, mesonlib.NoProjectVersion]) -> str: + def get_notice_str_prefix(tv: MesonVersionTarget) -> str: raise InterpreterException('get_notice_str_prefix not implemented') def __call__(self, f: TV_func) -> TV_func: @@ -711,8 +713,8 @@ class FeatureNew(FeatureCheckBase): feature_registry = {} @staticmethod - def check_version(target_version: T.Union[str, mesonlib.NoProjectVersion], feature_version: str) -> bool: - if isinstance(target_version, str): + def check_version(target_version: MesonVersionTarget, feature_version: str) -> bool: + if isinstance(target_version, mesonlib.Range): return mesonlib.version_compare_condition_with_min(target_version, feature_version) else: # Warn for anything newer than the current semver base slot. @@ -720,19 +722,19 @@ def check_version(target_version: T.Union[str, mesonlib.NoProjectVersion], featu return mesonlib.version_compare(feature_version, f'<{major}.0') @staticmethod - def get_warning_str_prefix(tv: T.Union[str, mesonlib.NoProjectVersion]) -> str: - if isinstance(tv, str): + def get_warning_str_prefix(tv: MesonVersionTarget) -> str: + if isinstance(tv, mesonlib.Range): return f'Project specifies a minimum meson_version \'{tv}\' but uses features which were added in newer versions:' else: return 'Project specifies no minimum version but uses features which were added in versions:' @staticmethod - def get_notice_str_prefix(tv: T.Union[str, mesonlib.NoProjectVersion]) -> str: + def get_notice_str_prefix(tv: MesonVersionTarget) -> str: return '' - def log_usage_warning(self, tv: T.Union[str, mesonlib.NoProjectVersion], location: T.Optional['mparser.BaseNode']) -> None: - if isinstance(tv, str): - prefix = f'Project targets {tv!r}' + def log_usage_warning(self, tv: MesonVersionTarget, location: T.Optional['mparser.BaseNode']) -> None: + if isinstance(tv, mesonlib.Range): + prefix = f"Project targets '{tv}'" else: prefix = 'Project does not target a minimum version' args = [ @@ -755,8 +757,8 @@ class FeatureDeprecated(FeatureCheckBase): emit_notice = True @staticmethod - def check_version(target_version: T.Union[str, mesonlib.NoProjectVersion], feature_version: str) -> bool: - if isinstance(target_version, str): + def check_version(target_version: MesonVersionTarget, feature_version: str) -> bool: + if isinstance(target_version, mesonlib.Range): # For deprecation checks we need to return the inverse of FeatureNew checks return not mesonlib.version_compare_condition_with_min(target_version, feature_version) else: @@ -764,16 +766,16 @@ def check_version(target_version: T.Union[str, mesonlib.NoProjectVersion], featu return False @staticmethod - def get_warning_str_prefix(tv: T.Union[str, mesonlib.NoProjectVersion]) -> str: + def get_warning_str_prefix(tv: MesonVersionTarget) -> str: return 'Deprecated features used:' @staticmethod - def get_notice_str_prefix(tv: T.Union[str, mesonlib.NoProjectVersion]) -> str: + def get_notice_str_prefix(tv: MesonVersionTarget) -> str: return 'Future-deprecated features used:' - def log_usage_warning(self, tv: T.Union[str, mesonlib.NoProjectVersion], location: T.Optional['mparser.BaseNode']) -> None: - if isinstance(tv, str): - prefix = f'Project targets {tv!r}' + def log_usage_warning(self, tv: MesonVersionTarget, location: T.Optional['mparser.BaseNode']) -> None: + if isinstance(tv, mesonlib.Range): + prefix = f"Project targets '{tv}'" else: prefix = 'Project does not target a minimum version' args = [ @@ -797,19 +799,19 @@ class FeatureBroken(FeatureCheckBase): unconditional = True @staticmethod - def check_version(target_version: T.Union[str, mesonlib.NoProjectVersion], feature_version: str) -> bool: + def check_version(target_version: MesonVersionTarget, feature_version: str) -> bool: # always warn for broken stuff return False @staticmethod - def get_warning_str_prefix(tv: T.Union[str, mesonlib.NoProjectVersion]) -> str: + def get_warning_str_prefix(tv: MesonVersionTarget) -> str: return 'Broken features used:' @staticmethod - def get_notice_str_prefix(tv: T.Union[str, mesonlib.NoProjectVersion]) -> str: + def get_notice_str_prefix(tv: MesonVersionTarget) -> str: return '' - def log_usage_warning(self, tv: T.Union[str, mesonlib.NoProjectVersion], location: T.Optional['mparser.BaseNode']) -> None: + def log_usage_warning(self, tv: MesonVersionTarget, location: T.Optional['mparser.BaseNode']) -> None: args = [ 'Project uses feature that was always broken,', 'and is now deprecated since', diff --git a/mesonbuild/interpreterbase/interpreterbase.py b/mesonbuild/interpreterbase/interpreterbase.py index a601e5c6d408..ffb7033d2c61 100644 --- a/mesonbuild/interpreterbase/interpreterbase.py +++ b/mesonbuild/interpreterbase/interpreterbase.py @@ -95,7 +95,7 @@ def __init__(self, source_root: str, subdir: str, subproject: SubProject, subpro # meson.version().compare_version(version_string) # If it was part of a if-clause, it is used to temporally override the # current meson version target within that if-block. - self.tmp_meson_version: T.Optional[str] = None + self.tmp_meson_version: T.Optional[mesonlib.Range[mesonlib.Version]] = None def handle_meson_version_from_ast(self, strict: bool = True) -> None: # do nothing in an AST interpreter @@ -310,15 +310,19 @@ def evaluate_if(self, node: mparser.IfClauseNode) -> T.Optional[Disabler]: res = result.operator_call(MesonOperator.BOOL, None) if not isinstance(res, bool): raise InvalidCode(f'If clause {result!r} does not evaluate to true or false.') - if res: - prev_meson_version = mesonlib.project_meson_versions[self.subproject] - if self.tmp_meson_version: - mesonlib.project_meson_versions[self.subproject] = self.tmp_meson_version - try: + prev_meson_version = mesonlib.project_meson_versions[self.subproject] + if self.tmp_meson_version and isinstance(prev_meson_version, mesonlib.Range): + always = prev_meson_version.always(self.tmp_meson_version) + if always is not None: + mlog.warning(f"Conditional on version {self.tmp_meson_version} always evaluates to {str(always).lower()}", + location=self.current_node) + mesonlib.project_meson_versions[self.subproject] = prev_meson_version.intersect(self.tmp_meson_version) + try: + if res: self.evaluate_codeblock(i.block) - finally: - mesonlib.project_meson_versions[self.subproject] = prev_meson_version - return None + return None + finally: + mesonlib.project_meson_versions[self.subproject] = prev_meson_version if not isinstance(node.elseblock, mparser.EmptyNode): self.evaluate_codeblock(node.elseblock.block) return None diff --git a/mesonbuild/utils/universal.py b/mesonbuild/utils/universal.py index 45ea8ccf5d56..77b92d4d8c7f 100644 --- a/mesonbuild/utils/universal.py +++ b/mesonbuild/utils/universal.py @@ -13,6 +13,7 @@ import stat import time import abc +import copy import multiprocessing import platform, subprocess, operator, os, shlex, shutil, re import collections @@ -30,7 +31,7 @@ from .core import MesonException, HoldableObject if T.TYPE_CHECKING: - from typing_extensions import Literal, Protocol + from typing_extensions import Literal, Protocol, Self from .._typing import ImmutableListProtocol from ..build import ConfigurationData @@ -48,6 +49,33 @@ class _VerPickleLoadable(Protocol): version: str + class Comparable(Protocol): + """Protocol for annotating comparable types.""" + + @abc.abstractmethod + def __eq__(self, other: object) -> bool: + ... + + @abc.abstractmethod + def __ne__(self, other: object) -> bool: + ... + + @abc.abstractmethod + def __lt__(self, other: Self) -> bool: + ... + + @abc.abstractmethod + def __le__(self, other: Self) -> bool: + ... + + @abc.abstractmethod + def __ge__(self, other: Self) -> bool: + ... + + @abc.abstractmethod + def __gt__(self, other: Self) -> bool: + ... + # A generic type for pickle_load. This allows any type that has either a # .version or a .environment to be passed. _PL = T.TypeVar('_PL', bound=T.Union[_EnvPickleLoadable, _VerPickleLoadable]) @@ -78,6 +106,7 @@ class _VerPickleLoadable(Protocol): 'PerThreeMachine', 'PerThreeMachineDefaultable', 'ProgressBar', + 'Range', 'RealPathAction', 'TemporaryDirectoryWinProof', 'Version', @@ -162,6 +191,7 @@ class _VerPickleLoadable(Protocol): 'typeslistify', 'unique_list', 'verbose_git', + 'version_check_to_range', 'version_compare', 'version_compare_condition_with_min', 'version_compare_many', @@ -178,7 +208,7 @@ class NoProjectVersion: # TODO: this is such a hack, this really should be either in coredata or in the # interpreter # {subproject: project_meson_version} -project_meson_versions: T.Dict[str, T.Union[str, NoProjectVersion]] = {} +project_meson_versions: T.Dict[str, T.Union[Range[Version], NoProjectVersion]] = {} from glob import glob @@ -856,10 +886,10 @@ def __init__(self, s: str) -> None: for m in _VERSION_TOK_RE.finditer(s)] def __str__(self) -> str: - return '{} (V={})'.format(self._s, str(self._v)) + return self._s def __repr__(self) -> str: - return f'' + return f'' def __lt__(self, other: object) -> bool: if isinstance(other, Version): @@ -954,45 +984,133 @@ def version_compare_many(vstr1: str, conditions: T.Union[str, T.Iterable[str]]) return not not_found, not_found, found +_V = T.TypeVar('_V', bound='Comparable') + +@dataclasses.dataclass(order=False) +class Range(T.Generic[_V]): + min: T.Optional[_V] = None + min_eq: bool = False + max: T.Optional[_V] = None + max_eq: bool = False + is_empty: bool = False + + def __str__(self) -> str: + if self.is_empty: + return '(empty)' + if self.min is not None and self.max is not None and self.min == self.max: + return f'=={self.min}' + parts = [] + if self.min is not None: + parts.append(f'>{"=" if self.min_eq else ""}{self.min}') + if self.max is not None: + parts.append(f'<{"=" if self.max_eq else ""}{self.max}') + return ' and '.join(parts) if parts else '(any)' + + def __contains__(self, x: _V) -> bool: + if self.is_empty: + return False + if self.min is not None and (x < self.min if self.min_eq else x <= self.min): + return False + if self.max is not None and (x > self.max if self.max_eq else x >= self.max): + return False + return True + + def __post_init__(self) -> None: + if self.min is None or self.max is None: + return + self.is_empty = False + if self.min < self.max: + return + if self.min == self.max and self.min_eq and self.max_eq: + return + self.min = None + self.max = None + self.is_empty = True + + def _intersect_min(self, v: _V, eq: bool) -> None: + if self.min is None or v > self.min: + self.min, self.min_eq = v, eq + elif v == self.min: + self.min_eq = eq and self.min_eq + + def _intersect_max(self, v: _V, eq: bool) -> None: + if self.max is None or v < self.max: + self.max, self.max_eq = v, eq + elif v == self.max: + self.max_eq = eq and self.max_eq + + def intersect(self, x: Range[_V]) -> Range[_V]: + if x.is_empty: + return copy.copy(x) + result = copy.copy(self) + if self.is_empty: + return result + if x.min is not None: + result._intersect_min(x.min, x.min_eq) + if x.max is not None: + result._intersect_max(x.max, x.max_eq) + result.__post_init__() + return result + + def always(self, inner: Range[_V]) -> T.Optional[bool]: + """Check if inner is always true or always false given self. + + Returns True if inner is always satisfied by any value in self, + False if no value in self satisfies inner, None if indeterminate.""" + narrowed = self.intersect(inner) + if narrowed.is_empty: + return False + if narrowed == self: + return True + return None + + +def version_check_to_range(checks: T.List[str], start: Range[Version] = Range()) -> Range[Version]: + for x in checks: + op, v = _version_extract_cmpop(x) + # Map versions in the constraint of the form '0.46' to '0.46.0', to + # ensure that '0.46' in project(meson_version: '>=0.46') allows + # using features in '0.46.0'. We know that in the meson versioning + # scheme '0.46.0' is the lowest version which satisfies the + # constraint '>=0.46'. + v = v.strip() + if re.match(r'^\d+.\d+$', v): + v += '.0' + if op is operator.ge: + r = Range(min=Version(v), min_eq=True) + elif op is operator.gt: + r = Range(min=Version(v), min_eq=False) + elif op is operator.le: + r = Range(max=Version(v), max_eq=True) + elif op is operator.lt: + r = Range(max=Version(v), max_eq=False) + elif op is operator.eq: + r = Range(min=Version(v), max=Version(v), min_eq=True, max_eq=True) + elif op is operator.ne: + v_ = Version(v) + # Do the best that we can, remove the extrema + r = Range() + if v_ == start.min: + r = Range(min=v_, min_eq=False) + if v_ == start.max: + r = r.intersect(Range(max=v_, max_eq=False)) + start = start.intersect(r) + return start + + # determine if the minimum version satisfying the condition |condition| exceeds # the minimum version for a feature |minimum| -def version_compare_condition_with_min(condition: str, minimum: str) -> bool: - if condition.startswith('>='): - cmpop = operator.le - condition = condition[2:] - elif condition.startswith('<='): - return False - elif condition.startswith('!='): - return False - elif condition.startswith('=='): - cmpop = operator.le - condition = condition[2:] - elif condition.startswith('='): - cmpop = operator.le - condition = condition[1:] - elif condition.startswith('>'): - cmpop = operator.lt - condition = condition[1:] - elif condition.startswith('<'): - return False +def version_compare_condition_with_min(condition: T.Union[str, Range[Version]], minimum: str) -> bool: + if isinstance(condition, str): + condition = version_check_to_range([condition]) + + if condition.min is None: + # A < constraint on the project version (max is not None) or a full + # range should always include versions older than minimum, return False. + # is_empty=True instead behaves like an absurdly high min and returns True. + return condition.is_empty else: - cmpop = operator.le - - # Declaring a project(meson_version: '>=0.46') and then using features in - # 0.46.0 is valid, because (knowing the meson versioning scheme) '0.46.0' is - # the lowest version which satisfies the constraint '>=0.46'. - # - # But this will fail here, because the minimum version required by the - # version constraint ('0.46') is strictly less (in our version comparison) - # than the minimum version needed for the feature ('0.46.0'). - # - # Map versions in the constraint of the form '0.46' to '0.46.0', to embed - # this knowledge of the meson versioning scheme. - condition = condition.strip() - if re.match(r'^\d+.\d+$', condition): - condition += '.0' - - return T.cast('bool', cmpop(Version(minimum), Version(condition))) + return Version(minimum) <= condition.min def search_version(text: str) -> str: # Usually of the type 4.1.4 but compiler output may contain diff --git a/test cases/common/255 module warnings/test.json b/test cases/common/255 module warnings/test.json index 5556b90e2f6e..63f277177f77 100644 --- a/test cases/common/255 module warnings/test.json +++ b/test cases/common/255 module warnings/test.json @@ -1,13 +1,13 @@ { "stdout": [ { - "line": "test cases/common/255 module warnings/meson.build:3: WARNING: Project targets '>= 0.56' but uses feature deprecated since '0.48.0': module python3." + "line": "test cases/common/255 module warnings/meson.build:3: WARNING: Project targets '>=0.56.0' but uses feature deprecated since '0.48.0': module python3." }, { - "line": "test cases/common/255 module warnings/meson.build:4: WARNING: Project targets '>= 0.56' but uses feature introduced in '0.60.0': module java." + "line": "test cases/common/255 module warnings/meson.build:4: WARNING: Project targets '>=0.56.0' but uses feature introduced in '0.60.0': module java." }, { - "line": "test cases/common/255 module warnings/meson.build:5: WARNING: Project targets '>= 0.56' but uses feature deprecated since '0.56.0': module keyval has been stabilized. drop \"unstable-\" prefix from the module name" + "line": "test cases/common/255 module warnings/meson.build:5: WARNING: Project targets '>=0.56.0' but uses feature deprecated since '0.56.0': module keyval has been stabilized. drop \"unstable-\" prefix from the module name" }, { "line": "test cases/common/255 module warnings/meson.build:6: DEPRECATION: Importing unstable modules as \"unstable_simd\" instead of \"unstable-simd\"" diff --git a/test cases/common/295 redundant version check/meson.build b/test cases/common/295 redundant version check/meson.build new file mode 100644 index 000000000000..e9a768c37cc3 --- /dev/null +++ b/test cases/common/295 redundant version check/meson.build @@ -0,0 +1,33 @@ +# written by Benjamin Gilbert +project('t', 'c', meson_version: '>=0.60.0') +if meson.version().version_compare('>=0.55.0') + v = 1 +endif +if meson.version().version_compare('<0.60.0') + v = 2 +endif +# narrowing: <0.60 is indeterminate, but >=0.40 is always true within >=0.50, <0.60 +if meson.version().version_compare('<99.0') + if meson.version().version_compare('>=0.40') + v = 3 + endif +endif + +# <= vs <, should not warn +if meson.version().version_compare('<=99.0') + if meson.version().version_compare('<99.0') + v = 4 + endif +endif + +# hide warnings about multiple args to version_compare() +if meson.version().version_compare('>1.10.0') + if meson.version().version_compare('<99.0') + if meson.version().version_compare('<99.0', '>=0.40') + v = 5 + endif + if meson.version().version_compare('>=0.40', '<99.0') + v = 6 + endif + endif +endif diff --git a/test cases/common/295 redundant version check/test.json b/test cases/common/295 redundant version check/test.json new file mode 100644 index 000000000000..0a44d48d9f10 --- /dev/null +++ b/test cases/common/295 redundant version check/test.json @@ -0,0 +1,19 @@ +{ + "stdout": [ + { + "line": "test cases/common/295 redundant version check/meson.build:3: WARNING: Conditional on version >=0.55.0 always evaluates to true" + }, + { + "line": "test cases/common/295 redundant version check/meson.build:6: WARNING: Conditional on version <0.60.0 always evaluates to false" + }, + { + "line": "test cases/common/295 redundant version check/meson.build:11: WARNING: Conditional on version >=0.40.0 always evaluates to true" + }, + { + "line": "test cases/common/295 redundant version check/meson.build:26: WARNING: Conditional on version >=0.40.0 and <99.0.0 always evaluates to true" + }, + { + "line": "test cases/common/295 redundant version check/meson.build:29: WARNING: Conditional on version >=0.40.0 and <99.0.0 always evaluates to true" + } + ] +} diff --git a/test cases/keyval/1 basic/test.json b/test cases/keyval/1 basic/test.json index 1f8fd9b06366..8879391e3bda 100644 --- a/test cases/keyval/1 basic/test.json +++ b/test cases/keyval/1 basic/test.json @@ -1,7 +1,7 @@ { "stdout": [ { - "line": "test cases/keyval/1 basic/meson.build:3: WARNING: Project targets '>= 0.55' but uses feature introduced in '0.56.0': module keyval as stable module. Consider either adding \"unstable-\" to the module name, or updating the meson required version to \">= 0.56.0\"" + "line": "test cases/keyval/1 basic/meson.build:3: WARNING: Project targets '>=0.55.0' but uses feature introduced in '0.56.0': module keyval as stable module. Consider either adding \"unstable-\" to the module name, or updating the meson required version to \">= 0.56.0\"" } ] } diff --git a/test cases/rust/12 bindgen/test.json b/test cases/rust/12 bindgen/test.json index a3b7e29f30e9..d792b46f5f63 100644 --- a/test cases/rust/12 bindgen/test.json +++ b/test cases/rust/12 bindgen/test.json @@ -4,7 +4,7 @@ }, "stdout": [ { - "line": "test cases/rust/12 bindgen/meson.build:47: WARNING: Project targets '>= 0.63' but uses feature introduced in '1.0.0': \"rust.bindgen\" keyword argument \"include_directories\" of type array[str]." + "line": "test cases/rust/12 bindgen/meson.build:47: WARNING: Project targets '>=0.63.0' but uses feature introduced in '1.0.0': \"rust.bindgen\" keyword argument \"include_directories\" of type array[str]." } ] } diff --git a/test cases/warning/7 module without unstable/test.json b/test cases/warning/7 module without unstable/test.json index 62b8aa1c628d..29997a6d768c 100644 --- a/test cases/warning/7 module without unstable/test.json +++ b/test cases/warning/7 module without unstable/test.json @@ -1,7 +1,7 @@ { "stdout": [ { - "line": "test cases/warning/7 module without unstable/meson.build:3: WARNING: Project targets '>= 0.55' but uses feature introduced in '0.56.0': module keyval as stable module. Consider either adding \"unstable-\" to the module name, or updating the meson required version to \">= 0.56.0\"" + "line": "test cases/warning/7 module without unstable/meson.build:3: WARNING: Project targets '>=0.55.0' but uses feature introduced in '0.56.0': module keyval as stable module. Consider either adding \"unstable-\" to the module name, or updating the meson required version to \">= 0.56.0\"" } ] } diff --git a/test cases/warning/8 meson.options/test.json b/test cases/warning/8 meson.options/test.json index f711924458e5..155bf62cebab 100644 --- a/test cases/warning/8 meson.options/test.json +++ b/test cases/warning/8 meson.options/test.json @@ -1,7 +1,7 @@ { "stdout": [ { - "line": "WARNING: Project targets '>= 0.63' but uses feature introduced in '1.1': meson.options file. Use meson_options.txt instead" + "line": "WARNING: Project targets '>=0.63.0' but uses feature introduced in '1.1': meson.options file. Use meson_options.txt instead" } ] } diff --git a/unittests/allplatformstests.py b/unittests/allplatformstests.py index 8026d9d95562..c19efa07444c 100644 --- a/unittests/allplatformstests.py +++ b/unittests/allplatformstests.py @@ -2955,16 +2955,16 @@ def test_feature_check_usage_subprojects(self): testdir = os.path.join(self.unit_test_dir, '40 featurenew subprojects') out = self.init(testdir) # Parent project warns correctly - self.assertRegex(out, "WARNING: Project targets '>=0.45'.*'0.47.0': dict") + self.assertRegex(out, "WARNING: Project targets '>=0.45.0'.*'0.47.0': dict") # Subprojects warn correctly - self.assertRegex(out, r"foo\| .*WARNING: Project targets '>=0.40'.*'0.44.0': disabler") - self.assertRegex(out, r"baz\| .*WARNING: Project targets '!=0.40'.*'0.44.0': disabler") + self.assertRegex(out, r"foo\| .*WARNING: Project targets '>=0.40.0'.*'0.44.0': disabler") + self.assertRegex(out, r"baz\| .*WARNING: Project targets '\(any\)'.*'0.44.0': disabler") # Subproject has a new-enough meson_version, no warning self.assertNotRegex(out, "WARNING: Project targets.*Python") # Ensure a summary is printed in the subproject and the outer project - self.assertRegex(out, r"\| WARNING: Project specifies a minimum meson_version '>=0.40'") + self.assertRegex(out, r"\| WARNING: Project specifies a minimum meson_version '>=0.40.0'") self.assertRegex(out, r"\| \* 0.44.0: {'disabler'}") - self.assertRegex(out, "WARNING: Project specifies a minimum meson_version '>=0.45'") + self.assertRegex(out, "WARNING: Project specifies a minimum meson_version '>=0.45.0'") self.assertRegex(out, " * 0.47.0: {'dict'}") def test_configure_file_warnings(self): diff --git a/unittests/internaltests.py b/unittests/internaltests.py index fb0e4a2fd0ad..68dfbf4f9e99 100644 --- a/unittests/internaltests.py +++ b/unittests/internaltests.py @@ -38,6 +38,7 @@ from mesonbuild.mesonlib import ( LibType, MachineChoice, PerMachine, Version, is_windows, is_osx, is_cygwin, is_openbsd, search_version, MesonException, python_command, + version_check_to_range, ) from mesonbuild.options import OptionKey from mesonbuild.interpreter.type_checking import in_set_validator, NoneType @@ -1412,7 +1413,7 @@ def _(obj, node, args: T.Tuple, kwargs: T.Dict[str, str]) -> None: with self.subTest('use before available'), \ mock.patch('sys.stdout', io.StringIO()) as out, \ - mock.patch('mesonbuild.mesonlib.project_meson_versions', {'': '0.1'}): + mock.patch('mesonbuild.mesonlib.project_meson_versions', {'': version_check_to_range(['>=0.1'])}): # With Meson 0.1 it should trigger the "introduced" warning but not the "deprecated" warning _(None, mock.Mock(subproject=''), [], {'input': 'foo'}) self.assertRegex(out.getvalue(), r'WARNING:.*introduced.*input arg in testfunc. It\'s awesome, use it') @@ -1420,14 +1421,14 @@ def _(obj, node, args: T.Tuple, kwargs: T.Dict[str, str]) -> None: with self.subTest('no warnings should be triggered'), \ mock.patch('sys.stdout', io.StringIO()) as out, \ - mock.patch('mesonbuild.mesonlib.project_meson_versions', {'': '1.5'}): + mock.patch('mesonbuild.mesonlib.project_meson_versions', {'': version_check_to_range(['>=1.5'])}): # With Meson 1.5 it shouldn't trigger any warning _(None, mock.Mock(subproject=''), [], {'input': 'foo'}) self.assertNotRegex(out.getvalue(), r'WARNING:.*') with self.subTest('use after deprecated'), \ mock.patch('sys.stdout', io.StringIO()) as out, \ - mock.patch('mesonbuild.mesonlib.project_meson_versions', {'': '2.0'}): + mock.patch('mesonbuild.mesonlib.project_meson_versions', {'': version_check_to_range(['>=2.0'])}): # With Meson 2.0 it should trigger the "deprecated" warning but not the "introduced" warning _(None, mock.Mock(subproject=''), [], {'input': 'foo'}) self.assertRegex(out.getvalue(), r'WARNING:.*deprecated.*input arg in testfunc. It\'s terrible, don\'t use it') @@ -1458,7 +1459,7 @@ def _(obj, node, args: T.Tuple, kwargs: T.Dict[str, MachineChoice]) -> None: _(None, mock.Mock(), tuple(), dict(native=True)) - @mock.patch('mesonbuild.mesonlib.project_meson_versions', {'': '1.0'}) + @mock.patch('mesonbuild.mesonlib.project_meson_versions', {'': version_check_to_range(['>=1.0'])}) def test_typed_kwarg_since_values(self) -> None: @typed_kwargs( 'testfunc', @@ -1486,84 +1487,84 @@ def _(obj, node, args: T.Tuple, kwargs: T.Dict[str, str]) -> None: with self.subTest('deprecated array string value'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'input': ['foo']}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*deprecated since '0.9': "testfunc" keyword argument "input" value "foo".*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*deprecated since '0.9': "testfunc" keyword argument "input" value "foo".*""") with self.subTest('new array string value'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'input': ['bar']}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*introduced in '1.1': "testfunc" keyword argument "input" value "bar".*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*introduced in '1.1': "testfunc" keyword argument "input" value "bar".*""") with self.subTest('deprecated dict string value'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'output': {'foo': 'a'}}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*deprecated since '0.9': "testfunc" keyword argument "output" value "foo".*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*deprecated since '0.9': "testfunc" keyword argument "output" value "foo".*""") with self.subTest('deprecated dict string value with msg'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'output': {'foo2': 'a'}}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*deprecated since '0.9': "testfunc" keyword argument "output" value "foo2" in dict keys. don't use it.*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*deprecated since '0.9': "testfunc" keyword argument "output" value "foo2" in dict keys. don't use it.*""") with self.subTest('new dict string value'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'output': {'bar': 'b'}}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*introduced in '1.1': "testfunc" keyword argument "output" value "bar".*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*introduced in '1.1': "testfunc" keyword argument "output" value "bar".*""") with self.subTest('new dict string value with msg'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'output': {'bar2': 'a'}}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*introduced in '1.1': "testfunc" keyword argument "output" value "bar2" in dict keys. use this.*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*introduced in '1.1': "testfunc" keyword argument "output" value "bar2" in dict keys. use this.*""") with self.subTest('new string type'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'foo': 'foo'}) - self.assertRegex(out.getvalue(), r"""WARNING: Project targets '1.0'.*introduced in '1.1': "testfunc" keyword argument "foo" of type str.*""") + self.assertRegex(out.getvalue(), r"""WARNING: Project targets '>=1.0.0'.*introduced in '1.1': "testfunc" keyword argument "foo" of type str.*""") with self.subTest('new array of string type'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'foo': ['foo']}) - self.assertRegex(out.getvalue(), r"""WARNING: Project targets '1.0'.*introduced in '1.2': "testfunc" keyword argument "foo" of type array\[str\].*""") + self.assertRegex(out.getvalue(), r"""WARNING: Project targets '>=1.0.0'.*introduced in '1.2': "testfunc" keyword argument "foo" of type array\[str\].*""") with self.subTest('new dict of string type'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'foo': {'plop': 'foo'}}) - self.assertRegex(out.getvalue(), r"""WARNING: Project targets '1.0'.*introduced in '1.3': "testfunc" keyword argument "foo" of type dict\[str\].*""") + self.assertRegex(out.getvalue(), r"""WARNING: Project targets '>=1.0.0'.*introduced in '1.3': "testfunc" keyword argument "foo" of type dict\[str\].*""") with self.subTest('deprecated int value'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'foo': 1}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*deprecated since '0.8': "testfunc" keyword argument "foo" of type int.*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*deprecated since '0.8': "testfunc" keyword argument "foo" of type int.*""") with self.subTest('deprecated array int value'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'foo': [1]}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*deprecated since '0.9': "testfunc" keyword argument "foo" of type array\[int\].*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*deprecated since '0.9': "testfunc" keyword argument "foo" of type array\[int\].*""") with self.subTest('new list[str] value'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'tuple': ['foo', 42]}) - self.assertRegex(out.getvalue(), r"""WARNING: Project targets '1.0'.*introduced in '1.1': "testfunc" keyword argument "tuple" of type array\[str\].*""") - self.assertRegex(out.getvalue(), r"""WARNING: Project targets '1.0'.*introduced in '1.2': "testfunc" keyword argument "tuple" of type array\[int\].*""") + self.assertRegex(out.getvalue(), r"""WARNING: Project targets '>=1.0.0'.*introduced in '1.1': "testfunc" keyword argument "tuple" of type array\[str\].*""") + self.assertRegex(out.getvalue(), r"""WARNING: Project targets '>=1.0.0'.*introduced in '1.2': "testfunc" keyword argument "tuple" of type array\[int\].*""") with self.subTest('deprecated array string value'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'input': 'foo'}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*deprecated since '0.9': "testfunc" keyword argument "input" value "foo".*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*deprecated since '0.9': "testfunc" keyword argument "input" value "foo".*""") with self.subTest('new array string value'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'input': 'bar'}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*introduced in '1.1': "testfunc" keyword argument "input" value "bar".*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*introduced in '1.1': "testfunc" keyword argument "input" value "bar".*""") with self.subTest('non string union'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'install_dir': False}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*deprecated since '0.9': "testfunc" keyword argument "install_dir" value "False".*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*deprecated since '0.9': "testfunc" keyword argument "install_dir" value "False".*""") with self.subTest('deprecated string union'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'mode': 'deprecated'}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*deprecated since '1.0': "testfunc" keyword argument "mode" value "deprecated".*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*deprecated since '1.0': "testfunc" keyword argument "mode" value "deprecated".*""") with self.subTest('new string union'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'mode': 'since'}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*introduced in '1.1': "testfunc" keyword argument "mode" value "since".*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*introduced in '1.1': "testfunc" keyword argument "mode" value "since".*""") with self.subTest('new container'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'dict': ['a=b']}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*introduced in '1.9': "testfunc" keyword argument "dict" of type list.*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*introduced in '1.9': "testfunc" keyword argument "dict" of type list.*""") with self.subTest('new container set to default'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {'new_dict': {}}) - self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*introduced in '1.1': "testfunc" keyword argument "new_dict" of type dict.*""") + self.assertRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*introduced in '1.1': "testfunc" keyword argument "new_dict" of type dict.*""") with self.subTest('new container default'), mock.patch('sys.stdout', io.StringIO()) as out: _(None, mock.Mock(subproject=''), [], {}) - self.assertNotRegex(out.getvalue(), r"""WARNING:.Project targets '1.0'.*introduced in '1.1': "testfunc" keyword argument "new_dict" of type dict.*""") + self.assertNotRegex(out.getvalue(), r"""WARNING:.Project targets '>=1.0.0'.*introduced in '1.1': "testfunc" keyword argument "new_dict" of type dict.*""") def test_typed_kwarg_evolve(self) -> None: k = KwargInfo('foo', str, required=True, default='foo') diff --git a/unittests/versiontests.py b/unittests/versiontests.py new file mode 100644 index 000000000000..3112cc2cdbe4 --- /dev/null +++ b/unittests/versiontests.py @@ -0,0 +1,355 @@ +# SPDX-License-Identifier: Apache-2.0 + +import unittest + +from mesonbuild.mesonlib import ( + Range, Version, version_compare, + version_compare_many, version_compare_condition_with_min, + search_version, +) + + +class VersionComparisonTests(unittest.TestCase): + + def test_version_ordering(self): + LT = -1 + EQ = 0 + #GT = 1 + + # mostly from RPM tests + for (a, b, result) in [ + ("1.0", "1.0", EQ), + ("1_0", "1_0", EQ), + ("1_0", "1.0", EQ), + ("1.0", "2.0", LT), + ("2.0", "2.0.0", LT), + ("2.0", "2.0.1", LT), + ("2.0.1", "2.0.1", EQ), + ("2.0.1", "2.0.1a", LT), + ("2.0.1a", "2.0.1a", EQ), + ("2.10", "3.111", LT), + ("2.456", "2.1000", LT), + ("1.2rc1", "1.2.0", LT), + ("5.5p1", "5.5p1", EQ), + ("5.5p1", "5.5p2", LT), + ("5.5p1", "5.5p10", LT), + ("10xyz", "10.1xyz", LT), + ("xyz10", "xyz10", EQ), + ("xyz10", "xyz10.1", LT), + ("xyz.4", "8", LT), + ("xyz.4", "2", LT), + ("5.5p2", "5.6p1", LT), + ("5.6p1", "6.5p1", LT), + ("6.0", "6.0.rc1", LT), + ("10a2", "10b2", LT), + ("1.0aa", "1.0aa", EQ), + ("1.0a", "1.0aa", LT), + ("10.0001", "10.1", EQ), + ("10.0001", "10.0039", LT), + ("1.05", "1.5", EQ), + ("2a", "2.0", LT), + ]: + ver_a = Version(a) + ver_b = Version(b) + + self.assertEqual(ver_a <= ver_b, result <= 0, f'{ver_a} <= {ver_b}') + self.assertEqual(ver_a < ver_b, result < 0, f'{ver_a} <= {ver_b}') + self.assertEqual(ver_a > ver_b, result > 0, f'{ver_a} <= {ver_b}') + self.assertEqual(ver_a >= ver_b, result >= 0, f'{ver_a} <= {ver_b}') + + self.assertEqual(ver_a == ver_b, result == 0, f'{ver_a} <= {ver_b}') + self.assertEqual(ver_b == ver_a, result == 0, f'{ver_a} <= {ver_b}') + self.assertEqual(ver_a != ver_b, result != 0, f'{ver_a} <= {ver_b}') + self.assertEqual(ver_b != ver_a, result != 0, f'{ver_a} <= {ver_b}') + + self.assertEqual(ver_b <= ver_a, -result <= 0, f'{ver_a} <= {ver_b}') + self.assertEqual(ver_b < ver_a, -result < 0, f'{ver_a} <= {ver_b}') + self.assertEqual(ver_b > ver_a, -result > 0, f'{ver_a} <= {ver_b}') + self.assertEqual(ver_b >= ver_a, -result >= 0, f'{ver_a} <= {ver_b}') + + def test_version_compare(self): + """Test version_compare with operator prefixes.""" + self.assertTrue(version_compare('1.0', '>=1.0')) + self.assertTrue(version_compare('1.1', '>=1.0')) + self.assertFalse(version_compare('0.9', '>=1.0')) + + self.assertTrue(version_compare('1.0', '<=1.0')) + self.assertTrue(version_compare('0.9', '<=1.0')) + self.assertFalse(version_compare('1.1', '<=1.0')) + + self.assertTrue(version_compare('1.0', '>0.9')) + self.assertFalse(version_compare('1.0', '>1.0')) + + self.assertTrue(version_compare('1.0', '<1.1')) + self.assertFalse(version_compare('1.0', '<1.0')) + + self.assertTrue(version_compare('1.0', '==1.0')) + self.assertFalse(version_compare('1.0', '==1.1')) + + self.assertTrue(version_compare('1.0', '!=1.1')) + self.assertFalse(version_compare('1.0', '!=1.0')) + + # bare version and = means == + self.assertTrue(version_compare('1.0', '1.0')) + self.assertFalse(version_compare('1.0', '1.1')) + self.assertTrue(version_compare('1.0', '=1.0')) + self.assertFalse(version_compare('1.0', '=1.1')) + + def test_version_compare_many(self): + result, not_found, found = version_compare_many('1.5', ['>=1.0', '<2.0']) + self.assertTrue(result) + self.assertEqual(not_found, []) + self.assertEqual(found, ['>=1.0', '<2.0']) + + result, not_found, found = version_compare_many('0.5', ['>=1.0', '<2.0']) + self.assertFalse(result) + self.assertEqual(not_found, ['>=1.0']) + self.assertEqual(found, ['<2.0']) + + result, not_found, found = version_compare_many('2.5', ['>=1.0', '<2.0']) + self.assertFalse(result) + self.assertEqual(not_found, ['<2.0']) + self.assertEqual(found, ['>=1.0']) + + # string condition is treated as single-element list + result, not_found, found = version_compare_many('1.0', '>=1.0') + self.assertTrue(result) + self.assertEqual(not_found, []) + self.assertEqual(found, ['>=1.0']) + + def test_version_compare_condition_with_min(self): + # >= condition: minimum must be <= condition version + self.assertTrue(version_compare_condition_with_min('>=0.46.0', '0.46.0')) + self.assertTrue(version_compare_condition_with_min('>=0.50.0', '0.46.0')) + self.assertFalse(version_compare_condition_with_min('>=0.40.0', '0.46.0')) + + # > condition: minimum must be <= condition version + self.assertTrue(version_compare_condition_with_min('>0.46.0', '0.46.0')) + self.assertTrue(version_compare_condition_with_min('>0.50.0', '0.46.0')) + self.assertFalse(version_compare_condition_with_min('>0.45.0', '0.46.0')) + + # == condition: minimum must be <= condition version + self.assertTrue(version_compare_condition_with_min('==0.46.0', '0.46.0')) + self.assertTrue(version_compare_condition_with_min('==0.50.0', '0.46.0')) + self.assertFalse(version_compare_condition_with_min('==0.40.0', '0.46.0')) + + # = or bare condition is the same as == + self.assertTrue(version_compare_condition_with_min('=0.46.0', '0.46.0')) + self.assertTrue(version_compare_condition_with_min('0.46.0', '0.46.0')) + self.assertFalse(version_compare_condition_with_min('0.40.0', '0.46.0')) + + # < and <= conditions always return False (includes versions older than minimum) + self.assertFalse(version_compare_condition_with_min('<1.0.0', '0.46.0')) + self.assertFalse(version_compare_condition_with_min('<=1.0.0', '0.46.0')) + self.assertFalse(version_compare_condition_with_min('<0.30.0', '0.46.0')) + self.assertFalse(version_compare_condition_with_min('<=0.30.0', '0.46.0')) + + # != condition always returns False + self.assertFalse(version_compare_condition_with_min('!=0.40.0', '0.46.0')) + + # two-component version in condition is mapped to three-component + # e.g. '>=0.46' is treated as '>=0.46.0' + self.assertTrue(version_compare_condition_with_min('>=0.46', '0.46.0')) + self.assertFalse(version_compare_condition_with_min('>=0.46', '0.46.1')) + + def test_range_contains(self): + """Test Range.__contains__.""" + # unbounded range contains everything + r = Range() + self.assertIn(5, r) + self.assertIn(0, r) + + # min only, inclusive + r = Range(min=3, min_eq=True) + self.assertIn(3, r) + self.assertIn(5, r) + self.assertNotIn(2, r) + + # min only, exclusive + r = Range(min=3, min_eq=False) + self.assertNotIn(3, r) + self.assertIn(4, r) + self.assertNotIn(2, r) + + # max only, inclusive + r = Range(max=7, max_eq=True) + self.assertIn(7, r) + self.assertIn(5, r) + self.assertNotIn(8, r) + + # max only, exclusive + r = Range(max=7, max_eq=False) + self.assertNotIn(7, r) + self.assertIn(6, r) + self.assertNotIn(8, r) + + # both bounds, inclusive + r = Range(min=3, min_eq=True, max=7, max_eq=True) + self.assertIn(3, r) + self.assertIn(5, r) + self.assertIn(7, r) + self.assertNotIn(2, r) + self.assertNotIn(8, r) + + # both bounds, exclusive + r = Range(min=3, min_eq=False, max=7, max_eq=False) + self.assertNotIn(3, r) + self.assertIn(5, r) + self.assertNotIn(7, r) + + # empty range contains nothing + r = Range(min=5, min_eq=False, max=5, max_eq=False) + self.assertNotIn(5, r) + + # min == max, both inclusive -> single point, not empty + r = Range(min=5, max=5, min_eq=True, max_eq=True) + self.assertIn(5, r) + + def test_range_init_trivial(self): + """Test that __post_init__ detects trivial ranges.""" + r = Range() + self.assertFalse(r.is_empty) + + # min > max + r = Range(min=7, max=3, min_eq=True, max_eq=True) + self.assertTrue(r.is_empty) + + # min == max, not both inclusive -> empty + r = Range(min=5, max=5, min_eq=True, max_eq=False) + self.assertTrue(r.is_empty) + + r = Range(min=5, max=5, min_eq=False, max_eq=True) + self.assertTrue(r.is_empty) + + r = Range(min=5, max=5, min_eq=False, max_eq=False) + self.assertTrue(r.is_empty) + + # min == max, both inclusive -> single point, not empty + r = Range(min=5, max=5, min_eq=True, max_eq=True) + self.assertFalse(r.is_empty) + + def test_range_intersect(self): + """Test Range.intersect.""" + # intersect two overlapping ranges + a = Range(min=1, min_eq=True, max=10, max_eq=True) + b = Range(min=5, min_eq=True, max=15, max_eq=True) + r = a.intersect(b) + self.assertIn(5, r) + self.assertIn(10, r) + self.assertNotIn(4, r) + self.assertNotIn(11, r) + + # intersect does not mutate original + self.assertIn(4, a) + + # intersect narrows unbounded range + a = Range() + b = Range(min=3, min_eq=True, max=7, max_eq=False) + r = a.intersect(b) + self.assertIn(3, r) + self.assertIn(6, r) + self.assertNotIn(2, r) + self.assertNotIn(7, r) + + # intersect non-overlapping ranges produces empty + a = Range(min=1, min_eq=True, max=3, max_eq=True) + b = Range(min=5, min_eq=True, max=7, max_eq=True) + r = a.intersect(b) + self.assertTrue(r.is_empty) + + # intersect with matching boundary tightens eq + a = Range(min=5, min_eq=True) + b = Range(min=5, min_eq=False) + r = a.intersect(b) + self.assertEqual(5, r.min) + self.assertNotIn(5, r) + + a = Range(max=5, max_eq=True) + b = Range(max=5, max_eq=False) + r = a.intersect(b) + self.assertEqual(5, r.max) + self.assertIn(4, r) + + # intersecting empty range stays empty + a = Range(min=7, max=3, min_eq=True, max_eq=True) + self.assertTrue(a.is_empty) + b = Range(min=1, min_eq=True, max=10, max_eq=True) + r = a.intersect(b) + self.assertTrue(r.is_empty) + + b = Range() + r = a.intersect(b) + self.assertTrue(r.is_empty) + + a = Range() + b = Range(min=7, max=3, min_eq=True, max_eq=True) + r = a.intersect(b) + self.assertTrue(r.is_empty) + + def test_range_always(self): + """Range.always checks if inner is always true/false given outer.""" + outer = Range(min=5, min_eq=True, max=10, max_eq=True) + unbounded = Range() + empty = Range(min=7, max=3, min_eq=True, max_eq=True) + + # inner fully contains outer => true + self.assertIs(outer.always(Range(min=3, min_eq=True)), True) + self.assertIs(outer.always(Range(max=12, max_eq=True)), True) + self.assertIs(outer.always(unbounded), True) + + # inner same as outer => true + self.assertIs(outer.always(Range(min=5, min_eq=True, max=10, max_eq=True)), True) + + # inner doesn't overlap outer => false + self.assertIs(outer.always(Range(min=11, min_eq=True)), False) + self.assertIs(outer.always(Range(max=4, max_eq=True)), False) + + # inner partially overlaps => indeterminate + self.assertIsNone(outer.always(Range(min=7, min_eq=True))) + self.assertIsNone(outer.always(Range(max=8, max_eq=True))) + + # exclusive boundaries count as a partial overlap + self.assertIs(outer.always(Range(min=5, min_eq=False)), None) + self.assertIs(outer.always(Range(max=10, max_eq=False)), None) + + # outer is unbounded + self.assertIs(unbounded.always(Range(min=5, min_eq=True)), None) + self.assertIs(unbounded.always(unbounded), True) + self.assertIs(unbounded.always(empty), False) + + # outer is empty => always false (intersection is empty) + self.assertIs(empty.always(Range(min=5, min_eq=True)), False) + self.assertIs(empty.always(unbounded), False) + self.assertIs(empty.always(empty), False) + + def test_range_str(self): + """Test Range.__str__.""" + self.assertEqual(str(Range()), '(any)') + self.assertEqual(str(Range(min=3, min_eq=True)), '>=3') + self.assertEqual(str(Range(min=3, min_eq=False)), '>3') + self.assertEqual(str(Range(max=7, max_eq=True)), '<=7') + self.assertEqual(str(Range(max=7, max_eq=False)), '<7') + self.assertEqual(str(Range(min=3, min_eq=True, max=7, max_eq=False)), '>=3 and <7') + self.assertEqual(str(Range(min=5, max=5, min_eq=True, max_eq=True)), '==5') + # empty range + r = Range(min=7, max=3, min_eq=True, max_eq=True) + self.assertEqual(str(r), '(empty)') + + def test_range_str_version(self): + """Test Range.__str__ with Version objects.""" + r = Range(min=Version('0.46.0'), min_eq=True) + self.assertEqual(str(r), '>=0.46.0') + r = Range(min=Version('1.0'), min_eq=True, max=Version('2.0'), max_eq=False) + self.assertEqual(str(r), '>=1.0 and <2.0') + + def test_search_version(self): + self.assertEqual(search_version('foo 1.2.3'), '1.2.3') + self.assertEqual(search_version('1.2.3'), '1.2.3') + self.assertEqual(search_version('foo 2026.03.20 1.2.3'), '1.2.3') + self.assertEqual(search_version('2026.03.20 1.2.3'), '1.2.3') + self.assertEqual(search_version('foo 2026.03.20'), '2026.03.20') + self.assertEqual(search_version('2026.03.20'), '2026.03.20') + self.assertEqual(search_version('2026.03'), '2026.03') + self.assertEqual(search_version('2026.03 1.2.3'), '1.2.3') + self.assertEqual(search_version('foo v1.2.3'), '1.2.3') + self.assertEqual(search_version('2026'), 'unknown version')