Skip to content

Commit 7016259

Browse files
authored
Dependency Groups (#122)
1 parent ece534c commit 7016259

File tree

19 files changed

+517
-197
lines changed

19 files changed

+517
-197
lines changed

cppython/console/entry.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,47 @@ def get_enabled_project(context: typer.Context) -> Project:
3030
return project
3131

3232

33+
def _parse_groups_argument(groups: str | None) -> list[str] | None:
34+
"""Parse pip-style dependency groups from command argument.
35+
36+
Args:
37+
groups: Groups string like '[test]' or '[dev,test]' or None
38+
39+
Returns:
40+
List of group names or None if no groups specified
41+
42+
Raises:
43+
typer.BadParameter: If the groups format is invalid
44+
"""
45+
if groups is None:
46+
return None
47+
48+
# Strip whitespace
49+
groups = groups.strip()
50+
51+
if not groups:
52+
return None
53+
54+
# Check for square brackets
55+
if not (groups.startswith('[') and groups.endswith(']')):
56+
raise typer.BadParameter(f"Invalid groups format: '{groups}'. Use square brackets like: [test] or [dev,test]")
57+
58+
# Extract content between brackets and split by comma
59+
content = groups[1:-1].strip()
60+
61+
if not content:
62+
raise typer.BadParameter('Empty groups specification. Provide at least one group name.')
63+
64+
# Split by comma and strip whitespace from each group
65+
group_list = [g.strip() for g in content.split(',')]
66+
67+
# Validate group names are not empty
68+
if any(not g for g in group_list):
69+
raise typer.BadParameter('Group names cannot be empty.')
70+
71+
return group_list
72+
73+
3374
def _find_pyproject_file() -> Path:
3475
"""Searches upward for a pyproject.toml file
3576
@@ -83,33 +124,57 @@ def info(
83124
@app.command()
84125
def install(
85126
context: typer.Context,
127+
groups: Annotated[
128+
str | None,
129+
typer.Argument(
130+
help='Dependency groups to install in addition to base dependencies. '
131+
'Use square brackets like: [test] or [dev,test]'
132+
),
133+
] = None,
86134
) -> None:
87135
"""Install API call
88136
89137
Args:
90138
context: The CLI configuration object
139+
groups: Optional dependency groups to install (e.g., [test] or [dev,test])
91140
92141
Raises:
93142
ValueError: If the configuration object is missing
94143
"""
95144
project = get_enabled_project(context)
96-
project.install()
145+
146+
# Parse groups from pip-style syntax
147+
group_list = _parse_groups_argument(groups)
148+
149+
project.install(groups=group_list)
97150

98151

99152
@app.command()
100153
def update(
101154
context: typer.Context,
155+
groups: Annotated[
156+
str | None,
157+
typer.Argument(
158+
help='Dependency groups to update in addition to base dependencies. '
159+
'Use square brackets like: [test] or [dev,test]'
160+
),
161+
] = None,
102162
) -> None:
103163
"""Update API call
104164
105165
Args:
106166
context: The CLI configuration object
167+
groups: Optional dependency groups to update (e.g., [test] or [dev,test])
107168
108169
Raises:
109170
ValueError: If the configuration object is missing
110171
"""
111172
project = get_enabled_project(context)
112-
project.update()
173+
174+
# Parse groups from pip-style syntax
175+
group_list = _parse_groups_argument(groups)
176+
177+
project.update(groups=group_list)
113178

114179

115180
@app.command(name='list')

cppython/core/plugin_schema/provider.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,21 @@ def features(directory: DirectoryPath) -> SupportedFeatures:
8080
raise NotImplementedError
8181

8282
@abstractmethod
83-
def install(self) -> None:
84-
"""Called when dependencies need to be installed from a lock file."""
83+
def install(self, groups: list[str] | None = None) -> None:
84+
"""Called when dependencies need to be installed from a lock file.
85+
86+
Args:
87+
groups: Optional list of dependency group names to install in addition to base dependencies
88+
"""
8589
raise NotImplementedError
8690

8791
@abstractmethod
88-
def update(self) -> None:
89-
"""Called when dependencies need to be updated and written to the lock file."""
92+
def update(self, groups: list[str] | None = None) -> None:
93+
"""Called when dependencies need to be updated and written to the lock file.
94+
95+
Args:
96+
groups: Optional list of dependency group names to update in addition to base dependencies
97+
"""
9098
raise NotImplementedError
9199

92100
@abstractmethod

cppython/core/resolution.py

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,21 @@ def resolve_pep621(
7676
return pep621_data
7777

7878

79+
def _resolve_absolute_path(path: Path, root_directory: Path) -> Path:
80+
"""Convert a path to absolute, using root_directory as base for relative paths.
81+
82+
Args:
83+
path: The path to resolve
84+
root_directory: The base directory for relative paths
85+
86+
Returns:
87+
The absolute path
88+
"""
89+
if path.is_absolute():
90+
return path
91+
return root_directory / path
92+
93+
7994
class PluginBuildData(CPPythonModel):
8095
"""Data needed to construct CoreData"""
8196

@@ -114,34 +129,20 @@ def resolve_cppython(
114129
"""
115130
root_directory = project_data.project_root.absolute()
116131

117-
# Add the base path to all relative paths
132+
# Resolve configuration path
118133
modified_configuration_path = local_configuration.configuration_path
119-
120-
# TODO: Grab configuration from the project, user, or system
121134
if modified_configuration_path is None:
122135
modified_configuration_path = root_directory / 'cppython.json'
136+
else:
137+
modified_configuration_path = _resolve_absolute_path(modified_configuration_path, root_directory)
123138

124-
if not modified_configuration_path.is_absolute():
125-
modified_configuration_path = root_directory / modified_configuration_path
126-
127-
modified_install_path = local_configuration.install_path
128-
129-
if not modified_install_path.is_absolute():
130-
modified_install_path = root_directory / modified_install_path
131-
132-
modified_tool_path = local_configuration.tool_path
133-
134-
if not modified_tool_path.is_absolute():
135-
modified_tool_path = root_directory / modified_tool_path
136-
137-
modified_build_path = local_configuration.build_path
138-
139-
if not modified_build_path.is_absolute():
140-
modified_build_path = root_directory / modified_build_path
139+
# Resolve other paths
140+
modified_install_path = _resolve_absolute_path(local_configuration.install_path, root_directory)
141+
modified_tool_path = _resolve_absolute_path(local_configuration.tool_path, root_directory)
142+
modified_build_path = _resolve_absolute_path(local_configuration.build_path, root_directory)
141143

142144
modified_provider_name = plugin_build_data.provider_name
143145
modified_generator_name = plugin_build_data.generator_name
144-
145146
modified_scm_name = plugin_build_data.scm_name
146147

147148
# Extract provider and generator configuration data
@@ -166,6 +167,18 @@ def resolve_cppython(
166167
except InvalidRequirement as error:
167168
invalid_requirements.append(f"Invalid requirement '{dependency}': {error}")
168169

170+
# Construct dependency groups from the local configuration
171+
dependency_groups: dict[str, list[Requirement]] = {}
172+
if local_configuration.dependency_groups:
173+
for group_name, group_dependencies in local_configuration.dependency_groups.items():
174+
resolved_group: list[Requirement] = []
175+
for dependency in group_dependencies:
176+
try:
177+
resolved_group.append(Requirement(dependency))
178+
except InvalidRequirement as error:
179+
invalid_requirements.append(f"Invalid requirement '{dependency}' in group '{group_name}': {error}")
180+
dependency_groups[group_name] = resolved_group
181+
169182
if invalid_requirements:
170183
raise ConfigException('\n'.join(invalid_requirements), [])
171184

@@ -179,6 +192,7 @@ def resolve_cppython(
179192
generator_name=modified_generator_name,
180193
scm_name=modified_scm_name,
181194
dependencies=dependencies,
195+
dependency_groups=dependency_groups,
182196
provider_data=provider_data,
183197
generator_data=generator_data,
184198
)
@@ -208,6 +222,7 @@ def resolve_cppython_plugin(cppython_data: CPPythonData, plugin_type: type[Plugi
208222
generator_name=cppython_data.generator_name,
209223
scm_name=cppython_data.scm_name,
210224
dependencies=cppython_data.dependencies,
225+
dependency_groups=cppython_data.dependency_groups,
211226
provider_data=cppython_data.provider_data,
212227
generator_data=cppython_data.generator_data,
213228
)

cppython/core/schema.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ class CPPythonData(CPPythonModel, extra='forbid'):
117117
generator_name: TypeName
118118
scm_name: TypeName
119119
dependencies: list[Requirement]
120+
dependency_groups: dict[str, list[Requirement]]
120121

121122
provider_data: Annotated[dict[str, Any], Field(description='Resolved provider configuration data')]
122123
generator_data: Annotated[dict[str, Any], Field(description='Resolved generator configuration data')]
@@ -329,6 +330,15 @@ class CPPythonLocalConfiguration(CPPythonModel, extra='forbid'):
329330
),
330331
] = None
331332

333+
dependency_groups: Annotated[
334+
dict[str, list[str]] | None,
335+
Field(
336+
alias='dependency-groups',
337+
description='Named groups of dependencies. Key is the group name, value is a list of pip compatible'
338+
' requirements strings. Similar to PEP 735 dependency groups.',
339+
),
340+
] = None
341+
332342

333343
class ToolData(CPPythonModel):
334344
"""Tool entry of pyproject.toml"""

cppython/data.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from dataclasses import dataclass
44
from logging import Logger
55

6+
from packaging.requirements import Requirement
7+
68
from cppython.core.plugin_schema.generator import Generator
79
from cppython.core.plugin_schema.provider import Provider
810
from cppython.core.plugin_schema.scm import SCM
@@ -27,12 +29,59 @@ def __init__(self, core_data: CoreData, plugins: Plugins, logger: Logger) -> Non
2729
self._core_data = core_data
2830
self._plugins = plugins
2931
self.logger = logger
32+
self._active_groups: list[str] | None = None
3033

3134
@property
3235
def plugins(self) -> Plugins:
3336
"""The plugin data for CPPython"""
3437
return self._plugins
3538

39+
def set_active_groups(self, groups: list[str] | None) -> None:
40+
"""Set the active dependency groups for the current operation.
41+
42+
Args:
43+
groups: List of group names to activate, or None for no additional groups
44+
"""
45+
self._active_groups = groups
46+
if groups:
47+
self.logger.info('Active dependency groups: %s', ', '.join(groups))
48+
49+
# Validate that requested groups exist
50+
available_groups = set(self._core_data.cppython_data.dependency_groups.keys())
51+
requested_groups = set(groups)
52+
missing_groups = requested_groups - available_groups
53+
54+
if missing_groups:
55+
self.logger.warning(
56+
'Requested dependency groups not found: %s. Available groups: %s',
57+
', '.join(sorted(missing_groups)),
58+
', '.join(sorted(available_groups)) if available_groups else 'none',
59+
)
60+
61+
def apply_dependency_groups(self, groups: list[str] | None) -> None:
62+
"""Validate and log the dependency groups to be used.
63+
64+
Args:
65+
groups: List of group names to apply, or None for base dependencies only
66+
"""
67+
if groups:
68+
self.set_active_groups(groups)
69+
70+
def get_active_dependencies(self) -> list:
71+
"""Get the combined list of base dependencies and active group dependencies.
72+
73+
Returns:
74+
Combined list of Requirement objects from base and active groups
75+
"""
76+
dependencies: list[Requirement] = list(self._core_data.cppython_data.dependencies)
77+
78+
if self._active_groups:
79+
for group_name in self._active_groups:
80+
if group_name in self._core_data.cppython_data.dependency_groups:
81+
dependencies.extend(self._core_data.cppython_data.dependency_groups[group_name])
82+
83+
return dependencies
84+
3685
def sync(self) -> None:
3786
"""Gathers sync information from providers and passes it to the generator
3887

cppython/plugins/cmake/builder.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,18 @@ def __init__(self) -> None:
1919

2020
@staticmethod
2121
def generate_cppython_preset(
22-
cppython_preset_directory: Path, provider_preset_file: Path, provider_data: CMakeSyncData
22+
cppython_preset_directory: Path,
23+
provider_preset_file: Path,
24+
provider_data: CMakeSyncData,
25+
project_root: Path,
2326
) -> CMakePresets:
2427
"""Generates the cppython preset which inherits from the provider presets
2528
2629
Args:
2730
cppython_preset_directory: The tool directory
2831
provider_preset_file: Path to the provider's preset file
2932
provider_data: The provider's synchronization data
33+
project_root: The project root directory (where CMakeLists.txt is located)
3034
3135
Returns:
3236
A CMakePresets object
@@ -43,7 +47,8 @@ def generate_cppython_preset(
4347
)
4448

4549
if provider_data.toolchain_file:
46-
default_configure.toolchainFile = provider_data.toolchain_file.as_posix()
50+
relative_toolchain = provider_data.toolchain_file.relative_to(project_root, walk_up=True)
51+
default_configure.toolchainFile = relative_toolchain.as_posix()
4752

4853
configure_presets.append(default_configure)
4954

@@ -55,20 +60,24 @@ def generate_cppython_preset(
5560

5661
@staticmethod
5762
def write_cppython_preset(
58-
cppython_preset_directory: Path, provider_preset_file: Path, provider_data: CMakeSyncData
63+
cppython_preset_directory: Path,
64+
provider_preset_file: Path,
65+
provider_data: CMakeSyncData,
66+
project_root: Path,
5967
) -> Path:
6068
"""Write the cppython presets which inherit from the provider presets
6169
6270
Args:
6371
cppython_preset_directory: The tool directory
6472
provider_preset_file: Path to the provider's preset file
6573
provider_data: The provider's synchronization data
74+
project_root: The project root directory (where CMakeLists.txt is located)
6675
6776
Returns:
6877
A file path to the written data
6978
"""
7079
generated_preset = Builder.generate_cppython_preset(
71-
cppython_preset_directory, provider_preset_file, provider_data
80+
cppython_preset_directory, provider_preset_file, provider_data, project_root
7281
)
7382
cppython_preset_file = cppython_preset_directory / 'cppython.json'
7483

cppython/plugins/cmake/plugin.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,10 @@ def sync(self, sync_data: SyncData) -> None:
6666

6767
cppython_preset_file = self._cppython_preset_directory / 'CPPython.json'
6868

69+
project_root = self.core_data.project_data.project_root
70+
6971
cppython_preset_file = self.builder.write_cppython_preset(
70-
self._cppython_preset_directory, cppython_preset_file, sync_data
72+
self._cppython_preset_directory, cppython_preset_file, sync_data, project_root
7173
)
7274

7375
self.builder.write_root_presets(

0 commit comments

Comments
 (0)