Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 41 additions & 8 deletions cppython/plugins/conan/builder.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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."""
Expand Down Expand Up @@ -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)
15 changes: 10 additions & 5 deletions cppython/plugins/conan/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions cppython/plugins/conan/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""

Expand Down
107 changes: 87 additions & 20 deletions tests/unit/plugins/conan/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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'
Expand All @@ -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."""
Expand All @@ -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
Expand All @@ -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')
Expand All @@ -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
Loading