From e8d7326d286230e62ff4f91b94b6e10903ef060d Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Sat, 29 Nov 2025 16:43:26 -0800 Subject: [PATCH 1/2] Pass CMake Bin to Conan --- cppython/plugins/conan/builder.py | 49 +++++++++-- cppython/plugins/conan/plugin.py | 15 ++-- cppython/plugins/conan/schema.py | 13 +++ tests/unit/plugins/conan/test_builder.py | 107 ++++++++++++++++++----- 4 files changed, 151 insertions(+), 33 deletions(-) diff --git a/cppython/plugins/conan/builder.py b/cppython/plugins/conan/builder.py index fc255ad..693bd32 100644 --- a/cppython/plugins/conan/builder.py +++ b/cppython/plugins/conan/builder.py @@ -1,10 +1,11 @@ """Construction of Conan data""" +import shutil from pathlib import Path from pydantic import DirectoryPath -from cppython.plugins.conan.schema import ConanDependency +from cppython.plugins.conan.schema import ConanDependency, ConanfileGenerationData class Builder: @@ -19,8 +20,16 @@ def _create_base_conanfile( base_file: Path, dependencies: list[ConanDependency], dependency_groups: dict[str, list[ConanDependency]], + cmake_binary: Path | None = None, ) -> None: - """Creates a conanfile_base.py with CPPython managed dependencies.""" + """Creates a conanfile_base.py with CPPython managed dependencies. + + Args: + base_file: Path to write the base conanfile + dependencies: List of main dependencies + dependency_groups: Dictionary of dependency groups (e.g., 'test') + cmake_binary: Optional path to CMake binary to use + """ test_dependencies = dependency_groups.get('test', []) # Generate requirements method content @@ -37,6 +46,16 @@ def _create_base_conanfile( '\n'.join(test_requires_lines) if test_requires_lines else ' pass # No test requirements' ) + # Generate configure method content for cmake_program if specified + if cmake_binary: + # Use forward slashes for cross-platform compatibility in Conan + cmake_path_str = str(cmake_binary.resolve()).replace('\\', '/') + configure_content = f''' def configure(self): + """CPPython managed configuration.""" + self.conf.define("tools.cmake:cmake_program", "{cmake_path_str}")''' + else: + configure_content = '' + content = f'''"""CPPython managed base ConanFile. This file is auto-generated by CPPython. Do not edit manually. @@ -48,6 +67,7 @@ def _create_base_conanfile( class CPPythonBase(ConanFile): """Base ConanFile with CPPython managed dependencies.""" +{configure_content} def requirements(self): """CPPython managed requirements.""" @@ -135,23 +155,36 @@ def export_sources(self): def generate_conanfile( self, directory: DirectoryPath, - dependencies: list[ConanDependency], - dependency_groups: dict[str, list[ConanDependency]], - name: str, - version: str, + data: ConanfileGenerationData, ) -> None: """Generate conanfile.py and conanfile_base.py for the project. Always generates the base conanfile with managed dependencies. Only creates conanfile.py if it doesn't exist (never modifies existing files). + + Args: + directory: The project directory + data: Generation data containing dependencies, project info, and cmake binary path. + If cmake_binary is not provided, attempts to find cmake in the current + Python environment. """ directory.mkdir(parents=True, exist_ok=True) + # Resolve cmake binary path + resolved_cmake: Path | None = None + if data.cmake_binary and data.cmake_binary != 'cmake': + resolved_cmake = Path(data.cmake_binary).resolve() + else: + # Try to find cmake in the current Python environment (venv) + cmake_path = shutil.which('cmake') + if cmake_path: + resolved_cmake = Path(cmake_path).resolve() + # Always regenerate the base conanfile with managed dependencies base_file = directory / 'conanfile_base.py' - self._create_base_conanfile(base_file, dependencies, dependency_groups) + self._create_base_conanfile(base_file, data.dependencies, data.dependency_groups, resolved_cmake) # Only create conanfile.py if it doesn't exist conan_file = directory / self._filename if not conan_file.exists(): - self._create_conanfile(conan_file, name, version) + self._create_conanfile(conan_file, data.name, data.version) diff --git a/cppython/plugins/conan/plugin.py b/cppython/plugins/conan/plugin.py index 077a0ca..23d6752 100644 --- a/cppython/plugins/conan/plugin.py +++ b/cppython/plugins/conan/plugin.py @@ -20,7 +20,7 @@ from cppython.plugins.cmake.schema import CMakeSyncData from cppython.plugins.conan.builder import Builder from cppython.plugins.conan.resolution import resolve_conan_data, resolve_conan_dependency -from cppython.plugins.conan.schema import ConanData +from cppython.plugins.conan.schema import ConanData, ConanfileGenerationData from cppython.utility.exception import NotSupportedError, ProviderInstallationError from cppython.utility.utility import TypeName @@ -116,12 +116,17 @@ def _prepare_installation(self, groups: list[str] | None = None) -> Path: for req in self.core_data.cppython_data.dependency_groups[group_name] ] + generation_data = ConanfileGenerationData( + dependencies=resolved_dependencies, + dependency_groups=resolved_dependency_groups, + name=self.core_data.pep621_data.name, + version=self.core_data.pep621_data.version, + cmake_binary=self._cmake_binary, + ) + self.builder.generate_conanfile( self.core_data.project_data.project_root, - resolved_dependencies, - resolved_dependency_groups, - self.core_data.pep621_data.name, - self.core_data.pep621_data.version, + generation_data, ) # Ensure build directory exists diff --git a/cppython/plugins/conan/schema.py b/cppython/plugins/conan/schema.py index f425452..eb81f9f 100644 --- a/cppython/plugins/conan/schema.py +++ b/cppython/plugins/conan/schema.py @@ -297,6 +297,19 @@ class ConanData(CPPythonModel): profile_dir: Path +class ConanfileGenerationData(CPPythonModel): + """Data required for generating conanfile.py and conanfile_base.py. + + Groups related parameters for conanfile generation to reduce function argument count. + """ + + dependencies: list[ConanDependency] + dependency_groups: dict[str, list[ConanDependency]] + name: str + version: str + cmake_binary: str | None = None + + class ConanConfiguration(CPPythonModel): """Conan provider configuration""" diff --git a/tests/unit/plugins/conan/test_builder.py b/tests/unit/plugins/conan/test_builder.py index 8d584fe..7eea5a4 100644 --- a/tests/unit/plugins/conan/test_builder.py +++ b/tests/unit/plugins/conan/test_builder.py @@ -6,7 +6,7 @@ import pytest from cppython.plugins.conan.builder import Builder -from cppython.plugins.conan.schema import ConanDependency, ConanVersion +from cppython.plugins.conan.schema import ConanDependency, ConanfileGenerationData, ConanVersion class TestBuilder: @@ -44,13 +44,16 @@ def test_creates_both_files(self, builder: Builder, tmp_path: Path) -> None: ] dependency_groups = {} - builder.generate_conanfile( - directory=tmp_path, + data = ConanfileGenerationData( dependencies=dependencies, dependency_groups=dependency_groups, name='test-project', version='1.0.0', ) + builder.generate_conanfile( + directory=tmp_path, + data=data, + ) base_file = tmp_path / 'conanfile_base.py' conan_file = tmp_path / 'conanfile.py' @@ -60,37 +63,43 @@ def test_creates_both_files(self, builder: Builder, tmp_path: Path) -> None: def test_regenerates_base_file(self, builder: Builder, tmp_path: Path) -> None: """Test base file is always regenerated with new dependencies.""" - dependencies_v1 = [ + initial_dependencies = [ ConanDependency(name='boost', version=ConanVersion.from_string('1.80.0')), ] - builder.generate_conanfile( - directory=tmp_path, - dependencies=dependencies_v1, + initial_data = ConanfileGenerationData( + dependencies=initial_dependencies, dependency_groups={}, name='test-project', version='1.0.0', ) + builder.generate_conanfile( + directory=tmp_path, + data=initial_data, + ) base_file = tmp_path / 'conanfile_base.py' - content_v1 = base_file.read_text(encoding='utf-8') - assert 'boost/1.80.0' in content_v1 + initial_content = base_file.read_text(encoding='utf-8') + assert 'boost/1.80.0' in initial_content - dependencies_v2 = [ + updated_dependencies = [ ConanDependency(name='zlib', version=ConanVersion.from_string('1.2.13')), ] - builder.generate_conanfile( - directory=tmp_path, - dependencies=dependencies_v2, + updated_data = ConanfileGenerationData( + dependencies=updated_dependencies, dependency_groups={}, name='test-project', version='1.0.0', ) + builder.generate_conanfile( + directory=tmp_path, + data=updated_data, + ) - content_v2 = base_file.read_text(encoding='utf-8') - assert 'zlib/1.2.13' in content_v2 - assert 'boost/1.80.0' not in content_v2 + updated_content = base_file.read_text(encoding='utf-8') + assert 'zlib/1.2.13' in updated_content + assert 'boost/1.80.0' not in updated_content def test_preserves_user_file(self, builder: Builder, tmp_path: Path) -> None: """Test user conanfile is never modified once created.""" @@ -112,13 +121,16 @@ def requirements(self): ConanDependency(name='boost', version=ConanVersion.from_string('1.80.0')), ] - builder.generate_conanfile( - directory=tmp_path, + data = ConanfileGenerationData( dependencies=dependencies, dependency_groups={}, name='new-name', version='2.0.0', ) + builder.generate_conanfile( + directory=tmp_path, + data=data, + ) final_content = conan_file.read_text() assert final_content == custom_content @@ -137,13 +149,16 @@ def test_inheritance_chain(self, builder: Builder, tmp_path: Path) -> None: ] } - builder.generate_conanfile( - directory=tmp_path, + data = ConanfileGenerationData( dependencies=dependencies, dependency_groups=dependency_groups, name='test-project', version='1.0.0', ) + builder.generate_conanfile( + directory=tmp_path, + data=data, + ) base_content = (tmp_path / 'conanfile_base.py').read_text(encoding='utf-8') user_content = (tmp_path / 'conanfile.py').read_text(encoding='utf-8') @@ -156,3 +171,55 @@ def test_inheritance_chain(self, builder: Builder, tmp_path: Path) -> None: assert 'class TestProjectPackage(CPPythonBase):' in user_content assert 'super().requirements()' in user_content assert 'super().build_requirements()' in user_content + + def test_cmake_binary_configure(self, builder: Builder, tmp_path: Path) -> None: + """Test that cmake_binary generates configure() with forward slashes.""" + base_file = tmp_path / 'conanfile_base.py' + cmake_path = Path('C:/Program Files/CMake/bin/cmake.exe') + + builder._create_base_conanfile(base_file, [], {}, cmake_binary=cmake_path) + + content = base_file.read_text(encoding='utf-8') + assert 'def configure(self):' in content + assert 'self.conf.define("tools.cmake:cmake_program"' in content + assert 'C:/Program Files/CMake/bin/cmake.exe' in content + assert '\\' not in content.split('tools.cmake:cmake_program')[1].split('"')[1] + + def test_no_cmake_binary(self, builder: Builder, tmp_path: Path) -> None: + """Test that no cmake_binary means no configure() method.""" + base_file = tmp_path / 'conanfile_base.py' + + builder._create_base_conanfile(base_file, [], {}, cmake_binary=None) + + content = base_file.read_text(encoding='utf-8') + assert 'def configure(self):' not in content + + @pytest.mark.parametrize( + ('venv_cmake', 'expect_configure'), + [ + ('/path/to/venv/bin/cmake', True), + (None, False), + ], + ) + def test_cmake_binary_venv_fallback( + self, + builder: Builder, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + venv_cmake: str | None, + expect_configure: bool, + ) -> None: + """Test venv cmake fallback when cmake_binary is default.""" + monkeypatch.setattr('cppython.plugins.conan.builder.shutil.which', lambda _: venv_cmake) + + data = ConanfileGenerationData( + dependencies=[], + dependency_groups={}, + name='test-project', + version='1.0.0', + cmake_binary='cmake', + ) + builder.generate_conanfile(directory=tmp_path, data=data) + + content = (tmp_path / 'conanfile_base.py').read_text(encoding='utf-8') + assert ('def configure(self):' in content) == expect_configure From 6c77d6d767ef0a9de60642f5d40186cb7942328d Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Sun, 30 Nov 2025 09:47:21 -0800 Subject: [PATCH 2/2] Default CMake Binary to VEnv --- cppython/plugins/conan/plugin.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/cppython/plugins/conan/plugin.py b/cppython/plugins/conan/plugin.py index 23d6752..362efe9 100644 --- a/cppython/plugins/conan/plugin.py +++ b/cppython/plugins/conan/plugin.py @@ -6,6 +6,7 @@ """ import os +import shutil from logging import Logger, getLogger from pathlib import Path from typing import Any @@ -45,8 +46,7 @@ def __init__( self._ensure_default_profiles() - # Initialize cmake_binary with system default. It may be overridden during sync. - self._cmake_binary = 'cmake' + self._cmake_binary: str | None = None @staticmethod def features(directory: Path) -> SupportedFeatures: @@ -174,7 +174,7 @@ def _run_conan_install(self, conanfile_path: Path, update: bool, build_type: str command_args.extend(['-s', f'build_type={build_type}']) # Add cmake binary configuration if specified - if self._cmake_binary and self._cmake_binary != 'cmake': + if self._cmake_binary: # Quote the path if it contains spaces cmake_path = f'"{self._cmake_binary}"' if ' ' in self._cmake_binary else self._cmake_binary command_args.extend(['-c', f'tools.cmake:cmake_program={cmake_path}']) @@ -239,6 +239,23 @@ def sync_data(self, consumer: SyncConsumer) -> SyncData: raise NotSupportedError(f'Unsupported sync types: {consumer.sync_types()}') + @staticmethod + def _resolve_cmake_binary(cmake_path: Path | str | None) -> str | None: + """Resolve the cmake binary path. + + If an explicit path is provided, use it. Otherwise, try to find cmake + in the current Python environment (venv) to ensure we use the same + cmake version for all operations including dependency builds. + + Args: + cmake_path: Explicit cmake path, or None to auto-detect + + Returns: + Resolved cmake path as string, or None if not found + """ + resolved = cmake_path or shutil.which('cmake') + return str(Path(resolved).resolve()) if resolved else None + def _sync_with_cmake(self, consumer: SyncConsumer) -> CMakeSyncData: """Synchronize with CMake generator and create sync data. @@ -250,10 +267,7 @@ def _sync_with_cmake(self, consumer: SyncConsumer) -> CMakeSyncData: """ # Extract cmake_binary from CMakeGenerator if available if isinstance(consumer, CMakeGenerator) and not os.environ.get('CMAKE_BINARY'): - # Only override if not already set from environment variable - # Convert Path to string, or use 'cmake' if None - cmake_path = consumer.data.cmake_binary - self._cmake_binary = str(cmake_path) if cmake_path else 'cmake' + self._cmake_binary = self._resolve_cmake_binary(consumer.data.cmake_binary) return self._create_cmake_sync_data() @@ -301,7 +315,7 @@ def publish(self) -> None: command_args.extend(['-c', 'tools.build:skip_test=True']) # Add cmake binary configuration if specified - if self._cmake_binary and self._cmake_binary != 'cmake': + if self._cmake_binary: # Quote the path if it contains spaces cmake_path = f'"{self._cmake_binary}"' if ' ' in self._cmake_binary else self._cmake_binary command_args.extend(['-c', f'tools.cmake:cmake_program={cmake_path}'])