Skip to content

Commit 2d58512

Browse files
committed
Implement Conan + Req CMake Changes
1 parent 7cd4064 commit 2d58512

File tree

12 files changed

+227
-84
lines changed

12 files changed

+227
-84
lines changed

cppython/plugins/cmake/builder.py

+84-26
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
"""Plugin builder"""
22

3-
import json
4-
from copy import deepcopy
53
from pathlib import Path
64

75
from cppython.plugins.cmake.schema import CMakePresets, CMakeSyncData, ConfigurePreset
@@ -10,48 +8,85 @@
108
class Builder:
119
"""Aids in building the information needed for the CMake plugin"""
1210

11+
def __init__(self) -> None:
12+
"""Initialize the builder"""
13+
1314
@staticmethod
14-
def write_provider_preset(provider_directory: Path, data: CMakeSyncData) -> None:
15+
def write_provider_preset(provider_directory: Path, provider_data: CMakeSyncData) -> None:
1516
"""Writes a provider preset from input sync data
1617
1718
Args:
1819
provider_directory: The base directory to place the preset files
19-
data: The providers synchronization data
20+
provider_data: The providers synchronization data
2021
"""
21-
configure_preset = ConfigurePreset(name=data.provider_name, cacheVariables=None)
22-
presets = CMakePresets(configurePresets=[configure_preset])
22+
generated_configure_preset = ConfigurePreset(name=provider_data.provider_name)
23+
24+
# Toss in that sync data from the provider
25+
generated_configure_preset.cacheVariables = {
26+
'CMAKE_PROJECT_TOP_LEVEL_INCLUDES': str(provider_data.top_level_includes.as_posix()),
27+
}
28+
29+
generated_preset = CMakePresets(configurePresets=[generated_configure_preset])
2330

24-
json_path = provider_directory / f'{data.provider_name}.json'
31+
provider_preset_file = provider_directory / f'{provider_data.provider_name}.json'
2532

26-
serialized = presets.model_dump_json(exclude_none=True, by_alias=False, indent=4)
27-
with open(json_path, 'w', encoding='utf8') as file:
28-
file.write(serialized)
33+
initial_preset = None
34+
35+
# If the file already exists, we need to compare it
36+
if provider_preset_file.exists():
37+
with open(provider_preset_file, encoding='utf-8') as file:
38+
initial_json = file.read()
39+
initial_preset = CMakePresets.model_validate_json(initial_json)
40+
41+
if generated_preset != initial_preset:
42+
serialized = generated_preset.model_dump_json(exclude_none=True, by_alias=False, indent=4)
43+
with open(provider_preset_file, 'w', encoding='utf8') as file:
44+
file.write(serialized)
2945

3046
@staticmethod
3147
def write_cppython_preset(
32-
cppython_preset_directory: Path, _provider_directory: Path, _provider_data: CMakeSyncData
48+
cppython_preset_directory: Path, provider_directory: Path, provider_data: CMakeSyncData
3349
) -> Path:
3450
"""Write the cppython presets which inherit from the provider presets
3551
3652
Args:
3753
cppython_preset_directory: The tool directory
54+
provider_directory: The base directory containing provider presets
55+
provider_data: The provider's synchronization data
3856
3957
Returns:
4058
A file path to the written data
4159
"""
42-
configure_preset = ConfigurePreset(name='cppython', cacheVariables=None)
43-
presets = CMakePresets(configurePresets=[configure_preset])
60+
generated_configure_preset = ConfigurePreset(name='cppython', inherits=provider_data.provider_name)
61+
generated_preset = CMakePresets(configurePresets=[generated_configure_preset])
62+
63+
# Get the relative path to the provider preset file
64+
provider_preset_file = provider_directory / f'{provider_data.provider_name}.json'
65+
relative_preset = provider_preset_file.relative_to(cppython_preset_directory, walk_up=True).as_posix()
4466

45-
cppython_json_path = cppython_preset_directory / 'cppython.json'
67+
# Set the data
68+
generated_preset.include = [relative_preset]
4669

47-
serialized = presets.model_dump_json(exclude_none=True, by_alias=False, indent=4)
48-
with open(cppython_json_path, 'w', encoding='utf8') as file:
49-
file.write(serialized)
70+
cppython_preset_file = cppython_preset_directory / 'cppython.json'
5071

