Skip to content

Commit 3493eb1

Browse files
committed
Add Imperative Command Layer
1 parent d976e03 commit 3493eb1

File tree

9 files changed

+390
-14
lines changed

9 files changed

+390
-14
lines changed

cppython/console/entry.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,3 +204,68 @@ def publish(
204204
"""
205205
project = get_enabled_project(context)
206206
project.publish()
207+
208+
209+
@app.command()
210+
def build(
211+
context: typer.Context,
212+
) -> None:
213+
"""Build the project
214+
215+
Assumes dependencies have been installed via `install`.
216+
217+
Args:
218+
context: The CLI configuration object
219+
"""
220+
project = get_enabled_project(context)
221+
project.build()
222+
223+
224+
@app.command()
225+
def test(
226+
context: typer.Context,
227+
) -> None:
228+
"""Run project tests
229+
230+
Assumes dependencies have been installed via `install`.
231+
232+
Args:
233+
context: The CLI configuration object
234+
"""
235+
project = get_enabled_project(context)
236+
project.test()
237+
238+
239+
@app.command()
240+
def bench(
241+
context: typer.Context,
242+
) -> None:
243+
"""Run project benchmarks
244+
245+
Assumes dependencies have been installed via `install`.
246+
247+
Args:
248+
context: The CLI configuration object
249+
"""
250+
project = get_enabled_project(context)
251+
project.bench()
252+
253+
254+
@app.command()
255+
def run(
256+
context: typer.Context,
257+
target: Annotated[
258+
str,
259+
typer.Argument(help='The name of the build target/executable to run'),
260+
],
261+
) -> None:
262+
"""Run a built executable
263+
264+
Assumes dependencies have been installed via `install`.
265+
266+
Args:
267+
context: The CLI configuration object
268+
target: The name of the build target to run
269+
"""
270+
project = get_enabled_project(context)
271+
project.run(target)

cppython/core/plugin_schema/generator.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,36 @@ def features(directory: DirectoryPath) -> SupportedFeatures:
6969
The supported features - `SupportedGeneratorFeatures`. Cast to this type to help us avoid generic typing
7070
"""
7171
raise NotImplementedError
72+
73+
@abstractmethod
74+
def build(self) -> None:
75+
"""Builds the project using the generator's build system.
76+
77+
Executes the build step (e.g. cmake --build --preset).
78+
"""
79+
raise NotImplementedError
80+
81+
@abstractmethod
82+
def test(self) -> None:
83+
"""Runs tests using the generator's build system.
84+
85+
Executes the test step (e.g. ctest --preset).
86+
"""
87+
raise NotImplementedError
88+
89+
@abstractmethod
90+
def bench(self) -> None:
91+
"""Runs benchmarks using the generator's build system.
92+
93+
Executes benchmarks, typically via test presets with a label filter.
94+
"""
95+
raise NotImplementedError
96+
97+
@abstractmethod
98+
def run(self, target: str) -> None:
99+
"""Runs a built executable by target name.
100+
101+
Args:
102+
target: The name of the build target/executable to run.
103+
"""
104+
raise NotImplementedError

cppython/data.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,23 @@ async def download_provider_tools(self) -> None:
103103

104104
self.logger.warning('Downloading the %s requirements to %s', self.plugins.provider.name(), path)
105105
await self.plugins.provider.download_tooling(path)
106+
107+
def build(self) -> None:
108+
"""Builds the project via the generator"""
109+
self.plugins.generator.build()
110+
111+
def test(self) -> None:
112+
"""Runs tests via the generator"""
113+
self.plugins.generator.test()
114+
115+
def bench(self) -> None:
116+
"""Runs benchmarks via the generator"""
117+
self.plugins.generator.bench()
118+
119+
def run(self, target: str) -> None:
120+
"""Runs a built executable via the generator
121+
122+
Args:
123+
target: The name of the build target to run
124+
"""
125+
self.plugins.generator.run(target)

cppython/plugins/cmake/builder.py

Lines changed: 78 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
CMakePresets,
99
CMakeSyncData,
1010
ConfigurePreset,
11+
TestPreset,
1112
)
1213

1314

