Skip to content

Commit 20ea4fc

Browse files
authored
cppython.toml Support (#123)
1 parent 7016259 commit 20ea4fc

File tree

9 files changed

+758
-12
lines changed

9 files changed

+758
-12
lines changed

cppython/builder.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@
55
from importlib.metadata import entry_points
66
from inspect import getmodule
77
from logging import Logger
8+
from pathlib import Path
89
from pprint import pformat
910
from typing import Any, cast
1011

1112
from rich.console import Console
1213
from rich.logging import RichHandler
1314

15+
from cppython.configuration import ConfigurationLoader
1416
from cppython.core.plugin_schema.generator import Generator
1517
from cppython.core.plugin_schema.provider import Provider
1618
from cppython.core.plugin_schema.scm import SCM, SupportedSCMFeatures
@@ -20,6 +22,7 @@
2022
resolve_cppython,
2123
resolve_cppython_plugin,
2224
resolve_generator,
25+
resolve_model,
2326
resolve_pep621,
2427
resolve_project_configuration,
2528
resolve_provider,
@@ -187,11 +190,21 @@ def generate_pep621_data(
187190

188191
@staticmethod
189192
def resolve_global_config() -> CPPythonGlobalConfiguration:
190-
"""Generates the global configuration object
193+
"""Generates the global configuration object by loading from ~/.cppython/config.toml
191194
192195
Returns:
193-
The global configuration object
196+
The global configuration object with loaded or default values
194197
"""
198+
loader = ConfigurationLoader(Path.cwd())
199+
200+
try:
201+
global_config_data = loader.load_global_config()
202+
if global_config_data:
203+
return resolve_model(CPPythonGlobalConfiguration, global_config_data)
204+
except (FileNotFoundError, ValueError):
205+
# If global config doesn't exist or is invalid, use defaults
206+
pass
207+
195208
return CPPythonGlobalConfiguration()
196209

197210
def find_generators(self) -> list[type[Generator]]:

cppython/configuration.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
"""Configuration loading and merging for CPPython
2+
3+
This module handles loading configuration from multiple sources:
4+
1. Global configuration (~/.cppython/config.toml) - User-wide settings for all projects
5+
2. Project configuration (pyproject.toml or cppython.toml) - Project-specific settings
6+
3. Local overrides (.cppython.toml) - Overrides for global configuration
7+
"""
8+
9+
from pathlib import Path
10+
from tomllib import loads
11+
from typing import Any
12+
13+
14+
class ConfigurationLoader:
15+
"""Loads and merges CPPython configuration from multiple sources"""
16+
17+
def __init__(self, project_root: Path) -> None:
18+
"""Initialize the configuration loader
19+
20+
Args:
21+
project_root: The root directory of the project
22+
"""
23+
self.project_root = project_root
24+
self.pyproject_path = project_root / 'pyproject.toml'
25+
self.cppython_path = project_root / 'cppython.toml'
26+
self.local_override_path = project_root / '.cppython.toml'
27+
self.global_config_path = Path.home() / '.cppython' / 'config.toml'
28+
29+
def load_pyproject_data(self) -> dict[str, Any]:
30+
"""Load complete pyproject.toml data
31+
32+
Returns:
33+
Dictionary containing the full pyproject.toml data
34+
35+
Raises:
36+
FileNotFoundError: If pyproject.toml does not exist
37+
"""
38+
if not self.pyproject_path.exists():
39+
raise FileNotFoundError(f'pyproject.toml not found at {self.pyproject_path}')
40+
41+
return loads(self.pyproject_path.read_text(encoding='utf-8'))
42+
43+
def load_cppython_config(self) -> dict[str, Any] | None:
44+
"""Load CPPython configuration from cppython.toml if it exists
45+
46+
Returns:
47+
Dictionary containing the cppython table data, or None if file doesn't exist
48+
"""
49+
if not self.cppython_path.exists():
50+
return None
51+
52+
data = loads(self.cppython_path.read_text(encoding='utf-8'))
53+
54+
# Validate that it contains a cppython table
55+
if 'cppython' not in data:
56+
raise ValueError(f'{self.cppython_path} must contain a [cppython] table')
57+
58+
return data['cppython']
59+
60+
def load_global_config(self) -> dict[str, Any] | None:
61+
"""Load global configuration from ~/.cppython/config.toml if it exists
62+
63+
Returns:
64+
Dictionary containing the global configuration, or None if file doesn't exist
65+
"""
66+
if not self.global_config_path.exists():
67+
return None
68+
69+
data = loads(self.global_config_path.read_text(encoding='utf-8'))
70+
71+
# Validate that it contains a cppython table
72+
if 'cppython' not in data:
73+
raise ValueError(f'{self.global_config_path} must contain a [cppython] table')
74+
75+
return data['cppython']
76+
77+
def load_local_overrides(self) -> dict[str, Any] | None:
78+
"""Load local overrides from .cppython.toml if it exists
79+
80+
These overrides only affect the global configuration, not project configuration.
81+
82+
Returns:
83+
Dictionary containing local override data, or None if file doesn't exist
84+
"""
85+
if not self.local_override_path.exists():
86+
return None
87+
88+
return loads(self.local_override_path.read_text(encoding='utf-8'))
89+
90+
def merge_configurations(self, base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
91+
"""Deep merge two configuration dictionaries
92+
93+
Args:
94+
base: Base configuration dictionary
95+
override: Override configuration dictionary
96+
97+
Returns:
98+
Merged configuration with overrides taking precedence
99+
"""
100+
result = base.copy()
101+
102+
for key, value in override.items():
103+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
104+
# Recursively merge nested dictionaries
105+
result[key] = self.merge_configurations(result[key], value)
106+
else:
107+
# Override value
108+
result[key] = value
109+
110+
return result
111+
112+
def load_cppython_table(self) -> dict[str, Any] | None:
113+
"""Load and merge the CPPython configuration table from all sources
114+
115+
Priority (highest to lowest):
116+
1. Project configuration (pyproject.toml or cppython.toml)
117+
2. Local overrides (.cppython.toml) merged with global config
118+
3. Global configuration (~/.cppython/config.toml)
119+
120+
Returns:
121+
Merged CPPython configuration dictionary, or None if no config found
122+
"""
123+
# Start with global configuration
124+
global_config = self.load_global_config()
125+
126+
# Apply local overrides to global config
127+
local_overrides = self.load_local_overrides()
128+
if local_overrides is not None and global_config is not None:
129+
global_config = self.merge_configurations(global_config, local_overrides)
130+
elif local_overrides is not None and global_config is None:
131+
# Local overrides exist but no global config - use overrides as base
132+
global_config = local_overrides
133+
134+
# Load project configuration (pyproject.toml or cppython.toml)
135+
pyproject_data = self.load_pyproject_data()
136+
project_config = pyproject_data.get('tool', {}).get('cppython')
137+
138+
# Try cppython.toml as alternative
139+
cppython_toml_config = self.load_cppython_config()
140+
if cppython_toml_config is not None:
141+
if project_config is not None:
142+
raise ValueError(
143+
'CPPython configuration found in both pyproject.toml and cppython.toml. '
144+
'Please use only one configuration source.'
145+
)
146+
project_config = cppython_toml_config
147+
148+
# Merge: global config (with local overrides) + project config
149+
# Project config has highest priority
150+
if project_config is not None and global_config is not None:
151+
return self.merge_configurations(global_config, project_config)
152+
elif project_config is not None:
153+
return project_config
154+
elif global_config is not None:
155+
return global_config
156+
157+
return None
158+
159+
def get_project_data(self) -> dict[str, Any]:
160+
"""Get the complete pyproject data with merged CPPython configuration
161+
162+
Returns:
163+
Dictionary containing pyproject data with merged tool.cppython table
164+
"""
165+
pyproject_data = self.load_pyproject_data()
166+
167+
# Load merged CPPython config
168+
cppython_config = self.load_cppython_table()
169+
170+
# Update the pyproject data with merged config
171+
if cppython_config is not None:
172+
if 'tool' not in pyproject_data:
173+
pyproject_data['tool'] = {}
174+
pyproject_data['tool']['cppython'] = cppython_config
175+
176+
return pyproject_data
177+
178+
def config_source_info(self) -> dict[str, bool]:
179+
"""Get information about which configuration files exist
180+
181+
Returns:
182+
Dictionary with boolean flags for each config file's existence
183+
"""
184+
return {
185+
'global_config': self.global_config_path.exists(),
186+
'pyproject': self.pyproject_path.exists(),
187+
'cppython': self.cppython_path.exists(),
188+
'local_overrides': self.local_override_path.exists(),
189+
}

cppython/console/entry.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
"""A click CLI for CPPython interfacing"""
22

33
from pathlib import Path
4-
from tomllib import loads
54
from typing import Annotated
65

76
import typer
87
from rich import print
98

9+
from cppython.configuration import ConfigurationLoader
1010
from cppython.console.schema import ConsoleConfiguration, ConsoleInterface
1111
from cppython.core.schema import ProjectConfiguration
1212
from cppython.project import Project
@@ -20,12 +20,18 @@ def get_enabled_project(context: typer.Context) -> Project:
2020
if configuration is None:
2121
raise ValueError('The configuration object is missing')
2222

23-
path = configuration.project_configuration.project_root / 'pyproject.toml'
24-
pyproject_data = loads(path.read_text(encoding='utf-8'))
23+
# Use ConfigurationLoader to load and merge all configuration sources
24+
loader = ConfigurationLoader(configuration.project_configuration.project_root)
25+
pyproject_data = loader.get_project_data()
2526

2627
project = Project(configuration.project_configuration, configuration.interface, pyproject_data)
2728
if not project.enabled:
28-
print('[bold red]Error[/bold red]: Project is not enabled. Please check your pyproject.toml configuration.')
29+
print('[bold red]Error[/bold red]: Project is not enabled. Please check your configuration files.')
30+
print('Configuration files checked:')
31+
config_info = loader.config_source_info()
32+
for config_file, exists in config_info.items():
33+
status = '✓' if exists else '✗'
34+
print(f' {status} {config_file}')
2935
raise typer.Exit(code=1)
3036
return project
3137

cppython/console/schema.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,13 @@ class ConsoleInterface(Interface):
99
"""Interface implementation to pass to the project"""
1010

1111
def write_pyproject(self) -> None:
12-
"""Write output"""
12+
"""Write output to pyproject.toml"""
1313

1414
def write_configuration(self) -> None:
15-
"""Write output"""
15+
"""Write output to primary configuration (pyproject.toml or cppython.toml)"""
16+
17+
def write_user_configuration(self) -> None:
18+
"""Write output to global user configuration (~/.cppython/config.toml)"""
1619

1720

1821
class ConsoleConfiguration(CPPythonModel):

cppython/core/schema.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,5 +371,16 @@ def write_pyproject(self) -> None:
371371

372372
@abstractmethod
373373
def write_configuration(self) -> None:
374-
"""Called when CPPython requires the interface to write out configuration changes"""
374+
"""Called when CPPython requires the interface to write out configuration changes
375+
376+
This writes to the primary configuration source (pyproject.toml or cppython.toml)
377+
"""
378+
raise NotImplementedError
379+
380+
@abstractmethod
381+
def write_user_configuration(self) -> None:
382+
"""Called when CPPython requires the interface to write out global configuration changes
383+
384+
This writes to ~/.cppython/config.toml for global user configuration
385+
"""
375386
raise NotImplementedError

cppython/plugins/pdm/plugin.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,25 @@ def __init__(self, core: Core) -> None:
2121
"""Initializes the plugin"""
2222
post_install.connect(self.on_post_install, weak=False)
2323
self.logger = getLogger('cppython.interface.pdm')
24+
self._core = core
2425

2526
# Register the cpp command
2627
register_commands(core)
2728

2829
def write_pyproject(self) -> None:
29-
"""Write to file"""
30+
"""Called when CPPython requires the interface to write out pyproject.toml changes"""
31+
self._core.ui.echo('Writing out pyproject.toml')
32+
# TODO: Implement writing to pyproject.toml through PDM
3033

3134
def write_configuration(self) -> None:
32-
"""Write to configuration"""
35+
"""Called when CPPython requires the interface to write out configuration changes"""
36+
self._core.ui.echo('Writing out configuration')
37+
# TODO: Implement writing to cppython.toml
38+
39+
def write_user_configuration(self) -> None:
40+
"""Called when CPPython requires the interface to write out user-specific configuration changes"""
41+
self._core.ui.echo('Writing out user configuration')
42+
# TODO: Implement writing to .cppython.toml
3343

3444
def on_post_install(self, project: Project, dry_run: bool, **_kwargs: Any) -> None:
3545
"""Called after a pdm install command is called

cppython/project.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ class Project(API):
1717
def __init__(
1818
self, project_configuration: ProjectConfiguration, interface: Interface, pyproject_data: dict[str, Any]
1919
) -> None:
20-
"""Initializes the project"""
20+
"""Initializes the project
21+
22+
Args:
23+
project_configuration: Project-wide configuration
24+
interface: Interface for callbacks to write configuration changes
25+
pyproject_data: Merged configuration data from all sources
26+
"""
2127
self._enabled = False
2228
self._interface = interface
2329
self.logger = logging.getLogger('cppython')

cppython/test/mock/interface.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ def write_pyproject(self) -> None:
1111

1212
def write_configuration(self) -> None:
1313
"""Implementation of Interface function"""
14+
15+
def write_user_configuration(self) -> None:
16+
"""Implementation of Interface function"""

0 commit comments

Comments
 (0)