51-
return cppython_json_path
72+
initial_preset = None
73+
74+
# If the file already exists, we need to compare it
75+
if cppython_preset_file.exists():
76+
with open(cppython_preset_file, encoding='utf-8') as file:
77+
initial_json = file.read()
78+
initial_preset = CMakePresets.model_validate_json(initial_json)
79+
80+
# Only write the file if the data has changed
81+
if generated_preset != initial_preset:
82+
serialized = generated_preset.model_dump_json(exclude_none=True, by_alias=False, indent=4)
83+
with open(cppython_preset_file, 'w', encoding='utf8') as file:
84+
file.write(serialized)
85+
86+
return cppython_preset_file
5287

5388
@staticmethod
54-
def write_root_presets(preset_file: Path, _: Path) -> None:
89+
def write_root_presets(preset_file: Path, cppython_preset_file: Path) -> None:
5590
"""Read the top level json file and insert the include reference.
5691
5792
Receives a relative path to the tool cmake json file
@@ -61,14 +96,37 @@ def write_root_presets(preset_file: Path, _: Path) -> None:
6196
6297
Args:
6398
preset_file: Preset file to modify
99+
cppython_preset_file: Path to the cppython preset file to include
64100
"""
65-
with open(preset_file, encoding='utf-8') as file:
66-
initial_json = file.read()
67-
68-
initial_root_preset = CMakePresets.model_validate_json(initial_json)
69-
70-
# Only write the file if the contents have changed
71-
if (root_preset := deepcopy(initial_root_preset)) != initial_root_preset:
101+
initial_root_preset = None
102+
103+
# If the file already exists, we need to compare it
104+
if preset_file.exists():
105+
with open(preset_file, encoding='utf-8') as file:
106+
initial_json = file.read()
107+
initial_root_preset = CMakePresets.model_validate_json(initial_json)
108+
root_preset = initial_root_preset.model_copy(deep=True)
109+
else:
110+
# If the file doesn't exist, we need to default it for the user
111+
112+
# Forward the tool's build directory
113+
default_configure_preset = ConfigurePreset(name='default', inherits='cppython', binaryDir='build')
114+
root_preset = CMakePresets(configurePresets=[default_configure_preset])
115+
116+
# Get the relative path to the cppython preset file
117+
preset_directory = preset_file.parent.absolute()
118+
relative_preset = cppython_preset_file.relative_to(preset_directory, walk_up=True).as_posix()
119+
120+
# If the include key doesn't exist, we know we will write to disk afterwards
121+
if not root_preset.include:
122+
root_preset.include = []
123+
124+
# Only the included preset file if it doesn't exist. Implied by the above check
125+
if str(relative_preset) not in root_preset.include:
126+
root_preset.include.append(str(relative_preset))
127+
128+
# Only write the file if the data has changed
129+
if root_preset != initial_root_preset:
72130
with open(preset_file, 'w', encoding='utf-8') as file:
73131
preset = root_preset.model_dump_json(exclude_none=True, indent=4)
74132
file.write(preset)

cppython/plugins/cmake/resolution.py

+1-10
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
"""Builder to help resolve cmake state"""
22

3-
import json
43
from typing import Any
54

65
from cppython.core.schema import CorePluginData
7-
from cppython.plugins.cmake.schema import CMakeConfiguration, CMakeData, CMakePresets
6+
from cppython.plugins.cmake.schema import CMakeConfiguration, CMakeData
87

98

109
def resolve_cmake_data(data: dict[str, Any], core_data: CorePluginData) -> CMakeData:
@@ -25,12 +24,4 @@ def resolve_cmake_data(data: dict[str, Any], core_data: CorePluginData) -> CMake
2524
if not modified_preset_dir.is_absolute():
2625
modified_preset_dir = root_directory / modified_preset_dir
2726

