diff --git a/colcon_cargo/package_augmentation/cargo.py b/colcon_cargo/package_augmentation/cargo.py index 5f4d0e9..9eb0a86 100644 --- a/colcon_cargo/package_augmentation/cargo.py +++ b/colcon_cargo/package_augmentation/cargo.py @@ -5,6 +5,7 @@ from colcon_cargo.package_identification.cargo import read_cargo_toml from colcon_core.dependency_descriptor import DependencyDescriptor +from colcon_core.package_augmentation import logger from colcon_core.package_augmentation \ import PackageAugmentationExtensionPoint from colcon_core.plugin_system import satisfies_version @@ -122,6 +123,44 @@ def filter_dependency_list(dependencies, filter_out=None): return filtered_dependencies.items() +def _next_caret(version): + # Drop pre-release + version = next(iter(version.split('-', 1))) + new_parts = [] + for part in version.split('.'): + value = int(part) + if value != 0: + new_parts.append(str(value + 1)) + break + new_parts.append(part) + else: + new_parts[-1] = '1' + return '.'.join(new_parts) + + +def _next_tilde(version): + # Drop pre-release + version = next(iter(version.split('-', 1))) + parts = version.split('.', 2) + if len(parts) == 1: + return str(int(parts[0]) + 1) + return parts[0] + '.' + str(int(parts[1]) + 1) + + +def _convert_wildcards(version): + while version.endswith('.*'): + version = version[:-2] + if version == '*': + return '>=0' + elif '*' in version or not version: + logger.warning(f"Ignoring unsupported version '{version}'") + return None + elif version[0].isnumeric(): + return '~' + version + else: + return version + + def create_dependency_descriptor(dependency_name, constraints, path): """ Create a dependency descriptor from a Cargo dependency specification. @@ -133,6 +172,17 @@ def create_dependency_descriptor(dependency_name, constraints, path): resolved :rtype: DependencyDescriptor """ + # The checking order matters, so we use tuple instead of dict + symbol_mappings = ( + ('^', 'version_gte', _next_caret), + ('~', 'version_gte', _next_tilde), + ('>=', 'version_gte', None), + ('<=', 'version_lte', None), + ('=', 'version_eq', None), + ('>', 'version_gt', None), + ('<', 'version_lt', None), + ) + if isinstance(constraints, dict): dep_path = constraints.get('path') if dep_path: @@ -141,12 +191,32 @@ def create_dependency_descriptor(dependency_name, constraints, path): else: source = constraints.get('git') or \ constraints.get('registry') + versions = constraints.get('version') else: source = None + versions = constraints + metadata = { 'origin': 'cargo', 'cargo_source': source, } - # TODO: Interpret SemVer constraints and add appropriate constraint - # metadata. Handling arbitrary wildcards will be non-trivial. + for version in (versions or '').split(','): + # Ignore version metadata during comparison + version = next(iter(version.split('+', 1))).strip() + if '*' in version: + version = _convert_wildcards(version) + if not version: + continue + for symbol, mapping, version_lt in symbol_mappings: + if version.startswith(symbol): + version = version[len(symbol):].lstrip() + metadata[mapping] = version + if version_lt: + metadata['version_lt'] = version_lt(version) + break + else: + # Bare versions are the same as caret + metadata['version_gte'] = version + metadata['version_lt'] = _next_caret(version) + return DependencyDescriptor(dependency_name, metadata=metadata) diff --git a/test/spell_check.words b/test/spell_check.words index 88b7069..3c41c6a 100644 --- a/test/spell_check.words +++ b/test/spell_check.words @@ -11,6 +11,7 @@ descs easymov etree getroot +isnumeric iterdir linter localhost @@ -47,6 +48,7 @@ tomli tomllib toprettyxml tostring +unittest wildcards workspaces xmlstr diff --git a/test/test_package_augmentation.py b/test/test_package_augmentation.py new file mode 100644 index 0000000..17d90d0 --- /dev/null +++ b/test/test_package_augmentation.py @@ -0,0 +1,75 @@ +# Copyright 2026 Open Source Robotics Foundation, Inc. +# Licensed under the Apache License, Version 2.0 + +from pathlib import Path +from unittest.mock import patch + +from colcon_cargo.package_augmentation.cargo \ + import create_dependency_descriptor +import pytest + + +VERSION_CONSTRAINTS = { + None: {}, + '': {}, + '1.2.3': {'version_gte': '1.2.3', 'version_lt': '2'}, + '1.2': {'version_gte': '1.2', 'version_lt': '2'}, + '1': {'version_gte': '1', 'version_lt': '2'}, + '0.2.3': {'version_gte': '0.2.3', 'version_lt': '0.3'}, + '0.2': {'version_gte': '0.2', 'version_lt': '0.3'}, + '0.0.3': {'version_gte': '0.0.3', 'version_lt': '0.0.4'}, + '0.0': {'version_gte': '0.0', 'version_lt': '0.1'}, + '0': {'version_gte': '0', 'version_lt': '1'}, + '^ 1.2.3': {'version_gte': '1.2.3', 'version_lt': '2'}, + '^ 1.2': {'version_gte': '1.2', 'version_lt': '2'}, + '^ 1': {'version_gte': '1', 'version_lt': '2'}, + '^ 0.2.3': {'version_gte': '0.2.3', 'version_lt': '0.3'}, + '^ 0.2': {'version_gte': '0.2', 'version_lt': '0.3'}, + '^ 0.0.3': {'version_gte': '0.0.3', 'version_lt': '0.0.4'}, + '^ 0.0': {'version_gte': '0.0', 'version_lt': '0.1'}, + '^ 0': {'version_gte': '0', 'version_lt': '1'}, + '~ 1.2.3': {'version_gte': '1.2.3', 'version_lt': '1.3'}, + '~ 1.2': {'version_gte': '1.2', 'version_lt': '1.3'}, + '~ 1': {'version_gte': '1', 'version_lt': '2'}, + '*': {'version_gte': '0'}, + '1.*': {'version_gte': '1', 'version_lt': '2'}, + '1.2.*': {'version_gte': '1.2', 'version_lt': '1.3'}, + '>=1.*': {'version_gte': '1'}, + '<1.2.*': {'version_lt': '1.2'}, + '>= 1.2.3': {'version_gte': '1.2.3'}, + '<= 1.2.3': {'version_lte': '1.2.3'}, + '= 1.2.3': {'version_eq': '1.2.3'}, + '> 1.2.3': {'version_gt': '1.2.3'}, + '< 1.2.3': {'version_lt': '1.2.3'}, + '>1.2.3, <=2.0': {'version_gt': '1.2.3', 'version_lte': '2.0'}, +} + + +@pytest.mark.parametrize('constraints', list(VERSION_CONSTRAINTS.keys())) +def test_create_dependency_descriptor(constraints): + metadata = { + 'origin': 'cargo', + **VERSION_CONSTRAINTS[constraints], + } + + dep = create_dependency_descriptor('dependency', constraints, Path.cwd()) + assert 'dependency' == dep.name + assert metadata == {k: v for k, v in dep.metadata.items() if k in metadata} + + +@pytest.mark.parametrize('constraints', ['1.*.3', '*.*.3', '*.2']) +def test_create_dependency_descriptor_unsupported(constraints): + with patch( + 'colcon_cargo.package_augmentation.cargo.logger.warning', + ) as log: + dep = create_dependency_descriptor( + 'dependency', constraints, Path.cwd()) + + # No constraint in the metadata + assert 'dependency' == dep.name + assert not any(k.startswith('version_') for k in dep.metadata.keys()) + + # Single call to logger.warning() + assert log.call_count == 1 + assert len(log.call_args[0]) >= 1 + assert 'unsupported' in log.call_args[0][0]