diff --git a/cppython/builder.py b/cppython/builder.py index beded73c..25fd4517 100644 --- a/cppython/builder.py +++ b/cppython/builder.py @@ -34,6 +34,7 @@ from cppython.data import Data, Plugins from cppython.defaults import DefaultSCM from cppython.utility.exception import PluginError +from cppython.utility.utility import TypeName class Resolver: @@ -59,14 +60,14 @@ def generate_plugins( raw_generator_plugins = self.find_generators() generator_plugins = self.filter_plugins( raw_generator_plugins, - cppython_local_configuration.generator_name, + self._get_effective_generator_name(cppython_local_configuration), 'Generator', ) raw_provider_plugins = self.find_providers() provider_plugins = self.filter_plugins( raw_provider_plugins, - cppython_local_configuration.provider_name, + self._get_effective_provider_name(cppython_local_configuration), 'Provider', ) @@ -79,6 +80,74 @@ def generate_plugins( return PluginBuildData(generator_type=generator_type, provider_type=provider_type, scm_type=scm_type) + 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 def generate_cppython_plugin_data(plugin_build_data: PluginBuildData) -> PluginCPPythonData: """Generates the CPPython plugin data from the resolved plugins @@ -447,11 +516,18 @@ def build( pep621_data = self._resolver.generate_pep621_data(pep621_configuration, self._project_configuration, scm) # 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 ) plugins = Plugins(generator=generator, provider=provider, scm=scm) diff --git a/cppython/core/resolution.py b/cppython/core/resolution.py index 0c7c09d3..8cb8f6a5 100644 --- a/cppython/core/resolution.py +++ b/cppython/core/resolution.py @@ -139,16 +139,22 @@ def resolve_cppython( if not modified_build_path.is_absolute(): modified_build_path = root_directory / modified_build_path - 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 dependencies: list[Requirement] = [] @@ -173,6 +179,8 @@ def resolve_cppython( generator_name=modified_generator_name, scm_name=modified_scm_name, dependencies=dependencies, + provider_data=provider_data, + generator_data=generator_data, ) return cppython_data @@ -200,6 +208,8 @@ def resolve_cppython_plugin(cppython_data: CPPythonData, plugin_type: type[Plugi generator_name=cppython_data.generator_name, scm_name=cppython_data.scm_name, dependencies=cppython_data.dependencies, + provider_data=cppython_data.provider_data, + generator_data=cppython_data.generator_data, ) return cast(CPPythonPluginData, plugin_data) diff --git a/cppython/core/schema.py b/cppython/core/schema.py index 0d29be3c..7b43589c 100644 --- a/cppython/core/schema.py +++ b/cppython/core/schema.py @@ -118,6 +118,9 @@ class CPPythonData(CPPythonModel, extra='forbid'): scm_name: TypeName dependencies: list[Requirement] + 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 @classmethod def validate_absolute_path(cls, value: Path) -> Path: @@ -302,29 +305,21 @@ class CPPythonLocalConfiguration(CPPythonModel, extra='forbid'): ), ] = Path('build') - 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 + ] = {} dependencies: Annotated[ list[str] | None, diff --git a/cppython/plugins/conan/builder.py b/cppython/plugins/conan/builder.py index 957d1f17..2be317d6 100644 --- a/cppython/plugins/conan/builder.py +++ b/cppython/plugins/conan/builder.py @@ -125,7 +125,7 @@ def _create_conanfile(conan_file: Path, dependencies: list[ConanDependency]) -> """Creates a conanfile.py file with the necessary content.""" template_string = """ from conan import ConanFile - from conan.tools.cmake import CMake, CMakeToolchain, cmake_layout + from conan.tools.cmake import CMake, cmake_layout class MyProject(ConanFile): name = "myproject" diff --git a/cppython/plugins/conan/plugin.py b/cppython/plugins/conan/plugin.py index a30d4b2c..aa80fcb3 100644 --- a/cppython/plugins/conan/plugin.py +++ b/cppython/plugins/conan/plugin.py @@ -12,7 +12,6 @@ import requests from conan.api.conan_api import ConanAPI from conan.api.model import ListPattern -from conan.internal.model.profile import Profile from cppython.core.plugin_schema.generator import SyncConsumer from cppython.core.plugin_schema.provider import Provider, ProviderPluginGroupData, SupportedProviderFeatures @@ -109,8 +108,8 @@ def _install_dependencies(self, *, update: bool = False) -> None: all_remotes = conan_api.remotes.list() logger.debug('Available remotes: %s', [remote.name for remote in all_remotes]) - # 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 path = str(conanfile_path) remotes = all_remotes @@ -249,8 +248,8 @@ def publish(self) -> None: remotes=all_remotes, # Use all remotes for dependency resolution during export ) - # 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 # Step 3: Build dependency graph for the package - prepare parameters path = str(conanfile_path) @@ -305,68 +304,3 @@ def publish(self) -> None: ) else: 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] diff --git a/cppython/plugins/conan/resolution.py b/cppython/plugins/conan/resolution.py index a287b803..2f1f663c 100644 --- a/cppython/plugins/conan/resolution.py +++ b/cppython/plugins/conan/resolution.py @@ -1,34 +1,296 @@ """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 from cppython.core.exception import ConfigException 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 ConanDependency( - name=requirement.name, - version_ge=min_version, - ) + return _handle_single_specifier(requirement.name, next(iter(specifiers))) + + # 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: @@ -43,4 +305,19 @@ def resolve_conan_data(data: dict[str, Any], core_data: CorePluginData) -> Conan """ parsed_data = ConanConfiguration(**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, + ) diff --git a/cppython/plugins/conan/schema.py b/cppython/plugins/conan/schema.py index 9f30206d..08129c05 100644 --- a/cppython/plugins/conan/schema.py +++ b/cppython/plugins/conan/schema.py @@ -5,32 +5,296 @@ provide structured configuration and data needed by the Conan Provider. """ +import re from typing import Annotated -from pydantic import Field +from conan.internal.model.profile import Profile +from pydantic import Field, field_validator 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): """Resolved conan data""" remotes: list[str] + host_profile: Profile + build_profile: Profile @property def local_only(self) -> bool: @@ -45,3 +309,17 @@ class ConanConfiguration(CPPythonModel): list[str], Field(description='List of remotes to upload to. Empty list means the local conan cache will be used.'), ] = ['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' diff --git a/cppython/test/pytest/fixtures.py b/cppython/test/pytest/fixtures.py index 5fa0175b..23fb3ca0 100644 --- a/cppython/test/pytest/fixtures.py +++ b/cppython/test/pytest/fixtures.py @@ -20,10 +20,12 @@ CPPythonData, CPPythonGlobalConfiguration, CPPythonLocalConfiguration, + GeneratorData, PEP621Configuration, PEP621Data, ProjectConfiguration, ProjectData, + ProviderData, PyProject, ToolData, ) @@ -92,7 +94,9 @@ def fixture_cppython_local_configuration(install_path: Path) -> CPPythonLocalCon Variation of CPPython data """ 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({})}, ) return cppython_local_configuration diff --git a/examples/conan_cmake/simple/README.md b/examples/conan_cmake/simple/README.md new file mode 100644 index 00000000..e69de29b diff --git a/examples/conan_cmake/simple/pyproject.toml b/examples/conan_cmake/simple/pyproject.toml index 6803860d..b8414d36 100644 --- a/examples/conan_cmake/simple/pyproject.toml +++ b/examples/conan_cmake/simple/pyproject.toml @@ -13,16 +13,13 @@ dependencies = ["cppython[conan, cmake, git]>=0.9.0"] [tool.cppython] -generator-name = "cmake" -provider-name = "conan" - install-path = "install" dependencies = ["fmt>=11.2.0"] -[tool.cppython.generator] +[tool.cppython.generators.cmake] -[tool.cppython.provider] +[tool.cppython.providers.conan] [tool.pdm] distribution = false diff --git a/examples/vcpkg_cmake/simple/pyproject.toml b/examples/vcpkg_cmake/simple/pyproject.toml index 47bae63d..be94ba0a 100644 --- a/examples/vcpkg_cmake/simple/pyproject.toml +++ b/examples/vcpkg_cmake/simple/pyproject.toml @@ -13,16 +13,13 @@ dependencies = ["cppython[vcpkg, cmake, git]>=0.9.0"] [tool.cppython] -generator-name = "cmake" -provider-name = "vcpkg" - install-path = "install" dependencies = ["fmt>=11.0.2"] -[tool.cppython.generator] +[tool.cppython.generators.cmake] -[tool.cppython.provider] +[tool.cppython.providers.vcpkg] [tool.pdm] distribution = false diff --git a/tests/fixtures/conan.py b/tests/fixtures/conan.py index 9571c24f..8fd06299 100644 --- a/tests/fixtures/conan.py +++ b/tests/fixtures/conan.py @@ -11,6 +11,23 @@ from cppython.plugins.conan.schema import ConanDependency +@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') def fixture_conan_mock_api(mocker: MockerFixture) -> Mock: """Creates a mock ConanAPI instance for install/update operations @@ -154,7 +171,7 @@ def fixture_conan_setup_mocks( # Mock resolve_conan_dependency def mock_resolve(requirement: Requirement) -> ConanDependency: - return ConanDependency(name=requirement.name, version_ge=None) + return ConanDependency(name=requirement.name) mock_resolve_conan_dependency = mocker.patch( 'cppython.plugins.conan.plugin.resolve_conan_dependency', side_effect=mock_resolve diff --git a/tests/integration/examples/test_conan_cmake.py b/tests/integration/examples/test_conan_cmake.py index a9a8f6cc..a796a904 100644 --- a/tests/integration/examples/test_conan_cmake.py +++ b/tests/integration/examples/test_conan_cmake.py @@ -14,7 +14,7 @@ from cppython.core.schema import ProjectConfiguration from cppython.project import Project -pytest_plugins = ['tests.fixtures.example'] +pytest_plugins = ['tests.fixtures.example', 'tests.fixtures.conan'] class TestConanCMake: @@ -49,3 +49,13 @@ def test_simple(example_runner: CliRunner) -> None: # Verify that the build directory contains the expected files 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() diff --git a/tests/integration/plugins/conan/test_provider.py b/tests/integration/plugins/conan/test_provider.py index c0ba43e0..761fff49 100644 --- a/tests/integration/plugins/conan/test_provider.py +++ b/tests/integration/plugins/conan/test_provider.py @@ -1,6 +1,5 @@ """Integration tests for the provider""" -from pathlib import Path from typing import Any import pytest @@ -8,22 +7,7 @@ from cppython.plugins.conan.plugin import ConanProvider from cppython.test.pytest.contracts import ProviderIntegrationTestContract - -@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_plugins = ['tests.fixtures.conan'] class TestConanProvider(ProviderIntegrationTestContract[ConanProvider]): diff --git a/tests/unit/plugins/cmake/test_schema.py b/tests/unit/plugins/cmake/test_schema.py index 765c9742..493d12ac 100644 --- a/tests/unit/plugins/cmake/test_schema.py +++ b/tests/unit/plugins/cmake/test_schema.py @@ -7,35 +7,35 @@ class TestCacheVariable: """Tests for the CacheVariable class""" @staticmethod - def test_cache_variable_bool() -> None: + def test_bool() -> None: """Tests the CacheVariable class with a boolean value""" var = CacheVariable(type=VariableType.BOOL, value=True) assert var.type == VariableType.BOOL assert var.value is True @staticmethod - def test_cache_variable_string() -> None: + def test_string() -> None: """Tests the CacheVariable class with a string value""" var = CacheVariable(type=VariableType.STRING, value='SomeValue') assert var.type == VariableType.STRING assert var.value == 'SomeValue' @staticmethod - def test_cache_variable_null_type() -> None: + def test_null_type() -> None: """Tests the CacheVariable class with a null type""" var = CacheVariable(type=None, value='Unset') assert var.type is None assert var.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""" # CMake allows bool as "TRUE"/"FALSE" as well var = CacheVariable(type=VariableType.BOOL, value='TRUE') assert var.value == 'TRUE' @staticmethod - def test_cache_variable_type_optional() -> None: + def test_type_optional() -> None: """Tests the CacheVariable class with an optional type""" # type is optional var = CacheVariable(value='SomeValue') diff --git a/tests/unit/plugins/conan/test_install.py b/tests/unit/plugins/conan/test_install.py index 04a157cd..c165ebab 100644 --- a/tests/unit/plugins/conan/test_install.py +++ b/tests/unit/plugins/conan/test_install.py @@ -49,7 +49,7 @@ def fixture_plugin_type() -> type[ConanProvider]: """ return ConanProvider - def test_install_with_dependencies( + def test_with_dependencies( self, plugin: ConanProvider, conan_temp_conanfile: Path, @@ -87,7 +87,7 @@ def test_install_with_dependencies( # Verify ConanAPI constructor was called conan_setup_mocks['conan_api_constructor'].assert_called_once() - def test_install_conan_command_failure( + def test_conan_command_failure( self, plugin: ConanProvider, conan_temp_conanfile: Path, @@ -117,7 +117,7 @@ def test_install_conan_command_failure( # Mock resolve_conan_dependency def mock_resolve(requirement: Requirement) -> ConanDependency: - return ConanDependency(name=requirement.name, version_ge=None) + return ConanDependency(name=requirement.name) mocker.patch('cppython.plugins.conan.plugin.resolve_conan_dependency', side_effect=mock_resolve) @@ -136,7 +136,7 @@ def mock_resolve(requirement: Requirement) -> ConanDependency: # Verify Conan API was attempted mock_conan_api_constructor.assert_called_once() - def test_install_with_profile_exception( + def test_with_default_profiles( self, plugin: ConanProvider, conan_temp_conanfile: Path, @@ -144,7 +144,7 @@ def test_install_with_profile_exception( conan_setup_mocks: dict[str, Mock], conan_mock_api: Mock, ) -> None: - """Test install method when profile operations throw exceptions but detect() works + """Test install method uses pre-resolved profiles from plugin construction Args: plugin: The plugin instance @@ -153,23 +153,21 @@ def test_install_with_profile_exception( conan_setup_mocks: Dictionary containing all mocks conan_mock_api: Mock ConanAPI instance """ - # 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 diff --git a/tests/unit/plugins/conan/test_publish.py b/tests/unit/plugins/conan/test_publish.py index b21f8f77..6e6e60d5 100644 --- a/tests/unit/plugins/conan/test_publish.py +++ b/tests/unit/plugins/conan/test_publish.py @@ -42,7 +42,7 @@ def fixture_plugin_type() -> type[ConanProvider]: """ return ConanProvider - def test_publish_local_only( + def test_local_only( self, plugin: ConanProvider, conan_mock_api_publish: Mock, conan_temp_conanfile: None, mocker: MockerFixture ) -> None: """Test that publish with remotes=[] only exports and builds locally @@ -87,7 +87,7 @@ def test_publish_local_only( # Verify upload was NOT called for local mode conan_mock_api_publish.upload.upload_full.assert_not_called() - def test_publish_with_upload( + def test_with_upload( self, plugin: ConanProvider, conan_mock_api_publish: Mock, conan_temp_conanfile: None, mocker: MockerFixture ) -> None: """Test that publish with remotes=['conancenter'] exports, builds, and uploads @@ -121,7 +121,7 @@ def test_publish_with_upload( conan_mock_api_publish.list.select.assert_called_once() conan_mock_api_publish.upload.upload_full.assert_called_once() - 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 ) -> None: """Test that publish raises error when no remotes are configured for upload @@ -149,7 +149,7 @@ def test_publish_no_remotes_configured( with pytest.raises(ProviderConfigurationError, match='No configured remotes found'): plugin.publish() - 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 ) -> None: """Test that publish raises error when no packages are found to upload @@ -179,10 +179,10 @@ def test_publish_no_packages_found( with pytest.raises(ProviderInstallationError, match='No packages found to upload'): plugin.publish() - 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 Args: plugin: The plugin instance @@ -203,12 +203,13 @@ def test_publish_uses_default_profiles( # Execute publish plugin.publish() - # 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 ) -> None: """Test that publish upload is called with correct parameters @@ -253,7 +254,7 @@ def test_publish_upload_parameters( dry_run=False, ) - 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 ) -> None: """Test that publish creates correct ListPattern for package selection diff --git a/tests/unit/plugins/conan/test_resolution.py b/tests/unit/plugins/conan/test_resolution.py new file mode 100644 index 00000000..85511c70 --- /dev/null +++ b/tests/unit/plugins/conan/test_resolution.py @@ -0,0 +1,496 @@ +"""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 diff --git a/tests/unit/plugins/vcpkg/test_resolution.py b/tests/unit/plugins/vcpkg/test_resolution.py index e8df2f17..927eb7f6 100644 --- a/tests/unit/plugins/vcpkg/test_resolution.py +++ b/tests/unit/plugins/vcpkg/test_resolution.py @@ -9,7 +9,7 @@ class TestVcpkgResolution: """Test the resolution of Vcpkg dependencies""" @staticmethod - def test_resolve_vcpkg_dependency() -> None: + def test_dependency_resolution() -> None: """Test resolving a VcpkgDependency from a packaging requirement.""" requirement = Requirement('example-package>=1.2.3') diff --git a/tests/unit/test_data.py b/tests/unit/test_data.py index d62efd22..8b5e8d2f 100644 --- a/tests/unit/test_data.py +++ b/tests/unit/test_data.py @@ -8,13 +8,16 @@ from cppython.core.resolution import PluginBuildData from cppython.core.schema import ( CPPythonLocalConfiguration, + GeneratorData, PEP621Configuration, ProjectConfiguration, + ProviderData, ) from cppython.data import Data from cppython.test.mock.generator import MockGenerator from cppython.test.mock.provider import MockProvider from cppython.test.mock.scm import MockSCM +from cppython.utility.utility import TypeName class TestData: @@ -58,3 +61,19 @@ def test_sync(data: Data) -> None: data: Fixture for the mocked data class """ 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 == {}