28-
# If the user hasn't specified a preset file, we need to create one
29-
if not modified_preset_dir.exists():
30-
modified_preset_dir.parent.mkdir(parents=True, exist_ok=True)
31-
presets_string = CMakePresets().model_dump_json(exclude_none=True, indent=4)
32-
33-
with modified_preset_dir.open('w', encoding='utf-8') as file:
34-
file.write(presets_string)
35-
3627
return CMakeData(preset_file=modified_preset_dir, configuration_name=parsed_data.configuration_name)

cppython/plugins/cmake/schema.py

+9-4
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ class ConfigurePreset(CPPythonModel, extra='allow'):
4747
"""Partial Configure Preset specification to allow cache variable injection"""
4848

4949
name: str
50+
inherits: Annotated[
51+
str | list[str] | None, Field(description='The inherits field allows inheriting from other presets.')
52+
] = None
53+
binaryDir: Annotated[str | None, Field(description='The binary directory for the build output.')] = None
5054
cacheVariables: dict[str, None | bool | str | CacheVariable] | None = None
5155

5256

@@ -57,9 +61,10 @@ class CMakePresets(CPPythonModel, extra='allow'):
5761
"""
5862

5963
version: Annotated[int, Field(description='The version of the JSON schema.')] = 9
60-
configurePresets: Annotated[list[ConfigurePreset], Field(description='The list of configure presets')] = [
61-
ConfigurePreset(name='default')
62-
]
64+
include: Annotated[
65+
list[str] | None, Field(description='The include field allows inheriting from another preset.')
66+
] = None
67+
configurePresets: Annotated[list[ConfigurePreset] | None, Field(description='The list of configure presets')] = None
6368

6469

6570
class CMakeSyncData(SyncData):
@@ -71,7 +76,7 @@ class CMakeSyncData(SyncData):
7176
class CMakeData(CPPythonModel):
7277
"""Resolved CMake data"""
7378

74-
preset_file: FilePath
79+
preset_file: Path
7580
configuration_name: str
7681

7782

cppython/plugins/conan/builder.py

+68
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Construction of Conan data"""
2+
3+
from pathlib import Path
4+
from string import Template
5+
from textwrap import dedent
6+
7+
from pydantic import DirectoryPath
8+
9+
from cppython.plugins.conan.schema import ConanDependency
10+
11+
12+
class Builder:
13+
"""Aids in building the information needed for the Conan plugin"""
14+
15+
def __init__(self) -> None:
16+
"""Initialize the builder"""
17+
self._filename = 'conanfile.py'
18+
19+
@staticmethod
20+
def _create_conanfile(conan_file: Path, dependencies: list[ConanDependency]) -> None:
21+
"""Creates a conanfile.py file with the necessary content."""
22+
template_string = """
23+
from conan import ConanFile
24+
from conan.tools.cmake import CMake, CMakeToolchain, cmake_layout
25+
26+
class MyProject(ConanFile):
27+
name = "myproject"
28+
version = "1.0"
29+
settings = "os", "compiler", "build_type", "arch"
30+
requires = ${dependencies}
31+
generators = "CMakeDeps"
32+
33+
def layout(self):
34+
cmake_layout(self)
35+
36+
def generate(self):
37+
tc = CMakeToolchain(self)
38+
tc.generate()
39+
40+
def build(self):
41+
cmake = CMake(self)
42+
cmake.configure()
43+
cmake.build()"""
44+
45+
template = Template(dedent(template_string))
46+
47+
values = {
48+
'dependencies': [dependency.requires() for dependency in dependencies],
49+
}
50+
51+
result = template.substitute(values)
52+
53+
with open(conan_file, 'w', encoding='utf-8') as file:
54+
file.write(result)
55+
56+
def generate_conanfile(self, directory: DirectoryPath, dependencies: list[ConanDependency]) -> None:
57+
"""Generate a conanfile.py file for the project."""
58+
conan_file = directory / self._filename
59+
60+
# If the file exists then we need to inject our information into it
61+
if conan_file.exists():
62+
raise NotImplementedError(
63+
'Updating existing conanfile.py is not yet supported. Please remove the file and try again.'
64+
)
65+
66+
else:
67+
directory.mkdir(parents=True, exist_ok=True)
68+
self._create_conanfile(conan_file, dependencies)