@@ -100,22 +101,25 @@ def write_cppython_preset(
100101
@staticmethod
101102
def _create_presets(
102103
cmake_data: CMakeData, build_directory: Path
103-
) -> tuple[list[ConfigurePreset], list[BuildPreset]]:
104-
"""Create the default configure and build presets for the user.
104+
) -> tuple[list[ConfigurePreset], list[BuildPreset], list[TestPreset]]:
105+
"""Create the default configure, build, and test presets for the user.
105106
106107
Args:
107108
cmake_data: The CMake data to use
108109
build_directory: The build directory to use
109110
110111
Returns:
111-
A tuple containing the configure preset and list of build presets
112+
A tuple containing the configure presets, build presets, and test presets
112113
"""
113114
user_configure_presets: list[ConfigurePreset] = []
114115
user_build_presets: list[BuildPreset] = []
116+
user_test_presets: list[TestPreset] = []
115117

116118
name = cmake_data.configuration_name
117119
release_name = name + '-release'
118120
debug_name = name + '-debug'
121+
bench_release_name = name + '-bench-release'
122+
bench_debug_name = name + '-bench-debug'
119123

120124
user_configure_presets.append(
121125
ConfigurePreset(
@@ -161,7 +165,43 @@ def _create_presets(
161165
)
162166
)
163167

164-
return user_configure_presets, user_build_presets
168+
# Test presets
169+
user_test_presets.append(
170+
TestPreset(
171+
name=release_name,
172+
description='Run tests for release configuration',
173+
configurePreset=release_name,
174+
)
175+
)
176+
177+
user_test_presets.append(
178+
TestPreset(
179+
name=debug_name,
180+
description='Run tests for debug configuration',
181+
configurePreset=debug_name,
182+
)
183+
)
184+
185+
# Benchmark test presets with label filter
186+
user_test_presets.append(
187+
TestPreset(
188+
name=bench_release_name,
189+
description='Run benchmark tests for release configuration',
190+
configurePreset=release_name,
191+
filter={'include': {'label': 'benchmark'}},
192+
)
193+
)
194+
195+
user_test_presets.append(
196+
TestPreset(
197+
name=bench_debug_name,
198+
description='Run benchmark tests for debug configuration',
199+
configurePreset=debug_name,
200+
filter={'include': {'label': 'benchmark'}},
201+
)
202+
)
203+
204+
return user_configure_presets, user_build_presets, user_test_presets
165205

166206
@staticmethod
167207
def _load_existing_preset(preset_file: Path) -> CMakePresets | None:
@@ -200,11 +240,35 @@ def _update_configure_preset(existing_preset: ConfigurePreset, build_directory:
200240
if not existing_preset.binaryDir:
201241
existing_preset.binaryDir = '${sourceDir}/' + build_directory.as_posix() # type: ignore[misc]
202242

243+
@staticmethod
244+
def _merge_presets[T: (ConfigurePreset, BuildPreset, TestPreset)](
245+
existing: list[T] | None,
246+
new_presets: list[T],
247+
) -> list[T]:
248+
"""Merge new presets into an existing list, adding only those not already present.
249+
250+
Args:
251+
existing: The existing preset list (may be None)
252+
new_presets: The new presets to merge in
253+
254+
Returns:
255+
The merged list of presets
256+
"""
257+
if existing is None:
258+
return new_presets.copy()
259+
260+
for preset in new_presets:
261+
if not any(p.name == preset.name for p in existing):
262+
existing.append(preset)
263+
264+
return existing
265+
203266
@staticmethod
204267
def _modify_presets(
205268
root_preset: CMakePresets,
206269
user_configure_presets: list[ConfigurePreset],
207270
user_build_presets: list[BuildPreset],
271+
user_test_presets: list[TestPreset],
208272
build_directory: Path,
209273
) -> None:
210274
"""Handle presets in the root preset.
@@ -213,6 +277,7 @@ def _modify_presets(
213277
root_preset: The root preset to modify
214278
user_configure_presets: The user's configure presets
215279
user_build_presets: The user's build presets
280+
user_test_presets: The user's test presets
216281
build_directory: The build directory to use
217282
"""
218283
if root_preset.configurePresets is None:
@@ -228,14 +293,8 @@ def _modify_presets(
228293
else:
229294
root_preset.configurePresets.append(user_configure_preset)
230295

231-
if root_preset.buildPresets is None:
232-
root_preset.buildPresets = user_build_presets.copy() # type: ignore[misc]
233-
else:
234-
# Add build presets if they don't exist
235-
for build_preset in user_build_presets:
236-
existing = next((p for p in root_preset.buildPresets if p.name == build_preset.name), None)
237-
if not existing:
238-
root_preset.buildPresets.append(build_preset)
296+
root_preset.buildPresets = Builder._merge_presets(root_preset.buildPresets, user_build_presets) # type: ignore[misc]
297+
root_preset.testPresets = Builder._merge_presets(root_preset.testPresets, user_test_presets) # type: ignore[misc]
239298

240299
@staticmethod
241300
def _modify_includes(root_preset: CMakePresets, preset_file: Path, cppython_preset_file: Path) -> None:
@@ -273,17 +332,22 @@ def generate_root_preset(
273332
A CMakePresets object
274333
"""
275334
# Create user presets
276-
user_configure_presets, user_build_presets = Builder._create_presets(cmake_data, build_directory)
335+
user_configure_presets, user_build_presets, user_test_presets = Builder._create_presets(
336+
cmake_data, build_directory
337+
)
277338

278339
# Load existing preset or create new one
279340
root_preset = Builder._load_existing_preset(preset_file)
280341
if root_preset is None:
281342
root_preset = CMakePresets(
282343
configurePresets=user_configure_presets,
283344
buildPresets=user_build_presets,
345+
testPresets=user_test_presets,
284346
)
285347
else:
286-
Builder._modify_presets(root_preset, user_configure_presets, user_build_presets, build_directory)
348+
Builder._modify_presets(
349+
root_preset, user_configure_presets, user_build_presets, user_test_presets, build_directory
350+
)
287351

288352
Builder._modify_includes(root_preset, preset_file, cppython_preset_file)
289353

cppython/plugins/cmake/plugin.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""The CMake generator implementation"""
22

3+
import subprocess
34
from pathlib import Path
45
from typing import Any
56

@@ -77,3 +78,73 @@ def sync(self, sync_data: SyncData) -> None:
7778
)
7879
case _:
7980
raise ValueError('Unsupported sync data type')
81+
82+
def _cmake_command(self) -> str:
83+
"""Returns the cmake command to use.
84+
85+
Returns:
86+
The cmake binary path as a string
87+
"""
88+
if self.data.cmake_binary:
89+
return str(self.data.cmake_binary)
90+
return 'cmake'
91+
92+
def _ctest_command(self) -> str:
93+
"""Returns the ctest command to use.
94+
95+
Derives the ctest path from the cmake binary path when available.
96+
97+
Returns:
98+
The ctest binary path as a string
99+
"""
100+
if self.data.cmake_binary:
101+
# ctest is typically in the same directory as cmake
102+
ctest_path = self.data.cmake_binary.parent / 'ctest'
103+
if ctest_path.exists():
104+
return str(ctest_path)
105+
# Try with .exe on Windows
106+
ctest_exe = self.data.cmake_binary.parent / 'ctest.exe'
107+
if ctest_exe.exists():
108+
return str(ctest_exe)
109+
return 'ctest'
110+
111+
def build(self) -> None:
112+
"""Builds the project using cmake --build with the configured preset."""
113+
release_preset = self.data.configuration_name + '-release'
114+
cmd = [self._cmake_command(), '--build', '--preset', release_preset]
115+
subprocess.run(cmd, check=True, cwd=self.data.preset_file.parent)
116+
117+
def test(self) -> None:
118+
"""Runs tests using ctest with the configured preset."""
119+
release_preset = self.data.configuration_name + '-release'
120+
cmd = [self._ctest_command(), '--preset', release_preset]
121+
subprocess.run(cmd, check=True, cwd=self.data.preset_file.parent)
122+
123+
def bench(self) -> None:
124+
"""Runs benchmarks using ctest with the configured benchmark preset."""
125+
bench_preset = self.data.configuration_name + '-bench-release'
126+
cmd = [self._ctest_command(), '--preset', bench_preset]
127+
subprocess.run(cmd, check=True, cwd=self.data.preset_file.parent)
128+
129+
def run(self, target: str) -> None:
130+
"""Runs a built executable by target name.
131+
132+
Searches the build directory for the executable matching the target name.
133+
134+
Args:
135+
target: The name of the build target/executable to run
136+
137+
Raises:
138+
FileNotFoundError: If the target executable cannot be found
139+
"""
140+
build_path = self.core_data.cppython_data.build_path
141+
142+
# Search for the executable in the build directory
143+
candidates = list(build_path.rglob(target)) + list(build_path.rglob(f'{target}.exe'))
144+
executables = [c for c in candidates if c.is_file()]
145+
146+
if not executables:
147+
raise FileNotFoundError(f"Could not find executable '{target}' in build directory: {build_path}")
148+
149+
executable = executables[0]
150+
subprocess.run([str(executable)], check=True, cwd=self.data.preset_file.parent)

0 commit comments

Comments
 (0)