From 4ac063a0716159af5430f768741eaa62e4f5baec Mon Sep 17 00:00:00 2001 From: Paolo Bonzini Date: Fri, 20 Mar 2026 11:51:24 +0100 Subject: [PATCH 1/8] add unit tests for version checks --- unittests/versiontests.py | 164 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 unittests/versiontests.py diff --git a/unittests/versiontests.py b/unittests/versiontests.py new file mode 100644 index 000000000000..b6aa39a1e033 --- /dev/null +++ b/unittests/versiontests.py @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: Apache-2.0 + +import unittest + +from mesonbuild.mesonlib import ( + 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 strictly < condition version + self.assertFalse(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_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') From fe6af14d6eec21ddac81640355a59774c3603ad2 Mon Sep 17 00:00:00 2001 From: Paolo Bonzini Date: Fri, 20 Mar 2026 14:54:49 +0100 Subject: [PATCH 2/8] utils: fix version_compare_condition_with_min with > condition Right now specifying 1.10.0 gives warnings like meson.build:18: WARNING: Project targets '>1.10.0' but uses feature introduced in '1.10.0': meson.version().version_compare() with multiple arguments. From 1.8.0 - 1.9.* it failed to match str.version_compare This is incorrect. A project targeting 1.10.1 or later can use features introduced by 1.10.0. Fix the bug in version_compare_condition_with_min and adjust the testcases. Signed-off-by: Paolo Bonzini --- mesonbuild/utils/universal.py | 8 +------- unittests/versiontests.py | 4 ++-- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/mesonbuild/utils/universal.py b/mesonbuild/utils/universal.py index 45ea8ccf5d56..d6f0a0c3820d 100644 --- a/mesonbuild/utils/universal.py +++ b/mesonbuild/utils/universal.py @@ -958,25 +958,19 @@ def version_compare_many(vstr1: str, conditions: T.Union[str, T.Iterable[str]]) # 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 - 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 @@ -992,7 +986,7 @@ def version_compare_condition_with_min(condition: str, minimum: str) -> bool: if re.match(r'^\d+.\d+$', condition): condition += '.0' - return T.cast('bool', cmpop(Version(minimum), Version(condition))) + return Version(minimum) <= Version(condition) def search_version(text: str) -> str: # Usually of the type 4.1.4 but compiler output may contain diff --git a/unittests/versiontests.py b/unittests/versiontests.py index b6aa39a1e033..11cca70057cd 100644 --- a/unittests/versiontests.py +++ b/unittests/versiontests.py @@ -122,8 +122,8 @@ def test_version_compare_condition_with_min(self): 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 strictly < condition version - self.assertFalse(version_compare_condition_with_min('>0.46.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')) From 5fdfc4d946e0b3366f63d0832087fda31ec7c9f0 Mon Sep 17 00:00:00 2001 From: Paolo Bonzini Date: Fri, 20 Mar 2026 11:55:38 +0100 Subject: [PATCH 3/8] utils: add Range class Add a class that can be used to encode a range of versions. It is useful to express the project's minimum version and to reason on it, for example to detect redundant Meson version checks. --- mesonbuild/utils/universal.py | 104 +++++++++++++++++++++- unittests/versiontests.py | 159 +++++++++++++++++++++++++++++++++- 2 files changed, 258 insertions(+), 5 deletions(-) diff --git a/mesonbuild/utils/universal.py b/mesonbuild/utils/universal.py index d6f0a0c3820d..f04f287923c1 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', @@ -856,10 +885,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,6 +983,75 @@ 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 + + # 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: diff --git a/unittests/versiontests.py b/unittests/versiontests.py index 11cca70057cd..172de926e105 100644 --- a/unittests/versiontests.py +++ b/unittests/versiontests.py @@ -3,8 +3,9 @@ import unittest from mesonbuild.mesonlib import ( - Version, version_compare, version_compare_many, - version_compare_condition_with_min, search_version, + Range, Version, version_compare, + version_compare_many, version_compare_condition_with_min, + search_version, ) @@ -151,6 +152,160 @@ def test_version_compare_condition_with_min(self): 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_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') From 6ff4132eeb47b1c4864db3fec6a27c92c043d956 Mon Sep 17 00:00:00 2001 From: Paolo Bonzini Date: Fri, 20 Mar 2026 14:55:43 +0100 Subject: [PATCH 4/8] reimplement version_compare_condition_with_min in terms of Range version_compare_condition_with_min includes both parsing and manipulation of the project's minimum version. Simplify it version_compare_condition_with_min by first converting the project(meson_version: ...) to a Range, and then operating (not surprisingly) on its .min field. --- mesonbuild/utils/universal.py | 69 +++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 31 deletions(-) diff --git a/mesonbuild/utils/universal.py b/mesonbuild/utils/universal.py index f04f287923c1..99dad1d55a2a 100644 --- a/mesonbuild/utils/universal.py +++ b/mesonbuild/utils/universal.py @@ -1052,39 +1052,46 @@ def intersect(self, x: Range[_V]) -> Range[_V]: return result -# 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('>='): - condition = condition[2:] - elif condition.startswith('<='): - return False - elif condition.startswith('!='): - return False - elif condition.startswith('=='): - condition = condition[2:] - elif condition.startswith('='): - condition = condition[1:] - elif condition.startswith('>'): - condition = condition[1:] - elif condition.startswith('<'): - return False +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: + continue # cop out + start = start.intersect(r) + return start - # 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 Version(minimum) <= Version(condition) +# determine if the minimum version satisfying the condition |condition| exceeds +# the minimum version for a feature |minimum| +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 return True. + return condition.is_empty + else: + return Version(minimum) <= condition.min def search_version(text: str) -> str: # Usually of the type 4.1.4 but compiler output may contain From 375a36718c1a3835714f6e127840ea5b47212d78 Mon Sep 17 00:00:00 2001 From: Paolo Bonzini Date: Fri, 20 Mar 2026 14:16:59 +0100 Subject: [PATCH 5/8] decorators: return None for the target version before project() An empty string does not really have an equivalent with Range; change it to None in preparation for tracking the current project version in a Range[Version] --- mesonbuild/interpreterbase/decorators.py | 40 +++++++++++++----------- mesonbuild/utils/universal.py | 2 +- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/mesonbuild/interpreterbase/decorators.py b/mesonbuild/interpreterbase/decorators.py index 967892e30225..3b45c0d144e7 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[str, 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,7 +713,7 @@ class FeatureNew(FeatureCheckBase): feature_registry = {} @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: if isinstance(target_version, str): return mesonlib.version_compare_condition_with_min(target_version, feature_version) else: @@ -720,17 +722,17 @@ 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: + def get_warning_str_prefix(tv: MesonVersionTarget) -> str: if isinstance(tv, str): 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: + def log_usage_warning(self, tv: MesonVersionTarget, location: T.Optional['mparser.BaseNode']) -> None: if isinstance(tv, str): prefix = f'Project targets {tv!r}' else: @@ -755,7 +757,7 @@ class FeatureDeprecated(FeatureCheckBase): emit_notice = 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: if isinstance(target_version, str): # For deprecation checks we need to return the inverse of FeatureNew checks return not mesonlib.version_compare_condition_with_min(target_version, feature_version) @@ -764,14 +766,14 @@ 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: + def log_usage_warning(self, tv: MesonVersionTarget, location: T.Optional['mparser.BaseNode']) -> None: if isinstance(tv, str): prefix = f'Project targets {tv!r}' else: @@ -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/utils/universal.py b/mesonbuild/utils/universal.py index 99dad1d55a2a..148780274aac 100644 --- a/mesonbuild/utils/universal.py +++ b/mesonbuild/utils/universal.py @@ -1088,7 +1088,7 @@ def version_compare_condition_with_min(condition: T.Union[str, Range[Version]], 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 return True. + # is_empty=True instead behaves like an absurdly high min and returns True. return condition.is_empty else: return Version(minimum) <= condition.min From 85634542c65d86e6f50246701e5fddd510bdfa3b Mon Sep 17 00:00:00 2001 From: Paolo Bonzini Date: Fri, 20 Mar 2026 12:19:18 +0100 Subject: [PATCH 6/8] interpreter: track minimum Meson version as a Range[Version] Parse it just once, ahead of time, and pass it to version_compare_condition_with_min. This gives, for free, support for multi-argument meson.version().version_compare(). Signed-off-by: Paolo Bonzini --- mesonbuild/interpreter/interpreter.py | 2 +- mesonbuild/interpreter/primitives/string.py | 15 +++--- mesonbuild/interpreterbase/decorators.py | 16 +++--- mesonbuild/interpreterbase/interpreterbase.py | 2 +- mesonbuild/utils/universal.py | 11 +++- unittests/internaltests.py | 51 ++++++++++--------- 6 files changed, 52 insertions(+), 45 deletions(-) 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 3b45c0d144e7..2d41b1dbd394 100644 --- a/mesonbuild/interpreterbase/decorators.py +++ b/mesonbuild/interpreterbase/decorators.py @@ -603,7 +603,7 @@ def emit_feature_change(values: T.Dict[_T, T.Union[str, T.Tuple[str, str]]], fea return inner -MesonVersionTarget = T.Optional[T.Union[str, mesonlib.NoProjectVersion]] +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): @@ -714,7 +714,7 @@ class FeatureNew(FeatureCheckBase): @staticmethod def check_version(target_version: MesonVersionTarget, feature_version: str) -> bool: - if isinstance(target_version, str): + 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. @@ -723,7 +723,7 @@ def check_version(target_version: MesonVersionTarget, feature_version: str) -> b @staticmethod def get_warning_str_prefix(tv: MesonVersionTarget) -> str: - if isinstance(tv, 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:' @@ -733,8 +733,8 @@ def get_notice_str_prefix(tv: MesonVersionTarget) -> str: return '' def log_usage_warning(self, tv: MesonVersionTarget, location: T.Optional['mparser.BaseNode']) -> None: - if isinstance(tv, str): - prefix = f'Project targets {tv!r}' + if isinstance(tv, mesonlib.Range): + prefix = f"Project targets '{tv}'" else: prefix = 'Project does not target a minimum version' args = [ @@ -758,7 +758,7 @@ class FeatureDeprecated(FeatureCheckBase): @staticmethod def check_version(target_version: MesonVersionTarget, feature_version: str) -> bool: - if isinstance(target_version, str): + 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: @@ -774,8 +774,8 @@ def get_notice_str_prefix(tv: MesonVersionTarget) -> str: return 'Future-deprecated features used:' def log_usage_warning(self, tv: MesonVersionTarget, location: T.Optional['mparser.BaseNode']) -> None: - if isinstance(tv, str): - prefix = f'Project targets {tv!r}' + if isinstance(tv, mesonlib.Range): + prefix = f"Project targets '{tv}'" else: prefix = 'Project does not target a minimum version' args = [ diff --git a/mesonbuild/interpreterbase/interpreterbase.py b/mesonbuild/interpreterbase/interpreterbase.py index a601e5c6d408..6a8cb973fd89 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 diff --git a/mesonbuild/utils/universal.py b/mesonbuild/utils/universal.py index 148780274aac..4c54c5ad9ee1 100644 --- a/mesonbuild/utils/universal.py +++ b/mesonbuild/utils/universal.py @@ -191,6 +191,7 @@ def __gt__(self, other: Self) -> bool: 'typeslistify', 'unique_list', 'verbose_git', + 'version_check_to_range', 'version_compare', 'version_compare_condition_with_min', 'version_compare_many', @@ -207,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 @@ -1074,7 +1075,13 @@ def version_check_to_range(checks: T.List[str], start: Range[Version] = Range()) elif op is operator.eq: r = Range(min=Version(v), max=Version(v), min_eq=True, max_eq=True) elif op is operator.ne: - continue # cop out + 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 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') From 2289e5a0ba9805a5b420da0b1a12bf443d57a604 Mon Sep 17 00:00:00 2001 From: Paolo Bonzini Date: Fri, 3 Apr 2026 15:02:52 +0200 Subject: [PATCH 7/8] WIP: adjust tests check if this is really necessary... --- test cases/common/255 module warnings/test.json | 6 +++--- test cases/keyval/1 basic/test.json | 2 +- test cases/rust/12 bindgen/test.json | 2 +- test cases/warning/7 module without unstable/test.json | 2 +- test cases/warning/8 meson.options/test.json | 2 +- unittests/allplatformstests.py | 10 +++++----- 6 files changed, 12 insertions(+), 12 deletions(-) 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/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): From ffe98cae5e1843c8324067755f09ce2c4a85b425 Mon Sep 17 00:00:00 2001 From: Paolo Bonzini Date: Fri, 20 Mar 2026 12:26:12 +0100 Subject: [PATCH 8/8] interpreterbase: warn on redundant Meson version checks We might encounter a Meson version check that always evaluates to true or to false: 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 Print warnings in such cases. Co-authored-by: Benjamin Gilbert Signed-off-by: Paolo Bonzini --- mesonbuild/interpreterbase/interpreterbase.py | 20 ++++++----- mesonbuild/utils/universal.py | 12 +++++++ .../295 redundant version check/meson.build | 33 +++++++++++++++++ .../295 redundant version check/test.json | 19 ++++++++++ unittests/versiontests.py | 36 +++++++++++++++++++ 5 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 test cases/common/295 redundant version check/meson.build create mode 100644 test cases/common/295 redundant version check/test.json diff --git a/mesonbuild/interpreterbase/interpreterbase.py b/mesonbuild/interpreterbase/interpreterbase.py index 6a8cb973fd89..ffb7033d2c61 100644 --- a/mesonbuild/interpreterbase/interpreterbase.py +++ b/mesonbuild/interpreterbase/interpreterbase.py @@ -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 4c54c5ad9ee1..77b92d4d8c7f 100644 --- a/mesonbuild/utils/universal.py +++ b/mesonbuild/utils/universal.py @@ -1052,6 +1052,18 @@ def intersect(self, x: Range[_V]) -> Range[_V]: 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: 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/unittests/versiontests.py b/unittests/versiontests.py index 172de926e105..3112cc2cdbe4 100644 --- a/unittests/versiontests.py +++ b/unittests/versiontests.py @@ -286,6 +286,42 @@ def test_range_intersect(self): 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)')