cppython/plugins/conan/plugin.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
from cppython.core.schema import CorePluginData, Information, SyncData
1616
from cppython.plugins.cmake.plugin import CMakeGenerator
1717
from cppython.plugins.cmake.schema import CMakeSyncData
18-
from cppython.plugins.conan.resolution import resolve_conan_data
18+
from cppython.plugins.conan.builder import Builder
19+
from cppython.plugins.conan.resolution import resolve_conan_data, resolve_conan_dependency
1920
from cppython.plugins.conan.schema import ConanData
2021
from cppython.utility.exception import NotSupportedError
2122
from cppython.utility.utility import TypeName
@@ -34,6 +35,8 @@ def __init__(
3435
self.core_data: CorePluginData = core_data
3536
self.data: ConanData = resolve_conan_data(configuration_data, core_data)
3637

38+
self.builder = Builder()
39+
3740
@staticmethod
3841
def _download_file(url: str, file: Path) -> None:
3942
"""Replaces the given file with the contents of the url"""
@@ -66,9 +69,19 @@ def information() -> Information:
6669

6770
def install(self) -> None:
6871
"""Installs the provider"""
72+
resolved_dependencies = [resolve_conan_dependency(req) for req in self.core_data.cppython_data.dependencies]
73+
74+
self.builder.generate_conanfile(self.core_data.project_data.project_root, resolved_dependencies)
75+
76+
self.core_data.cppython_data.build_path.mkdir(parents=True, exist_ok=True)
6977

7078
def update(self) -> None:
7179
"""Updates the provider"""
80+
resolved_dependencies = [resolve_conan_dependency(req) for req in self.core_data.cppython_data.dependencies]
81+
82+
self.builder.generate_conanfile(self.core_data.project_data.project_root, resolved_dependencies)
83+
84+
self.core_data.cppython_data.build_path.mkdir(parents=True, exist_ok=True)
7285

7386
@staticmethod
7487
def supported_sync_type(sync_type: type[SyncData]) -> bool:

cppython/plugins/conan/resolution.py

+26-1
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,33 @@
22

33
from typing import Any
44

5+
from packaging.requirements import Requirement
6+
7+
from cppython.core.exception import ConfigException
58
from cppython.core.schema import CorePluginData
6-
from cppython.plugins.conan.schema import ConanData
9+
from cppython.plugins.conan.schema import ConanData, ConanDependency
10+
11+
12+
def resolve_conan_dependency(requirement: Requirement) -> ConanDependency:
13+
"""Resolves a Conan dependency from a requirement"""
14+
specifiers = requirement.specifier
15+
16+
# If the length of specifiers is greater than one, raise a configuration error
17+
if len(specifiers) > 1:
18+
raise ConfigException('Multiple specifiers are not supported. Please provide a single specifier.', [])
19+
20+
# Extract the version from the single specifier
21+
min_version = None
22+
if len(specifiers) == 1:
23+
specifier = next(iter(specifiers))
24+
if specifier.operator != '>=':
25+
raise ConfigException(f"Unsupported specifier '{specifier.operator}'. Only '>=' is supported.", [])
26+
min_version = specifier.version
27+
28+
return ConanDependency(
29+
name=requirement.name,
30+
version_ge=min_version,
31+
)
732

833

934
def resolve_conan_data(data: dict[str, Any], core_data: CorePluginData) -> ConanData:

cppython/plugins/conan/schema.py

+15
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@
88
from cppython.core.schema import CPPythonModel
99

1010

11+
class ConanDependency(CPPythonModel):
12+
"""Dependency information"""
13+
14+
name: str
15+
version_ge: str | None = None
16+
include_prerelease: bool | None = None
17+
18+
def requires(self) -> str:
19+
"""Generate the requires attribute for Conan"""
20+
# TODO: Implement lower and upper bounds per conan documentation
21+
if self.version_ge:
22+
return f'{self.name}/[>={self.version_ge}]'
23+
return self.name
24+
25+
1126
class ConanData(CPPythonModel):
1227
"""Resolved conan data"""
1328

cppython/plugins/vcpkg/plugin.py

-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
"""The vcpkg provider implementation"""
22

3-
import json
43
from logging import getLogger
54
from os import name as system_name
65
from pathlib import Path, PosixPath, WindowsPath

0 commit comments

Comments
 (0)