Skip to content

Commit 331a024

Browse files
committed
Add version constraints to cargo dependencies
This change brings support for reading the version constraints from the dependencies specified in Cargo.toml manifests. It supports the full range of syntax with the exception of complex wildcards such as `1.*.3`. The behavior enabled by this change aligns colcon-cargo with other build system extensions, and will notify the user with a warning during a build where an incompatible version of a package is present in a workspace which has one or more other packages requiring a different version of it.
1 parent 518a4f9 commit 331a024

File tree

3 files changed

+152
-2
lines changed

3 files changed

+152
-2
lines changed

colcon_cargo/package_augmentation/cargo.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from colcon_cargo.package_identification.cargo import read_cargo_toml
77
from colcon_core.dependency_descriptor import DependencyDescriptor
8+
from colcon_core.package_augmentation import logger
89
from colcon_core.package_augmentation \
910
import PackageAugmentationExtensionPoint
1011
from colcon_core.plugin_system import satisfies_version
@@ -122,6 +123,44 @@ def filter_dependency_list(dependencies, filter_out=None):
122123
return filtered_dependencies.items()
123124

124125

126+
def _next_caret(version):
127+
# Drop pre-release
128+
version = next(iter(version.split('-', 1)))
129+
new_parts = []
130+
for part in version.split('.'):
131+
value = int(part)
132+
if value != 0:
133+
new_parts.append(str(value + 1))
134+
break
135+
new_parts.append(part)
136+
else:
137+
new_parts[-1] = '1'
138+
return '.'.join(new_parts)
139+
140+
141+
def _next_tilde(version):
142+
# Drop pre-release
143+
version = next(iter(version.split('-', 1)))
144+
if '.' not in version:
145+
return str(int(version) + 1)
146+
parts = version.split('.', 2)
147+
if len(parts) == 1:
148+
return str(int(parts[0]) + 1)
149+
return parts[0] + '.' + str(int(parts[1]) + 1)
150+
151+
152+
def _convert_wildcards(version):
153+
while version.endswith('.*'):
154+
version = version[:-2]
155+
if version == '*':
156+
return '>=0'
157+
elif '*' in version:
158+
logger.warning(f"Ignoring unsupported version '{version}'")
159+
return None
160+
else:
161+
return '~' + version
162+
163+
125164
def create_dependency_descriptor(dependency_name, constraints, path):
126165
"""
127166
Create a dependency descriptor from a Cargo dependency specification.
@@ -133,6 +172,17 @@ def create_dependency_descriptor(dependency_name, constraints, path):
133172
resolved
134173
:rtype: DependencyDescriptor
135174
"""
175+
# The checking order matters, so we use tuple instead of dict
176+
symbol_mappings = (
177+
('^', 'version_gte', _next_caret),
178+
('~', 'version_gte', _next_tilde),
179+
('>=', 'version_gte', None),
180+
('<=', 'version_lte', None),
181+
('=', 'version_eq', None),
182+
('>', 'version_gt', None),
183+
('<', 'version_lt', None),
184+
)
185+
136186
if isinstance(constraints, dict):
137187
dep_path = constraints.get('path')
138188
if dep_path:
@@ -141,12 +191,32 @@ def create_dependency_descriptor(dependency_name, constraints, path):
141191
else:
142192
source = constraints.get('git') or \
143193
constraints.get('registry')
194+
versions = constraints.get('version')
144195
else:
145196
source = None
197+
versions = constraints
198+
146199
metadata = {
147200
'origin': 'cargo',
148201
'cargo_source': source,
149202
}
150-
# TODO: Interpret SemVer constraints and add appropriate constraint
151-
# metadata. Handling arbitrary wildcards will be non-trivial.
203+
for version in (versions or '').split(','):
204+
# Ignore version metadata during comparison
205+
version = next(iter(version.split('+', 1))).strip()
206+
if '*' in version:
207+
version = _convert_wildcards(version)
208+
if not version:
209+
continue
210+
for symbol, mapping, version_lt in symbol_mappings:
211+
if version.startswith(symbol):
212+
version = version[len(symbol):].lstrip()
213+
metadata[mapping] = version
214+
if version_lt:
215+
metadata['version_lt'] = version_lt(version)
216+
break
217+
else:
218+
# Bare versions are the same as caret
219+
metadata['version_gte'] = version
220+
metadata['version_lt'] = _next_caret(version)
221+
152222
return DependencyDescriptor(dependency_name, metadata=metadata)

