cppython
Advanced tools
| """Unit tests for Conan resolution functionality.""" | ||
| import logging | ||
| from unittest.mock import Mock, patch | ||
| import pytest | ||
| from conan.internal.model.profile import Profile | ||
| from packaging.requirements import Requirement | ||
| from cppython.core.exception import ConfigException | ||
| from cppython.core.schema import CorePluginData | ||
| from cppython.plugins.conan.resolution import ( | ||
| _profile_post_process, | ||
| _resolve_profiles, | ||
| resolve_conan_data, | ||
| resolve_conan_dependency, | ||
| ) | ||
| from cppython.plugins.conan.schema import ( | ||
| ConanData, | ||
| ConanDependency, | ||
| ConanRevision, | ||
| ConanUserChannel, | ||
| ConanVersion, | ||
| ConanVersionRange, | ||
| ) | ||
| from cppython.utility.exception import ProviderConfigurationError | ||
| # Constants for test validation | ||
| EXPECTED_PROFILE_CALL_COUNT = 2 | ||
| class TestResolveDependency: | ||
| """Test dependency resolution.""" | ||
| def test_with_version(self) -> None: | ||
| """Test resolving a dependency with a >= version specifier.""" | ||
| requirement = Requirement('boost>=1.80.0') | ||
| result = resolve_conan_dependency(requirement) | ||
| assert result.name == 'boost' | ||
| assert result.version_range is not None | ||
| assert result.version_range.expression == '>=1.80.0' | ||
| assert result.version is None | ||
| def test_with_exact_version(self) -> None: | ||
| """Test resolving a dependency with an exact version specifier.""" | ||
| requirement = Requirement('abseil==20240116.2') | ||
| result = resolve_conan_dependency(requirement) | ||
| assert result.name == 'abseil' | ||
| assert result.version is not None | ||
| assert str(result.version) == '20240116.2' | ||
| assert result.version_range is None | ||
| def test_without_version(self) -> None: | ||
| """Test resolving a dependency without a version specifier.""" | ||
| requirement = Requirement('boost') | ||
| result = resolve_conan_dependency(requirement) | ||
| assert result.name == 'boost' | ||
| assert result.version is None | ||
| assert result.version_range is None | ||
| def test_compatible_release(self) -> None: | ||
| """Test resolving a dependency with ~= (compatible release) operator.""" | ||
| requirement = Requirement('package~=1.2.3') | ||
| result = resolve_conan_dependency(requirement) | ||
| assert result.name == 'package' | ||
| assert result.version_range is not None | ||
| assert result.version_range.expression == '~1.2' | ||
| assert result.version is None | ||
| def test_multiple_specifiers(self) -> None: | ||
| """Test resolving a dependency with multiple specifiers.""" | ||
| requirement = Requirement('boost>=1.80.0,<2.0.0') | ||
| result = resolve_conan_dependency(requirement) | ||
| assert result.name == 'boost' | ||
| assert result.version_range is not None | ||
| assert result.version_range.expression == '>=1.80.0 <2.0.0' | ||
| assert result.version is None | ||
| def test_unsupported_operator(self) -> None: | ||
| """Test that unsupported operators raise an error.""" | ||
| requirement = Requirement('boost===1.80.0') | ||
| with pytest.raises(ConfigException, match="Unsupported single specifier '==='"): | ||
| resolve_conan_dependency(requirement) | ||
| def test_contradictory_exact_versions(self) -> None: | ||
| """Test that multiple specifiers work correctly for valid ranges.""" | ||
| # Test our logic with a valid range instead of invalid syntax | ||
| requirement = Requirement('package>=1.0,<=2.0') # Valid range | ||
| result = resolve_conan_dependency(requirement) | ||
| assert result.name == 'package' | ||
| assert result.version_range is not None | ||
| assert result.version_range.expression == '>=1.0 <=2.0' | ||
| def test_requires_exact_version(self) -> None: | ||
| """Test that ConanDependency generates correct requires for exact versions.""" | ||
| dependency = ConanDependency(name='abseil', version=ConanVersion.from_string('20240116.2')) | ||
| assert dependency.requires() == 'abseil/20240116.2' | ||
| def test_requires_version_range(self) -> None: | ||
| """Test that ConanDependency generates correct requires for version ranges.""" | ||
| dependency = ConanDependency(name='boost', version_range=ConanVersionRange(expression='>=1.80.0 <2.0')) | ||
| assert dependency.requires() == 'boost/[>=1.80.0 <2.0]' | ||
| def test_requires_legacy_minimum_version(self) -> None: | ||
| """Test that ConanDependency generates correct requires for legacy minimum versions.""" | ||
| dependency = ConanDependency(name='boost', version_range=ConanVersionRange(expression='>=1.80.0')) | ||
| assert dependency.requires() == 'boost/[>=1.80.0]' | ||
| def test_requires_legacy_exact_version(self) -> None: | ||
| """Test that ConanDependency generates correct requires for legacy exact versions.""" | ||
| dependency = ConanDependency(name='abseil', version=ConanVersion.from_string('20240116.2')) | ||
| assert dependency.requires() == 'abseil/20240116.2' | ||
| def test_requires_no_version(self) -> None: | ||
| """Test that ConanDependency generates correct requires for dependencies without version.""" | ||
| dependency = ConanDependency(name='somelib') | ||
| assert dependency.requires() == 'somelib' | ||
| def test_with_user_channel(self) -> None: | ||
| """Test that ConanDependency handles user/channel correctly.""" | ||
| dependency = ConanDependency( | ||
| name='mylib', | ||
| version=ConanVersion.from_string('1.0.0'), | ||
| user_channel=ConanUserChannel(user='myuser', channel='stable'), | ||
| ) | ||
| assert dependency.requires() == 'mylib/1.0.0@myuser/stable' | ||
| def test_with_revision(self) -> None: | ||
| """Test that ConanDependency handles revisions correctly.""" | ||
| dependency = ConanDependency( | ||
| name='mylib', version=ConanVersion.from_string('1.0.0'), revision=ConanRevision(revision='abc123') | ||
| ) | ||
| assert dependency.requires() == 'mylib/1.0.0#abc123' | ||
| def test_full_reference(self) -> None: | ||
| """Test that ConanDependency handles full references correctly.""" | ||
| dependency = ConanDependency( | ||
| name='mylib', | ||
| version=ConanVersion.from_string('1.0.0'), | ||
| user_channel=ConanUserChannel(user='myuser', channel='stable'), | ||
| revision=ConanRevision(revision='abc123'), | ||
| ) | ||
| assert dependency.requires() == 'mylib/1.0.0@myuser/stable#abc123' | ||
| def test_from_reference_simple(self) -> None: | ||
| """Test parsing a simple package name.""" | ||
| dependency = ConanDependency.from_conan_reference('mylib') | ||
| assert dependency.name == 'mylib' | ||
| assert dependency.version is None | ||
| assert dependency.user_channel is None | ||
| assert dependency.revision is None | ||
| def test_from_reference_with_version(self) -> None: | ||
| """Test parsing a package with version.""" | ||
| dependency = ConanDependency.from_conan_reference('mylib/1.0.0') | ||
| assert dependency.name == 'mylib' | ||
| assert dependency.version is not None | ||
| assert str(dependency.version) == '1.0.0' | ||
| assert dependency.user_channel is None | ||
| assert dependency.revision is None | ||
| def test_from_reference_with_version_range(self) -> None: | ||
| """Test parsing a package with version range.""" | ||
| dependency = ConanDependency.from_conan_reference('mylib/[>=1.0 <2.0]') | ||
| assert dependency.name == 'mylib' | ||
| assert dependency.version is None | ||
| assert dependency.version_range is not None | ||
| assert dependency.version_range.expression == '>=1.0 <2.0' | ||
| assert dependency.user_channel is None | ||
| assert dependency.revision is None | ||
| def test_from_reference_full(self) -> None: | ||
| """Test parsing a full Conan reference.""" | ||
| dependency = ConanDependency.from_conan_reference('mylib/1.0.0@myuser/stable#abc123') | ||
| assert dependency.name == 'mylib' | ||
| assert dependency.version is not None | ||
| assert str(dependency.version) == '1.0.0' | ||
| assert dependency.user_channel is not None | ||
| assert dependency.user_channel.user == 'myuser' | ||
| assert dependency.user_channel.channel == 'stable' | ||
| assert dependency.revision is not None | ||
| assert dependency.revision.revision == 'abc123' | ||
| class TestProfileProcessing: | ||
| """Test profile processing functionality.""" | ||
| def test_success(self) -> None: | ||
| """Test successful profile processing.""" | ||
| mock_conan_api = Mock() | ||
| mock_profile = Mock() | ||
| mock_cache_settings = Mock() | ||
| mock_plugin = Mock() | ||
| mock_conan_api.profiles._load_profile_plugin.return_value = mock_plugin | ||
| profiles = [mock_profile] | ||
| _profile_post_process(profiles, mock_conan_api, mock_cache_settings) | ||
| mock_plugin.assert_called_once_with(mock_profile) | ||
| mock_profile.process_settings.assert_called_once_with(mock_cache_settings) | ||
| def test_no_plugin(self) -> None: | ||
| """Test profile processing when no plugin is available.""" | ||
| mock_conan_api = Mock() | ||
| mock_profile = Mock() | ||
| mock_cache_settings = Mock() | ||
| mock_conan_api.profiles._load_profile_plugin.return_value = None | ||
| profiles = [mock_profile] | ||
| _profile_post_process(profiles, mock_conan_api, mock_cache_settings) | ||
| mock_profile.process_settings.assert_called_once_with(mock_cache_settings) | ||
| def test_plugin_failure(self, caplog: pytest.LogCaptureFixture) -> None: | ||
| """Test profile processing when plugin fails.""" | ||
| mock_conan_api = Mock() | ||
| mock_profile = Mock() | ||
| mock_cache_settings = Mock() | ||
| mock_plugin = Mock() | ||
| mock_conan_api.profiles._load_profile_plugin.return_value = mock_plugin | ||
| mock_plugin.side_effect = Exception('Plugin failed') | ||
| profiles = [mock_profile] | ||
| with caplog.at_level(logging.WARNING): | ||
| _profile_post_process(profiles, mock_conan_api, mock_cache_settings) | ||
| assert 'Profile plugin failed for profile' in caplog.text | ||
| mock_profile.process_settings.assert_called_once_with(mock_cache_settings) | ||
| def test_settings_failure(self, caplog: pytest.LogCaptureFixture) -> None: | ||
| """Test profile processing when settings processing fails.""" | ||
| mock_conan_api = Mock() | ||
| mock_profile = Mock() | ||
| mock_cache_settings = Mock() | ||
| mock_conan_api.profiles._load_profile_plugin.return_value = None | ||
| mock_profile.process_settings.side_effect = Exception('Settings failed') | ||
| profiles = [mock_profile] | ||
| with caplog.at_level(logging.DEBUG): | ||
| _profile_post_process(profiles, mock_conan_api, mock_cache_settings) | ||
| assert 'Settings processing failed for profile' in caplog.text | ||
| class TestResolveProfiles: | ||
| """Test profile resolution functionality.""" | ||
| def test_by_name(self) -> None: | ||
| """Test resolving profiles by name.""" | ||
| mock_conan_api = Mock() | ||
| mock_host_profile = Mock() | ||
| mock_build_profile = Mock() | ||
| mock_conan_api.profiles.get_profile.side_effect = [mock_host_profile, mock_build_profile] | ||
| host_result, build_result = _resolve_profiles( | ||
| 'host-profile', 'build-profile', mock_conan_api, cmake_program=None | ||
| ) | ||
| assert host_result == mock_host_profile | ||
| assert build_result == mock_build_profile | ||
| assert mock_conan_api.profiles.get_profile.call_count == EXPECTED_PROFILE_CALL_COUNT | ||
| mock_conan_api.profiles.get_profile.assert_any_call(['host-profile']) | ||
| mock_conan_api.profiles.get_profile.assert_any_call(['build-profile']) | ||
| def test_by_name_failure(self) -> None: | ||
| """Test resolving profiles by name when host profile fails.""" | ||
| mock_conan_api = Mock() | ||
| mock_conan_api.profiles.get_profile.side_effect = Exception('Profile not found') | ||
| with pytest.raises(ProviderConfigurationError, match='Failed to load host profile'): | ||
| _resolve_profiles('missing-profile', 'other-profile', mock_conan_api, cmake_program=None) | ||
| def test_auto_detect(self) -> None: | ||
| """Test auto-detecting profiles.""" | ||
| mock_conan_api = Mock() | ||
| mock_host_profile = Mock() | ||
| mock_build_profile = Mock() | ||
| mock_host_default_path = 'host-default' | ||
| mock_build_default_path = 'build-default' | ||
| mock_conan_api.profiles.get_default_host.return_value = mock_host_default_path | ||
| mock_conan_api.profiles.get_default_build.return_value = mock_build_default_path | ||
| mock_conan_api.profiles.get_profile.side_effect = [mock_host_profile, mock_build_profile] | ||
| host_result, build_result = _resolve_profiles(None, None, mock_conan_api, cmake_program=None) | ||
| assert host_result == mock_host_profile | ||
| assert build_result == mock_build_profile | ||
| mock_conan_api.profiles.get_default_host.assert_called_once() | ||
| mock_conan_api.profiles.get_default_build.assert_called_once() | ||
| mock_conan_api.profiles.get_profile.assert_any_call([mock_host_default_path]) | ||
| mock_conan_api.profiles.get_profile.assert_any_call([mock_build_default_path]) | ||
| @patch('cppython.plugins.conan.resolution._profile_post_process') | ||
| def test_fallback_to_detect(self, mock_post_process: Mock) -> None: | ||
| """Test falling back to profile detection when defaults fail.""" | ||
| mock_conan_api = Mock() | ||
| mock_host_profile = Mock() | ||
| mock_build_profile = Mock() | ||
| mock_cache_settings = Mock() | ||
| # Mock the default profile methods to fail | ||
| mock_conan_api.profiles.get_default_host.side_effect = Exception('No default profile') | ||
| mock_conan_api.profiles.get_default_build.side_effect = Exception('No default profile') | ||
| mock_conan_api.profiles.get_profile.side_effect = Exception('Profile not found') | ||
| # Mock detect to succeed | ||
| mock_conan_api.profiles.detect.side_effect = [mock_host_profile, mock_build_profile] | ||
| mock_conan_api.config.settings_yml = mock_cache_settings | ||
| host_result, build_result = _resolve_profiles(None, None, mock_conan_api, cmake_program=None) | ||
| assert host_result == mock_host_profile | ||
| assert build_result == mock_build_profile | ||
| assert mock_conan_api.profiles.detect.call_count == EXPECTED_PROFILE_CALL_COUNT | ||
| assert mock_post_process.call_count == EXPECTED_PROFILE_CALL_COUNT | ||
| mock_post_process.assert_any_call([mock_host_profile], mock_conan_api, mock_cache_settings, None) | ||
| mock_post_process.assert_any_call([mock_build_profile], mock_conan_api, mock_cache_settings, None) | ||
| @patch('cppython.plugins.conan.resolution._profile_post_process') | ||
| def test_default_fallback_to_detect(self, mock_post_process: Mock) -> None: | ||
| """Test falling back to profile detection when default profile fails.""" | ||
| mock_conan_api = Mock() | ||
| mock_host_profile = Mock() | ||
| mock_build_profile = Mock() | ||
| mock_cache_settings = Mock() | ||
| # Mock the default profile to fail (this simulates the "default" profile not existing) | ||
| mock_conan_api.profiles.get_profile.side_effect = Exception('Profile not found') | ||
| mock_conan_api.profiles.get_default_host.side_effect = Exception('No default profile') | ||
| mock_conan_api.profiles.get_default_build.side_effect = Exception('No default profile') | ||
| # Mock detect to succeed | ||
| mock_conan_api.profiles.detect.side_effect = [mock_host_profile, mock_build_profile] | ||
| mock_conan_api.config.settings_yml = mock_cache_settings | ||
| host_result, build_result = _resolve_profiles('default', 'default', mock_conan_api, cmake_program=None) | ||
| assert host_result == mock_host_profile | ||
| assert build_result == mock_build_profile | ||
| assert mock_conan_api.profiles.detect.call_count == EXPECTED_PROFILE_CALL_COUNT | ||
| assert mock_post_process.call_count == EXPECTED_PROFILE_CALL_COUNT | ||
| mock_post_process.assert_any_call([mock_host_profile], mock_conan_api, mock_cache_settings, None) | ||
| mock_post_process.assert_any_call([mock_build_profile], mock_conan_api, mock_cache_settings, None) | ||
| class TestResolveConanData: | ||
| """Test Conan data resolution.""" | ||
| @patch('cppython.plugins.conan.resolution.ConanAPI') | ||
| @patch('cppython.plugins.conan.resolution._resolve_profiles') | ||
| @patch('cppython.plugins.conan.resolution._detect_cmake_program') | ||
| def test_with_profiles( | ||
| self, mock_detect_cmake: Mock, mock_resolve_profiles: Mock, mock_conan_api_class: Mock | ||
| ) -> None: | ||
| """Test resolving ConanData with profile configuration.""" | ||
| mock_detect_cmake.return_value = None # No cmake detected for test | ||
| mock_conan_api = Mock() | ||
| mock_conan_api_class.return_value = mock_conan_api | ||
| mock_host_profile = Mock(spec=Profile) | ||
| mock_build_profile = Mock(spec=Profile) | ||
| mock_resolve_profiles.return_value = (mock_host_profile, mock_build_profile) | ||
| data = {'host_profile': 'linux-x64', 'build_profile': 'linux-gcc11', 'remotes': ['conancenter']} | ||
| core_data = Mock(spec=CorePluginData) | ||
| result = resolve_conan_data(data, core_data) | ||
| assert isinstance(result, ConanData) | ||
| assert result.host_profile == mock_host_profile | ||
| assert result.build_profile == mock_build_profile | ||
| assert result.remotes == ['conancenter'] | ||
| # Verify profile resolution was called correctly | ||
| mock_resolve_profiles.assert_called_once_with('linux-x64', 'linux-gcc11', mock_conan_api, None) | ||
| @patch('cppython.plugins.conan.resolution.ConanAPI') | ||
| @patch('cppython.plugins.conan.resolution._resolve_profiles') | ||
| @patch('cppython.plugins.conan.resolution._detect_cmake_program') | ||
| def test_default_profiles( | ||
| self, mock_detect_cmake: Mock, mock_resolve_profiles: Mock, mock_conan_api_class: Mock | ||
| ) -> None: | ||
| """Test resolving ConanData with default profile configuration.""" | ||
| mock_detect_cmake.return_value = None # No cmake detected for test | ||
| mock_conan_api = Mock() | ||
| mock_conan_api_class.return_value = mock_conan_api | ||
| mock_host_profile = Mock(spec=Profile) | ||
| mock_build_profile = Mock(spec=Profile) | ||
| mock_resolve_profiles.return_value = (mock_host_profile, mock_build_profile) | ||
| data = {} # Empty data should use defaults | ||
| core_data = Mock(spec=CorePluginData) | ||
| result = resolve_conan_data(data, core_data) | ||
| assert isinstance(result, ConanData) | ||
| assert result.host_profile == mock_host_profile | ||
| assert result.build_profile == mock_build_profile | ||
| assert result.remotes == ['conancenter'] # Default remote | ||
| # Verify profile resolution was called with default values | ||
| mock_resolve_profiles.assert_called_once_with('default', 'default', mock_conan_api, None) | ||
| @patch('cppython.plugins.conan.resolution.ConanAPI') | ||
| @patch('cppython.plugins.conan.resolution._resolve_profiles') | ||
| @patch('cppython.plugins.conan.resolution._detect_cmake_program') | ||
| def test_null_profiles( | ||
| self, mock_detect_cmake: Mock, mock_resolve_profiles: Mock, mock_conan_api_class: Mock | ||
| ) -> None: | ||
| """Test resolving ConanData with null profile configuration.""" | ||
| mock_detect_cmake.return_value = None # No cmake detected for test | ||
| mock_conan_api = Mock() | ||
| mock_conan_api_class.return_value = mock_conan_api | ||
| mock_host_profile = Mock(spec=Profile) | ||
| mock_build_profile = Mock(spec=Profile) | ||
| mock_resolve_profiles.return_value = (mock_host_profile, mock_build_profile) | ||
| data = {'host_profile': None, 'build_profile': None, 'remotes': []} | ||
| core_data = Mock(spec=CorePluginData) | ||
| result = resolve_conan_data(data, core_data) | ||
| assert isinstance(result, ConanData) | ||
| assert result.host_profile == mock_host_profile | ||
| assert result.build_profile == mock_build_profile | ||
| assert result.remotes == [] | ||
| # Verify profile resolution was called with None values | ||
| mock_resolve_profiles.assert_called_once_with(None, None, mock_conan_api, None) | ||
| @patch('cppython.plugins.conan.resolution.ConanAPI') | ||
| @patch('cppython.plugins.conan.resolution._profile_post_process') | ||
| def test_auto_detected_profile_processing(self, mock_post_process: Mock, mock_conan_api_class: Mock): | ||
| """Test that auto-detected profiles get proper post-processing. | ||
| Args: | ||
| mock_post_process: Mock for _profile_post_process function | ||
| mock_conan_api_class: Mock for ConanAPI class | ||
| """ | ||
| mock_conan_api = Mock() | ||
| mock_conan_api_class.return_value = mock_conan_api | ||
| # Configure the mock to simulate no default profiles | ||
| mock_conan_api.profiles.get_default_host.side_effect = Exception('No default profile') | ||
| mock_conan_api.profiles.get_default_build.side_effect = Exception('No default profile') | ||
| # Create a profile that simulates auto-detection | ||
| mock_profile = Mock() | ||
| mock_profile.settings = {'os': 'Windows', 'arch': 'x86_64'} | ||
| mock_profile.process_settings = Mock() | ||
| mock_profile.conf = Mock() | ||
| mock_profile.conf.validate = Mock() | ||
| mock_profile.conf.rebase_conf_definition = Mock() | ||
| mock_conan_api.profiles.detect.return_value = mock_profile | ||
| mock_conan_api.config.global_conf = Mock() | ||
| # Call the resolution - this should trigger auto-detection and post-processing | ||
| host_profile, build_profile = _resolve_profiles(None, None, mock_conan_api, cmake_program=None) | ||
| # Verify that auto-detection was called for both profiles | ||
| assert mock_conan_api.profiles.detect.call_count == EXPECTED_PROFILE_CALL_COUNT | ||
| # Verify that post-processing was called for both profiles | ||
| assert mock_post_process.call_count == EXPECTED_PROFILE_CALL_COUNT |
+80
-4
@@ -37,2 +37,3 @@ """Defines the data and routines for building a CPPython project type""" | ||
| from cppython.utility.exception import PluginError | ||
| from cppython.utility.utility import TypeName | ||
@@ -63,3 +64,3 @@ | ||
| raw_generator_plugins, | ||
| cppython_local_configuration.generator_name, | ||
| self._get_effective_generator_name(cppython_local_configuration), | ||
| 'Generator', | ||
@@ -71,3 +72,3 @@ ) | ||
| raw_provider_plugins, | ||
| cppython_local_configuration.provider_name, | ||
| self._get_effective_provider_name(cppython_local_configuration), | ||
| 'Provider', | ||
@@ -85,2 +86,70 @@ ) | ||
| def _get_effective_generator_name(self, config: CPPythonLocalConfiguration) -> str | None: | ||
| """Get the effective generator name from configuration | ||
| Args: | ||
| config: The local configuration | ||
| Returns: | ||
| The generator name to use, or None for auto-detection | ||
| """ | ||
| if config.generators: | ||
| # For now, pick the first generator (in future, could support selection logic) | ||
| return list(config.generators.keys())[0] | ||
| # No generators specified, use auto-detection | ||
| return None | ||
| def _get_effective_provider_name(self, config: CPPythonLocalConfiguration) -> str | None: | ||
| """Get the effective provider name from configuration | ||
| Args: | ||
| config: The local configuration | ||
| Returns: | ||
| The provider name to use, or None for auto-detection | ||
| """ | ||
| if config.providers: | ||
| # For now, pick the first provider (in future, could support selection logic) | ||
| return list(config.providers.keys())[0] | ||
| # No providers specified, use auto-detection | ||
| return None | ||
| def _get_effective_generator_config( | ||
| self, config: CPPythonLocalConfiguration, generator_name: str | ||
| ) -> dict[str, Any]: | ||
| """Get the effective generator configuration | ||
| Args: | ||
| config: The local configuration | ||
| generator_name: The name of the generator being used | ||
| Returns: | ||
| The configuration dict for the generator | ||
| """ | ||
| generator_type_name = TypeName(generator_name) | ||
| if config.generators and generator_type_name in config.generators: | ||
| return config.generators[generator_type_name] | ||
| # Return empty config if not found | ||
| return {} | ||
| def _get_effective_provider_config(self, config: CPPythonLocalConfiguration, provider_name: str) -> dict[str, Any]: | ||
| """Get the effective provider configuration | ||
| Args: | ||
| config: The local configuration | ||
| provider_name: The name of the provider being used | ||
| Returns: | ||
| The configuration dict for the provider | ||
| """ | ||
| provider_type_name = TypeName(provider_name) | ||
| if config.providers and provider_type_name in config.providers: | ||
| return config.providers[provider_type_name] | ||
| # Return empty config if not found | ||
| return {} | ||
| @staticmethod | ||
@@ -454,7 +523,14 @@ def generate_cppython_plugin_data(plugin_build_data: PluginBuildData) -> PluginCPPythonData: | ||
| # Create the chosen plugins | ||
| generator_config = self._resolver._get_effective_generator_config( | ||
| cppython_local_configuration, plugin_build_data.generator_type.name() | ||
| ) | ||
| generator = self._resolver.create_generator( | ||
| core_data, pep621_data, cppython_local_configuration.generator, plugin_build_data.generator_type | ||
| core_data, pep621_data, generator_config, plugin_build_data.generator_type | ||
| ) | ||
| provider_config = self._resolver._get_effective_provider_config( | ||
| cppython_local_configuration, plugin_build_data.provider_type.name() | ||
| ) | ||
| provider = self._resolver.create_provider( | ||
| core_data, pep621_data, cppython_local_configuration.provider, plugin_build_data.provider_type | ||
| core_data, pep621_data, provider_config, plugin_build_data.provider_type | ||
| ) | ||
@@ -461,0 +537,0 @@ |
@@ -142,13 +142,19 @@ """Data conversion routines""" | ||
| modified_provider_name = local_configuration.provider_name | ||
| modified_generator_name = local_configuration.generator_name | ||
| modified_provider_name = plugin_build_data.provider_name | ||
| modified_generator_name = plugin_build_data.generator_name | ||
| if modified_provider_name is None: | ||
| modified_provider_name = plugin_build_data.provider_name | ||
| modified_scm_name = plugin_build_data.scm_name | ||
| if modified_generator_name is None: | ||
| modified_generator_name = plugin_build_data.generator_name | ||
| # Extract provider and generator configuration data | ||
| provider_type_name = TypeName(modified_provider_name) | ||
| generator_type_name = TypeName(modified_generator_name) | ||
| modified_scm_name = plugin_build_data.scm_name | ||
| provider_data = {} | ||
| if local_configuration.providers and provider_type_name in local_configuration.providers: | ||
| provider_data = local_configuration.providers[provider_type_name] | ||
| generator_data = {} | ||
| if local_configuration.generators and generator_type_name in local_configuration.generators: | ||
| generator_data = local_configuration.generators[generator_type_name] | ||
| # Construct dependencies from the local configuration only | ||
@@ -177,2 +183,4 @@ dependencies: list[Requirement] = [] | ||
| dependencies=dependencies, | ||
| provider_data=provider_data, | ||
| generator_data=generator_data, | ||
| ) | ||
@@ -205,2 +213,4 @@ return cppython_data | ||
| dependencies=cppython_data.dependencies, | ||
| provider_data=cppython_data.provider_data, | ||
| generator_data=cppython_data.generator_data, | ||
| ) | ||
@@ -207,0 +217,0 @@ |
+13
-18
@@ -121,2 +121,5 @@ """Data types for CPPython that encapsulate the requirements between the plugins and the core library""" | ||
| provider_data: Annotated[dict[str, Any], Field(description='Resolved provider configuration data')] | ||
| generator_data: Annotated[dict[str, Any], Field(description='Resolved generator configuration data')] | ||
| @field_validator('configuration_path', 'install_path', 'tool_path', 'build_path') # type: ignore | ||
@@ -306,25 +309,17 @@ @classmethod | ||
| provider: Annotated[ProviderData, Field(description="Provider plugin data associated with 'provider_name")] = ( | ||
| ProviderData({}) | ||
| ) | ||
| provider_name: Annotated[ | ||
| TypeName | None, | ||
| providers: Annotated[ | ||
| dict[TypeName, ProviderData], | ||
| Field( | ||
| alias='provider-name', | ||
| description='If empty, the provider will be automatically deduced.', | ||
| description='Named provider configurations. Key is the provider name, value is the provider configuration.' | ||
| ), | ||
| ] = None | ||
| ] = {} | ||
| generator: Annotated[GeneratorData, Field(description="Generator plugin data associated with 'generator_name'")] = ( | ||
| GeneratorData({}) | ||
| ) | ||
| generator_name: Annotated[ | ||
| TypeName | None, | ||
| generators: Annotated[ | ||
| dict[TypeName, GeneratorData], | ||
| Field( | ||
| alias='generator-name', | ||
| description='If empty, the generator will be automatically deduced.', | ||
| description=( | ||
| 'Named generator configurations. Key is the generator name, value is the generator configuration.' | ||
| ) | ||
| ), | ||
| ] = None | ||
| ] = {} | ||
@@ -331,0 +326,0 @@ dependencies: Annotated[ |
@@ -128,3 +128,3 @@ """Construction of Conan data""" | ||
| from conan import ConanFile | ||
| from conan.tools.cmake import CMake, CMakeToolchain, cmake_layout | ||
| from conan.tools.cmake import CMake, cmake_layout | ||
@@ -131,0 +131,0 @@ class MyProject(ConanFile): |
@@ -15,3 +15,2 @@ """Conan Provider Plugin | ||
| from conan.api.model import ListPattern | ||
| from conan.internal.model.profile import Profile | ||
@@ -113,4 +112,4 @@ from cppython.core.plugin_schema.generator import SyncConsumer | ||
| # Get profiles with fallback to auto-detection | ||
| profile_host, profile_build = self._get_profiles(conan_api) | ||
| # Get profiles from resolved data | ||
| profile_host, profile_build = self.data.host_profile, self.data.build_profile | ||
@@ -254,4 +253,4 @@ path = str(conanfile_path) | ||
| # Step 2: Get profiles with fallback to auto-detection | ||
| profile_host, profile_build = self._get_profiles(conan_api) | ||
| # Step 2: Get profiles from resolved data | ||
| profile_host, profile_build = self.data.host_profile, self.data.build_profile | ||
@@ -311,66 +310,1 @@ # Step 3: Build dependency graph for the package - prepare parameters | ||
| raise ProviderInstallationError('conan', 'No packages found to upload') | ||
| def _apply_profile_processing(self, profiles: list[Profile], conan_api: ConanAPI, cache_settings: Any) -> None: | ||
| """Apply profile plugin and settings processing to a list of profiles. | ||
| Args: | ||
| profiles: List of profiles to process | ||
| conan_api: The Conan API instance | ||
| cache_settings: The settings configuration | ||
| """ | ||
| logger = logging.getLogger('cppython.conan') | ||
| # Apply profile plugin processing | ||
| try: | ||
| profile_plugin = conan_api.profiles._load_profile_plugin() | ||
| if profile_plugin is not None: | ||
| for profile in profiles: | ||
| try: | ||
| profile_plugin(profile) | ||
| except Exception as plugin_error: | ||
| logger.warning('Profile plugin failed for profile: %s', str(plugin_error)) | ||
| except (AttributeError, Exception): | ||
| logger.debug('Profile plugin not available or failed to load') | ||
| # Process settings to initialize processed_settings | ||
| for profile in profiles: | ||
| try: | ||
| profile.process_settings(cache_settings) | ||
| except (AttributeError, Exception) as settings_error: | ||
| logger.debug('Settings processing failed for profile: %s', str(settings_error)) | ||
| def _get_profiles(self, conan_api: ConanAPI) -> tuple[Profile, Profile]: | ||
| """Get Conan profiles with fallback to auto-detection. | ||
| Args: | ||
| conan_api: The Conan API instance | ||
| Returns: | ||
| A tuple of (profile_host, profile_build) objects | ||
| """ | ||
| logger = logging.getLogger('cppython.conan') | ||
| try: | ||
| # Gather default profile paths, these can raise exceptions if not available | ||
| profile_host_path = conan_api.profiles.get_default_host() | ||
| profile_build_path = conan_api.profiles.get_default_build() | ||
| # Load the actual profile objects, can raise if data is invalid | ||
| profile_host = conan_api.profiles.get_profile([profile_host_path]) | ||
| profile_build = conan_api.profiles.get_profile([profile_build_path]) | ||
| logger.debug('Using existing default profiles') | ||
| return profile_host, profile_build | ||
| except Exception as e: | ||
| logger.warning('Default profiles not available, using auto-detection. Conan message: %s', str(e)) | ||
| # Create auto-detected profiles | ||
| profiles = [conan_api.profiles.detect(), conan_api.profiles.detect()] | ||
| cache_settings = conan_api.config.settings_yml | ||
| # Apply profile plugin processing to both profiles | ||
| self._apply_profile_processing(profiles, conan_api, cache_settings) | ||
| logger.debug('Auto-detected profiles with plugin processing applied') | ||
| return profiles[0], profiles[1] |
| """Provides functionality to resolve Conan-specific data for the CPPython project.""" | ||
| import importlib | ||
| import logging | ||
| from pathlib import Path | ||
| from typing import Any | ||
| from conan.api.conan_api import ConanAPI | ||
| from conan.internal.model.profile import Profile | ||
| from packaging.requirements import Requirement | ||
@@ -9,27 +14,284 @@ | ||
| from cppython.core.schema import CorePluginData | ||
| from cppython.plugins.conan.schema import ConanConfiguration, ConanData, ConanDependency | ||
| from cppython.plugins.conan.schema import ( | ||
| ConanConfiguration, | ||
| ConanData, | ||
| ConanDependency, | ||
| ConanVersion, | ||
| ConanVersionRange, | ||
| ) | ||
| from cppython.utility.exception import ProviderConfigurationError | ||
| def _detect_cmake_program() -> str | None: | ||
| """Detect CMake program path from the cmake module if available. | ||
| Returns: | ||
| Path to cmake executable, or None if not found | ||
| """ | ||
| try: | ||
| # Try to import cmake module and get its executable path | ||
| # Note: cmake is an optional dependency, so we import it conditionally | ||
| cmake = importlib.import_module('cmake') | ||
| cmake_bin_dir = Path(cmake.CMAKE_BIN_DIR) | ||
| # Try common cmake executable names (pathlib handles platform differences) | ||
| for cmake_name in ['cmake.exe', 'cmake']: | ||
| cmake_exe = cmake_bin_dir / cmake_name | ||
| if cmake_exe.exists(): | ||
| return str(cmake_exe) | ||
| return None | ||
| except ImportError: | ||
| # cmake module not available | ||
| return None | ||
| except (AttributeError, Exception): | ||
| # If cmake module doesn't have expected attributes | ||
| return None | ||
| def _profile_post_process( | ||
| profiles: list[Profile], conan_api: ConanAPI, cache_settings: Any, cmake_program: str | None = None | ||
| ) -> None: | ||
| """Apply profile plugin and settings processing to a list of profiles. | ||
| Args: | ||
| profiles: List of profiles to process | ||
| conan_api: The Conan API instance | ||
| cache_settings: The settings configuration | ||
| cmake_program: Optional path to cmake program to configure in profiles | ||
| """ | ||
| logger = logging.getLogger('cppython.conan') | ||
| # Get global configuration | ||
| global_conf = conan_api.config.global_conf | ||
| # Apply profile plugin processing | ||
| try: | ||
| profile_plugin = conan_api.profiles._load_profile_plugin() | ||
| if profile_plugin is not None: | ||
| for profile in profiles: | ||
| try: | ||
| profile_plugin(profile) | ||
| except Exception as plugin_error: | ||
| logger.warning('Profile plugin failed for profile: %s', str(plugin_error)) | ||
| except (AttributeError, Exception): | ||
| logger.debug('Profile plugin not available or failed to load') | ||
| # Apply the full profile processing pipeline for each profile | ||
| for profile in profiles: | ||
| # Set cmake program configuration if provided | ||
| if cmake_program is not None: | ||
| try: | ||
| # Set the tools.cmake:cmake_program configuration in the profile | ||
| profile.conf.update('tools.cmake:cmake_program', cmake_program) | ||
| logger.debug('Set tools.cmake:cmake_program=%s in profile', cmake_program) | ||
| except (AttributeError, Exception) as cmake_error: | ||
| logger.debug('Failed to set cmake program configuration: %s', str(cmake_error)) | ||
| # Process settings to initialize processed_settings | ||
| try: | ||
| profile.process_settings(cache_settings) | ||
| except (AttributeError, Exception) as settings_error: | ||
| logger.debug('Settings processing failed for profile: %s', str(settings_error)) | ||
| # Validate configuration | ||
| try: | ||
| profile.conf.validate() | ||
| except (AttributeError, Exception) as conf_error: | ||
| logger.debug('Configuration validation failed for profile: %s', str(conf_error)) | ||
| # Apply global configuration to the profile | ||
| try: | ||
| if global_conf is not None: | ||
| profile.conf.rebase_conf_definition(global_conf) | ||
| except (AttributeError, Exception) as rebase_error: | ||
| logger.debug('Configuration rebase failed for profile: %s', str(rebase_error)) | ||
| def _apply_cmake_config_to_profile(profile: Profile, cmake_program: str | None, profile_type: str) -> None: | ||
| """Apply cmake program configuration to a profile. | ||
| Args: | ||
| profile: The profile to configure | ||
| cmake_program: Path to cmake program to configure | ||
| profile_type: Type of profile (for logging) | ||
| """ | ||
| if cmake_program is not None: | ||
| logger = logging.getLogger('cppython.conan') | ||
| try: | ||
| profile.conf.update('tools.cmake:cmake_program', cmake_program) | ||
| logger.debug('Set tools.cmake:cmake_program=%s in %s profile', cmake_program, profile_type) | ||
| except (AttributeError, Exception) as cmake_error: | ||
| logger.debug('Failed to set cmake program in %s profile: %s', profile_type, str(cmake_error)) | ||
| def _resolve_profiles( | ||
| host_profile_name: str | None, build_profile_name: str | None, conan_api: ConanAPI, cmake_program: str | None = None | ||
| ) -> tuple[Profile, Profile]: | ||
| """Resolve host and build profiles, with fallback to auto-detection. | ||
| Args: | ||
| host_profile_name: The host profile name to resolve, or None for auto-detection | ||
| build_profile_name: The build profile name to resolve, or None for auto-detection | ||
| conan_api: The Conan API instance | ||
| cmake_program: Optional path to cmake program to configure in profiles | ||
| Returns: | ||
| A tuple of (host_profile, build_profile) | ||
| """ | ||
| logger = logging.getLogger('cppython.conan') | ||
| def _resolve_profile(profile_name: str | None, is_host: bool) -> Profile: | ||
| """Helper to resolve a single profile.""" | ||
| profile_type = 'host' if is_host else 'build' | ||
| if profile_name is not None and profile_name != 'default': | ||
| # Explicitly specified profile name (not the default) - fail if not found | ||
| try: | ||
| logger.debug('Loading %s profile: %s', profile_type, profile_name) | ||
| profile = conan_api.profiles.get_profile([profile_name]) | ||
| logger.debug('Successfully loaded %s profile: %s', profile_type, profile_name) | ||
| _apply_cmake_config_to_profile(profile, cmake_program, profile_type) | ||
| return profile | ||
| except Exception as e: | ||
| logger.error('Failed to load %s profile %s: %s', profile_type, profile_name, str(e)) | ||
| raise ProviderConfigurationError( | ||
| 'conan', | ||
| f'Failed to load {profile_type} profile {profile_name}: {str(e)}', | ||
| f'{profile_type}_profile', | ||
| ) from e | ||
| elif profile_name == 'default': | ||
| # Try to load default profile, but fall back to auto-detection if it fails | ||
| try: | ||
| logger.debug('Loading %s profile: %s', profile_type, profile_name) | ||
| profile = conan_api.profiles.get_profile([profile_name]) | ||
| logger.debug('Successfully loaded %s profile: %s', profile_type, profile_name) | ||
| _apply_cmake_config_to_profile(profile, cmake_program, profile_type) | ||
| return profile | ||
| except Exception as e: | ||
| logger.debug( | ||
| 'Failed to load %s profile %s: %s. Falling back to auto-detection.', | ||
| profile_type, | ||
| profile_name, | ||
| str(e), | ||
| ) | ||
| # Fall back to auto-detection | ||
| try: | ||
| if is_host: | ||
| default_profile_path = conan_api.profiles.get_default_host() | ||
| else: | ||
| default_profile_path = conan_api.profiles.get_default_build() | ||
| profile = conan_api.profiles.get_profile([default_profile_path]) | ||
| logger.debug('Using default %s profile', profile_type) | ||
| _apply_cmake_config_to_profile(profile, cmake_program, profile_type) | ||
| return profile | ||
| except Exception as e: | ||
| logger.warning('Default %s profile not available, using auto-detection: %s', profile_type, str(e)) | ||
| # Create auto-detected profile | ||
| profile = conan_api.profiles.detect() | ||
| cache_settings = conan_api.config.settings_yml | ||
| # Apply profile plugin processing | ||
| _profile_post_process([profile], conan_api, cache_settings, cmake_program) | ||
| logger.debug('Auto-detected %s profile with plugin processing applied', profile_type) | ||
| return profile | ||
| # Resolve both profiles | ||
| host_profile = _resolve_profile(host_profile_name, is_host=True) | ||
| build_profile = _resolve_profile(build_profile_name, is_host=False) | ||
| return host_profile, build_profile | ||
| def _handle_single_specifier(name: str, specifier) -> ConanDependency: | ||
| """Handle a single version specifier.""" | ||
| MINIMUM_VERSION_PARTS = 2 | ||
| operator_handlers = { | ||
| '==': lambda v: ConanDependency(name=name, version=ConanVersion.from_string(v)), | ||
| '>=': lambda v: ConanDependency(name=name, version_range=ConanVersionRange(expression=f'>={v}')), | ||
| '>': lambda v: ConanDependency(name=name, version_range=ConanVersionRange(expression=f'>{v}')), | ||
| '<': lambda v: ConanDependency(name=name, version_range=ConanVersionRange(expression=f'<{v}')), | ||
| '<=': lambda v: ConanDependency(name=name, version_range=ConanVersionRange(expression=f'<={v}')), | ||
| '!=': lambda v: ConanDependency(name=name, version_range=ConanVersionRange(expression=f'!={v}')), | ||
| } | ||
| if specifier.operator in operator_handlers: | ||
| return operator_handlers[specifier.operator](specifier.version) | ||
| elif specifier.operator == '~=': | ||
| # Compatible release - convert to Conan tilde syntax | ||
| version_parts = specifier.version.split('.') | ||
| if len(version_parts) >= MINIMUM_VERSION_PARTS: | ||
| conan_version = '.'.join(version_parts[:MINIMUM_VERSION_PARTS]) | ||
| return ConanDependency(name=name, version_range=ConanVersionRange(expression=f'~{conan_version}')) | ||
| else: | ||
| return ConanDependency(name=name, version_range=ConanVersionRange(expression=f'>={specifier.version}')) | ||
| else: | ||
| raise ConfigException( | ||
| f"Unsupported single specifier '{specifier.operator}'. Supported: '==', '>=', '>', '<', '<=', '!=', '~='", | ||
| [], | ||
| ) | ||
| def resolve_conan_dependency(requirement: Requirement) -> ConanDependency: | ||
| """Resolves a Conan dependency from a requirement""" | ||
| """Resolves a Conan dependency from a Python requirement string. | ||
| Converts Python packaging requirements to Conan version specifications: | ||
| - package>=1.0.0 -> package/[>=1.0.0] | ||
| - package==1.0.0 -> package/1.0.0 | ||
| - package~=1.2.0 -> package/[~1.2] | ||
| - package>=1.0,<2.0 -> package/[>=1.0 <2.0] | ||
| """ | ||
| specifiers = requirement.specifier | ||
| # If the length of specifiers is greater than one, raise a configuration error | ||
| if len(specifiers) > 1: | ||
| raise ConfigException('Multiple specifiers are not supported. Please provide a single specifier.', []) | ||
| # Handle no version specifiers | ||
| if not specifiers: | ||
| return ConanDependency(name=requirement.name) | ||
| # Extract the version from the single specifier | ||
| min_version = None | ||
| # Handle single specifier (most common case) | ||
| if len(specifiers) == 1: | ||
| specifier = next(iter(specifiers)) | ||
| if specifier.operator != '>=': | ||
| raise ConfigException(f"Unsupported specifier '{specifier.operator}'. Only '>=' is supported.", []) | ||
| min_version = specifier.version | ||
| return _handle_single_specifier(requirement.name, next(iter(specifiers))) | ||
| return ConanDependency( | ||
| name=requirement.name, | ||
| version_ge=min_version, | ||
| ) | ||
| # Handle multiple specifiers - convert to Conan range syntax | ||
| range_parts = [] | ||
| # Define order for operators to ensure consistent output | ||
| operator_order = ['>=', '>', '<=', '<', '!='] | ||
| # Group specifiers by operator to ensure consistent ordering | ||
| specifier_groups = {op: [] for op in operator_order} | ||
| for specifier in specifiers: | ||
| if specifier.operator in ('>=', '>', '<', '<=', '!='): | ||
| specifier_groups[specifier.operator].append(specifier.version) | ||
| elif specifier.operator == '==': | ||
| # Multiple == operators would be contradictory | ||
| raise ConfigException( | ||
| "Multiple '==' specifiers are contradictory. Use a single '==' or range operators.", [] | ||
| ) | ||
| elif specifier.operator == '~=': | ||
| # ~= with other operators is complex, for now treat as >= | ||
| specifier_groups['>='].append(specifier.version) | ||
| else: | ||
| raise ConfigException( | ||
| f"Unsupported specifier '{specifier.operator}' in multi-specifier requirement. " | ||
| f"Supported: '>=', '>', '<', '<=', '!='", | ||
| [], | ||
| ) | ||
| # Build range parts in consistent order | ||
| for operator in operator_order: | ||
| for version in specifier_groups[operator]: | ||
| range_parts.append(f'{operator}{version}') | ||
| # Join range parts with spaces (Conan AND syntax) | ||
| version_range = ' '.join(range_parts) | ||
| return ConanDependency(name=requirement.name, version_range=ConanVersionRange(expression=version_range)) | ||
| def resolve_conan_data(data: dict[str, Any], core_data: CorePluginData) -> ConanData: | ||
@@ -47,2 +309,17 @@ """Resolves the conan data | ||
| return ConanData(remotes=parsed_data.remotes) | ||
| # Initialize Conan API for profile resolution | ||
| conan_api = ConanAPI() | ||
| # Try to detect cmake program path from current virtual environment | ||
| cmake_program = _detect_cmake_program() | ||
| # Resolve profiles | ||
| host_profile, build_profile = _resolve_profiles( | ||
| parsed_data.host_profile, parsed_data.build_profile, conan_api, cmake_program | ||
| ) | ||
| return ConanData( | ||
| remotes=parsed_data.remotes, | ||
| host_profile=host_profile, | ||
| build_profile=build_profile, | ||
| ) |
@@ -8,5 +8,7 @@ """Conan plugin schema | ||
| import re | ||
| from typing import Annotated | ||
| from pydantic import Field | ||
| from conan.internal.model.profile import Profile | ||
| from pydantic import Field, field_validator | ||
@@ -16,17 +18,277 @@ from cppython.core.schema import CPPythonModel | ||
| class ConanVersion(CPPythonModel): | ||
| """Represents a single Conan version with optional pre-release suffix.""" | ||
| major: int | ||
| minor: int | ||
| patch: int | None = None | ||
| prerelease: str | None = None | ||
| @field_validator('major', 'minor', mode='before') # type: ignore | ||
| @classmethod | ||
| def validate_version_parts(cls, v: int) -> int: | ||
| """Validate version parts are non-negative integers.""" | ||
| if v < 0: | ||
| raise ValueError('Version parts must be non-negative') | ||
| return v | ||
| @field_validator('patch', mode='before') # type: ignore | ||
| @classmethod | ||
| def validate_patch(cls, v: int | None) -> int | None: | ||
| """Validate patch is non-negative integer or None.""" | ||
| if v is not None and v < 0: | ||
| raise ValueError('Version parts must be non-negative') | ||
| return v | ||
| @field_validator('prerelease', mode='before') # type: ignore | ||
| @classmethod | ||
| def validate_prerelease(cls, v: str | None) -> str | None: | ||
| """Validate prerelease is not an empty string.""" | ||
| if v is not None and not v.strip(): | ||
| raise ValueError('Pre-release cannot be empty string') | ||
| return v | ||
| def __str__(self) -> str: | ||
| """String representation of the version.""" | ||
| version = f'{self.major}.{self.minor}.{self.patch}' if self.patch is not None else f'{self.major}.{self.minor}' | ||
| if self.prerelease: | ||
| version += f'-{self.prerelease}' | ||
| return version | ||
| @classmethod | ||
| def from_string(cls, version_str: str) -> 'ConanVersion': | ||
| """Parse a version string into a ConanVersion.""" | ||
| if '-' in version_str: | ||
| version_part, prerelease = version_str.split('-', 1) | ||
| else: | ||
| version_part = version_str | ||
| prerelease = None | ||
| parts = version_part.split('.') | ||
| # Parse parts based on what's actually provided | ||
| MAJOR_INDEX = 0 | ||
| MINOR_INDEX = 1 | ||
| PATCH_INDEX = 2 | ||
| major = int(parts[MAJOR_INDEX]) | ||
| minor = int(parts[MINOR_INDEX]) if len(parts) > MINOR_INDEX else 0 | ||
| patch = int(parts[PATCH_INDEX]) if len(parts) > PATCH_INDEX else None | ||
| return cls( | ||
| major=major, | ||
| minor=minor, | ||
| patch=patch, | ||
| prerelease=prerelease, | ||
| ) | ||
| class ConanVersionRange(CPPythonModel): | ||
| """Represents a Conan version range expression like '>=1.0 <2.0' or complex expressions.""" | ||
| expression: str | ||
| @field_validator('expression') # type: ignore | ||
| @classmethod | ||
| def validate_expression(cls, v: str) -> str: | ||
| """Validate the version range expression contains valid operators.""" | ||
| if not v.strip(): | ||
| raise ValueError('Version range expression cannot be empty') | ||
| # Basic validation - ensure it contains valid operators | ||
| valid_operators = {'>=', '>', '<=', '<', '!=', '~', '||', '&&'} | ||
| # Split by spaces and logical operators to get individual components | ||
| tokens = re.split(r'(\|\||&&|\s+)', v) | ||
| for token in tokens: | ||
| current_token = token.strip() | ||
| if not current_token or current_token in {'||', '&&'}: | ||
| continue | ||
| # Check if token starts with a valid operator | ||
| has_valid_operator = any(current_token.startswith(op) for op in valid_operators) | ||
| if not has_valid_operator: | ||
| raise ValueError(f'Invalid operator in version range: {current_token}') | ||
| return v | ||
| def __str__(self) -> str: | ||
| """Return the version range expression.""" | ||
| return self.expression | ||
| class ConanUserChannel(CPPythonModel): | ||
| """Represents a Conan user/channel pair.""" | ||
| user: str | ||
| channel: str | None = None | ||
| @field_validator('user') # type: ignore | ||
| @classmethod | ||
| def validate_user(cls, v: str) -> str: | ||
| """Validate user is not empty.""" | ||
| if not v.strip(): | ||
| raise ValueError('User cannot be empty') | ||
| return v.strip() | ||
| @field_validator('channel') # type: ignore | ||
| @classmethod | ||
| def validate_channel(cls, v: str | None) -> str | None: | ||
| """Validate channel is not an empty string.""" | ||
| if v is not None and not v.strip(): | ||
| raise ValueError('Channel cannot be empty string') | ||
| return v.strip() if v else None | ||
| def __str__(self) -> str: | ||
| """String representation for use in requires().""" | ||
| if self.channel: | ||
| return f'{self.user}/{self.channel}' | ||
| return f'{self.user}/_' | ||
| class ConanRevision(CPPythonModel): | ||
| """Represents a Conan revision identifier.""" | ||
| revision: str | ||
| @field_validator('revision') # type: ignore | ||
| @classmethod | ||
| def validate_revision(cls, v: str) -> str: | ||
| """Validate revision is not empty.""" | ||
| if not v.strip(): | ||
| raise ValueError('Revision cannot be empty') | ||
| return v.strip() | ||
| def __str__(self) -> str: | ||
| """Return the revision identifier.""" | ||
| return self.revision | ||
| class ConanDependency(CPPythonModel): | ||
| """Dependency information""" | ||
| """Dependency information following Conan's full version specification. | ||
| Supports: | ||
| - Exact versions: package/1.0.0 | ||
| - Pre-release versions: package/1.0.0-alpha1 | ||
| - Version ranges: package/[>1.0 <2.0] | ||
| - Revisions: package/1.0.0#revision | ||
| - User/channel: package/1.0.0@user/channel | ||
| - Complex expressions: package/[>=1.0 <2.0 || >=3.0] | ||
| - Pre-release handling: resolve_prereleases setting | ||
| """ | ||
| name: str | ||
| version_ge: str | None = None | ||
| include_prerelease: bool | None = None | ||
| version: ConanVersion | None = None | ||
| version_range: ConanVersionRange | None = None | ||
| user_channel: ConanUserChannel | None = None | ||
| revision: ConanRevision | None = None | ||
| # Pre-release handling | ||
| resolve_prereleases: bool | None = None | ||
| def requires(self) -> str: | ||
| """Generate the requires attribute for Conan""" | ||
| # TODO: Implement lower and upper bounds per conan documentation | ||
| if self.version_ge: | ||
| return f'{self.name}/[>={self.version_ge}]' | ||
| return self.name | ||
| """Generate the requires attribute for Conan following the full specification. | ||
| Examples: | ||
| - package -> package | ||
| - package/1.0.0 -> package/1.0.0 | ||
| - package/1.0.0-alpha1 -> package/1.0.0-alpha1 | ||
| - package/[>=1.0 <2.0] -> package/[>=1.0 <2.0] | ||
| - package/1.0.0@user/channel -> package/1.0.0@user/channel | ||
| - package/1.0.0#revision -> package/1.0.0#revision | ||
| - package/1.0.0@user/channel#revision -> package/1.0.0@user/channel#revision | ||
| """ | ||
| result = self.name | ||
| # Add version or version range | ||
| if self.version_range: | ||
| # Complex version range | ||
| result += f'/[{self.version_range}]' | ||
| elif self.version: | ||
| # Simple version (can include pre-release suffixes) | ||
| result += f'/{self.version}' | ||
| # Add user/channel | ||
| if self.user_channel: | ||
| result += f'@{self.user_channel}' | ||
| # Add revision | ||
| if self.revision: | ||
| result += f'#{self.revision}' | ||
| return result | ||
| @classmethod | ||
| def from_conan_reference(cls, reference: str) -> 'ConanDependency': | ||
| """Parse a Conan reference string into a ConanDependency. | ||
| Examples: | ||
| - package -> ConanDependency(name='package') | ||
| - package/1.0.0 -> ConanDependency(name='package', version=ConanVersion.from_string('1.0.0')) | ||
| - package/[>=1.0 <2.0] -> ConanDependency(name='package', version_range=ConanVersionRange('>=1.0 <2.0')) | ||
| - package/1.0.0@user/channel -> ConanDependency(name='package', version=..., user_channel=ConanUserChannel(...)) | ||
| - package/1.0.0#revision -> ConanDependency(name='package', version=..., revision=ConanRevision('revision')) | ||
| """ | ||
| # Split revision first (everything after #) | ||
| revision_obj = None | ||
| if '#' in reference: | ||
| reference, revision_str = reference.rsplit('#', 1) | ||
| revision_obj = ConanRevision(revision=revision_str) | ||
| # Split user/channel (everything after @) | ||
| user_channel_obj = None | ||
| if '@' in reference: | ||
| reference, user_channel_str = reference.rsplit('@', 1) | ||
| if '/' in user_channel_str: | ||
| user, channel = user_channel_str.split('/', 1) | ||
| if channel == '_': | ||
| channel = None | ||
| else: | ||
| user = user_channel_str | ||
| channel = None | ||
| user_channel_obj = ConanUserChannel(user=user, channel=channel) | ||
| # Split name and version | ||
| name = reference | ||
| version_obj = None | ||
| version_range_obj = None | ||
| if '/' in reference: | ||
| name, version_part = reference.split('/', 1) | ||
| # Check if it's a version range (enclosed in brackets) | ||
| if version_part.startswith('[') and version_part.endswith(']'): | ||
| version_range_obj = ConanVersionRange(expression=version_part[1:-1]) # Remove brackets | ||
| else: | ||
| version_obj = ConanVersion.from_string(version_part) | ||
| return cls( | ||
| name=name, | ||
| version=version_obj, | ||
| version_range=version_range_obj, | ||
| user_channel=user_channel_obj, | ||
| revision=revision_obj, | ||
| ) | ||
| def is_prerelease(self) -> bool: | ||
| """Check if this dependency specifies a pre-release version. | ||
| Pre-release versions contain hyphens followed by pre-release identifiers | ||
| like: 1.0.0-alpha1, 1.0.0-beta2, 1.0.0-rc1, 1.0.0-dev, etc. | ||
| """ | ||
| # Check version object for pre-release | ||
| if self.version and self.version.prerelease: | ||
| prerelease_keywords = {'alpha', 'beta', 'rc', 'dev', 'pre', 'snapshot'} | ||
| return any(keyword in self.version.prerelease.lower() for keyword in prerelease_keywords) | ||
| # Also check version_range for pre-release patterns | ||
| if self.version_range and '-' in self.version_range.expression: | ||
| prerelease_keywords = {'alpha', 'beta', 'rc', 'dev', 'pre', 'snapshot'} | ||
| return any(keyword in self.version_range.expression.lower() for keyword in prerelease_keywords) | ||
| return False | ||
| class ConanData(CPPythonModel): | ||
@@ -36,2 +298,4 @@ """Resolved conan data""" | ||
| remotes: list[str] | ||
| host_profile: Profile | ||
| build_profile: Profile | ||
@@ -51,1 +315,15 @@ @property | ||
| ] = ['conancenter'] | ||
| host_profile: Annotated[ | ||
| str | None, | ||
| Field( | ||
| description='Conan host profile defining the target platform where the built software will run. ' | ||
| 'Used for cross-compilation scenarios.' | ||
| ), | ||
| ] = 'default' | ||
| build_profile: Annotated[ | ||
| str | None, | ||
| Field( | ||
| description='Conan build profile defining the platform where the compilation process executes. ' | ||
| 'Typically matches the development machine.' | ||
| ), | ||
| ] = 'default' |
@@ -23,2 +23,3 @@ """Global fixtures for the test suite""" | ||
| CPPythonLocalConfiguration, | ||
| GeneratorData, | ||
| PEP621Configuration, | ||
@@ -28,2 +29,3 @@ PEP621Data, | ||
| ProjectData, | ||
| ProviderData, | ||
| PyProject, | ||
@@ -97,3 +99,5 @@ ToolData, | ||
| cppython_local_configuration = CPPythonLocalConfiguration( | ||
| install_path=install_path, provider_name=TypeName('mock'), generator_name=TypeName('mock') | ||
| install_path=install_path, | ||
| providers={TypeName('mock'): ProviderData({})}, | ||
| generators={TypeName('mock'): GeneratorData({})}, | ||
| ) | ||
@@ -100,0 +104,0 @@ |
+1
-1
| Metadata-Version: 2.1 | ||
| Name: cppython | ||
| Version: 0.9.3 | ||
| Version: 0.9.4.dev1 | ||
| Summary: A Python management solution for C++ dependencies | ||
@@ -5,0 +5,0 @@ Author-Email: Synodic Software <contact@synodic.software> |
+1
-1
@@ -17,3 +17,3 @@ [project] | ||
| ] | ||
| version = "0.9.3" | ||
| version = "0.9.4.dev1" | ||
@@ -20,0 +20,0 @@ [project.license] |
@@ -14,2 +14,19 @@ """Shared fixtures for Conan plugin tests""" | ||
| @pytest.fixture(autouse=True) | ||
| def clean_conan_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): | ||
| """Sets CONAN_HOME to a temporary directory for each test. | ||
| This ensures all tests run with a clean Conan cache. | ||
| Args: | ||
| tmp_path: Pytest temporary directory fixture | ||
| monkeypatch: Pytest monkeypatch fixture for environment variable manipulation | ||
| """ | ||
| conan_home = tmp_path / 'conan_home' | ||
| conan_home.mkdir() | ||
| # Set CONAN_HOME to the temporary directory | ||
| monkeypatch.setenv('CONAN_HOME', str(conan_home)) | ||
| @pytest.fixture(name='conan_mock_api') | ||
@@ -158,3 +175,3 @@ def fixture_conan_mock_api(mocker: MockerFixture) -> Mock: | ||
| def mock_resolve(requirement: Requirement) -> ConanDependency: | ||
| return ConanDependency(name=requirement.name, version_ge=None) | ||
| return ConanDependency(name=requirement.name) | ||
@@ -161,0 +178,0 @@ mock_resolve_conan_dependency = mocker.patch( |
@@ -17,3 +17,3 @@ """Integration tests for the conan and CMake project variation. | ||
| pytest_plugins = ['tests.fixtures.example'] | ||
| pytest_plugins = ['tests.fixtures.example', 'tests.fixtures.conan'] | ||
@@ -53,1 +53,11 @@ | ||
| assert (path / 'CMakeCache.txt').exists(), f'{path / "CMakeCache.txt"} not found' | ||
| # --- Setup for Publish with modified config --- | ||
| # Modify the in-memory representation of the pyproject data | ||
| pyproject_data['tool']['cppython']['providers']['conan']['remotes'] = [] | ||
| # Create a new project instance with the modified configuration for the 'publish' step | ||
| publish_project = Project(project_configuration, interface, pyproject_data) | ||
| # Publish the project to the local cache | ||
| publish_project.publish() |
| """Integration tests for the provider""" | ||
| from pathlib import Path | ||
| from typing import Any | ||
@@ -11,20 +10,5 @@ | ||
| pytest_plugins = ['tests.fixtures.conan'] | ||
| @pytest.fixture(autouse=True) | ||
| def clean_conan_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): | ||
| """Sets CONAN_HOME to a temporary directory for each test. | ||
| This ensures all tests run with a clean Conan cache. | ||
| Args: | ||
| tmp_path: Pytest temporary directory fixture | ||
| monkeypatch: Pytest monkeypatch fixture for environment variable manipulation | ||
| """ | ||
| conan_home = tmp_path / 'conan_home' | ||
| conan_home.mkdir() | ||
| # Set CONAN_HOME to the temporary directory | ||
| monkeypatch.setenv('CONAN_HOME', str(conan_home)) | ||
| class TestConanProvider(ProviderIntegrationTestContract[ConanProvider]): | ||
@@ -31,0 +15,0 @@ """The tests for the conan provider""" |
@@ -10,3 +10,3 @@ """Tests for the CMake schema""" | ||
| @staticmethod | ||
| def test_cache_variable_bool() -> None: | ||
| def test_bool() -> None: | ||
| """Tests the CacheVariable class with a boolean value""" | ||
@@ -18,3 +18,3 @@ var = CacheVariable(type=VariableType.BOOL, value=True) | ||
| @staticmethod | ||
| def test_cache_variable_string() -> None: | ||
| def test_string() -> None: | ||
| """Tests the CacheVariable class with a string value""" | ||
@@ -26,3 +26,3 @@ var = CacheVariable(type=VariableType.STRING, value='SomeValue') | ||
| @staticmethod | ||
| def test_cache_variable_null_type() -> None: | ||
| def test_null_type() -> None: | ||
| """Tests the CacheVariable class with a null type""" | ||
@@ -34,3 +34,3 @@ var = CacheVariable(type=None, value='Unset') | ||
| @staticmethod | ||
| def test_cache_variable_bool_value_as_string() -> None: | ||
| def test_bool_value_as_string() -> None: | ||
| """Tests the CacheVariable class with a boolean value as a string""" | ||
@@ -42,3 +42,3 @@ # CMake allows bool as "TRUE"/"FALSE" as well | ||
| @staticmethod | ||
| def test_cache_variable_type_optional() -> None: | ||
| def test_type_optional() -> None: | ||
| """Tests the CacheVariable class with an optional type""" | ||
@@ -45,0 +45,0 @@ # type is optional |
@@ -52,3 +52,3 @@ """Unit tests for the conan plugin install functionality""" | ||
| def test_install_with_dependencies( | ||
| def test_with_dependencies( | ||
| self, | ||
@@ -91,3 +91,3 @@ plugin: ConanProvider, | ||
| def test_install_conan_command_failure( | ||
| def test_conan_command_failure( | ||
| self, | ||
@@ -122,3 +122,3 @@ plugin: ConanProvider, | ||
| def mock_resolve(requirement: Requirement) -> ConanDependency: | ||
| return ConanDependency(name=requirement.name, version_ge=None) | ||
| return ConanDependency(name=requirement.name) | ||
@@ -142,3 +142,3 @@ mocker.patch('cppython.plugins.conan.plugin.resolve_conan_dependency', side_effect=mock_resolve) | ||
| def test_install_with_profile_exception( | ||
| def test_with_default_profiles( | ||
| self, | ||
@@ -151,3 +151,3 @@ plugin: ConanProvider, | ||
| ) -> None: | ||
| """Test install method when profile operations throw exceptions but detect() works | ||
| """Test install method uses pre-resolved profiles from plugin construction | ||
@@ -161,21 +161,19 @@ Args: | ||
| """ | ||
| # Configure the API mock to throw exception on profile calls but detect() works | ||
| conan_mock_api.profiles.get_default_host.side_effect = Exception('Profile not found') | ||
| # Setup dependencies | ||
| plugin.core_data.cppython_data.dependencies = conan_mock_dependencies | ||
| # Execute - should succeed using fallback detect profiles | ||
| # Execute - should use the profiles resolved during plugin construction | ||
| plugin.install() | ||
| # Verify that the fallback was used | ||
| # Verify that the API was used for installation | ||
| conan_setup_mocks['conan_api_constructor'].assert_called_once() | ||
| conan_mock_api.profiles.get_default_host.assert_called_once() | ||
| # Verify detect was called for fallback (should be called twice for fallback) | ||
| assert conan_mock_api.profiles.detect.call_count >= EXPECTED_PROFILE_CALLS | ||
| # Verify the rest of the process continued | ||
| # Verify the rest of the process continued with resolved profiles | ||
| conan_mock_api.graph.load_graph_consumer.assert_called_once() | ||
| conan_mock_api.install.install_binaries.assert_called_once() | ||
| conan_mock_api.install.install_consumer.assert_called_once() | ||
| # Verify that the resolved profiles were used in the graph loading | ||
| call_args = conan_mock_api.graph.load_graph_consumer.call_args | ||
| assert call_args.kwargs['profile_host'] == plugin.data.host_profile | ||
| assert call_args.kwargs['profile_build'] == plugin.data.build_profile |
@@ -45,3 +45,3 @@ """Unit tests for the conan plugin publish functionality""" | ||
| def test_publish_local_only( | ||
| def test_local_only( | ||
| self, plugin: ConanProvider, conan_mock_api_publish: Mock, conan_temp_conanfile: None, mocker: MockerFixture | ||
@@ -91,3 +91,3 @@ ) -> None: | ||
| def test_publish_with_upload( | ||
| def test_with_upload( | ||
| self, plugin: ConanProvider, conan_mock_api_publish: Mock, conan_temp_conanfile: None, mocker: MockerFixture | ||
@@ -126,3 +126,3 @@ ) -> None: | ||
| def test_publish_no_remotes_configured( | ||
| def test_no_remotes_configured( | ||
| self, plugin: ConanProvider, conan_mock_api_publish: Mock, conan_temp_conanfile: None, mocker: MockerFixture | ||
@@ -155,3 +155,3 @@ ) -> None: | ||
| def test_publish_no_packages_found( | ||
| def test_no_packages_found( | ||
| self, plugin: ConanProvider, conan_mock_api_publish: Mock, conan_temp_conanfile: None, mocker: MockerFixture | ||
@@ -186,6 +186,6 @@ ) -> None: | ||
| def test_publish_uses_default_profiles( | ||
| def test_with_default_profiles( | ||
| self, plugin: ConanProvider, conan_mock_api_publish: Mock, conan_temp_conanfile: None, mocker: MockerFixture | ||
| ) -> None: | ||
| """Test that publish uses default profiles from API | ||
| """Test that publish uses pre-resolved profiles from plugin construction | ||
@@ -211,8 +211,9 @@ Args: | ||
| # Verify profiles were obtained from API | ||
| conan_mock_api_publish.profiles.get_default_host.assert_called_once() | ||
| conan_mock_api_publish.profiles.get_default_build.assert_called_once() | ||
| conan_mock_api_publish.profiles.get_profile.assert_called() | ||
| # Verify that the resolved profiles were used in the graph loading | ||
| conan_mock_api_publish.graph.load_graph_consumer.assert_called_once() | ||
| call_args = conan_mock_api_publish.graph.load_graph_consumer.call_args | ||
| assert call_args.kwargs['profile_host'] == plugin.data.host_profile | ||
| assert call_args.kwargs['profile_build'] == plugin.data.build_profile | ||
| def test_publish_upload_parameters( | ||
| def test_upload_parameters( | ||
| self, plugin: ConanProvider, conan_mock_api_publish: Mock, conan_temp_conanfile: None, mocker: MockerFixture | ||
@@ -262,3 +263,3 @@ ) -> None: | ||
| def test_publish_list_pattern_creation( | ||
| def test_list_pattern_creation( | ||
| self, plugin: ConanProvider, conan_mock_api_publish: Mock, conan_temp_conanfile: None, mocker: MockerFixture | ||
@@ -265,0 +266,0 @@ ) -> None: |
@@ -12,3 +12,3 @@ """Unit tests for the Vcpkg resolution plugin.""" | ||
| @staticmethod | ||
| def test_resolve_vcpkg_dependency() -> None: | ||
| def test_dependency_resolution() -> None: | ||
| """Test resolving a VcpkgDependency from a packaging requirement.""" | ||
@@ -15,0 +15,0 @@ requirement = Requirement('example-package>=1.2.3') |
@@ -11,4 +11,6 @@ """Tests the Data type""" | ||
| CPPythonLocalConfiguration, | ||
| GeneratorData, | ||
| PEP621Configuration, | ||
| ProjectConfiguration, | ||
| ProviderData, | ||
| ) | ||
@@ -19,2 +21,3 @@ from cppython.data import Data | ||
| from cppython.test.mock.scm import MockSCM | ||
| from cppython.utility.utility import TypeName | ||
@@ -63,1 +66,17 @@ | ||
| data.sync() | ||
| @staticmethod | ||
| def test_named_plugin_configuration() -> None: | ||
| """Test that named plugin configuration is properly validated""" | ||
| # Test valid named configuration | ||
| config = CPPythonLocalConfiguration( | ||
| providers={TypeName('conan'): ProviderData({'some_setting': 'value'})}, | ||
| generators={TypeName('cmake'): GeneratorData({'another_setting': True})}, | ||
| ) | ||
| assert config.providers == {TypeName('conan'): {'some_setting': 'value'}} | ||
| assert config.generators == {TypeName('cmake'): {'another_setting': True}} | ||
| # Test empty configuration is valid | ||
| config_empty = CPPythonLocalConfiguration() | ||
| assert config_empty.providers == {} | ||
| assert config_empty.generators == {} |
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
306204
17.82%120
0.84%6583
15.41%