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
17 changes: 15 additions & 2 deletions cppython/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
from importlib.metadata import entry_points
from inspect import getmodule
from logging import Logger
from pathlib import Path
from pprint import pformat
from typing import Any, cast

from rich.console import Console
from rich.logging import RichHandler

from cppython.configuration import ConfigurationLoader
from cppython.core.plugin_schema.generator import Generator
from cppython.core.plugin_schema.provider import Provider
from cppython.core.plugin_schema.scm import SCM, SupportedSCMFeatures
Expand All @@ -20,6 +22,7 @@
resolve_cppython,
resolve_cppython_plugin,
resolve_generator,
resolve_model,
resolve_pep621,
resolve_project_configuration,
resolve_provider,
Expand Down Expand Up @@ -187,11 +190,21 @@ def generate_pep621_data(

@staticmethod
def resolve_global_config() -> CPPythonGlobalConfiguration:
"""Generates the global configuration object
"""Generates the global configuration object by loading from ~/.cppython/config.toml

Returns:
The global configuration object
The global configuration object with loaded or default values
"""
loader = ConfigurationLoader(Path.cwd())

try:
global_config_data = loader.load_global_config()
if global_config_data:
return resolve_model(CPPythonGlobalConfiguration, global_config_data)
except (FileNotFoundError, ValueError):
# If global config doesn't exist or is invalid, use defaults
pass

return CPPythonGlobalConfiguration()

def find_generators(self) -> list[type[Generator]]:
Expand Down
189 changes: 189 additions & 0 deletions cppython/configuration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
"""Configuration loading and merging for CPPython

This module handles loading configuration from multiple sources:
1. Global configuration (~/.cppython/config.toml) - User-wide settings for all projects
2. Project configuration (pyproject.toml or cppython.toml) - Project-specific settings
3. Local overrides (.cppython.toml) - Overrides for global configuration
"""

from pathlib import Path
from tomllib import loads
from typing import Any


class ConfigurationLoader:
"""Loads and merges CPPython configuration from multiple sources"""

def __init__(self, project_root: Path) -> None:
"""Initialize the configuration loader

Args:
project_root: The root directory of the project
"""
self.project_root = project_root
self.pyproject_path = project_root / 'pyproject.toml'
self.cppython_path = project_root / 'cppython.toml'
self.local_override_path = project_root / '.cppython.toml'
self.global_config_path = Path.home() / '.cppython' / 'config.toml'

def load_pyproject_data(self) -> dict[str, Any]:
"""Load complete pyproject.toml data

Returns:
Dictionary containing the full pyproject.toml data

Raises:
FileNotFoundError: If pyproject.toml does not exist
"""
if not self.pyproject_path.exists():
raise FileNotFoundError(f'pyproject.toml not found at {self.pyproject_path}')

return loads(self.pyproject_path.read_text(encoding='utf-8'))

def load_cppython_config(self) -> dict[str, Any] | None:
"""Load CPPython configuration from cppython.toml if it exists

Returns:
Dictionary containing the cppython table data, or None if file doesn't exist
"""
if not self.cppython_path.exists():
return None

data = loads(self.cppython_path.read_text(encoding='utf-8'))

# Validate that it contains a cppython table
if 'cppython' not in data:
raise ValueError(f'{self.cppython_path} must contain a [cppython] table')

return data['cppython']

def load_global_config(self) -> dict[str, Any] | None:
"""Load global configuration from ~/.cppython/config.toml if it exists

Returns:
Dictionary containing the global configuration, or None if file doesn't exist
"""
if not self.global_config_path.exists():
return None

data = loads(self.global_config_path.read_text(encoding='utf-8'))

# Validate that it contains a cppython table
if 'cppython' not in data:
raise ValueError(f'{self.global_config_path} must contain a [cppython] table')

return data['cppython']

def load_local_overrides(self) -> dict[str, Any] | None:
"""Load local overrides from .cppython.toml if it exists

These overrides only affect the global configuration, not project configuration.

Returns:
Dictionary containing local override data, or None if file doesn't exist
"""
if not self.local_override_path.exists():
return None

return loads(self.local_override_path.read_text(encoding='utf-8'))

def merge_configurations(self, base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
"""Deep merge two configuration dictionaries

Args:
base: Base configuration dictionary
override: Override configuration dictionary

Returns:
Merged configuration with overrides taking precedence
"""
result = base.copy()

for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
# Recursively merge nested dictionaries
result[key] = self.merge_configurations(result[key], value)
else:
# Override value
result[key] = value

return result

def load_cppython_table(self) -> dict[str, Any] | None:
"""Load and merge the CPPython configuration table from all sources

Priority (highest to lowest):
1. Project configuration (pyproject.toml or cppython.toml)
2. Local overrides (.cppython.toml) merged with global config
3. Global configuration (~/.cppython/config.toml)

Returns:
Merged CPPython configuration dictionary, or None if no config found
"""
# Start with global configuration
global_config = self.load_global_config()

# Apply local overrides to global config
local_overrides = self.load_local_overrides()
if local_overrides is not None and global_config is not None:
global_config = self.merge_configurations(global_config, local_overrides)
elif local_overrides is not None and global_config is None:
# Local overrides exist but no global config - use overrides as base
global_config = local_overrides

# Load project configuration (pyproject.toml or cppython.toml)
pyproject_data = self.load_pyproject_data()
project_config = pyproject_data.get('tool', {}).get('cppython')

# Try cppython.toml as alternative
cppython_toml_config = self.load_cppython_config()
if cppython_toml_config is not None:
if project_config is not None:
raise ValueError(
'CPPython configuration found in both pyproject.toml and cppython.toml. '
'Please use only one configuration source.'
)
project_config = cppython_toml_config

# Merge: global config (with local overrides) + project config
# Project config has highest priority
if project_config is not None and global_config is not None:
return self.merge_configurations(global_config, project_config)
elif project_config is not None:
return project_config
elif global_config is not None:
return global_config

return None

def get_project_data(self) -> dict[str, Any]:
"""Get the complete pyproject data with merged CPPython configuration

Returns:
Dictionary containing pyproject data with merged tool.cppython table
"""
pyproject_data = self.load_pyproject_data()

# Load merged CPPython config
cppython_config = self.load_cppython_table()

# Update the pyproject data with merged config
if cppython_config is not None:
if 'tool' not in pyproject_data:
pyproject_data['tool'] = {}
pyproject_data['tool']['cppython'] = cppython_config

return pyproject_data

def config_source_info(self) -> dict[str, bool]:
"""Get information about which configuration files exist

Returns:
Dictionary with boolean flags for each config file's existence
"""
return {
'global_config': self.global_config_path.exists(),
'pyproject': self.pyproject_path.exists(),
'cppython': self.cppython_path.exists(),
'local_overrides': self.local_override_path.exists(),
}
83 changes: 77 additions & 6 deletions cppython/console/entry.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
"""A click CLI for CPPython interfacing"""

from pathlib import Path
from tomllib import loads
from typing import Annotated

import typer
from rich import print

from cppython.configuration import ConfigurationLoader
from cppython.console.schema import ConsoleConfiguration, ConsoleInterface
from cppython.core.schema import ProjectConfiguration
from cppython.project import Project
Expand All @@ -20,16 +20,63 @@ def get_enabled_project(context: typer.Context) -> Project:
if configuration is None:
raise ValueError('The configuration object is missing')

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

project = Project(configuration.project_configuration, configuration.interface, pyproject_data)
if not project.enabled:
print('[bold red]Error[/bold red]: Project is not enabled. Please check your pyproject.toml configuration.')
print('[bold red]Error[/bold red]: Project is not enabled. Please check your configuration files.')
print('Configuration files checked:')
config_info = loader.config_source_info()
for config_file, exists in config_info.items():
status = '✓' if exists else '✗'
print(f' {status} {config_file}')
raise typer.Exit(code=1)
return project


def _parse_groups_argument(groups: str | None) -> list[str] | None:
"""Parse pip-style dependency groups from command argument.

Args:
groups: Groups string like '[test]' or '[dev,test]' or None

Returns:
List of group names or None if no groups specified

Raises:
typer.BadParameter: If the groups format is invalid
"""
if groups is None:
return None

# Strip whitespace
groups = groups.strip()

if not groups:
return None

# Check for square brackets
if not (groups.startswith('[') and groups.endswith(']')):
raise typer.BadParameter(f"Invalid groups format: '{groups}'. Use square brackets like: [test] or [dev,test]")

# Extract content between brackets and split by comma
content = groups[1:-1].strip()

if not content:
raise typer.BadParameter('Empty groups specification. Provide at least one group name.')

# Split by comma and strip whitespace from each group
group_list = [g.strip() for g in content.split(',')]

# Validate group names are not empty
if any(not g for g in group_list):
raise typer.BadParameter('Group names cannot be empty.')

return group_list


def _find_pyproject_file() -> Path:
"""Searches upward for a pyproject.toml file

Expand Down Expand Up @@ -83,33 +130,57 @@ def info(
@app.command()
def install(
context: typer.Context,
groups: Annotated[
str | None,
typer.Argument(
help='Dependency groups to install in addition to base dependencies. '
'Use square brackets like: [test] or [dev,test]'
),
] = None,
) -> None:
"""Install API call

Args:
context: The CLI configuration object
groups: Optional dependency groups to install (e.g., [test] or [dev,test])

Raises:
ValueError: If the configuration object is missing
"""
project = get_enabled_project(context)
project.install()

# Parse groups from pip-style syntax
group_list = _parse_groups_argument(groups)

project.install(groups=group_list)


@app.command()
def update(
context: typer.Context,
groups: Annotated[
str | None,
typer.Argument(
help='Dependency groups to update in addition to base dependencies. '
'Use square brackets like: [test] or [dev,test]'
),
] = None,
) -> None:
"""Update API call

Args:
context: The CLI configuration object
groups: Optional dependency groups to update (e.g., [test] or [dev,test])

Raises:
ValueError: If the configuration object is missing
"""
project = get_enabled_project(context)
project.update()

# Parse groups from pip-style syntax
group_list = _parse_groups_argument(groups)

project.update(groups=group_list)


@app.command(name='list')
Expand Down
7 changes: 5 additions & 2 deletions cppython/console/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ class ConsoleInterface(Interface):
"""Interface implementation to pass to the project"""

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

def write_configuration(self) -> None:
"""Write output"""
"""Write output to primary configuration (pyproject.toml or cppython.toml)"""

def write_user_configuration(self) -> None:
"""Write output to global user configuration (~/.cppython/config.toml)"""


class ConsoleConfiguration(CPPythonModel):
Expand Down
Loading
Loading