cppython
Advanced tools
| """Shared fixtures for VCPkg plugin tests""" |
+29
-2
| """Defines the data and routines for building a CPPython project type""" | ||
| import logging | ||
| import os | ||
| from importlib.metadata import entry_points | ||
| from inspect import getmodule | ||
| from logging import Logger | ||
| from pprint import pformat | ||
| from typing import Any, cast | ||
| from rich.console import Console | ||
| from rich.logging import RichHandler | ||
| from cppython.core.plugin_schema.generator import Generator | ||
@@ -474,4 +479,24 @@ from cppython.core.plugin_schema.provider import Provider | ||
| # Add default output stream | ||
| self._logger.addHandler(logging.StreamHandler()) | ||
| # Informal standard to check for color | ||
| force_color = os.getenv('FORCE_COLOR', '1') != '0' | ||
| console = Console( | ||
| force_terminal=force_color, | ||
| color_system='auto', | ||
| width=120, | ||
| legacy_windows=False, | ||
| no_color=False, | ||
| ) | ||
| rich_handler = RichHandler( | ||
| console=console, | ||
| rich_tracebacks=True, | ||
| show_time=False, | ||
| show_path=False, | ||
| markup=True, | ||
| show_level=False, | ||
| enable_link_path=False, | ||
| ) | ||
| self._logger.addHandler(rich_handler) | ||
| self._logger.setLevel(Builder.levels[project_configuration.verbosity]) | ||
@@ -536,2 +561,4 @@ | ||
| self._logger.debug('Project data:\n%s', pformat(dict(core_data))) | ||
| return Data(core_data, plugins, self._logger) |
@@ -47,3 +47,3 @@ """Data types for CPPython that encapsulate the requirements between the plugins and the core library""" | ||
| @field_validator('verbosity') # type: ignore | ||
| @field_validator('verbosity') | ||
| @classmethod | ||
@@ -125,3 +125,3 @@ def min_max(cls, value: int) -> int: | ||
| @field_validator('configuration_path', 'install_path', 'tool_path', 'build_path') # type: ignore | ||
| @field_validator('configuration_path', 'install_path', 'tool_path', 'build_path') | ||
| @classmethod | ||
@@ -128,0 +128,0 @@ def validate_absolute_path(cls, value: Path) -> Path: |
@@ -5,3 +5,9 @@ """Plugin builder""" | ||
| from cppython.plugins.cmake.schema import CMakeData, CMakePresets, CMakeSyncData, ConfigurePreset | ||
| from cppython.plugins.cmake.schema import ( | ||
| BuildPreset, | ||
| CMakeData, | ||
| CMakePresets, | ||
| CMakeSyncData, | ||
| ConfigurePreset, | ||
| ) | ||
@@ -16,46 +22,4 @@ | ||
| @staticmethod | ||
| def generate_provider_preset(provider_data: CMakeSyncData) -> CMakePresets: | ||
| """Generates a provider preset from input sync data | ||
| Args: | ||
| provider_directory: The base directory to place the preset files | ||
| provider_data: The providers synchronization data | ||
| """ | ||
| generated_configure_preset = ConfigurePreset(name=provider_data.provider_name, hidden=True) | ||
| # Toss in that sync data from the provider | ||
| generated_configure_preset.cacheVariables = { | ||
| 'CMAKE_PROJECT_TOP_LEVEL_INCLUDES': str(provider_data.top_level_includes.as_posix()), | ||
| } | ||
| return CMakePresets(configurePresets=[generated_configure_preset]) | ||
| @staticmethod | ||
| def write_provider_preset(provider_directory: Path, provider_data: CMakeSyncData) -> None: | ||
| """Writes a provider preset from input sync data | ||
| Args: | ||
| provider_directory: The base directory to place the preset files | ||
| provider_data: The providers synchronization data | ||
| """ | ||
| generated_preset = Builder.generate_provider_preset(provider_data) | ||
| provider_preset_file = provider_directory / f'{provider_data.provider_name}.json' | ||
| initial_preset = None | ||
| # If the file already exists, we need to compare it | ||
| if provider_preset_file.exists(): | ||
| with open(provider_preset_file, encoding='utf-8') as file: | ||
| initial_json = file.read() | ||
| initial_preset = CMakePresets.model_validate_json(initial_json) | ||
| if generated_preset != initial_preset: | ||
| serialized = generated_preset.model_dump_json(exclude_none=True, by_alias=False, indent=4) | ||
| with open(provider_preset_file, 'w', encoding='utf8') as file: | ||
| file.write(serialized) | ||
| @staticmethod | ||
| def generate_cppython_preset( | ||
| cppython_preset_directory: Path, provider_directory: Path, provider_data: CMakeSyncData | ||
| cppython_preset_directory: Path, provider_preset_file: Path, provider_data: CMakeSyncData | ||
| ) -> CMakePresets: | ||
@@ -66,3 +30,3 @@ """Generates the cppython preset which inherits from the provider presets | ||
| cppython_preset_directory: The tool directory | ||
| provider_directory: The base directory containing provider presets | ||
| provider_preset_file: Path to the provider's preset file | ||
| provider_data: The provider's synchronization data | ||
@@ -73,11 +37,22 @@ | ||
| """ | ||
| generated_configure_preset = ConfigurePreset(name='cppython', inherits=provider_data.provider_name, hidden=True) | ||
| generated_preset = CMakePresets(configurePresets=[generated_configure_preset]) | ||
| configure_presets = [] | ||
| # Get the relative path to the provider preset file | ||
| provider_preset_file = provider_directory / f'{provider_data.provider_name}.json' | ||
| relative_preset = provider_preset_file.relative_to(cppython_preset_directory, walk_up=True).as_posix() | ||
| preset_name = 'cppython' | ||
| # Set the data | ||
| generated_preset.include = [relative_preset] | ||
| # Create a default preset that inherits from provider's default preset | ||
| default_configure = ConfigurePreset( | ||
| name=preset_name, | ||
| hidden=True, | ||
| description='Injected configuration preset for CPPython', | ||
| ) | ||
| if provider_data.toolchain_file: | ||
| default_configure.toolchainFile = provider_data.toolchain_file.as_posix() | ||
| configure_presets.append(default_configure) | ||
| generated_preset = CMakePresets( | ||
| configurePresets=configure_presets, | ||
| ) | ||
| return generated_preset | ||
@@ -87,3 +62,3 @@ | ||
| def write_cppython_preset( | ||
| cppython_preset_directory: Path, provider_directory: Path, provider_data: CMakeSyncData | ||
| cppython_preset_directory: Path, provider_preset_file: Path, provider_data: CMakeSyncData | ||
| ) -> Path: | ||
@@ -94,3 +69,3 @@ """Write the cppython presets which inherit from the provider presets | ||
| cppython_preset_directory: The tool directory | ||
| provider_directory: The base directory containing provider presets | ||
| provider_preset_file: Path to the provider's preset file | ||
| provider_data: The provider's synchronization data | ||
@@ -102,3 +77,3 @@ | ||
| generated_preset = Builder.generate_cppython_preset( | ||
| cppython_preset_directory, provider_directory, provider_data | ||
| cppython_preset_directory, provider_preset_file, provider_data | ||
| ) | ||
@@ -124,10 +99,8 @@ cppython_preset_file = cppython_preset_directory / 'cppython.json' | ||
| @staticmethod | ||
| def generate_root_preset( | ||
| preset_file: Path, cppython_preset_file: Path, cmake_data: CMakeData, build_directory: Path | ||
| ) -> CMakePresets: | ||
| """Generates the top level root preset with the include reference. | ||
| def _create_presets( | ||
| cmake_data: CMakeData, build_directory: Path | ||
| ) -> tuple[list[ConfigurePreset], list[BuildPreset]]: | ||
| """Create the default configure and build presets for the user. | ||
| Args: | ||
| preset_file: Preset file to modify | ||
| cppython_preset_file: Path to the cppython preset file to include | ||
| cmake_data: The CMake data to use | ||
@@ -137,38 +110,140 @@ build_directory: The build directory to use | ||
| Returns: | ||
| A CMakePresets object | ||
| A tuple containing the configure preset and list of build presets | ||
| """ | ||
| default_configure_preset = ConfigurePreset( | ||
| name=cmake_data.configuration_name, | ||
| inherits='cppython', | ||
| binaryDir=build_directory.as_posix(), | ||
| cacheVariables={ | ||
| 'CMAKE_BUILD_TYPE': 'Release' # Ensure compatibility for single-config and multi-config generators | ||
| }, | ||
| user_configure_presets: list[ConfigurePreset] = [] | ||
| user_build_presets: list[BuildPreset] = [] | ||
| name = cmake_data.configuration_name | ||
| release_name = name + '-release' | ||
| debug_name = name + '-debug' | ||
| user_configure_presets.append( | ||
| ConfigurePreset( | ||
| name=name, | ||
| description='All multi-configuration generators should inherit from this preset', | ||
| inherits='cppython', | ||
| binaryDir='${sourceDir}/' + build_directory.as_posix(), | ||
| cacheVariables={'CMAKE_CONFIGURATION_TYPES': 'Debug;Release'}, | ||
| ) | ||
| ) | ||
| if preset_file.exists(): | ||
| with open(preset_file, encoding='utf-8') as file: | ||
| initial_json = file.read() | ||
| root_preset = CMakePresets.model_validate_json(initial_json) | ||
| user_configure_presets.append( | ||
| ConfigurePreset( | ||
| name=release_name, | ||
| description='All single-configuration generators should inherit from this preset', | ||
| inherits=name, | ||
| cacheVariables={'CMAKE_BUILD_TYPE': 'Release'}, | ||
| ) | ||
| ) | ||
| if root_preset.configurePresets is None: | ||
| root_preset.configurePresets = [default_configure_preset] | ||
| user_configure_presets.append( | ||
| ConfigurePreset( | ||
| name=debug_name, | ||
| description='All single-configuration generators should inherit from this preset', | ||
| inherits=name, | ||
| cacheVariables={'CMAKE_BUILD_TYPE': 'Debug'}, | ||
| ) | ||
| ) | ||
| # Set defaults | ||
| preset = next((p for p in root_preset.configurePresets if p.name == default_configure_preset.name), None) | ||
| if preset: | ||
| # If the name matches, we need to verify it inherits from cppython | ||
| if preset.inherits is None: | ||
| preset.inherits = 'cppython' | ||
| elif isinstance(preset.inherits, str) and preset.inherits != 'cppython': | ||
| preset.inherits = [preset.inherits, 'cppython'] | ||
| elif isinstance(preset.inherits, list) and 'cppython' not in preset.inherits: | ||
| preset.inherits.append('cppython') | ||
| else: | ||
| root_preset.configurePresets.append(default_configure_preset) | ||
| user_build_presets.append( | ||
| BuildPreset( | ||
| name=release_name, | ||
| description='An example build preset for release', | ||
| configurePreset=release_name, | ||
| ) | ||
| ) | ||
| user_build_presets.append( | ||
| BuildPreset( | ||
| name=debug_name, | ||
| description='An example build preset for debug', | ||
| configurePreset=debug_name, | ||
| ) | ||
| ) | ||
| return user_configure_presets, user_build_presets | ||
| @staticmethod | ||
| def _load_existing_preset(preset_file: Path) -> CMakePresets | None: | ||
| """Load existing preset file if it exists. | ||
| Args: | ||
| preset_file: Path to the preset file | ||
| Returns: | ||
| CMakePresets object if file exists, None otherwise | ||
| """ | ||
| if not preset_file.exists(): | ||
| return None | ||
| with open(preset_file, encoding='utf-8') as file: | ||
| initial_json = file.read() | ||
| return CMakePresets.model_validate_json(initial_json) | ||
| @staticmethod | ||
| def _update_configure_preset(existing_preset: ConfigurePreset, build_directory: Path) -> None: | ||
| """Update an existing configure preset to ensure proper inheritance and binary directory. | ||
| Args: | ||
| existing_preset: The preset to update | ||
| build_directory: The build directory to use | ||
| """ | ||
| # Update existing preset to ensure it inherits from 'cppython' | ||
| if existing_preset.inherits is None: | ||
| existing_preset.inherits = 'cppython' # type: ignore[misc] | ||
| elif isinstance(existing_preset.inherits, str) and existing_preset.inherits != 'cppython': | ||
| existing_preset.inherits = ['cppython', existing_preset.inherits] # type: ignore[misc] | ||
| elif isinstance(existing_preset.inherits, list) and 'cppython' not in existing_preset.inherits: | ||
| existing_preset.inherits.insert(0, 'cppython') | ||
| # Update binary directory if not set | ||
| if not existing_preset.binaryDir: | ||
| existing_preset.binaryDir = '${sourceDir}/' + build_directory.as_posix() # type: ignore[misc] | ||
| @staticmethod | ||
| def _modify_presets( | ||
| root_preset: CMakePresets, | ||
| user_configure_presets: list[ConfigurePreset], | ||
| user_build_presets: list[BuildPreset], | ||
| build_directory: Path, | ||
| ) -> None: | ||
| """Handle presets in the root preset. | ||
| Args: | ||
| root_preset: The root preset to modify | ||
| user_configure_presets: The user's configure presets | ||
| user_build_presets: The user's build presets | ||
| build_directory: The build directory to use | ||
| """ | ||
| if root_preset.configurePresets is None: | ||
| root_preset.configurePresets = user_configure_presets.copy() # type: ignore[misc] | ||
| else: | ||
| # If the file doesn't exist, we need to default it for the user | ||
| root_preset = CMakePresets(configurePresets=[default_configure_preset]) | ||
| # Update or add the user's configure preset | ||
| for user_configure_preset in user_configure_presets: | ||
| existing_preset = next( | ||
| (p for p in root_preset.configurePresets if p.name == user_configure_preset.name), None | ||
| ) | ||
| if existing_preset: | ||
| Builder._update_configure_preset(existing_preset, build_directory) | ||
| else: | ||
| root_preset.configurePresets.append(user_configure_preset) | ||
| if root_preset.buildPresets is None: | ||
| root_preset.buildPresets = user_build_presets.copy() # type: ignore[misc] | ||
| else: | ||
| # Add build presets if they don't exist | ||
| for build_preset in user_build_presets: | ||
| existing = next((p for p in root_preset.buildPresets if p.name == build_preset.name), None) | ||
| if not existing: | ||
| root_preset.buildPresets.append(build_preset) | ||
| @staticmethod | ||
| def _modify_includes(root_preset: CMakePresets, preset_file: Path, cppython_preset_file: Path) -> None: | ||
| """Handle include paths in the root preset. | ||
| Args: | ||
| root_preset: The root preset to modify | ||
| preset_file: Path to the preset file | ||
| cppython_preset_file: Path to the cppython preset file to include | ||
| """ | ||
| # Get the relative path to the cppython preset file | ||
@@ -178,10 +253,39 @@ preset_directory = preset_file.parent.absolute() | ||
| # If the include key doesn't exist, we know we will write to disk afterwards | ||
| # Handle includes | ||
| if not root_preset.include: | ||
| root_preset.include = [] | ||
| root_preset.include = [] # type: ignore[misc] | ||
| # Only the included preset file if it doesn't exist. Implied by the above check | ||
| if str(relative_preset) not in root_preset.include: | ||
| root_preset.include.append(str(relative_preset)) | ||
| @staticmethod | ||
| def generate_root_preset( | ||
| preset_file: Path, cppython_preset_file: Path, cmake_data: CMakeData, build_directory: Path | ||
| ) -> CMakePresets: | ||
| """Generates the top level root preset with the include reference. | ||
| Args: | ||
| preset_file: Preset file to modify | ||
| cppython_preset_file: Path to the cppython preset file to include | ||
| cmake_data: The CMake data to use | ||
| build_directory: The build directory to use | ||
| Returns: | ||
| A CMakePresets object | ||
| """ | ||
| # Create user presets | ||
| user_configure_presets, user_build_presets = Builder._create_presets(cmake_data, build_directory) | ||
| # Load existing preset or create new one | ||
| root_preset = Builder._load_existing_preset(preset_file) | ||
| if root_preset is None: | ||
| root_preset = CMakePresets( | ||
| configurePresets=user_configure_presets, | ||
| buildPresets=user_build_presets, | ||
| ) | ||
| else: | ||
| Builder._modify_presets(root_preset, user_configure_presets, user_build_presets, build_directory) | ||
| Builder._modify_includes(root_preset, preset_file, cppython_preset_file) | ||
| return root_preset | ||
@@ -213,2 +317,5 @@ | ||
| # Ensure that the build_directory is relative to the preset_file, allowing upward traversal | ||
| build_directory = build_directory.relative_to(preset_file.parent, walk_up=True) | ||
| root_preset = Builder.generate_root_preset(preset_file, cppython_preset_file, cmake_data, build_directory) | ||
@@ -215,0 +322,0 @@ |
@@ -66,8 +66,7 @@ """The CMake generator implementation""" | ||
| self._cppython_preset_directory.mkdir(parents=True, exist_ok=True) | ||
| self._provider_directory.mkdir(parents=True, exist_ok=True) | ||
| self.builder.write_provider_preset(self._provider_directory, sync_data) | ||
| cppython_preset_file = self._cppython_preset_directory / 'CPPython.json' | ||
| cppython_preset_file = self.builder.write_cppython_preset( | ||
| self._cppython_preset_directory, self._provider_directory, sync_data | ||
| self._cppython_preset_directory, cppython_preset_file, sync_data | ||
| ) | ||
@@ -74,0 +73,0 @@ |
@@ -13,3 +13,2 @@ """CMake plugin schema | ||
| from pydantic import Field | ||
| from pydantic.types import FilePath | ||
@@ -51,2 +50,4 @@ from cppython.core.schema import CPPythonModel, SyncData | ||
| name: str | ||
| description: Annotated[str | None, Field(description='A human-readable description of the preset.')] = None | ||
| hidden: Annotated[bool | None, Field(description='If true, the preset is hidden and cannot be used directly.')] = ( | ||
@@ -63,11 +64,35 @@ None | ||
| ] = None | ||
| toolchainFile: Annotated[ | ||
| str | Path | None, | ||
| Field(description='Path to the toolchain file.'), | ||
| ] = None | ||
| cacheVariables: dict[str, None | bool | str | CacheVariable] | None = None | ||
| class BuildPreset(CPPythonModel, extra='allow'): | ||
| """Partial Build Preset specification for CMake build presets""" | ||
| name: str | ||
| description: Annotated[str | None, Field(description='A human-readable description of the preset.')] = None | ||
| hidden: Annotated[bool | None, Field(description='If true, the preset is hidden and cannot be used directly.')] = ( | ||
| None | ||
| ) | ||
| inherits: Annotated[ | ||
| str | list[str] | None, Field(description='The inherits field allows inheriting from other presets.') | ||
| ] = None | ||
| configurePreset: Annotated[ | ||
| str | None, | ||
| Field(description='The name of a configure preset to associate with this build preset.'), | ||
| ] = None | ||
| configuration: Annotated[ | ||
| str | None, | ||
| Field(description='Build configuration. Equivalent to --config on the command line.'), | ||
| ] = None | ||
| class CMakePresets(CPPythonModel, extra='allow'): | ||
| """The schema for the CMakePresets and CMakeUserPresets files. | ||
| """The schema for the CMakePresets and CMakeUserPresets files.""" | ||
| The only information needed is the configure preset list for cache variable injection | ||
| """ | ||
| version: Annotated[int, Field(description='The version of the JSON schema.')] = 9 | ||
@@ -78,2 +103,3 @@ include: Annotated[ | ||
| configurePresets: Annotated[list[ConfigurePreset] | None, Field(description='The list of configure presets')] = None | ||
| buildPresets: Annotated[list[BuildPreset] | None, Field(description='The list of build presets')] = None | ||
@@ -84,3 +110,3 @@ | ||
| top_level_includes: FilePath | ||
| toolchain_file: Path | None = None | ||
@@ -106,3 +132,8 @@ | ||
| configuration_name: Annotated[ | ||
| str, Field(description='The CMake configuration preset to look for and override inside the given `preset_file`') | ||
| str, | ||
| Field( | ||
| description='The CMake configuration preset to look for and override inside the given `preset_file`. ' | ||
| 'Additional configurations will be added using this option as the base. For example, given "default", ' | ||
| '"default-release" will also be written' | ||
| ), | ||
| ] = 'default' |
@@ -124,14 +124,14 @@ """Construction of Conan data""" | ||
| @staticmethod | ||
| def _create_conanfile(conan_file: Path, dependencies: list[ConanDependency]) -> None: | ||
| def _create_conanfile(conan_file: Path, dependencies: list[ConanDependency], name: str, version: str) -> None: | ||
| """Creates a conanfile.py file with the necessary content.""" | ||
| template_string = """ | ||
| from conan import ConanFile | ||
| from conan.tools.cmake import CMake, cmake_layout | ||
| from conan.tools.cmake import CMake, CMakeDeps, CMakeToolchain, cmake_layout | ||
| from conan.tools.files import copy | ||
| class MyProject(ConanFile): | ||
| name = "myproject" | ||
| version = "1.0" | ||
| class AutoPackage(ConanFile): | ||
| name = "${name}" | ||
| version = "${version}" | ||
| settings = "os", "compiler", "build_type", "arch" | ||
| requires = ${dependencies} | ||
| generators = "CMakeDeps" | ||
@@ -141,10 +141,33 @@ def layout(self): | ||
| def generate(self): | ||
| deps = CMakeDeps(self) | ||
| deps.generate() | ||
| tc = CMakeToolchain(self) | ||
| tc.user_presets_path = None | ||
| tc.generate() | ||
| def build(self): | ||
| cmake = CMake(self) | ||
| cmake.configure() | ||
| cmake.build()""" | ||
| cmake.build() | ||
| def package(self): | ||
| cmake = CMake(self) | ||
| cmake.install() | ||
| def package_info(self): | ||
| self.cpp_info.libs = ["${name}"] | ||
| def export_sources(self): | ||
| copy(self, "CMakeLists.txt", src=self.recipe_folder, dst=self.export_sources_folder) | ||
| copy(self, "include/*", src=self.recipe_folder, dst=self.export_sources_folder) | ||
| copy(self, "src/*", src=self.recipe_folder, dst=self.export_sources_folder) | ||
| copy(self, "cmake/*", src=self.recipe_folder, dst=self.export_sources_folder) | ||
| """ | ||
| template = Template(dedent(template_string)) | ||
| values = { | ||
| 'name': name, | ||
| 'version': version, | ||
| 'dependencies': [dependency.requires() for dependency in dependencies], | ||
@@ -158,3 +181,5 @@ } | ||
| def generate_conanfile(self, directory: DirectoryPath, dependencies: list[ConanDependency]) -> None: | ||
| def generate_conanfile( | ||
| self, directory: DirectoryPath, dependencies: list[ConanDependency], name: str, version: str | ||
| ) -> None: | ||
| """Generate a conanfile.py file for the project.""" | ||
@@ -173,2 +198,2 @@ conan_file = directory / self._filename | ||
| directory.mkdir(parents=True, exist_ok=True) | ||
| self._create_conanfile(conan_file, dependencies) | ||
| self._create_conanfile(conan_file, dependencies, name, version) |
+136
-181
@@ -8,8 +8,9 @@ """Conan Provider Plugin | ||
| import os | ||
| from logging import Logger, getLogger | ||
| from pathlib import Path | ||
| from typing import Any | ||
| import requests | ||
| from conan.api.conan_api import ConanAPI | ||
| from conan.api.model import ListPattern | ||
| from conan.cli.cli import Cli | ||
@@ -24,3 +25,3 @@ from cppython.core.plugin_schema.generator import SyncConsumer | ||
| from cppython.plugins.conan.schema import ConanData | ||
| from cppython.utility.exception import NotSupportedError, ProviderConfigurationError, ProviderInstallationError | ||
| from cppython.utility.exception import NotSupportedError, ProviderInstallationError | ||
| from cppython.utility.utility import TypeName | ||
@@ -32,4 +33,2 @@ | ||
| _provider_url = 'https://raw.githubusercontent.com/conan-io/cmake-conan/refs/heads/develop2/conan_provider.cmake' | ||
| def __init__( | ||
@@ -44,12 +43,10 @@ self, group_data: ProviderPluginGroupData, core_data: CorePluginData, configuration_data: dict[str, Any] | ||
| self.builder = Builder() | ||
| # Initialize ConanAPI once and reuse it | ||
| self._conan_api = ConanAPI() | ||
| # Initialize CLI for command API to work properly | ||
| self._cli = Cli(self._conan_api) | ||
| self._cli.add_commands() | ||
| @staticmethod | ||
| def _download_file(url: str, file: Path) -> None: | ||
| """Replaces the given file with the contents of the url""" | ||
| file.parent.mkdir(parents=True, exist_ok=True) | ||
| self._ensure_default_profiles() | ||
| with open(file, 'wb') as out_file: | ||
| content = requests.get(url, stream=True).content | ||
| out_file.write(content) | ||
| @staticmethod | ||
@@ -77,3 +74,3 @@ def features(directory: Path) -> SupportedFeatures: | ||
| def _install_dependencies(self, *, update: bool = False) -> None: | ||
| """Install/update dependencies using Conan API. | ||
| """Install/update dependencies using Conan CLI. | ||
@@ -85,6 +82,7 @@ Args: | ||
| operation = 'update' if update else 'install' | ||
| logger = getLogger('cppython.conan') | ||
| try: | ||
| # Setup environment and generate conanfile | ||
| conan_api, conanfile_path = self._prepare_installation() | ||
| conanfile_path = self._prepare_installation() | ||
| except Exception as e: | ||
@@ -94,28 +92,23 @@ raise ProviderInstallationError('conan', f'Failed to prepare {operation} environment: {e}', e) from e | ||
| try: | ||
| # Load dependency graph | ||
| deps_graph = self._load_dependency_graph(conan_api, conanfile_path, update) | ||
| build_types = ['Release', 'Debug'] | ||
| for build_type in build_types: | ||
| logger.info('Installing dependencies for build type: %s', build_type) | ||
| self._run_conan_install(conanfile_path, update, build_type, logger) | ||
| except Exception as e: | ||
| raise ProviderInstallationError('conan', f'Failed to load dependency graph: {e}', e) from e | ||
| raise ProviderInstallationError('conan', f'Failed to install dependencies: {e}', e) from e | ||
| try: | ||
| # Install dependencies | ||
| self._install_binaries(conan_api, deps_graph, update) | ||
| except Exception as e: | ||
| raise ProviderInstallationError('conan', f'Failed to install binary dependencies: {e}', e) from e | ||
| try: | ||
| # Generate consumer files | ||
| self._generate_consumer_files(conan_api, deps_graph) | ||
| except Exception as e: | ||
| raise ProviderInstallationError('conan', f'Failed to generate consumer files: {e}', e) from e | ||
| def _prepare_installation(self) -> tuple[ConanAPI, Path]: | ||
| def _prepare_installation(self) -> Path: | ||
| """Prepare the installation environment and generate conanfile. | ||
| Returns: | ||
| Tuple of (ConanAPI instance, conanfile path) | ||
| Path to conanfile.py | ||
| """ | ||
| # Resolve dependencies and generate conanfile.py | ||
| resolved_dependencies = [resolve_conan_dependency(req) for req in self.core_data.cppython_data.dependencies] | ||
| self.builder.generate_conanfile(self.core_data.project_data.project_root, resolved_dependencies) | ||
| self.builder.generate_conanfile( | ||
| self.core_data.project_data.project_root, | ||
| resolved_dependencies, | ||
| self.core_data.pep621_data.name, | ||
| self.core_data.pep621_data.version, | ||
| ) | ||
@@ -125,4 +118,3 @@ # Ensure build directory exists | ||
| # Setup paths and API | ||
| conan_api = ConanAPI() | ||
| # Setup paths | ||
| project_root = self.core_data.project_data.project_root | ||
@@ -134,71 +126,53 @@ conanfile_path = project_root / 'conanfile.py' | ||
| return conan_api, conanfile_path | ||
| return conanfile_path | ||
| def _load_dependency_graph(self, conan_api: ConanAPI, conanfile_path: Path, update: bool): | ||
| """Load and build the dependency graph. | ||
| def _ensure_default_profiles(self) -> None: | ||
| """Ensure default Conan profiles exist, creating them if necessary.""" | ||
| try: | ||
| self._conan_api.profiles.get_default_host() | ||
| self._conan_api.profiles.get_default_build() | ||
| except Exception: | ||
| # If profiles don't exist, create them using profile detect | ||
| self._conan_api.command.run(['profile', 'detect']) | ||
| def _run_conan_install(self, conanfile_path: Path, update: bool, build_type: str, logger: Logger) -> None: | ||
| """Run conan install command using Conan API with optional build type. | ||
| Args: | ||
| conan_api: The Conan API instance | ||
| conanfile_path: Path to the conanfile.py | ||
| update: Whether to check for updates | ||
| Returns: | ||
| The loaded dependency graph | ||
| build_type: Build type (Release, Debug, etc.) or None for default | ||
| logger: Logger instance | ||
| """ | ||
| all_remotes = conan_api.remotes.list() | ||
| profile_host, profile_build = self.data.host_profile, self.data.build_profile | ||
| # Build conan install command arguments | ||
| command_args = ['install', str(conanfile_path)] | ||
| return conan_api.graph.load_graph_consumer( | ||
| path=str(conanfile_path), | ||
| name=None, | ||
| version=None, | ||
| user=None, | ||
| channel=None, | ||
| lockfile=None, | ||
| remotes=all_remotes, | ||
| update=update or None, | ||
| check_updates=update, | ||
| is_build_require=False, | ||
| profile_host=profile_host, | ||
| profile_build=profile_build, | ||
| ) | ||
| # Add build missing flag | ||
| command_args.extend(['--build', 'missing']) | ||
| def _install_binaries(self, conan_api: ConanAPI, deps_graph, update: bool) -> None: | ||
| """Analyze and install binary dependencies. | ||
| # Add update flag if needed | ||
| if update: | ||
| command_args.append('--update') | ||
| Args: | ||
| conan_api: The Conan API instance | ||
| deps_graph: The dependency graph | ||
| update: Whether to check for updates | ||
| """ | ||
| all_remotes = conan_api.remotes.list() | ||
| # Add build type setting if specified | ||
| if build_type: | ||
| command_args.extend(['-s', f'build_type={build_type}']) | ||
| # Analyze binaries to determine what needs to be built/downloaded | ||
| conan_api.graph.analyze_binaries( | ||
| graph=deps_graph, | ||
| build_mode=['missing'], | ||
| remotes=all_remotes, | ||
| update=update or None, | ||
| lockfile=None, | ||
| ) | ||
| # Log the command being executed | ||
| logger.info('Executing conan command: conan %s', ' '.join(command_args)) | ||
| # Install all dependencies | ||
| conan_api.install.install_binaries(deps_graph=deps_graph, remotes=all_remotes) | ||
| try: | ||
| # Use reusable Conan API instance instead of subprocess | ||
| # Change to project directory since Conan API might not handle cwd like subprocess | ||
| original_cwd = os.getcwd() | ||
| try: | ||
| os.chdir(str(self.core_data.project_data.project_root)) | ||
| self._conan_api.command.run(command_args) | ||
| finally: | ||
| os.chdir(original_cwd) | ||
| except Exception as e: | ||
| error_msg = str(e) | ||
| logger.error('Conan install failed: %s', error_msg, exc_info=True) | ||
| raise ProviderInstallationError('conan', error_msg, e) from e | ||
| def _generate_consumer_files(self, conan_api: ConanAPI, deps_graph) -> None: | ||
| """Generate consumer files (CMake toolchain, deps, etc.). | ||
| Args: | ||
| conan_api: The Conan API instance | ||
| deps_graph: The dependency graph | ||
| """ | ||
| project_root = self.core_data.project_data.project_root | ||
| conan_api.install.install_consumer( | ||
| deps_graph=deps_graph, | ||
| generators=['CMakeToolchain', 'CMakeDeps'], | ||
| source_folder=str(project_root), | ||
| output_folder=str(self.core_data.cppython_data.build_path), | ||
| ) | ||
| def install(self) -> None: | ||
@@ -238,14 +212,29 @@ """Installs the provider""" | ||
| if sync_type == CMakeSyncData: | ||
| return CMakeSyncData( | ||
| provider_name=TypeName('conan'), | ||
| top_level_includes=self.core_data.cppython_data.install_path / 'conan_provider.cmake', | ||
| ) | ||
| return self._create_cmake_sync_data() | ||
| raise NotSupportedError(f'Unsupported sync types: {consumer.sync_types()}') | ||
| def _create_cmake_sync_data(self) -> CMakeSyncData: | ||
| """Creates CMake synchronization data with Conan toolchain configuration. | ||
| Returns: | ||
| CMakeSyncData configured for Conan integration | ||
| """ | ||
| conan_toolchain_path = self.core_data.cppython_data.build_path / 'generators' / 'conan_toolchain.cmake' | ||
| return CMakeSyncData( | ||
| provider_name=TypeName('conan'), | ||
| toolchain_file=conan_toolchain_path, | ||
| ) | ||
| @classmethod | ||
| async def download_tooling(cls, directory: Path) -> None: | ||
| """Downloads the conan provider file""" | ||
| cls._download_file(cls._provider_url, directory / 'conan_provider.cmake') | ||
| """Download external tooling required by the Conan provider. | ||
| Since we're using CMakeToolchain generator instead of cmake-conan provider, | ||
| no external tooling needs to be downloaded. | ||
| """ | ||
| # No external tooling required when using CMakeToolchain | ||
| pass | ||
| def publish(self) -> None: | ||
@@ -255,2 +244,3 @@ """Publishes the package using conan create workflow.""" | ||
| conanfile_path = project_root / 'conanfile.py' | ||
| logger = getLogger('cppython.conan') | ||
@@ -260,95 +250,60 @@ if not conanfile_path.exists(): | ||
| conan_api = ConanAPI() | ||
| try: | ||
| # Build conan create command arguments | ||
| command_args = ['create', str(conanfile_path)] | ||
| all_remotes = conan_api.remotes.list() | ||
| # Add build mode (build everything for publishing) | ||
| command_args.extend(['--build', 'missing']) | ||
| # Configure remotes for upload | ||
| configured_remotes = self._get_configured_remotes(all_remotes) | ||
| # Log the command being executed | ||
| logger.info('Executing conan create command: conan %s', ' '.join(command_args)) | ||
| # Export the recipe to cache | ||
| ref, _ = conan_api.export.export( | ||
| path=str(conanfile_path), | ||
| name=None, | ||
| version=None, | ||
| user=None, | ||
| channel=None, | ||
| lockfile=None, | ||
| remotes=all_remotes, | ||
| ) | ||
| # Run conan create using reusable Conan API instance | ||
| # Change to project directory since Conan API might not handle cwd like subprocess | ||
| original_cwd = os.getcwd() | ||
| try: | ||
| os.chdir(str(project_root)) | ||
| self._conan_api.command.run(command_args) | ||
| finally: | ||
| os.chdir(original_cwd) | ||
| # Build dependency graph and install | ||
| profile_host, profile_build = self.data.host_profile, self.data.build_profile | ||
| deps_graph = conan_api.graph.load_graph_consumer( | ||
| path=str(conanfile_path), | ||
| name=None, | ||
| version=None, | ||
| user=None, | ||
| channel=None, | ||
| lockfile=None, | ||
| remotes=all_remotes, # Use all remotes for dependency resolution | ||
| update=None, | ||
| check_updates=False, | ||
| is_build_require=False, | ||
| profile_host=profile_host, | ||
| profile_build=profile_build, | ||
| ) | ||
| # Upload if not skipped | ||
| if not self.data.skip_upload: | ||
| self._upload_package(logger) | ||
| # Analyze and build binaries | ||
| conan_api.graph.analyze_binaries( | ||
| graph=deps_graph, | ||
| build_mode=['*'], | ||
| remotes=all_remotes, # Use all remotes for dependency resolution | ||
| update=None, | ||
| lockfile=None, | ||
| ) | ||
| except Exception as e: | ||
| error_msg = str(e) | ||
| logger.error('Conan create failed: %s', error_msg, exc_info=True) | ||
| raise ProviderInstallationError('conan', error_msg, e) from e | ||
| conan_api.install.install_binaries(deps_graph=deps_graph, remotes=all_remotes) | ||
| if not self.data.skip_upload: | ||
| self._upload_package(conan_api, ref, configured_remotes) | ||
| def _get_configured_remotes(self, all_remotes): | ||
| """Get and validate configured remotes for upload. | ||
| Note: This only affects upload behavior. For dependency resolution, | ||
| we always use all available system remotes regardless of this config. | ||
| """ | ||
| # If skip_upload is True, don't upload anywhere | ||
| if self.data.skip_upload: | ||
| return [] | ||
| # If no remotes specified, upload to all available remotes | ||
| def _upload_package(self, logger) -> None: | ||
| """Upload the package to configured remotes using Conan API.""" | ||
| # If no remotes configured, upload to all remotes | ||
| if not self.data.remotes: | ||
| return all_remotes | ||
| # Upload to all available remotes | ||
| command_args = ['upload', '*', '--all', '--confirm'] | ||
| else: | ||
| # Upload only to specified remotes | ||
| for remote in self.data.remotes: | ||
| command_args = ['upload', '*', '--remote', remote, '--all', '--confirm'] | ||
| # Otherwise, upload only to specified remotes | ||
| configured_remotes = [remote for remote in all_remotes if remote.name in self.data.remotes] | ||
| # Log the command being executed | ||
| logger.info('Executing conan upload command: conan %s', ' '.join(command_args)) | ||
| if not configured_remotes: | ||
| available_remotes = [remote.name for remote in all_remotes] | ||
| raise ProviderConfigurationError( | ||
| 'conan', | ||
| f'No configured remotes found. Available: {available_remotes}, Configured: {self.data.remotes}', | ||
| 'remotes', | ||
| ) | ||
| try: | ||
| self._conan_api.command.run(command_args) | ||
| except Exception as e: | ||
| error_msg = str(e) | ||
| logger.error('Conan upload failed for remote %s: %s', remote, error_msg, exc_info=True) | ||
| raise ProviderInstallationError('conan', f'Upload to {remote} failed: {error_msg}', e) from e | ||
| return | ||
| return configured_remotes | ||
| # Log the command for uploading to all remotes | ||
| logger.info('Executing conan upload command: conan %s', ' '.join(command_args)) | ||
| def _upload_package(self, conan_api, ref, configured_remotes): | ||
| """Upload the package to configured remotes.""" | ||
| ref_pattern = ListPattern(f'{ref.name}/*', package_id='*', only_recipe=False) | ||
| package_list = conan_api.list.select(ref_pattern) | ||
| if not package_list.recipes: | ||
| raise ProviderInstallationError('conan', 'No packages found to upload') | ||
| remote = configured_remotes[0] | ||
| conan_api.upload.upload_full( | ||
| package_list=package_list, | ||
| remote=remote, | ||
| enabled_remotes=configured_remotes, | ||
| check_integrity=False, | ||
| force=False, | ||
| metadata=None, | ||
| dry_run=False, | ||
| ) | ||
| try: | ||
| self._conan_api.command.run(command_args) | ||
| except Exception as e: | ||
| error_msg = str(e) | ||
| logger.error('Conan upload failed: %s', error_msg, exc_info=True) | ||
| raise ProviderInstallationError('conan', error_msg, e) from e |
| """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 | ||
@@ -21,191 +17,4 @@ | ||
| ) | ||
| 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: | ||
@@ -261,3 +70,3 @@ """Handle a single version specifier.""" | ||
| # Handle multiple specifiers - convert to Conan range syntax | ||
| range_parts = [] | ||
| range_parts: list[str] = [] | ||
@@ -310,18 +119,11 @@ # Define order for operators to ensure consistent output | ||
| # Initialize Conan API for profile resolution | ||
| conan_api = ConanAPI() | ||
| profile_dir = Path(parsed_data.profile_dir) | ||
| # Try to detect cmake program path from current virtual environment | ||
| cmake_program = _detect_cmake_program() | ||
| if not profile_dir.is_absolute(): | ||
| profile_dir = core_data.cppython_data.tool_path / profile_dir | ||
| # 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, | ||
| skip_upload=parsed_data.skip_upload, | ||
| host_profile=host_profile, | ||
| build_profile=build_profile, | ||
| profile_dir=profile_dir, | ||
| ) |
@@ -9,5 +9,5 @@ """Conan plugin schema | ||
| import re | ||
| from pathlib import Path | ||
| from typing import Annotated | ||
| from conan.internal.model.profile import Profile | ||
| from pydantic import Field, field_validator | ||
@@ -298,4 +298,3 @@ | ||
| skip_upload: bool | ||
| host_profile: Profile | ||
| build_profile: Profile | ||
| profile_dir: Path | ||
@@ -312,17 +311,11 @@ | ||
| bool, | ||
| Field(description='If true, skip uploading packages during publish (local-only mode).'), | ||
| Field(description='If true, skip uploading packages to a remote during publishing.'), | ||
| ] = False | ||
| host_profile: Annotated[ | ||
| str | None, | ||
| profile_dir: Annotated[ | ||
| str, | ||
| Field( | ||
| description='Conan host profile defining the target platform where the built software will run. ' | ||
| 'Used for cross-compilation scenarios.' | ||
| description='Directory containing Conan profiles. Profiles will be looked up relative to this directory. ' | ||
| 'If profiles do not exist in this directory, Conan will fall back to default profiles.' | ||
| "If a relative path is provided, it will be resolved relative to the tool's working directory." | ||
| ), | ||
| ] = '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' | ||
| ] = 'profiles' |
@@ -36,2 +36,35 @@ """The vcpkg provider implementation""" | ||
| @staticmethod | ||
| def _handle_subprocess_error( | ||
| logger_instance, operation: str, error: subprocess.CalledProcessError, exception_class: type | ||
| ) -> None: | ||
| """Handles subprocess errors with comprehensive error message formatting. | ||
| Args: | ||
| logger_instance: The logger instance to use for error logging | ||
| operation: Description of the operation that failed (e.g., 'install', 'clone') | ||
| error: The CalledProcessError exception | ||
| exception_class: The exception class to raise | ||
| Raises: | ||
| The specified exception_class with the formatted error message | ||
| """ | ||
| # Capture both stdout and stderr for better error reporting | ||
| stdout_msg = error.stdout.strip() if error.stdout else '' | ||
| stderr_msg = error.stderr.strip() if error.stderr else '' | ||
| # Combine both outputs for comprehensive error message | ||
| error_parts = [] | ||
| if stderr_msg: | ||
| error_parts.append(f'stderr: {stderr_msg}') | ||
| if stdout_msg: | ||
| error_parts.append(f'stdout: {stdout_msg}') | ||
| if not error_parts: | ||
| error_parts.append(f'Command failed with exit code {error.returncode}') | ||
| error_msg = ' | '.join(error_parts) | ||
| logger_instance.error('Unable to %s: %s', operation, error_msg, exc_info=True) | ||
| raise exception_class('vcpkg', operation, error_msg, error) from error | ||
| @staticmethod | ||
| def features(directory: Path) -> SupportedFeatures: | ||
@@ -86,2 +119,3 @@ """Queries vcpkg support | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
@@ -95,7 +129,6 @@ elif system_name == 'posix': | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| except subprocess.CalledProcessError as e: | ||
| error_msg = e.stderr.decode() if e.stderr else str(e) | ||
| logger.error('Unable to bootstrap the vcpkg repository: %s', error_msg, exc_info=True) | ||
| raise ProviderToolingError('vcpkg', 'bootstrap', error_msg, e) from e | ||
| cls._handle_subprocess_error(logger, 'bootstrap the vcpkg repository', e, ProviderToolingError) | ||
@@ -116,9 +149,20 @@ def sync_data(self, consumer: SyncConsumer) -> SyncData: | ||
| if sync_type == CMakeSyncData: | ||
| return CMakeSyncData( | ||
| provider_name=TypeName('vcpkg'), | ||
| top_level_includes=self.core_data.cppython_data.install_path / 'scripts/buildsystems/vcpkg.cmake', | ||
| ) | ||
| return self._create_cmake_sync_data() | ||
| raise NotSupportedError('OOF') | ||
| def _create_cmake_sync_data(self) -> CMakeSyncData: | ||
| """Creates CMake synchronization data with vcpkg configuration. | ||
| Returns: | ||
| CMakeSyncData configured for vcpkg integration | ||
| """ | ||
| # Create CMakeSyncData with vcpkg configuration | ||
| vcpkg_cmake_path = self.core_data.cppython_data.install_path / 'scripts/buildsystems/vcpkg.cmake' | ||
| return CMakeSyncData( | ||
| provider_name=TypeName('vcpkg'), | ||
| toolchain_file=vcpkg_cmake_path, | ||
| ) | ||
| @classmethod | ||
@@ -165,2 +209,3 @@ def tooling_downloaded(cls, path: Path) -> bool: | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
@@ -172,7 +217,6 @@ subprocess.run( | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| except subprocess.CalledProcessError as e: | ||
| error_msg = e.stderr.decode() if e.stderr else str(e) | ||
| logger.error('Unable to update the vcpkg repository: %s', error_msg, exc_info=True) | ||
| raise ProviderToolingError('vcpkg', 'update', error_msg, e) from e | ||
| cls._handle_subprocess_error(logger, 'update the vcpkg repository', e, ProviderToolingError) | ||
| else: | ||
@@ -188,8 +232,7 @@ try: | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| except subprocess.CalledProcessError as e: | ||
| error_msg = e.stderr.decode() if e.stderr else str(e) | ||
| logger.error('Unable to clone the vcpkg repository: %s', error_msg, exc_info=True) | ||
| raise ProviderToolingError('vcpkg', 'clone', error_msg, e) from e | ||
| cls._handle_subprocess_error(logger, 'clone the vcpkg repository', e, ProviderToolingError) | ||
@@ -219,7 +262,6 @@ cls._update_provider(directory) | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| except subprocess.CalledProcessError as e: | ||
| error_msg = e.stderr.decode() if e.stderr else str(e) | ||
| logger.error('Unable to install project dependencies: %s', error_msg, exc_info=True) | ||
| raise ProviderInstallationError('vcpkg', error_msg, e) from e | ||
| self._handle_subprocess_error(logger, 'install project dependencies', e, ProviderInstallationError) | ||
@@ -248,7 +290,6 @@ def update(self) -> None: | ||
| capture_output=True, | ||
| text=True, | ||
| ) | ||
| except subprocess.CalledProcessError as e: | ||
| error_msg = e.stderr.decode() if e.stderr else str(e) | ||
| logger.error('Unable to update project dependencies: %s', error_msg, exc_info=True) | ||
| raise ProviderInstallationError('vcpkg', error_msg, e) from e | ||
| self._handle_subprocess_error(logger, 'update project dependencies', e, ProviderInstallationError) | ||
@@ -255,0 +296,0 @@ def publish(self) -> None: |
@@ -101,3 +101,12 @@ """Manages data flow to and from plugins""" | ||
| """ | ||
| if not self._enabled: | ||
| self.logger.info('Skipping publish because the project is not enabled') | ||
| return | ||
| self.logger.info('Publishing project') | ||
| # Ensure sync is performed before publishing to generate necessary files | ||
| self._data.sync() | ||
| # Let provider handle its own exceptions for better error context | ||
| self._data.plugins.provider.publish() |
+6
-6
| Metadata-Version: 2.1 | ||
| Name: cppython | ||
| Version: 0.9.6 | ||
| Version: 0.9.7.dev1 | ||
| Summary: A Python management solution for C++ dependencies | ||
@@ -14,3 +14,3 @@ Author-Email: Synodic Software <contact@synodic.software> | ||
| Requires-Dist: requests>=2.32.4 | ||
| Requires-Dist: types-requests>=2.32.4.20250611 | ||
| Requires-Dist: types-requests>=2.32.4.20250809 | ||
| Provides-Extra: pytest | ||
@@ -20,9 +20,9 @@ Requires-Dist: pytest>=8.4.1; extra == "pytest" | ||
| Provides-Extra: git | ||
| Requires-Dist: dulwich>=0.23.2; extra == "git" | ||
| Requires-Dist: dulwich>=0.24.1; extra == "git" | ||
| Provides-Extra: pdm | ||
| Requires-Dist: pdm>=2.25.4; extra == "pdm" | ||
| Requires-Dist: pdm>=2.25.6; extra == "pdm" | ||
| Provides-Extra: cmake | ||
| Requires-Dist: cmake>=4.0.3; extra == "cmake" | ||
| Requires-Dist: cmake>=4.1.0; extra == "cmake" | ||
| Provides-Extra: conan | ||
| Requires-Dist: conan>=2.18.1; extra == "conan" | ||
| Requires-Dist: conan>=2.19.1; extra == "conan" | ||
| Requires-Dist: libcst>=1.8.2; extra == "conan" | ||
@@ -29,0 +29,0 @@ Description-Content-Type: text/markdown |
+13
-8
@@ -15,5 +15,5 @@ [project] | ||
| "requests>=2.32.4", | ||
| "types-requests>=2.32.4.20250611", | ||
| "types-requests>=2.32.4.20250809", | ||
| ] | ||
| version = "0.9.6" | ||
| version = "0.9.7.dev1" | ||
@@ -29,12 +29,12 @@ [project.license] | ||
| git = [ | ||
| "dulwich>=0.23.2", | ||
| "dulwich>=0.24.1", | ||
| ] | ||
| pdm = [ | ||
| "pdm>=2.25.4", | ||
| "pdm>=2.25.6", | ||
| ] | ||
| cmake = [ | ||
| "cmake>=4.0.3", | ||
| "cmake>=4.1.0", | ||
| ] | ||
| conan = [ | ||
| "conan>=2.18.1", | ||
| "conan>=2.19.1", | ||
| "libcst>=1.8.2", | ||
@@ -68,4 +68,4 @@ ] | ||
| lint = [ | ||
| "ruff>=0.12.4", | ||
| "pyrefly>=0.24.2", | ||
| "ruff>=0.12.9", | ||
| "pyrefly>=0.28.1", | ||
| ] | ||
@@ -122,2 +122,7 @@ test = [ | ||
| [tool.pyrefly] | ||
| project-excludes = [ | ||
| "examples", | ||
| ] | ||
| [tool.pdm] | ||
@@ -124,0 +129,0 @@ plugins = [ |
@@ -169,3 +169,2 @@ """Shared fixtures for Conan plugin tests""" | ||
| plugin: ConanProvider, | ||
| conan_mock_api: Mock, | ||
| mocker: MockerFixture, | ||
@@ -177,3 +176,2 @@ ) -> dict[str, Mock]: | ||
| plugin: The plugin instance | ||
| conan_mock_api: Mock ConanAPI instance | ||
| mocker: Pytest mocker fixture | ||
@@ -190,4 +188,5 @@ | ||
| # Mock ConanAPI constructor | ||
| mock_conan_api_constructor = mocker.patch('cppython.plugins.conan.plugin.ConanAPI', return_value=conan_mock_api) | ||
| # Mock subprocess.run to simulate successful command execution | ||
| mock_subprocess_run = mocker.patch('cppython.plugins.conan.plugin.subprocess.run') | ||
| mock_subprocess_run.return_value = mocker.Mock(returncode=0) | ||
@@ -202,6 +201,11 @@ # Mock resolve_conan_dependency | ||
| # Mock getLogger to avoid logging setup issues | ||
| mock_logger = mocker.Mock() | ||
| mocker.patch('cppython.plugins.conan.plugin.getLogger', return_value=mock_logger) | ||
| return { | ||
| 'builder': mock_builder, | ||
| 'conan_api_constructor': mock_conan_api_constructor, | ||
| 'subprocess_run': mock_subprocess_run, | ||
| 'resolve_conan_dependency': mock_resolve_conan_dependency, | ||
| 'logger': mock_logger, | ||
| } |
@@ -24,39 +24,81 @@ """Integration tests for the conan and CMake project variation. | ||
| @staticmethod | ||
| def test_simple(example_runner: CliRunner) -> None: | ||
| """Simple project""" | ||
| # Create project configuration | ||
| def _create_project(skip_upload: bool = True) -> Project: | ||
| """Create a project instance with common configuration.""" | ||
| project_root = Path.cwd() | ||
| project_configuration = ProjectConfiguration(project_root=project_root, version=None, verbosity=2, debug=True) | ||
| # Create console interface | ||
| config = ProjectConfiguration(project_root=project_root, version=None, verbosity=2, debug=True) | ||
| interface = ConsoleInterface() | ||
| # Load pyproject.toml data | ||
| pyproject_path = project_root / 'pyproject.toml' | ||
| pyproject_data = loads(pyproject_path.read_text(encoding='utf-8')) | ||
| # Create and use the project directly | ||
| project = Project(project_configuration, interface, pyproject_data) | ||
| if skip_upload: | ||
| TestConanCMake._ensure_conan_config(pyproject_data) | ||
| pyproject_data['tool']['cppython']['providers']['conan']['skip_upload'] = True | ||
| # Call install directly to get structured results | ||
| project.install() | ||
| return Project(config, interface, pyproject_data) | ||
| # Run the CMake configuration command | ||
| @staticmethod | ||
| def _run_cmake_configure() -> None: | ||
| """Run CMake configuration and assert success.""" | ||
| result = subprocess.run(['cmake', '--preset=default'], capture_output=True, text=True, check=False) | ||
| assert result.returncode == 0, f'CMake configuration failed: {result.stderr}' | ||
| assert result.returncode == 0, f'Cmake failed: {result.stderr}' | ||
| @staticmethod | ||
| def _run_cmake_build() -> None: | ||
| """Run CMake build and assert success.""" | ||
| result = subprocess.run(['cmake', '--build', 'build'], capture_output=True, text=True, check=False) | ||
| assert result.returncode == 0, f'CMake build failed: {result.stderr}' | ||
| path = Path('build').absolute() | ||
| @staticmethod | ||
| def _verify_build_artifacts() -> Path: | ||
| """Verify basic build artifacts exist and return build path.""" | ||
| build_path = Path('build').absolute() | ||
| assert (build_path / 'CMakeCache.txt').exists(), f'CMakeCache.txt not found in {build_path}' | ||
| return build_path | ||
| # Verify that the build directory contains the expected files | ||
| assert (path / 'CMakeCache.txt').exists(), f'{path / "CMakeCache.txt"} not found' | ||
| @staticmethod | ||
| def _ensure_conan_config(pyproject_data: dict) -> None: | ||
| """Helper method to ensure Conan configuration exists in pyproject data""" | ||
| if 'tool' not in pyproject_data: | ||
| pyproject_data['tool'] = {} | ||
| if 'cppython' not in pyproject_data['tool']: | ||
| pyproject_data['tool']['cppython'] = {} | ||
| if 'providers' not in pyproject_data['tool']['cppython']: | ||
| pyproject_data['tool']['cppython']['providers'] = {} | ||
| if 'conan' not in pyproject_data['tool']['cppython']['providers']: | ||
| pyproject_data['tool']['cppython']['providers']['conan'] = {} | ||
| # --- Setup for Publish with modified config --- | ||
| # Modify the in-memory representation of the pyproject data | ||
| pyproject_data['tool']['cppython']['providers']['conan']['skip_upload'] = True | ||
| @staticmethod | ||
| def test_simple(example_runner: CliRunner) -> None: | ||
| """Simple project""" | ||
| # Create project and install dependencies | ||
| project = TestConanCMake._create_project(skip_upload=False) | ||
| project.install() | ||
| # Create a new project instance with the modified configuration for the 'publish' step | ||
| publish_project = Project(project_configuration, interface, pyproject_data) | ||
| # Configure and verify build | ||
| TestConanCMake._run_cmake_configure() | ||
| TestConanCMake._verify_build_artifacts() | ||
| # Publish the project to the local cache | ||
| # Test publishing with skip_upload enabled | ||
| publish_project = TestConanCMake._create_project(skip_upload=True) | ||
| publish_project.publish() | ||
| @staticmethod | ||
| def test_library(example_runner: CliRunner) -> None: | ||
| """Test library creation and packaging workflow""" | ||
| # Create project and install dependencies | ||
| project = TestConanCMake._create_project(skip_upload=False) | ||
| project.install() | ||
| # Configure, build, and verify | ||
| TestConanCMake._run_cmake_configure() | ||
| TestConanCMake._run_cmake_build() | ||
| build_path = TestConanCMake._verify_build_artifacts() | ||
| # Verify library files exist (platform-specific) | ||
| lib_files = list(build_path.glob('**/libmathutils.*')) + list(build_path.glob('**/mathutils.lib')) | ||
| assert len(lib_files) > 0, f'No library files found in {build_path}' | ||
| # Package the library to local cache | ||
| publish_project = TestConanCMake._create_project(skip_upload=True) | ||
| publish_project.publish() |
@@ -9,2 +9,3 @@ """Integration tests for the vcpkg and CMake project variation. | ||
| from pathlib import Path | ||
| from tomllib import loads | ||
@@ -14,5 +15,10 @@ import pytest | ||
| pytest_plugins = ['tests.fixtures.example'] | ||
| from cppython.console.schema import ConsoleInterface | ||
| from cppython.core.schema import ProjectConfiguration | ||
| from cppython.project import Project | ||
| pytest_plugins = ['tests.fixtures.example', 'tests.fixtures.vcpkg'] | ||
| @pytest.mark.skip(reason='Address file locks.') | ||
| class TestVcpkgCMake: | ||
@@ -22,10 +28,36 @@ """Test project variation of vcpkg and CMake""" | ||
| @staticmethod | ||
| @pytest.mark.skip(reason='TODO') | ||
| def _create_project(skip_upload: bool = True) -> Project: | ||
| """Create a project instance with common configuration.""" | ||
| project_root = Path.cwd() | ||
| config = ProjectConfiguration(project_root=project_root, version=None, verbosity=2, debug=True) | ||
| interface = ConsoleInterface() | ||
| pyproject_path = project_root / 'pyproject.toml' | ||
| pyproject_data = loads(pyproject_path.read_text(encoding='utf-8')) | ||
| if skip_upload: | ||
| TestVcpkgCMake._ensure_vcpkg_config(pyproject_data) | ||
| pyproject_data['tool']['cppython']['providers']['vcpkg']['skip_upload'] = True | ||
| return Project(config, interface, pyproject_data) | ||
| @staticmethod | ||
| def _ensure_vcpkg_config(pyproject_data: dict) -> None: | ||
| """Helper method to ensure Vcpkg configuration exists in pyproject data""" | ||
| if 'tool' not in pyproject_data: | ||
| pyproject_data['tool'] = {} | ||
| if 'cppython' not in pyproject_data['tool']: | ||
| pyproject_data['tool']['cppython'] = {} | ||
| if 'providers' not in pyproject_data['tool']['cppython']: | ||
| pyproject_data['tool']['cppython']['providers'] = {} | ||
| if 'vcpkg' not in pyproject_data['tool']['cppython']['providers']: | ||
| pyproject_data['tool']['cppython']['providers']['vcpkg'] = {} | ||
| @staticmethod | ||
| def test_simple(example_runner: CliRunner) -> None: | ||
| """Simple project""" | ||
| # By nature of running the test, we require PDM to develop the project and so it will be installed | ||
| result = subprocess.run(['pdm', 'install'], capture_output=True, text=True, check=False) | ||
| # Create project and install dependencies | ||
| project = TestVcpkgCMake._create_project(skip_upload=False) | ||
| project.install() | ||
| assert result.returncode == 0, f'PDM install failed: {result.stderr}' | ||
| # Run the CMake configuration command | ||
@@ -32,0 +64,0 @@ result = subprocess.run(['cmake', '--preset=default'], capture_output=True, text=True, check=False) |
| """Tests for CMakePresets""" | ||
| from pathlib import Path | ||
| import json | ||
@@ -57,39 +57,2 @@ from cppython.core.schema import ProjectData | ||
| @staticmethod | ||
| def test_provider_write(tmp_path: Path) -> None: | ||
| """Verifies that the provider preset writing works as intended | ||
| Args: | ||
| tmp_path: The input path the use | ||
| """ | ||
| builder = Builder() | ||
| includes_file = tmp_path / 'includes.cmake' | ||
| with includes_file.open('w', encoding='utf-8') as file: | ||
| file.write('example contents') | ||
| data = CMakeSyncData(provider_name=TypeName('test-provider'), top_level_includes=includes_file) | ||
| builder.write_provider_preset(tmp_path, data) | ||
| @staticmethod | ||
| def test_cppython_write(tmp_path: Path) -> None: | ||
| """Verifies that the cppython preset writing works as intended | ||
| Args: | ||
| tmp_path: The input path the use | ||
| """ | ||
| builder = Builder() | ||
| provider_directory = tmp_path / 'providers' | ||
| provider_directory.mkdir(parents=True, exist_ok=True) | ||
| includes_file = provider_directory / 'includes.cmake' | ||
| with includes_file.open('w', encoding='utf-8') as file: | ||
| file.write('example contents') | ||
| data = CMakeSyncData(provider_name=TypeName('test-provider'), top_level_includes=includes_file) | ||
| builder.write_provider_preset(provider_directory, data) | ||
| builder.write_cppython_preset(tmp_path, provider_directory, data) | ||
| @staticmethod | ||
| def test_root_write(project_data: ProjectData) -> None: | ||
@@ -120,7 +83,12 @@ """Verifies that the root preset writing works as intended | ||
| data = CMakeSyncData(provider_name=TypeName('test-provider'), top_level_includes=includes_file) | ||
| builder.write_provider_preset(provider_directory, data) | ||
| # Create a mock provider preset file | ||
| provider_preset_file = provider_directory / 'CMakePresets.json' | ||
| provider_preset_data = {'version': 3, 'configurePresets': [{'name': 'test-provider-base', 'hidden': True}]} | ||
| with provider_preset_file.open('w') as f: | ||
| json.dump(provider_preset_data, f) | ||
| cppython_preset_file = builder.write_cppython_preset(cppython_preset_directory, provider_directory, data) | ||
| data = CMakeSyncData(provider_name=TypeName('test-provider')) | ||
| cppython_preset_file = builder.write_cppython_preset(cppython_preset_directory, provider_preset_file, data) | ||
| build_directory = project_data.project_root / 'build' | ||
@@ -162,7 +130,12 @@ builder.write_root_presets( | ||
| data = CMakeSyncData(provider_name=TypeName('test-provider'), top_level_includes=includes_file) | ||
| builder.write_provider_preset(provider_directory, data) | ||
| # Create a mock provider preset file | ||
| provider_preset_file = provider_directory / 'CMakePresets.json' | ||
| provider_preset_data = {'version': 3, 'configurePresets': [{'name': 'test-provider-base', 'hidden': True}]} | ||
| with provider_preset_file.open('w') as f: | ||
| json.dump(provider_preset_data, f) | ||
| cppython_preset_file = builder.write_cppython_preset(cppython_preset_directory, provider_directory, data) | ||
| data = CMakeSyncData(provider_name=TypeName('test-provider')) | ||
| cppython_preset_file = builder.write_cppython_preset(cppython_preset_directory, provider_preset_file, data) | ||
| build_directory = project_data.project_root / 'build' | ||
@@ -169,0 +142,0 @@ builder.write_root_presets( |
| """Unit tests for the conan plugin install functionality""" | ||
| from pathlib import Path | ||
| from typing import Any | ||
| from unittest.mock import Mock | ||
| import pytest | ||
| from packaging.requirements import Requirement | ||
| from pytest_mock import MockerFixture | ||
| from cppython.plugins.conan.plugin import ConanProvider | ||
| from cppython.plugins.conan.schema import ConanDependency | ||
| from cppython.test.pytest.mixins import ProviderPluginTestMixin | ||
| from cppython.utility.exception import ProviderInstallationError | ||
| # Constants for test assertions | ||
| EXPECTED_PROFILE_CALLS = 2 | ||
| EXPECTED_GET_PROFILE_CALLS = 2 | ||
| # Use shared fixtures | ||
@@ -49,125 +39,1 @@ pytest_plugins = ['tests.fixtures.conan'] | ||
| return ConanProvider | ||
| def test_with_dependencies( | ||
| self, | ||
| plugin: ConanProvider, | ||
| conan_temp_conanfile: Path, | ||
| conan_mock_dependencies: list[Requirement], | ||
| conan_setup_mocks: dict[str, Mock], | ||
| ) -> None: | ||
| """Test install method with dependencies and existing conanfile | ||
| Args: | ||
| plugin: The plugin instance | ||
| conan_temp_conanfile: Path to temporary conanfile.py | ||
| conan_mock_dependencies: List of mock dependencies | ||
| conan_setup_mocks: Dictionary containing all mocks | ||
| """ | ||
| # Setup dependencies | ||
| plugin.core_data.cppython_data.dependencies = conan_mock_dependencies | ||
| # Execute | ||
| plugin.install() | ||
| # Verify builder was called | ||
| conan_setup_mocks['builder'].generate_conanfile.assert_called_once() | ||
| assert ( | ||
| conan_setup_mocks['builder'].generate_conanfile.call_args[0][0] | ||
| == plugin.core_data.project_data.project_root | ||
| ) | ||
| assert len(conan_setup_mocks['builder'].generate_conanfile.call_args[0][1]) == EXPECTED_DEPENDENCY_COUNT | ||
| # Verify dependency resolution was called | ||
| assert conan_setup_mocks['resolve_conan_dependency'].call_count == EXPECTED_DEPENDENCY_COUNT | ||
| # Verify build path was created | ||
| assert plugin.core_data.cppython_data.build_path.exists() | ||
| # Verify ConanAPI constructor was called | ||
| conan_setup_mocks['conan_api_constructor'].assert_called_once() | ||
| def test_conan_command_failure( | ||
| self, | ||
| plugin: ConanProvider, | ||
| conan_temp_conanfile: Path, | ||
| conan_mock_dependencies: list[Requirement], | ||
| conan_mock_api: Mock, | ||
| mocker: MockerFixture, | ||
| ) -> None: | ||
| """Test install method when conan API operations fail | ||
| Args: | ||
| plugin: The plugin instance | ||
| conan_temp_conanfile: Path to temporary conanfile.py | ||
| conan_mock_dependencies: List of mock dependencies | ||
| conan_mock_api: Mock ConanAPI instance | ||
| mocker: Pytest mocker fixture | ||
| """ | ||
| # Mock builder | ||
| mock_builder = mocker.Mock() | ||
| mock_builder.generate_conanfile = mocker.Mock() | ||
| plugin.builder = mock_builder # type: ignore[attr-defined] | ||
| # Configure the API mock to fail on graph loading | ||
| conan_mock_api.graph.load_graph_consumer.side_effect = Exception('Conan API error: package not found') | ||
| # Mock ConanAPI constructor to return our configured mock | ||
| mock_conan_api_constructor = mocker.patch('cppython.plugins.conan.plugin.ConanAPI', return_value=conan_mock_api) | ||
| # Mock resolve_conan_dependency | ||
| def mock_resolve(requirement: Requirement) -> ConanDependency: | ||
| return ConanDependency(name=requirement.name) | ||
| mocker.patch('cppython.plugins.conan.plugin.resolve_conan_dependency', side_effect=mock_resolve) | ||
| # Add a dependency | ||
| plugin.core_data.cppython_data.dependencies = [conan_mock_dependencies[0]] | ||
| # Execute and verify exception is raised | ||
| with pytest.raises( | ||
| ProviderInstallationError, | ||
| match='Failed to load dependency graph: Conan API error: package not found', | ||
| ): | ||
| plugin.install() | ||
| # Verify builder was still called | ||
| mock_builder.generate_conanfile.assert_called_once() | ||
| # Verify Conan API was attempted | ||
| mock_conan_api_constructor.assert_called_once() | ||
| def test_with_default_profiles( | ||
| self, | ||
| plugin: ConanProvider, | ||
| conan_temp_conanfile: Path, | ||
| conan_mock_dependencies: list[Requirement], | ||
| conan_setup_mocks: dict[str, Mock], | ||
| conan_mock_api: Mock, | ||
| ) -> None: | ||
| """Test install method uses pre-resolved profiles from plugin construction | ||
| Args: | ||
| plugin: The plugin instance | ||
| conan_temp_conanfile: Path to temporary conanfile.py | ||
| conan_mock_dependencies: List of mock dependencies | ||
| conan_setup_mocks: Dictionary containing all mocks | ||
| conan_mock_api: Mock ConanAPI instance | ||
| """ | ||
| # Setup dependencies | ||
| plugin.core_data.cppython_data.dependencies = conan_mock_dependencies | ||
| # Execute - should use the profiles resolved during plugin construction | ||
| plugin.install() | ||
| # Verify that the API was used for installation | ||
| conan_setup_mocks['conan_api_constructor'].assert_called_once() | ||
| # 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 |
| """Unit tests for the conan plugin publish functionality""" | ||
| from typing import Any | ||
| from unittest.mock import MagicMock, Mock | ||
| import pytest | ||
| from pytest_mock import MockerFixture | ||
| from cppython.plugins.conan.plugin import ConanProvider | ||
| from cppython.test.pytest.mixins import ProviderPluginTestMixin | ||
| from cppython.utility.exception import ProviderConfigurationError, ProviderInstallationError | ||
@@ -16,6 +13,3 @@ # Use shared fixtures | ||
| # Constants for test assertions | ||
| EXPECTED_PROFILE_CALLS = 2 | ||
| class TestConanPublish(ProviderPluginTestMixin[ConanProvider]): | ||
@@ -45,246 +39,1 @@ """Tests for the Conan provider publish functionality""" | ||
| return ConanProvider | ||
| def test_skip_upload( | ||
| self, plugin: ConanProvider, conan_mock_api_publish: Mock, conan_temp_conanfile: None, mocker: MockerFixture | ||
| ) -> None: | ||
| """Test that publish with skip_upload=True only exports and builds locally | ||
| Args: | ||
| plugin: The plugin instance | ||
| conan_mock_api_publish: Mock ConanAPI for publish operations | ||
| conan_temp_conanfile: Fixture to create conanfile.py | ||
| mocker: Pytest mocker fixture | ||
| """ | ||
| # Set plugin to skip upload mode | ||
| plugin.data.skip_upload = True | ||
| # Mock the necessary imports and API creation | ||
| mocker.patch('cppython.plugins.conan.plugin.ConanAPI', return_value=conan_mock_api_publish) | ||
| # Mock the dependencies graph | ||
| mock_graph = mocker.Mock() | ||
| conan_mock_api_publish.graph.load_graph_consumer.return_value = mock_graph | ||
| # Execute publish | ||
| plugin.publish() | ||
| # Verify export was called | ||
| conan_mock_api_publish.export.export.assert_called_once() | ||
| # Verify graph loading and analysis | ||
| conan_mock_api_publish.graph.load_graph_consumer.assert_called_once() | ||
| conan_mock_api_publish.graph.analyze_binaries.assert_called_once_with( | ||
| graph=mock_graph, | ||
| build_mode=['*'], | ||
| remotes=conan_mock_api_publish.remotes.list(), | ||
| update=None, | ||
| lockfile=None, | ||
| ) | ||
| # Verify install was called | ||
| conan_mock_api_publish.install.install_binaries.assert_called_once_with( | ||
| deps_graph=mock_graph, remotes=conan_mock_api_publish.remotes.list() | ||
| ) | ||
| # Verify upload was NOT called for local mode | ||
| conan_mock_api_publish.upload.upload_full.assert_not_called() | ||
| 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 | ||
| Args: | ||
| plugin: The plugin instance | ||
| conan_mock_api_publish: Mock ConanAPI for publish operations | ||
| conan_temp_conanfile: Fixture to create conanfile.py | ||
| mocker: Pytest mocker fixture | ||
| """ | ||
| # Set plugin to upload mode | ||
| plugin.data.remotes = ['conancenter'] | ||
| # Mock the necessary imports and API creation | ||
| mocker.patch('cppython.plugins.conan.plugin.ConanAPI', return_value=conan_mock_api_publish) | ||
| # Mock the dependencies graph | ||
| mock_graph = mocker.Mock() | ||
| conan_mock_api_publish.graph.load_graph_consumer.return_value = mock_graph | ||
| # Execute publish | ||
| plugin.publish() | ||
| # Verify all steps were called | ||
| conan_mock_api_publish.export.export.assert_called_once() | ||
| conan_mock_api_publish.graph.load_graph_consumer.assert_called_once() | ||
| conan_mock_api_publish.graph.analyze_binaries.assert_called_once() | ||
| conan_mock_api_publish.install.install_binaries.assert_called_once() | ||
| # Verify upload was called | ||
| conan_mock_api_publish.list.select.assert_called_once() | ||
| conan_mock_api_publish.upload.upload_full.assert_called_once() | ||
| 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 | ||
| Args: | ||
| plugin: The plugin instance | ||
| conan_mock_api_publish: Mock ConanAPI for publish operations | ||
| conan_temp_conanfile: Fixture to create conanfile.py | ||
| mocker: Pytest mocker fixture | ||
| """ | ||
| # Set plugin to upload mode | ||
| plugin.data.remotes = ['conancenter'] | ||
| # Mock the necessary imports and API creation | ||
| mocker.patch('cppython.plugins.conan.plugin.ConanAPI', return_value=conan_mock_api_publish) | ||
| # Mock the dependencies graph | ||
| mock_graph = mocker.Mock() | ||
| conan_mock_api_publish.graph.load_graph_consumer.return_value = mock_graph | ||
| # Mock no remotes configured | ||
| conan_mock_api_publish.remotes.list.return_value = [] | ||
| # Execute publish and expect ProviderConfigurationError | ||
| with pytest.raises(ProviderConfigurationError, match='No configured remotes found'): | ||
| plugin.publish() | ||
| 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 | ||
| Args: | ||
| plugin: The plugin instance | ||
| conan_mock_api_publish: Mock ConanAPI for publish operations | ||
| conan_temp_conanfile: Fixture to create conanfile.py | ||
| mocker: Pytest mocker fixture | ||
| """ | ||
| # Set plugin to upload mode | ||
| plugin.data.remotes = ['conancenter'] | ||
| # Mock the necessary imports and API creation | ||
| mocker.patch('cppython.plugins.conan.plugin.ConanAPI', return_value=conan_mock_api_publish) | ||
| # Mock the dependencies graph | ||
| mock_graph = mocker.Mock() | ||
| conan_mock_api_publish.graph.load_graph_consumer.return_value = mock_graph | ||
| # Mock empty package list | ||
| mock_select_result = mocker.Mock() | ||
| mock_select_result.recipes = [] | ||
| conan_mock_api_publish.list.select.return_value = mock_select_result | ||
| # Execute publish and expect ProviderInstallationError | ||
| with pytest.raises(ProviderInstallationError, match='No packages found to upload'): | ||
| plugin.publish() | ||
| def test_with_default_profiles( | ||
| self, plugin: ConanProvider, conan_mock_api_publish: Mock, conan_temp_conanfile: None, mocker: MockerFixture | ||
| ) -> None: | ||
| """Test that publish uses pre-resolved profiles from plugin construction | ||
| Args: | ||
| plugin: The plugin instance | ||
| conan_mock_api_publish: Mock ConanAPI for publish operations | ||
| conan_temp_conanfile: Fixture to create conanfile.py | ||
| mocker: Pytest mocker fixture | ||
| """ | ||
| # Set plugin to skip upload mode | ||
| plugin.data.skip_upload = True | ||
| # Mock the necessary imports and API creation | ||
| mocker.patch('cppython.plugins.conan.plugin.ConanAPI', return_value=conan_mock_api_publish) | ||
| # Mock the dependencies graph | ||
| mock_graph = mocker.Mock() | ||
| conan_mock_api_publish.graph.load_graph_consumer.return_value = mock_graph | ||
| # Execute publish | ||
| plugin.publish() | ||
| # 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_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 | ||
| Args: | ||
| plugin: The plugin instance | ||
| conan_mock_api_publish: Mock ConanAPI for publish operations | ||
| conan_temp_conanfile: Fixture to create conanfile.py | ||
| mocker: Pytest mocker fixture | ||
| """ | ||
| # Set plugin to upload mode | ||
| plugin.data.remotes = ['conancenter'] | ||
| # Mock the necessary imports and API creation | ||
| mocker.patch('cppython.plugins.conan.plugin.ConanAPI', return_value=conan_mock_api_publish) | ||
| # Mock the dependencies graph | ||
| mock_graph = mocker.Mock() | ||
| conan_mock_api_publish.graph.load_graph_consumer.return_value = mock_graph | ||
| # Mock remotes and package list | ||
| mock_remote = MagicMock() | ||
| mock_remote.name = 'conancenter' | ||
| remotes = [mock_remote] | ||
| conan_mock_api_publish.remotes.list.return_value = remotes | ||
| mock_package_list = MagicMock() | ||
| mock_package_list.recipes = ['test_package/1.0@user/channel'] | ||
| conan_mock_api_publish.list.select.return_value = mock_package_list | ||
| # Execute publish | ||
| plugin.publish() | ||
| # Verify upload_full was called with correct parameters | ||
| conan_mock_api_publish.upload.upload_full.assert_called_once_with( | ||
| package_list=mock_package_list, | ||
| remote=mock_remote, | ||
| enabled_remotes=remotes, | ||
| check_integrity=False, | ||
| force=False, | ||
| metadata=None, | ||
| dry_run=False, | ||
| ) | ||
| 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 | ||
| Args: | ||
| plugin: The plugin instance | ||
| conan_mock_api_publish: Mock ConanAPI for publish operations | ||
| conan_temp_conanfile: Fixture to create conanfile.py | ||
| mocker: Pytest mocker fixture | ||
| """ | ||
| # Set plugin to upload mode | ||
| plugin.data.remotes = ['conancenter'] | ||
| # Mock the necessary imports and API creation | ||
| mocker.patch('cppython.plugins.conan.plugin.ConanAPI', return_value=conan_mock_api_publish) | ||
| mock_list_pattern = mocker.patch('cppython.plugins.conan.plugin.ListPattern') | ||
| # Mock the dependencies graph | ||
| mock_graph = mocker.Mock() | ||
| conan_mock_api_publish.graph.load_graph_consumer.return_value = mock_graph | ||
| # Execute publish | ||
| plugin.publish() | ||
| # Get the ref from the export call to verify ListPattern creation | ||
| # The export call returns (ref, conanfile) - we need the ref.name | ||
| export_return = conan_mock_api_publish.export.export.return_value | ||
| ref = export_return[0] # First element of the tuple | ||
| # Verify ListPattern was created with correct reference pattern | ||
| mock_list_pattern.assert_called_once_with(f'{ref.name}/*', package_id='*', only_recipe=False) |
| """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, | ||
@@ -26,3 +17,2 @@ ConanRevision, | ||
| ) | ||
| from cppython.utility.exception import ProviderConfigurationError | ||
@@ -140,3 +130,3 @@ # Constants for test validation | ||
| dependency = ConanDependency( | ||
| name='mylib', | ||
| name='example', | ||
| version=ConanVersion.from_string('1.0.0'), | ||
@@ -146,3 +136,3 @@ user_channel=ConanUserChannel(user='myuser', channel='stable'), | ||
| assert dependency.requires() == 'mylib/1.0.0@myuser/stable' | ||
| assert dependency.requires() == 'example/1.0.0@myuser/stable' | ||
@@ -152,6 +142,6 @@ def test_with_revision(self) -> None: | ||
| dependency = ConanDependency( | ||
| name='mylib', version=ConanVersion.from_string('1.0.0'), revision=ConanRevision(revision='abc123') | ||
| name='example', version=ConanVersion.from_string('1.0.0'), revision=ConanRevision(revision='abc123') | ||
| ) | ||
| assert dependency.requires() == 'mylib/1.0.0#abc123' | ||
| assert dependency.requires() == 'example/1.0.0#abc123' | ||
@@ -161,3 +151,3 @@ def test_full_reference(self) -> None: | ||
| dependency = ConanDependency( | ||
| name='mylib', | ||
| name='example', | ||
| version=ConanVersion.from_string('1.0.0'), | ||
@@ -168,9 +158,9 @@ user_channel=ConanUserChannel(user='myuser', channel='stable'), | ||
| assert dependency.requires() == 'mylib/1.0.0@myuser/stable#abc123' | ||
| assert dependency.requires() == 'example/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') | ||
| dependency = ConanDependency.from_conan_reference('example') | ||
| assert dependency.name == 'mylib' | ||
| assert dependency.name == 'example' | ||
| assert dependency.version is None | ||
@@ -182,5 +172,5 @@ assert dependency.user_channel is None | ||
| """Test parsing a package with version.""" | ||
| dependency = ConanDependency.from_conan_reference('mylib/1.0.0') | ||
| dependency = ConanDependency.from_conan_reference('example/1.0.0') | ||
| assert dependency.name == 'mylib' | ||
| assert dependency.name == 'example' | ||
| assert dependency.version is not None | ||
@@ -193,5 +183,5 @@ assert str(dependency.version) == '1.0.0' | ||
| """Test parsing a package with version range.""" | ||
| dependency = ConanDependency.from_conan_reference('mylib/[>=1.0 <2.0]') | ||
| dependency = ConanDependency.from_conan_reference('example/[>=1.0 <2.0]') | ||
| assert dependency.name == 'mylib' | ||
| assert dependency.name == 'example' | ||
| assert dependency.version is None | ||
@@ -205,5 +195,5 @@ assert dependency.version_range is not None | ||
| """Test parsing a full Conan reference.""" | ||
| dependency = ConanDependency.from_conan_reference('mylib/1.0.0@myuser/stable#abc123') | ||
| dependency = ConanDependency.from_conan_reference('example/1.0.0@myuser/stable#abc123') | ||
| assert dependency.name == 'mylib' | ||
| assert dependency.name == 'example' | ||
| assert dependency.version is not None | ||
@@ -218,290 +208,7 @@ assert str(dependency.version) == '1.0.0' | ||
| 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': [], 'skip_upload': False} | ||
| 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 == [] | ||
| assert result.skip_upload is False | ||
| # 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 |
Alert delta unavailable
Currently unable to show alert delta for PyPI packages.
121
0.83%278430
-9.53%6125
-7.53%