You're Invited:Meet the Socket Team at RSAC and BSidesSF 2026, March 23–26.RSVP
Socket
Book a DemoSign in
Socket

cppython

Package Overview
Dependencies
Maintainers
1
Versions
121
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

cppython - pypi Package Compare versions

Comparing version
0.9.3
to
0.9.4.dev1
+496
tests/unit/plugins/conan/test_resolution.py
"""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 @@

+17
-7

@@ -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 @@

@@ -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 @@

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>

@@ -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 == {}