test/spell_check.words

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ tomli
4747
tomllib
4848
toprettyxml
4949
tostring
50+
unittest
5051
wildcards
5152
workspaces
5253
xmlstr

test/test_package_augmentation.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Copyright 2026 Open Source Robotics Foundation, Inc.
2+
# Licensed under the Apache License, Version 2.0
3+
4+
from pathlib import Path
5+
from unittest.mock import patch
6+
7+
from colcon_cargo.package_augmentation.cargo \
8+
import create_dependency_descriptor
9+
import pytest
10+
11+
12+
VERSION_CONSTRAINTS = {
13+
None: {},
14+
'': {},
15+
'1.2.3': {'version_gte': '1.2.3', 'version_lt': '2'},
16+
'1.2': {'version_gte': '1.2', 'version_lt': '2'},
17+
'1': {'version_gte': '1', 'version_lt': '2'},
18+
'0.2.3': {'version_gte': '0.2.3', 'version_lt': '0.3'},
19+
'0.2': {'version_gte': '0.2', 'version_lt': '0.3'},
20+
'0.0.3': {'version_gte': '0.0.3', 'version_lt': '0.0.4'},
21+
'0.0': {'version_gte': '0.0', 'version_lt': '0.1'},
22+
'0': {'version_gte': '0', 'version_lt': '1'},
23+
'^ 1.2.3': {'version_gte': '1.2.3', 'version_lt': '2'},
24+
'^ 1.2': {'version_gte': '1.2', 'version_lt': '2'},
25+
'^ 1': {'version_gte': '1', 'version_lt': '2'},
26+
'^ 0.2.3': {'version_gte': '0.2.3', 'version_lt': '0.3'},
27+
'^ 0.2': {'version_gte': '0.2', 'version_lt': '0.3'},
28+
'^ 0.0.3': {'version_gte': '0.0.3', 'version_lt': '0.0.4'},
29+
'^ 0.0': {'version_gte': '0.0', 'version_lt': '0.1'},
30+
'^ 0': {'version_gte': '0', 'version_lt': '1'},
31+
'~ 1.2.3': {'version_gte': '1.2.3', 'version_lt': '1.3'},
32+
'~ 1.2': {'version_gte': '1.2', 'version_lt': '1.3'},
33+
'~ 1': {'version_gte': '1', 'version_lt': '2'},
34+
'*': {'version_gte': '0'},
35+
'1.*': {'version_gte': '1', 'version_lt': '2'},
36+
'1.2.*': {'version_gte': '1.2', 'version_lt': '1.3'},
37+
'>= 1.2.3': {'version_gte': '1.2.3'},
38+
'<= 1.2.3': {'version_lte': '1.2.3'},
39+
'= 1.2.3': {'version_eq': '1.2.3'},
40+
'> 1.2.3': {'version_gt': '1.2.3'},
41+
'< 1.2.3': {'version_lt': '1.2.3'},
42+
'>1.2.3, <=2.0': {'version_gt': '1.2.3', 'version_lte': '2.0'},
43+
}
44+
45+
46+
@pytest.mark.parametrize('constraints', list(VERSION_CONSTRAINTS.keys()))
47+
def test_create_dependency_descriptor(constraints):
48+
metadata = {
49+
'origin': 'cargo',
50+
'cargo_source': None,
51+
**VERSION_CONSTRAINTS[constraints],
52+
}
53+
54+
dep = create_dependency_descriptor('dependency', constraints, Path.cwd())
55+
assert 'dependency' == dep.name
56+
assert metadata == dep.metadata
57+
58+
59+
@pytest.mark.parametrize('constraints', ['1.*.3', '*.*.3', '*.2'])
60+
def test_create_dependency_descriptor_unsupported(constraints):
61+
metadata = {
62+
'origin': 'cargo',
63+
'cargo_source': None,
64+
}
65+
66+
with patch(
67+
'colcon_cargo.package_augmentation.cargo.logger.warning',
68+
) as log:
69+
dep = create_dependency_descriptor(
70+
'dependency', constraints, Path.cwd())
71+
72+
# No constraint in the metadata
73+
assert 'dependency' == dep.name
74+
assert metadata == dep.metadata
75+
76+
# Single call to logger.warning()
77+
assert log.call_count == 1
78+
assert len(log.call_args[0]) >= 1
79+
assert 'unsupported' in log.call_args[0][0]

0 commit comments

Comments
 (0)