diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..a95d9ada --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*] +indent_style = space +indent_size = 4 \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..b38df29f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/python-check.yml b/.github/workflows/python-check.yml new file mode 100644 index 00000000..019e747e --- /dev/null +++ b/.github/workflows/python-check.yml @@ -0,0 +1,7 @@ +name: Check Python Status +on: + - pull_request + +jobs: + check: + uses: synodic/.github/.github/workflows/python-check.yml@stable diff --git a/.github/workflows/python-deploy-docs.yml b/.github/workflows/python-deploy-docs.yml new file mode 100644 index 00000000..a6ca751f --- /dev/null +++ b/.github/workflows/python-deploy-docs.yml @@ -0,0 +1,29 @@ +name: Publish Website to GitHub Pages +on: + push: + paths: + - docs/** + branches: + - "**" + + # Manual trigger + workflow_dispatch: + + # External trigger + repository_dispatch: + types: + - docs-deploy + +# Allow one concurrent deployment for the workflow +concurrency: + group: page-deploy + cancel-in-progress: true + +jobs: + build: + permissions: + contents: read + pages: write + id-token: write + if: github.repository_owner == 'synodic' + uses: synodic/.github/.github/workflows/python-deploy-docs.yml@stable diff --git a/.github/workflows/python-merge.yml b/.github/workflows/python-merge.yml new file mode 100644 index 00000000..3cfac8bb --- /dev/null +++ b/.github/workflows/python-merge.yml @@ -0,0 +1,14 @@ +name: Update Python Development Release +on: + push: + branches: + - '**' + +jobs: + publish_release: + if: github.repository_owner == 'synodic' + uses: synodic/.github/.github/workflows/python-merge.yml@stable + with: + repository_url: https://upload.pypi.org/legacy/ + secrets: + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml new file mode 100644 index 00000000..83bc92b2 --- /dev/null +++ b/.github/workflows/python-publish.yml @@ -0,0 +1,13 @@ +name: Publish Python Package +on: + release: + types: [published] + +jobs: + publish_release: + if: github.repository_owner == 'synodic' + uses: synodic/.github/.github/workflows/python-publish.yml@stable + with: + repository_url: https://upload.pypi.org/legacy/ + secrets: + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7a6f244d --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +#Project +TestResults/ + +#Visual Studio +*.sln +*.pyproj + +#Python +*.egg-info +*.pyc +dist/ +.vs/ +.venv/ + +#Pytest +.pytest_cache/ + +#PDM +.pdm-python +__pypackages__/ +.pdm-build/ + +#ruff +.ruff_cache/ + +#Coverage +.coverage + +#VisualStudioCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +/.mypy_cache +node_modules/ \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..1c7d5e90 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "charliermarsh.ruff", + "tamasfe.even-better-toml", + "meta.pyrefly" + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..09fb406e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,23 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "editor.formatOnSave": true, + "mypy-type-checker.reportingScope": "workspace", + "mypy-type-checker.preferDaemon": true, + "mypy-type-checker.importStrategy": "fromEnvironment", + "[python]": { + "editor.codeActionsOnSave": { + "source.fixAll": "explicit", + "source.organizeImports": "explicit" + }, + "editor.defaultFormatter": "charliermarsh.ruff" + }, + "cmake.ignoreCMakeListsMissing": true, + "files.associations": { + "*.md": "markdown", + "xstring": "cpp" + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..b442c82b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# AGENTS.md + +This repository doesn't contain any agent specific instructions other than its README.md and its linked resources. diff --git a/LICENSE b/LICENSE.md similarity index 96% rename from LICENSE rename to LICENSE.md index 60c11c28..aecae73a 100644 --- a/LICENSE +++ b/LICENSE.md @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Synodic Software +Copyright (c) 2025 Synodic Software Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index c1775ee4..0db76faf 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,35 @@ -# Conan-Poetry-Plugin -A Plugin for Poetry that enables integrated Conan support. +# CPPython + +A transparent Python management solution for C++ dependencies and building. + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.md) +[![PyPI version](https://img.shields.io/pypi/v/cppython.svg)](https://pypi.org/project/cppython/) + +## Goals + +1. **CLI** — Provide imperative commands (`build`, `test`, `bench`, `run`, `install`) for managing C++ projects within a Python ecosystem. +2. **Plugin Architecture** — Support pluggable generators (CMake, Meson) and providers (Conan, vcpkg) so users can mix and match toolchains. +3. **PEP 517 Build Backend** — Act as a transparent build backend that delegates to scikit-build-core or meson-python after ensuring C++ dependencies are in place. +4. **Package Manager Integration** — Integrate with Python package managers so that ` install` seamlessly handles C++ dependency installation alongside Python dependencies. + +## Features + +## Setup + +See [Setup](https://synodic.github.io/cppython/setup) for setup instructions. + +## Development + +We use [pdm](https://pdm-project.org/en/latest/) as our build system and package manager. Scripts for development tasks are defined in `pyproject.toml` under the `[tool.pdm.scripts]` section. + +See [Development](https://synodic.github.io/cppython/development) for additional build, test, and installation instructions. + +For contribution guidelines, see [CONTRIBUTING.md](https://github.com/synodic/.github/blob/stable/CONTRIBUTING.md). + +## Documentation + +## License + +This project is licensed under the MIT License — see [LICENSE.md](LICENSE.md) for details. + +Copyright © 2026 Synodic Software diff --git a/cppython/__init__.py b/cppython/__init__.py new file mode 100644 index 00000000..6d58322e --- /dev/null +++ b/cppython/__init__.py @@ -0,0 +1,7 @@ +"""The CPPython project. + +This module serves as the entry point for the CPPython project, a Python-based +solution for managing C++ dependencies. It includes core functionality, plugin +interfaces, and utility functions that facilitate the integration and management +of various tools and systems. +""" diff --git a/cppython/build/__init__.py b/cppython/build/__init__.py new file mode 100644 index 00000000..05dbe90f --- /dev/null +++ b/cppython/build/__init__.py @@ -0,0 +1,34 @@ +"""CPPython build backend wrapping scikit-build-core and meson-python. + +This module provides PEP 517/518 build backend hooks that wrap scikit-build-core +or meson-python depending on the active generator, automatically running +CPPython's provider workflow before building to inject the generated +toolchain or native/cross files into the build configuration. + +Usage in pyproject.toml: + [build-system] + requires = ["cppython[conan, cmake]"] + build-backend = "cppython.build" +""" + +from cppython.build.backend import ( + build_editable, + build_sdist, + build_wheel, + get_requires_for_build_editable, + get_requires_for_build_sdist, + get_requires_for_build_wheel, + prepare_metadata_for_build_editable, + prepare_metadata_for_build_wheel, +) + +__all__ = [ + 'build_editable', + 'build_sdist', + 'build_wheel', + 'get_requires_for_build_editable', + 'get_requires_for_build_sdist', + 'get_requires_for_build_wheel', + 'prepare_metadata_for_build_editable', + 'prepare_metadata_for_build_wheel', +] diff --git a/cppython/build/backend.py b/cppython/build/backend.py new file mode 100644 index 00000000..445c2342 --- /dev/null +++ b/cppython/build/backend.py @@ -0,0 +1,246 @@ +"""PEP 517 build backend implementation wrapping scikit-build-core and meson-python. + +This module provides the actual build hooks that delegate to the appropriate +underlying build backend (scikit-build-core for CMake, meson-python for Meson) +after running CPPython's preparation workflow. +""" + +import logging +import tomllib +from pathlib import Path +from types import ModuleType +from typing import Any + +import mesonpy +from scikit_build_core import build as skbuild + +from cppython.build.prepare import BuildPreparationResult, prepare_build +from cppython.plugins.cmake.schema import CMakeSyncData +from cppython.plugins.meson.schema import MesonSyncData + +logger = logging.getLogger('cppython.build') + + +def _is_meson_project() -> bool: + """Detect if the current project uses Meson by checking pyproject.toml. + + Looks for ``[tool.cppython.generator]`` containing "meson" or the + presence of a ``meson.build`` file in the source directory. + + Returns: + True if the project appears to be Meson-based + """ + source_dir = Path.cwd() + + # Check pyproject.toml for cppython generator configuration + pyproject_path = source_dir / 'pyproject.toml' + if pyproject_path.exists(): + with open(pyproject_path, 'rb') as f: + data = tomllib.load(f) + generator = data.get('tool', {}).get('cppython', {}).get('generator', '') + if isinstance(generator, str) and 'meson' in generator.lower(): + return True + + # Fallback: check for meson.build file + return (source_dir / 'meson.build').exists() + + +def _get_backend(is_meson: bool) -> ModuleType: + """Get the appropriate backend module. + + Args: + is_meson: Whether to use meson-python instead of scikit-build-core + + Returns: + The backend module (mesonpy or scikit_build_core.build) + """ + if is_meson: + return mesonpy + return skbuild + + +def _inject_cmake_toolchain(config_settings: dict[str, Any] | None, toolchain_file: Path | None) -> dict[str, Any]: + """Inject the toolchain file into config settings for scikit-build-core. + + Args: + config_settings: The original config settings (may be None) + toolchain_file: Path to the toolchain file to inject + + Returns: + Updated config settings with toolchain file injected + """ + settings = dict(config_settings) if config_settings else {} + + if toolchain_file and toolchain_file.exists(): + # scikit-build-core accepts cmake.args for passing CMake arguments + # Using cmake.args passes the toolchain via -DCMAKE_TOOLCHAIN_FILE=... + args_key = 'cmake.args' + toolchain_arg = f'-DCMAKE_TOOLCHAIN_FILE={toolchain_file.absolute()}' + + # Append to existing args or create new + if args_key in settings: + existing = settings[args_key] + # Check if toolchain is already specified + if 'CMAKE_TOOLCHAIN_FILE' not in existing: + settings[args_key] = f'{existing};{toolchain_arg}' + logger.info('CPPython: Appended CMAKE_TOOLCHAIN_FILE to cmake.args') + else: + logger.info('CPPython: User-specified toolchain file takes precedence') + else: + settings[args_key] = toolchain_arg + logger.info('CPPython: Injected CMAKE_TOOLCHAIN_FILE=%s', toolchain_file) + + return settings + + +def _inject_meson_files( + config_settings: dict[str, Any] | None, + native_file: Path | None, + cross_file: Path | None, +) -> dict[str, Any]: + """Inject native/cross files into config settings for meson-python. + + Args: + config_settings: The original config settings (may be None) + native_file: Path to the Meson native file to inject + cross_file: Path to the Meson cross file to inject + + Returns: + Updated config settings with Meson files injected + """ + settings = dict(config_settings) if config_settings else {} + + setup_args_key = 'setup-args' + existing_args = settings.get(setup_args_key, '') + + args_to_add: list[str] = [] + + if native_file and native_file.exists(): + native_arg = f'--native-file={native_file.absolute()}' + if '--native-file' not in existing_args: + args_to_add.append(native_arg) + logger.info('CPPython: Injected --native-file=%s', native_file) + else: + logger.info('CPPython: User-specified native file takes precedence') + + if cross_file and cross_file.exists(): + cross_arg = f'--cross-file={cross_file.absolute()}' + if '--cross-file' not in existing_args: + args_to_add.append(cross_arg) + logger.info('CPPython: Injected --cross-file=%s', cross_file) + else: + logger.info('CPPython: User-specified cross file takes precedence') + + if args_to_add: + if existing_args: + settings[setup_args_key] = f'{existing_args};' + ';'.join(args_to_add) + else: + settings[setup_args_key] = ';'.join(args_to_add) + + return settings + + +def _prepare_and_get_result( + config_settings: dict[str, Any] | None, +) -> tuple[BuildPreparationResult, dict[str, Any]]: + """Run CPPython preparation and merge config into settings. + + Args: + config_settings: The original config settings + + Returns: + Tuple of (preparation result, updated config settings) + """ + # Determine source directory (current working directory during build) + source_dir = Path.cwd() + + # Run CPPython preparation + result = prepare_build(source_dir) + + # Inject settings based on sync data type + settings = dict(config_settings) if config_settings else {} + + if result.sync_data is not None: + if isinstance(result.sync_data, CMakeSyncData): + settings = _inject_cmake_toolchain(settings, result.sync_data.toolchain_file) + elif isinstance(result.sync_data, MesonSyncData): + settings = _inject_meson_files(settings, result.sync_data.native_file, result.sync_data.cross_file) + + return result, settings + + +def _is_meson_build(result: BuildPreparationResult) -> bool: + """Determine if the build should use meson-python based on sync data. + + Args: + result: The build preparation result + + Returns: + True if meson-python should be used, False for scikit-build-core + """ + return isinstance(result.sync_data, MesonSyncData) + + +# PEP 517 Hooks - dispatching to the appropriate backend after preparation + + +def get_requires_for_build_wheel(config_settings: dict[str, Any] | None = None) -> list[str]: + """Get additional requirements for building a wheel.""" + return _get_backend(_is_meson_project()).get_requires_for_build_wheel(config_settings) + + +def get_requires_for_build_sdist(config_settings: dict[str, Any] | None = None) -> list[str]: + """Get additional requirements for building an sdist.""" + return _get_backend(_is_meson_project()).get_requires_for_build_sdist(config_settings) + + +def get_requires_for_build_editable(config_settings: dict[str, Any] | None = None) -> list[str]: + """Get additional requirements for building an editable install.""" + return _get_backend(_is_meson_project()).get_requires_for_build_editable(config_settings) + + +def build_wheel( + wheel_directory: str, + config_settings: dict[str, Any] | None = None, + metadata_directory: str | None = None, +) -> str: + """Build a wheel, running CPPython preparation first.""" + logger.info('CPPython: Starting wheel build') + result, settings = _prepare_and_get_result(config_settings) + return _get_backend(_is_meson_build(result)).build_wheel(wheel_directory, settings, metadata_directory) + + +def build_sdist( + sdist_directory: str, + config_settings: dict[str, Any] | None = None, +) -> str: + """Build a source distribution (no CPPython workflow needed).""" + logger.info('CPPython: Starting sdist build') + return _get_backend(_is_meson_project()).build_sdist(sdist_directory, config_settings) + + +def build_editable( + wheel_directory: str, + config_settings: dict[str, Any] | None = None, + metadata_directory: str | None = None, +) -> str: + """Build an editable wheel, running CPPython preparation first.""" + logger.info('CPPython: Starting editable build') + result, settings = _prepare_and_get_result(config_settings) + return _get_backend(_is_meson_build(result)).build_editable(wheel_directory, settings, metadata_directory) + + +def prepare_metadata_for_build_wheel( + metadata_directory: str, + config_settings: dict[str, Any] | None = None, +) -> str: + """Prepare metadata for wheel build.""" + return _get_backend(_is_meson_project()).prepare_metadata_for_build_wheel(metadata_directory, config_settings) + + +def prepare_metadata_for_build_editable( + metadata_directory: str, + config_settings: dict[str, Any] | None = None, +) -> str: + """Prepare metadata for editable build.""" + return _get_backend(_is_meson_project()).prepare_metadata_for_build_editable(metadata_directory, config_settings) diff --git a/cppython/build/prepare.py b/cppython/build/prepare.py new file mode 100644 index 00000000..2461ad9f --- /dev/null +++ b/cppython/build/prepare.py @@ -0,0 +1,136 @@ +"""Build preparation utilities for CPPython. + +This module handles the pre-build workflow: running CPPython's provider +to install C++ dependencies and extract sync data for injection into +the appropriate build backend (scikit-build-core or meson-python). +""" + +import logging +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from rich.console import Console + +from cppython.core.interface import NoOpInterface +from cppython.core.schema import ProjectConfiguration, SyncData +from cppython.project import Project +from cppython.utility.exception import InstallationVerificationError +from cppython.utility.output import OutputSession + + +@dataclass +class BuildPreparationResult: + """Result of the build preparation step. + + Contains the sync data from the provider, which the build backend + uses to determine which underlying backend to delegate to and what + configuration to inject. + """ + + sync_data: SyncData | None = None + + +BuildInterface = NoOpInterface +"""Interface implementation for the build backend (no-op write-backs).""" + + +class BuildPreparation: + """Handles CPPython preparation before the build backend runs.""" + + def __init__(self, source_dir: Path) -> None: + """Initialize build preparation. + + Args: + source_dir: The source directory containing pyproject.toml + """ + self.source_dir = source_dir.absolute() + self.logger = logging.getLogger('cppython.build') + + def _load_pyproject(self) -> dict[str, Any]: + """Load pyproject.toml from the source directory. + + Returns: + The parsed pyproject.toml contents + + Raises: + FileNotFoundError: If pyproject.toml doesn't exist + """ + pyproject_path = self.source_dir / 'pyproject.toml' + if not pyproject_path.exists(): + raise FileNotFoundError(f'pyproject.toml not found at {pyproject_path}') + + with open(pyproject_path, 'rb') as f: + return tomllib.load(f) + + def prepare(self) -> BuildPreparationResult: + """Run CPPython preparation and return the build preparation result. + + Syncs provider config and verifies that C++ dependencies have been + installed by a prior ``install()`` call. Does **not** install + dependencies itself — the build backend is not responsible for that. + + Returns: + BuildPreparationResult containing sync data for the active generator + + Raises: + InstallationVerificationError: If provider artifacts are missing + """ + self.logger.info('CPPython: Preparing build environment') + + pyproject_data = self._load_pyproject() + + # Get version from pyproject if available + project_data = pyproject_data.get('project', {}) + version = project_data.get('version') + + # Create project configuration + project_config = ProjectConfiguration( + project_root=self.source_dir, + version=version, + verbosity=1, + ) + + # Use a headless console on stderr — no spinner in build backend context + console = Console(stderr=True, width=120) + + with OutputSession(console, verbose=False) as session: + # Create the CPPython project + interface = BuildInterface() + project = Project(project_config, interface, pyproject_data, session=session) + + if not project.enabled: + self.logger.info('CPPython: Project not enabled, skipping preparation') + return BuildPreparationResult() + + # Sync and verify — does NOT install dependencies + self.logger.info('CPPython: Verifying C++ dependencies are installed') + + try: + sync_data = project.prepare_build() + except InstallationVerificationError: + self.logger.error( + "CPPython: C++ dependencies not installed. Run 'cppython install' or 'pdm install' before building." + ) + raise + + if sync_data: + self.logger.info('CPPython: Sync data obtained from provider: %s', type(sync_data).__name__) + else: + self.logger.warning('CPPython: No sync data generated') + + return BuildPreparationResult(sync_data=sync_data) + + +def prepare_build(source_dir: Path) -> BuildPreparationResult: + """Convenience function to prepare the build environment. + + Args: + source_dir: The source directory containing pyproject.toml + + Returns: + BuildPreparationResult containing sync data for the active generator + """ + preparation = BuildPreparation(source_dir) + return preparation.prepare() diff --git a/cppython/builder.py b/cppython/builder.py new file mode 100644 index 00000000..b25f19ec --- /dev/null +++ b/cppython/builder.py @@ -0,0 +1,483 @@ +"""Defines the data and routines for building a CPPython project type""" + +import logging +import os +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 +from cppython.core.resolution import ( + PluginBuildData, + PluginCPPythonData, + resolve_cppython, + resolve_cppython_plugin, + resolve_generator, + resolve_model, + resolve_pep621, + resolve_project_configuration, + resolve_provider, + resolve_scm, +) +from cppython.core.schema import ( + CoreData, + CorePluginData, + CPPythonGlobalConfiguration, + CPPythonLocalConfiguration, + DataPlugin, + PEP621Configuration, + PEP621Data, + Plugin, + ProjectConfiguration, + ProjectData, +) +from cppython.data import Data, Plugins +from cppython.defaults import DefaultSCM +from cppython.utility.exception import PluginError +from cppython.utility.utility import TypeName + + +class Resolver: + """The resolution of data sources for the builder""" + + def __init__(self, project_configuration: ProjectConfiguration, logger: Logger) -> None: + """Initializes the resolver""" + self._project_configuration = project_configuration + self._logger = logger + + def generate_plugins( + self, cppython_local_configuration: CPPythonLocalConfiguration, project_data: ProjectData + ) -> PluginBuildData: + """Generates the plugin data from the local configuration and project data + + Args: + cppython_local_configuration: The local configuration + project_data: The project data + + Returns: + The resolved plugin data + """ + raw_generator_plugins = self._find_plugins('generator', Generator) + generator_plugins = self.filter_plugins( + raw_generator_plugins, + self._get_effective_plugin_name(cppython_local_configuration.generators), + 'Generator', + ) + + raw_provider_plugins = self._find_plugins('provider', Provider) + provider_plugins = self.filter_plugins( + raw_provider_plugins, + self._get_effective_plugin_name(cppython_local_configuration.providers), + 'Provider', + ) + + scm_plugins = self._find_plugins('scm', SCM) + + scm_type = self.select_scm(scm_plugins, project_data) + + # Solve the messy interactions between plugins + generator_type, provider_type = self.solve(generator_plugins, provider_plugins) + + return PluginBuildData(generator_type=generator_type, provider_type=provider_type, scm_type=scm_type) + + @staticmethod + def _get_effective_plugin_name(plugins: dict[TypeName, Any]) -> str | None: + """Get the effective plugin name from a plugins configuration dict. + + Args: + plugins: The plugins dict (e.g. config.generators or config.providers) + + Returns: + The first plugin name if any are configured, or None for auto-detection + """ + if plugins: + return list(plugins.keys())[0] + return None + + @staticmethod + def get_plugin_config(plugins: dict[TypeName, Any], plugin_name: str) -> dict[str, Any]: + """Get the configuration dict for a specific plugin. + + Args: + plugins: The plugins dict (e.g. config.generators or config.providers) + plugin_name: The name of the plugin + + Returns: + The configuration dict for the plugin, or empty dict if not found + """ + type_name = TypeName(plugin_name) + if type_name in plugins: + return plugins[type_name] + return {} + + @staticmethod + def generate_cppython_plugin_data(plugin_build_data: PluginBuildData) -> PluginCPPythonData: + """Generates the CPPython plugin data from the resolved plugins + + Args: + plugin_build_data: The resolved plugin data + + Returns: + The plugin data used by CPPython + """ + return PluginCPPythonData( + generator_name=plugin_build_data.generator_type.name(), + provider_name=plugin_build_data.provider_type.name(), + scm_name=plugin_build_data.scm_type.name(), + ) + + @staticmethod + def generate_pep621_data( + pep621_configuration: PEP621Configuration, project_configuration: ProjectConfiguration, scm: SCM | None + ) -> PEP621Data: + """Generates the PEP621 data from configuration sources + + Args: + pep621_configuration: The PEP621 configuration + project_configuration: The project configuration + scm: The source control manager, if any + + Returns: + The resolved PEP621 data + """ + return resolve_pep621(pep621_configuration, project_configuration, scm) + + @staticmethod + def resolve_global_config() -> CPPythonGlobalConfiguration: + """Generates the global configuration object by loading from ~/.cppython/config.toml + + Returns: + 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_plugins[T: Plugin](self, group_name: str, base_type: type[T]) -> list[type[T]]: + """Extracts plugins of a given type from entry points. + + Args: + group_name: The entry point group suffix (e.g. 'generator', 'provider', 'scm') + base_type: The expected base type to filter against + + Raises: + PluginError: Raised if no plugins can be found + + Returns: + The list of discovered plugin types + """ + plugin_types: list[type[T]] = [] + + entries = entry_points(group=f'cppython.{group_name}') + + for entry_point in list(entries): + loaded_type = entry_point.load() + if not issubclass(loaded_type, base_type): + self._logger.warning( + f"Found incompatible plugin. The '{loaded_type.name()}' plugin must be an instance of" + f" '{group_name}'" + ) + else: + self._logger.info(f'{group_name} plugin found: {loaded_type.name()} from {getmodule(loaded_type)}') + plugin_types.append(loaded_type) + + if not plugin_types: + raise PluginError(f'No {group_name} plugin was found') + + return plugin_types + + def filter_plugins[T: DataPlugin]( + self, plugin_types: list[type[T]], pinned_name: str | None, group_name: str + ) -> list[type[T]]: + """Finds and filters data plugins + + Args: + plugin_types: The plugin type to lookup + pinned_name: The configuration name + group_name: The group name + + Raises: + PluginError: Raised if no plugins can be found + + Returns: + The list of applicable plugins + """ + # Lookup the requested plugin if given + if pinned_name is not None: + for loaded_type in plugin_types: + if loaded_type.name() == pinned_name: + self._logger.info(f'Using {group_name} plugin: {loaded_type.name()} from {getmodule(loaded_type)}') + return [loaded_type] + + self._logger.info(f"'{group_name}_name' was empty. Trying to deduce {group_name}s") + + supported_types: list[type[T]] = [] + + # Deduce types + for loaded_type in plugin_types: + self._logger.info(f'A {group_name} plugin is supported: {loaded_type.name()} from {getmodule(loaded_type)}') + supported_types.append(loaded_type) + + # Fail + if supported_types is None: + raise PluginError(f'No {group_name} could be deduced from the root directory.') + + return supported_types + + def select_scm(self, scm_plugins: list[type[SCM]], project_data: ProjectData) -> type[SCM]: + """Given data constraints, selects the SCM plugin to use + + Args: + scm_plugins: The list of SCM plugin types + project_data: The project data + + Returns: + The selected SCM plugin type + """ + for scm_type in scm_plugins: + if cast(SupportedSCMFeatures, scm_type.features(project_data.project_root)).repository: + return scm_type + + self._logger.info('No SCM plugin was found that supports the given path') + + return DefaultSCM + + @staticmethod + def solve( + generator_types: list[type[Generator]], provider_types: list[type[Provider]] + ) -> tuple[type[Generator], type[Provider]]: + """Selects the first generator and provider that can work together + + Args: + generator_types: The list of generator plugin types + provider_types: The list of provider plugin types + + Raises: + PluginError: Raised if no provider that supports a given generator could be deduced + + Returns: + A tuple of the selected generator and provider plugin types + """ + combos: list[tuple[type[Generator], type[Provider]]] = [] + + for generator_type in generator_types: + sync_types = generator_type.sync_types() + for provider_type in provider_types: + for sync_type in sync_types: + if provider_type.supported_sync_type(sync_type): + combos.append((generator_type, provider_type)) + break + + if not combos: + raise PluginError('No provider that supports a given generator could be deduced') + + return combos[0] + + @staticmethod + def create_scm[T: SCM]( + core_data: CoreData, + scm_type: type[T], + ) -> T: + """Creates a source control manager from input configuration + + Args: + core_data: The resolved configuration data + scm_type: The plugin type + + Returns: + The constructed source control manager + """ + cppython_plugin_data = resolve_cppython_plugin(core_data.cppython_data, scm_type) + scm_data = resolve_scm(core_data.project_data, cppython_plugin_data) + + plugin = scm_type(scm_data) + + return plugin + + def create_generator[T: Generator]( + self, + core_data: CoreData, + pep621_data: PEP621Data, + generator_configuration: dict[str, Any], + generator_type: type[T], + ) -> T: + """Creates a generator from input configuration + + Args: + core_data: The resolved configuration data + pep621_data: The PEP621 data + generator_configuration: The generator table of the CPPython configuration data + generator_type: The plugin type + + Returns: + The constructed generator + """ + cppython_plugin_data = resolve_cppython_plugin(core_data.cppython_data, generator_type) + + generator_data = resolve_generator(core_data.project_data, cppython_plugin_data) + + if not generator_configuration: + self._logger.info( + "The pyproject.toml table 'tool.cppython.generator' does not exist. Sending generator empty data", + ) + + core_plugin_data = CorePluginData( + project_data=core_data.project_data, + pep621_data=pep621_data, + cppython_data=cppython_plugin_data, + ) + + return generator_type(generator_data, core_plugin_data, generator_configuration) + + def create_provider[T: Provider]( + self, + core_data: CoreData, + pep621_data: PEP621Data, + provider_configuration: dict[str, Any], + provider_type: type[T], + ) -> T: + """Creates Providers from input data + + Args: + core_data: The resolved configuration data + pep621_data: The PEP621 data + provider_configuration: The provider data table + provider_type: The type to instantiate + + Returns: + A constructed provider plugins + """ + cppython_plugin_data = resolve_cppython_plugin(core_data.cppython_data, provider_type) + + provider_data = resolve_provider(core_data.project_data, cppython_plugin_data) + + if not provider_configuration: + self._logger.info( + "The pyproject.toml table 'tool.cppython.provider' does not exist. Sending provider empty data", + ) + + core_plugin_data = CorePluginData( + project_data=core_data.project_data, + pep621_data=pep621_data, + cppython_data=cppython_plugin_data, + ) + + return provider_type(provider_data, core_plugin_data, provider_configuration) + + +class Builder: + """Helper class for building CPPython projects""" + + levels = [logging.WARNING, logging.INFO, logging.DEBUG] + + def __init__(self, project_configuration: ProjectConfiguration, logger: Logger) -> None: + """Initializes the builder""" + self._project_configuration = project_configuration + self._logger = logger + + # Informal standard to check for color + force_color = os.getenv('FORCE_COLOR', '1') != '0' + + self._console = Console( + force_terminal=force_color, + color_system='auto', + width=120, + legacy_windows=False, + no_color=False, + ) + + rich_handler = RichHandler( + console=self._console, + rich_tracebacks=True, + show_time=False, + show_path=False, + markup=True, + show_level=False, + enable_link_path=False, + ) + + self._logger.addHandler(rich_handler) + self._logger.setLevel(Builder.levels[project_configuration.verbosity]) + + self._logger.info('Logging setup complete') + + self._resolver = Resolver(self._project_configuration, self._logger) + + @property + def console(self) -> Console: + """The Rich console instance used for terminal output.""" + return self._console + + def build( + self, + pep621_configuration: PEP621Configuration, + cppython_local_configuration: CPPythonLocalConfiguration, + plugin_build_data: PluginBuildData | None = None, + ) -> Data: + """Builds the project data + + Args: + pep621_configuration: The PEP621 configuration + cppython_local_configuration: The local configuration + plugin_build_data: Plugin override data. If it exists, the build will use the given types + instead of resolving them + + Returns: + The built data object + """ + project_data = resolve_project_configuration(self._project_configuration) + + if plugin_build_data is None: + plugin_build_data = self._resolver.generate_plugins(cppython_local_configuration, project_data) + + plugin_cppython_data = self._resolver.generate_cppython_plugin_data(plugin_build_data) + + global_configuration = self._resolver.resolve_global_config() + + cppython_data = resolve_cppython( + cppython_local_configuration, global_configuration, project_data, plugin_cppython_data + ) + + core_data = CoreData(project_data=project_data, cppython_data=cppython_data) + + scm = self._resolver.create_scm(core_data, plugin_build_data.scm_type) + + pep621_data = self._resolver.generate_pep621_data(pep621_configuration, self._project_configuration, scm) + + # Create the chosen plugins + generator_config = Resolver.get_plugin_config( + cppython_local_configuration.generators, plugin_build_data.generator_type.name() + ) + generator = self._resolver.create_generator( + core_data, pep621_data, generator_config, plugin_build_data.generator_type + ) + + provider_config = Resolver.get_plugin_config( + cppython_local_configuration.providers, plugin_build_data.provider_type.name() + ) + provider = self._resolver.create_provider( + core_data, pep621_data, provider_config, plugin_build_data.provider_type + ) + + plugins = Plugins(generator=generator, provider=provider, scm=scm) + + self._logger.debug('Project data:\n%s', pformat(dict(core_data))) + + return Data(core_data, plugins, self._logger) diff --git a/cppython/configuration.py b/cppython/configuration.py new file mode 100644 index 00000000..54fc4c82 --- /dev/null +++ b/cppython/configuration.py @@ -0,0 +1,195 @@ +"""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) - User-specific overrides, repository ignored + +Local overrides (.cppython.toml) can override any field from both CPPythonLocalConfiguration +and CPPythonGlobalConfiguration. Validation occurs on the merged result, not on the override +file itself, allowing flexible user-specific customization. +""" + +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 have the highest priority and override both global + and project configuration. This file should be gitignored as it + contains machine-specific or user-specific settings. + + The override file can contain any fields from CPPythonLocalConfiguration + or CPPythonGlobalConfiguration. Validation occurs on the merged result. + + 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. Local overrides (.cppython.toml) - Machine/user-specific settings + 2. Project configuration (pyproject.toml or cppython.toml) - Project-specific settings + 3. Global configuration (~/.cppython/config.toml) - User-wide defaults + + Returns: + Merged CPPython configuration dictionary, or None if no config found + """ + # Start with global configuration (lowest priority) + result_config = self.load_global_config() + + # 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 project config over global config + if project_config is not None and result_config is not None: + result_config = self.merge_configurations(result_config, project_config) + elif project_config is not None: + result_config = project_config + + # Apply local overrides with highest priority + local_overrides = self.load_local_overrides() + if local_overrides is not None: + if result_config is not None: + result_config = self.merge_configurations(result_config, local_overrides) + else: + result_config = local_overrides + + return result_config + + 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(), + } diff --git a/cppython/console/__init__.py b/cppython/console/__init__.py new file mode 100644 index 00000000..dd8a4408 --- /dev/null +++ b/cppython/console/__init__.py @@ -0,0 +1,6 @@ +"""Console interface for the CPPython project. + +This module provides a command-line interface (CLI) for interacting with the +CPPython project. It includes commands for managing project configurations, +installing dependencies, and updating project data. +""" diff --git a/cppython/console/entry.py b/cppython/console/entry.py new file mode 100644 index 00000000..509d54eb --- /dev/null +++ b/cppython/console/entry.py @@ -0,0 +1,411 @@ +"""A Typer CLI for CPPython interfacing""" + +import contextlib +from collections.abc import Generator +from importlib.metadata import entry_points +from pathlib import Path +from typing import Annotated + +import typer +from rich import print +from rich.console import Console +from rich.syntax import Syntax + +from cppython.configuration import ConfigurationLoader +from cppython.console.schema import ConsoleConfiguration, ConsoleInterface +from cppython.core.schema import PluginReport, ProjectConfiguration +from cppython.project import Project +from cppython.utility.output import OutputSession + +app = typer.Typer(no_args_is_help=True) + +info_app = typer.Typer( + no_args_is_help=True, + help='Prints project information including plugin configuration, managed files, and templates.', +) +app.add_typer(info_app, name='info') + +list_app = typer.Typer(no_args_is_help=True, help='List project entities.') +app.add_typer(list_app, name='list') + + +def _get_configuration(context: typer.Context) -> ConsoleConfiguration: + """Extract the ConsoleConfiguration object from the CLI context. + + Raises: + ValueError: If the configuration object is missing + """ + configuration = context.find_object(ConsoleConfiguration) + if configuration is None: + raise ValueError('The configuration object is missing') + return configuration + + +def get_enabled_project(context: typer.Context) -> Project: + """Helper to load and validate an enabled Project from CLI context.""" + configuration = _get_configuration(context) + + # 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 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 + + +@contextlib.contextmanager +def _session_project(context: typer.Context) -> Generator[Project]: + """Create an enabled Project wrapped in an OutputSession. + + Yields the project with its session already attached. The session + (spinner + log file) is torn down when the ``with`` block exits. + """ + project = get_enabled_project(context) + configuration = _get_configuration(context) + verbose = configuration.project_configuration.verbosity > 0 + console = Console(width=120) + + with OutputSession(console, verbose=verbose) as session: + project.session = session + yield 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. + + Returns: + The directory containing pyproject.toml + + Raises: + AssertionError: If no pyproject.toml is found up to the filesystem root + """ + path = Path.cwd() + + while True: + if (path / 'pyproject.toml').exists(): + return path + parent = path.parent + if parent == path: + raise AssertionError( + 'This is not a valid project. No pyproject.toml found in the current directory or any of its parents.' + ) + path = parent + + +@app.callback() +def main( + context: typer.Context, + verbose: Annotated[ + int, typer.Option('-v', '--verbose', count=True, min=0, max=2, help='Print additional output') + ] = 0, + debug: Annotated[bool, typer.Option()] = False, +) -> None: + """entry_point group for the CLI commands + + Args: + context: The typer context + verbose: The verbosity level + debug: Debug mode + """ + path = _find_pyproject_file() + + project_configuration = ProjectConfiguration(verbosity=verbose, debug=debug, project_root=path, version=None) + + interface = ConsoleInterface() + context.obj = ConsoleConfiguration(project_configuration=project_configuration, interface=interface) + + +def _print_plugin_report(role: str, name: str, report: PluginReport) -> None: + """Print a single plugin's report to the console. + + Args: + role: The plugin role label (e.g. 'Provider', 'Generator') + name: The plugin name + report: The plugin report to display + """ + print(f'\n[bold]{role}:[/bold] {name}') + + if report.configuration: + print(' [bold]Configuration:[/bold]') + for key, value in report.configuration.items(): + print(f' {key}: {value}') + + if report.managed_files: + print(' [bold]Managed files:[/bold]') + for file_path in report.managed_files: + print(f' {file_path}') + + if report.template_files: + print(' [bold]Templates:[/bold]') + for filename, content in report.template_files.items(): + print(f' [cyan]{filename}[/cyan]') + print() + print(Syntax(content, 'python', theme='monokai', line_numbers=True)) + + +@info_app.command() +def info_provider( + context: typer.Context, +) -> None: + """Show provider plugin information.""" + project = get_enabled_project(context) + project_info = project.info() + + entry = project_info.get('provider') + if entry is None: + return + + _print_plugin_report('Provider', entry['name'], entry['report']) + + +@info_app.command() +def info_generator( + context: typer.Context, +) -> None: + """Show generator plugin information.""" + project = get_enabled_project(context) + project_info = project.info() + + entry = project_info.get('generator') + if entry is None: + return + + _print_plugin_report('Generator', entry['name'], entry['report']) + + +@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 + """ + group_list = _parse_groups_argument(groups) + + with _session_project(context) as project: + 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 + """ + group_list = _parse_groups_argument(groups) + + with _session_project(context) as project: + project.update(groups=group_list) + + +@list_app.command() +def plugins() -> None: + """List all installed CPPython plugins.""" + groups = { + 'Generators': 'cppython.generator', + 'Providers': 'cppython.provider', + 'SCM': 'cppython.scm', + } + + for label, group in groups.items(): + entries = entry_points(group=group) + print(f'\n[bold]{label}:[/bold]') + if not entries: + print(' (none installed)') + else: + for ep in sorted(entries, key=lambda e: e.name): + print(f' {ep.name}') + + +@list_app.command() +def targets( + context: typer.Context, +) -> None: + """List discovered build targets.""" + project = get_enabled_project(context) + target_list = project.list_targets() + + if not target_list: + print('[dim]No targets found. Have you run install and build?[/dim]') + return + + print('\n[bold]Targets:[/bold]') + for target_name in sorted(target_list): + print(f' {target_name}') + + +@app.command() +def publish( + context: typer.Context, +) -> None: + """Publish API call + + Args: + context: The CLI configuration object + + Raises: + ValueError: If the configuration object is missing + """ + with _session_project(context) as project: + project.publish() + + +@app.command() +def build( + context: typer.Context, + configuration: Annotated[ + str | None, + typer.Option(help='Named build configuration to use (e.g. CMake preset name, Meson build directory)'), + ] = None, +) -> None: + """Build the project + + Assumes dependencies have been installed via `install`. + + Args: + context: The CLI configuration object + configuration: Optional named configuration + """ + with _session_project(context) as project: + project.build(configuration=configuration) + + +@app.command() +def test( + context: typer.Context, + configuration: Annotated[ + str | None, + typer.Option(help='Named build configuration to use (e.g. CMake preset name, Meson build directory)'), + ] = None, +) -> None: + """Run project tests + + Assumes dependencies have been installed via `install`. + + Args: + context: The CLI configuration object + configuration: Optional named configuration + """ + with _session_project(context) as project: + project.test(configuration=configuration) + + +@app.command() +def bench( + context: typer.Context, + configuration: Annotated[ + str | None, + typer.Option(help='Named build configuration to use (e.g. CMake preset name, Meson build directory)'), + ] = None, +) -> None: + """Run project benchmarks + + Assumes dependencies have been installed via `install`. + + Args: + context: The CLI configuration object + configuration: Optional named configuration + """ + with _session_project(context) as project: + project.bench(configuration=configuration) + + +@app.command() +def run( + context: typer.Context, + target: Annotated[ + str, + typer.Argument(help='The name of the build target/executable to run'), + ], + configuration: Annotated[ + str | None, + typer.Option(help='Named build configuration to use (e.g. CMake preset name, Meson build directory)'), + ] = None, +) -> None: + """Run a built executable + + Assumes dependencies have been installed via `install`. + + Args: + context: The CLI configuration object + target: The name of the build target to run + configuration: Optional named configuration + """ + with _session_project(context) as project: + project.run(target, configuration=configuration) diff --git a/cppython/console/schema.py b/cppython/console/schema.py new file mode 100644 index 00000000..32590001 --- /dev/null +++ b/cppython/console/schema.py @@ -0,0 +1,18 @@ +"""Data definitions for the console application""" + +from pydantic import ConfigDict + +from cppython.core.interface import NoOpInterface +from cppython.core.schema import CPPythonModel, Interface, ProjectConfiguration + +ConsoleInterface = NoOpInterface +"""Interface implementation for the console application (no-op write-backs).""" + + +class ConsoleConfiguration(CPPythonModel): + """Configuration data for the console application""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + project_configuration: ProjectConfiguration + interface: Interface diff --git a/cppython/core/__init__.py b/cppython/core/__init__.py new file mode 100644 index 00000000..353b88fc --- /dev/null +++ b/cppython/core/__init__.py @@ -0,0 +1,6 @@ +"""Core functionality for the CPPython project. + +This module contains the core components and utilities that form the foundation +of the CPPython project. It includes schema definitions, exception handling, +resolution processes, and utility functions. +""" diff --git a/cppython/core/exception.py b/cppython/core/exception.py new file mode 100644 index 00000000..bc7d5216 --- /dev/null +++ b/cppython/core/exception.py @@ -0,0 +1,5 @@ +"""Custom exceptions used by CPPython""" + + +class ConfigException(ValueError): + """Raised when there is a configuration error.""" diff --git a/cppython/core/interface.py b/cppython/core/interface.py new file mode 100644 index 00000000..0cf612b4 --- /dev/null +++ b/cppython/core/interface.py @@ -0,0 +1,20 @@ +"""Default interface implementations.""" + +from cppython.core.schema import Interface + + +class NoOpInterface(Interface): + """No-op implementation of Interface. + + Used when no write-back to configuration files is needed, + e.g. in the build backend and console application contexts. + """ + + def write_pyproject(self) -> None: + """No-op.""" + + def write_configuration(self) -> None: + """No-op.""" + + def write_user_configuration(self) -> None: + """No-op.""" diff --git a/cppython/core/plugin_schema/__init__.py b/cppython/core/plugin_schema/__init__.py new file mode 100644 index 00000000..65baf09d --- /dev/null +++ b/cppython/core/plugin_schema/__init__.py @@ -0,0 +1,7 @@ +"""Schema definitions for CPPython plugins. + +This module defines the schemas and protocols for CPPython plugins, including +generators, providers, and SCMs. It provides the necessary interfaces and data +structures to ensure consistent communication and functionality between the core +CPPython system and its plugins. +""" diff --git a/cppython/core/plugin_schema/generator.py b/cppython/core/plugin_schema/generator.py new file mode 100644 index 00000000..ab4b58d2 --- /dev/null +++ b/cppython/core/plugin_schema/generator.py @@ -0,0 +1,120 @@ +"""Generator data plugin definitions""" + +from abc import abstractmethod +from typing import Any, Protocol, runtime_checkable + +from pydantic.types import DirectoryPath + +from cppython.core.schema import ( + CorePluginData, + DataPlugin, + DataPluginGroupData, + SupportedDataFeatures, + SupportedFeatures, + SyncData, +) + + +class GeneratorPluginGroupData(DataPluginGroupData): + """Base class for the configuration data that is set by the project for the generator""" + + +class SupportedGeneratorFeatures(SupportedDataFeatures): + """Generator plugin feature support""" + + +class SyncConsumer(Protocol): + """Interface for consuming synchronization data from providers""" + + @staticmethod + @abstractmethod + def sync_types() -> list[type[SyncData]]: + """Broadcasts supported types + + Returns: + A list of synchronization types that are supported + """ + raise NotImplementedError + + @abstractmethod + def sync(self, sync_data: SyncData) -> None: + """Synchronizes generator files and state with the providers input + + Args: + sync_data: The input data to sync with + """ + raise NotImplementedError + + +@runtime_checkable +class Generator(DataPlugin, SyncConsumer, Protocol): + """Abstract type to be inherited by CPPython Generator plugins""" + + @abstractmethod + def __init__( + self, group_data: GeneratorPluginGroupData, core_data: CorePluginData, configuration_data: dict[str, Any] + ) -> None: + """Initializes the generator plugin""" + raise NotImplementedError + + @staticmethod + @abstractmethod + def features(directory: DirectoryPath) -> SupportedFeatures: + """Broadcasts the shared features of the generator plugin to CPPython + + Args: + directory: The root directory where features are evaluated + + Returns: + The supported features - `SupportedGeneratorFeatures`. Cast to this type to help us avoid generic typing + """ + raise NotImplementedError + + @abstractmethod + def build(self, configuration: str | None = None) -> None: + """Builds the project using the generator's build system. + + Executes the build step. The interpretation of ``configuration`` is + generator-specific (e.g. CMake preset name, Meson build directory). + + Args: + configuration: Optional named configuration override. + """ + raise NotImplementedError + + @abstractmethod + def test(self, configuration: str | None = None) -> None: + """Runs tests using the generator's build system. + + Args: + configuration: Optional named configuration override. + """ + raise NotImplementedError + + @abstractmethod + def bench(self, configuration: str | None = None) -> None: + """Runs benchmarks using the generator's build system. + + Args: + configuration: Optional named configuration override. + """ + raise NotImplementedError + + @abstractmethod + def run(self, target: str, configuration: str | None = None) -> None: + """Runs a built executable by target name. + + Args: + target: The name of the build target/executable to run. + configuration: Optional named configuration override. + """ + raise NotImplementedError + + @abstractmethod + def list_targets(self) -> list[str]: + """Lists discovered build targets/executables. + + Returns: + A list of target names found in the build directory. + """ + raise NotImplementedError diff --git a/cppython/core/plugin_schema/provider.py b/cppython/core/plugin_schema/provider.py new file mode 100644 index 00000000..740a00c6 --- /dev/null +++ b/cppython/core/plugin_schema/provider.py @@ -0,0 +1,116 @@ +"""Provider data plugin definitions""" + +from abc import abstractmethod +from typing import Any, Protocol, runtime_checkable + +from pydantic.types import DirectoryPath + +from cppython.core.plugin_schema.generator import SyncConsumer +from cppython.core.schema import ( + CorePluginData, + DataPlugin, + DataPluginGroupData, + SupportedDataFeatures, + SupportedFeatures, + SyncData, +) + + +class ProviderPluginGroupData(DataPluginGroupData): + """Base class for the configuration data that is set by the project for the provider""" + + +class SupportedProviderFeatures(SupportedDataFeatures): + """Provider plugin feature support""" + + +class SyncProducer(Protocol): + """Interface for producing synchronization data with generators""" + + @staticmethod + @abstractmethod + def supported_sync_type(sync_type: type[SyncData]) -> bool: + """Queries for support for a given synchronization type + + Args: + sync_type: The type to query support for + + Returns: + Support + """ + raise NotImplementedError + + @abstractmethod + def sync_data(self, consumer: SyncConsumer) -> SyncData | None: + """Requests generator information from the provider. + + The generator is either defined by a provider specific file or the CPPython configuration table + + Args: + consumer: The consumer + + Returns: + An instantiated data type, or None if no instantiation is made + """ + raise NotImplementedError + + +@runtime_checkable +class Provider(DataPlugin, SyncProducer, Protocol): + """Abstract type to be inherited by CPPython Provider plugins""" + + @abstractmethod + def __init__( + self, group_data: ProviderPluginGroupData, core_data: CorePluginData, configuration_data: dict[str, Any] + ) -> None: + """Initializes the provider""" + raise NotImplementedError + + @staticmethod + @abstractmethod + def features(directory: DirectoryPath) -> SupportedFeatures: + """Broadcasts the shared features of the Provider plugin to CPPython + + Args: + directory: The root directory where features are evaluated + + Returns: + The supported features - `SupportedProviderFeatures`. Cast to this type to help us avoid generic typing + """ + raise NotImplementedError + + @abstractmethod + def verify_installed(self) -> None: + """Verify that provider artifacts exist on disk. + + This is called by the build backend to confirm that C++ dependencies + have been installed (via a prior ``install()`` call) before delegating + to the downstream build backend. + + Raises: + InstallationVerificationError: If required artifacts are missing + """ + raise NotImplementedError + + @abstractmethod + def install(self, groups: list[str] | None = None) -> None: + """Called when dependencies need to be installed from a lock file. + + Args: + groups: Optional list of dependency group names to install in addition to base dependencies + """ + raise NotImplementedError + + @abstractmethod + def update(self, groups: list[str] | None = None) -> None: + """Called when dependencies need to be updated and written to the lock file. + + Args: + groups: Optional list of dependency group names to update in addition to base dependencies + """ + raise NotImplementedError + + @abstractmethod + def publish(self) -> None: + """Called when the project needs to be published.""" + raise NotImplementedError diff --git a/cppython/core/plugin_schema/scm.py b/cppython/core/plugin_schema/scm.py new file mode 100644 index 00000000..8726121d --- /dev/null +++ b/cppython/core/plugin_schema/scm.py @@ -0,0 +1,62 @@ +"""Version control data plugin definitions""" + +from abc import abstractmethod +from typing import Annotated, Protocol, runtime_checkable + +from pydantic import DirectoryPath, Field + +from cppython.core.schema import Plugin, PluginGroupData, SupportedFeatures + + +class SCMPluginGroupData(PluginGroupData): + """SCM plugin input data""" + + +class SupportedSCMFeatures(SupportedFeatures): + """SCM plugin feature support""" + + repository: Annotated[ + bool, Field(description='True if the directory is a repository for the SCM. False, otherwise') + ] + + +@runtime_checkable +class SCM(Plugin, Protocol): + """Base class for version control systems""" + + @abstractmethod + def __init__(self, group_data: SCMPluginGroupData) -> None: + """Initializes the SCM plugin""" + raise NotImplementedError + + @staticmethod + @abstractmethod + def features(directory: DirectoryPath) -> SupportedFeatures: + """Broadcasts the shared features of the SCM plugin to CPPython + + Args: + directory: The root directory where features are evaluated + + Returns: + The supported features - `SupportedSCMFeatures`. Cast to this type to help us avoid generic typing + """ + raise NotImplementedError + + @abstractmethod + def version(self, directory: DirectoryPath) -> str: + """Extracts the system's version metadata + + Args: + directory: The input directory + + Returns: + A version string + """ + raise NotImplementedError + + def description(self) -> str | None: + """Requests extraction of the project description + + Returns: + Returns the project description, or none if unavailable + """ diff --git a/cppython/core/resolution.py b/cppython/core/resolution.py new file mode 100644 index 00000000..e2e4f67c --- /dev/null +++ b/cppython/core/resolution.py @@ -0,0 +1,327 @@ +"""Data conversion routines""" + +import logging +from pathlib import Path +from typing import Any, cast + +from packaging.requirements import InvalidRequirement, Requirement +from pydantic import BaseModel, DirectoryPath, ValidationError + +from cppython.core.exception import ConfigException +from cppython.core.plugin_schema.generator import Generator, GeneratorPluginGroupData +from cppython.core.plugin_schema.provider import Provider, ProviderPluginGroupData +from cppython.core.plugin_schema.scm import SCM, SCMPluginGroupData +from cppython.core.schema import ( + CPPythonData, + CPPythonGlobalConfiguration, + CPPythonLocalConfiguration, + CPPythonModel, + CPPythonPluginData, + PEP621Configuration, + PEP621Data, + Plugin, + PluginGroupData, + ProjectConfiguration, + ProjectData, +) +from cppython.utility.utility import TypeName + + +def resolve_project_configuration(project_configuration: ProjectConfiguration) -> ProjectData: + """Creates a resolved type + + Args: + project_configuration: Input configuration + + Returns: + The resolved data + """ + return ProjectData(project_root=project_configuration.project_root, verbosity=project_configuration.verbosity) + + +def resolve_pep621( + pep621_configuration: PEP621Configuration, project_configuration: ProjectConfiguration, scm: SCM | None +) -> PEP621Data: + """Creates a resolved type + + Args: + pep621_configuration: Input PEP621 configuration + project_configuration: The input configuration used to aid the resolve + scm: SCM + + Raises: + ConfigError: Raised when the tooling did not satisfy the configuration request + ValueError: Raised if there is a broken schema + + Returns: + The resolved type + """ + # Update the dynamic version + if 'version' in pep621_configuration.dynamic: + if project_configuration.version is not None: + modified_version = project_configuration.version + elif scm is not None: + modified_version = scm.version(project_configuration.project_root) + else: + raise ValueError("Version can't be resolved. No SCM") + + elif pep621_configuration.version is not None: + modified_version = pep621_configuration.version + + else: + raise ValueError("Version can't be resolved. Schema error") + + pep621_data = PEP621Data( + name=pep621_configuration.name, version=modified_version, description=pep621_configuration.description + ) + return pep621_data + + +def _resolve_absolute_path(path: Path, root_directory: Path) -> Path: + """Convert a path to absolute, using root_directory as base for relative paths. + + Args: + path: The path to resolve + root_directory: The base directory for relative paths + + Returns: + The absolute path + """ + if path.is_absolute(): + return path + return root_directory / path + + +class PluginBuildData(CPPythonModel): + """Data needed to construct CoreData""" + + generator_type: type[Generator] + provider_type: type[Provider] + scm_type: type[SCM] + + +class PluginCPPythonData(CPPythonModel): + """Plugin data needed to construct CPPythonData""" + + generator_name: TypeName + provider_name: TypeName + scm_name: TypeName + + +def resolve_cppython( + local_configuration: CPPythonLocalConfiguration, + global_configuration: CPPythonGlobalConfiguration, + project_data: ProjectData, + plugin_build_data: PluginCPPythonData, +) -> CPPythonData: + """Creates a copy and resolves dynamic attributes + + Args: + local_configuration: Local project configuration + global_configuration: Shared project configuration + project_data: Project information to aid in the resolution + plugin_build_data: Plugin build data + + Raises: + ConfigError: Raised when the tooling did not satisfy the configuration request + + Returns: + An instance of the resolved type + """ + root_directory = project_data.project_root.absolute() + + # Resolve configuration path + modified_configuration_path = local_configuration.configuration_path + if modified_configuration_path is None: + modified_configuration_path = root_directory / 'cppython.json' + else: + modified_configuration_path = _resolve_absolute_path(modified_configuration_path, root_directory) + + # Resolve other paths + modified_install_path = _resolve_absolute_path(local_configuration.install_path, root_directory) + modified_tool_path = _resolve_absolute_path(local_configuration.tool_path, root_directory) + modified_build_path = _resolve_absolute_path(local_configuration.build_path, root_directory) + + modified_provider_name = plugin_build_data.provider_name + modified_generator_name = plugin_build_data.generator_name + modified_scm_name = plugin_build_data.scm_name + + # Extract provider and generator configuration data + provider_type_name = TypeName(modified_provider_name) + generator_type_name = TypeName(modified_generator_name) + + provider_data = {} + if local_configuration.providers and provider_type_name in local_configuration.providers: + provider_data = local_configuration.providers[provider_type_name] + + generator_data = {} + if local_configuration.generators and generator_type_name in local_configuration.generators: + generator_data = local_configuration.generators[generator_type_name] + + # Construct dependencies from the local configuration only + dependencies: list[Requirement] = [] + invalid_requirements: list[str] = [] + if local_configuration.dependencies: + for dependency in local_configuration.dependencies: + try: + dependencies.append(Requirement(dependency)) + except InvalidRequirement as error: + invalid_requirements.append(f"Invalid requirement '{dependency}': {error}") + + # Construct dependency groups from the local configuration + dependency_groups: dict[str, list[Requirement]] = {} + if local_configuration.dependency_groups: + for group_name, group_dependencies in local_configuration.dependency_groups.items(): + resolved_group: list[Requirement] = [] + for dependency in group_dependencies: + try: + resolved_group.append(Requirement(dependency)) + except InvalidRequirement as error: + invalid_requirements.append(f"Invalid requirement '{dependency}' in group '{group_name}': {error}") + dependency_groups[group_name] = resolved_group + + if invalid_requirements: + raise ConfigException('\n'.join(invalid_requirements)) + + cppython_data = CPPythonData( + configuration_path=modified_configuration_path, + install_path=modified_install_path, + tool_path=modified_tool_path, + build_path=modified_build_path, + current_check=global_configuration.current_check, + provider_name=modified_provider_name, + generator_name=modified_generator_name, + scm_name=modified_scm_name, + dependencies=dependencies, + dependency_groups=dependency_groups, + provider_data=provider_data, + generator_data=generator_data, + ) + return cppython_data + + +def resolve_cppython_plugin(cppython_data: CPPythonData, plugin_type: type[Plugin]) -> CPPythonPluginData: + """Resolve project configuration for plugins + + Args: + cppython_data: The CPPython data + plugin_type: The plugin type + + Returns: + The resolved type with plugin specific modifications + """ + # Add plugin specific paths to the base path + modified_install_path = cppython_data.install_path / plugin_type.name() + + plugin_data = cppython_data.model_copy(update={'install_path': modified_install_path}) + + return cast(CPPythonPluginData, plugin_data) + + +def _write_tool_directory(cppython_data: CPPythonData, directory: Path) -> DirectoryPath: + """Creates directories following a certain format + + Args: + cppython_data: The cppython data + directory: The directory to create + + Returns: + The written path + """ + plugin_directory = cppython_data.tool_path / 'cppython' / directory + + return plugin_directory + + +def _resolve_plugin_group[T: PluginGroupData]( + project_data: ProjectData, cppython_data: CPPythonPluginData, category: str, plugin_name: str, group_type: type[T] +) -> T: + """Generic helper to resolve plugin group data. + + Args: + project_data: The input project data + cppython_data: The input cppython data + category: The subfolder category (e.g. 'generators', 'providers', 'managers') + plugin_name: The name of the specific plugin + group_type: The PluginGroupData subclass to construct + + Returns: + The plugin specific configuration + """ + root_directory = project_data.project_root + tool_directory = _write_tool_directory(cppython_data, Path(category) / plugin_name) + return group_type(root_directory=root_directory, tool_directory=tool_directory) + + +def resolve_generator(project_data: ProjectData, cppython_data: CPPythonPluginData) -> GeneratorPluginGroupData: + """Creates generator plugin group data from the given project. + + Args: + project_data: The input project data + cppython_data: The input cppython data + + Returns: + The plugin specific configuration + """ + return _resolve_plugin_group( + project_data, cppython_data, 'generators', cppython_data.generator_name, GeneratorPluginGroupData + ) + + +def resolve_provider(project_data: ProjectData, cppython_data: CPPythonPluginData) -> ProviderPluginGroupData: + """Creates provider plugin group data from the given project. + + Args: + project_data: The input project data + cppython_data: The input cppython data + + Returns: + The plugin specific configuration + """ + return _resolve_plugin_group( + project_data, cppython_data, 'providers', cppython_data.provider_name, ProviderPluginGroupData + ) + + +def resolve_scm(project_data: ProjectData, cppython_data: CPPythonPluginData) -> SCMPluginGroupData: + """Creates SCM plugin group data from the given project. + + Args: + project_data: The input project data + cppython_data: The input cppython data + + Returns: + The plugin specific configuration + """ + return _resolve_plugin_group(project_data, cppython_data, 'managers', cppython_data.scm_name, SCMPluginGroupData) + + +def resolve_model[T: BaseModel](model: type[T], data: dict[str, Any]) -> T: + """Wraps the model validation and conversion + + Args: + model: The model to create + data: The input data to create the model from + + Raises: + ConfigException: Raised when the input does not satisfy the given schema + + Returns: + The instance of the model + """ + try: + # BaseModel is setup to ignore extra fields + return model(**data) + except ValidationError as e: + # Log the raw ValidationError for debugging + logging.getLogger('cppython').debug('ValidationError details: %s', e.errors()) + + if e.errors(): + formatted_errors = '\n'.join( + f"Field '{'.'.join(map(str, error['loc']))}': {error['msg']}" + for error in e.errors(include_input=True, include_context=True) + ) + else: + formatted_errors = 'An unknown validation error occurred.' + + raise ConfigException(f'The input project failed validation:\n{formatted_errors}') from e diff --git a/cppython/core/schema.py b/cppython/core/schema.py new file mode 100644 index 00000000..6d9f5297 --- /dev/null +++ b/cppython/core/schema.py @@ -0,0 +1,422 @@ +"""Data types for CPPython that encapsulate the requirements between the plugins and the core library""" + +from abc import abstractmethod +from pathlib import Path +from typing import Annotated, Any, NewType, Protocol, Self, runtime_checkable + +from packaging.requirements import Requirement +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic.types import DirectoryPath + +from cppython.utility.plugin import Plugin as SynodicPlugin +from cppython.utility.utility import TypeName + + +class CPPythonModel(BaseModel): + """The base model to use for all CPPython models""" + + model_config = ConfigDict(validate_by_name=True, validate_by_alias=True, arbitrary_types_allowed=True) + + +class ProjectData(CPPythonModel, extra='forbid'): + """Resolved data of 'ProjectConfiguration'""" + + project_root: Annotated[Path, Field(description='The path where the pyproject.toml exists')] + verbosity: Annotated[int, Field(description='The verbosity level as an integer [0,2]')] = 0 + + +class ProjectConfiguration(CPPythonModel, extra='forbid'): + """Project-wide configuration""" + + project_root: Annotated[Path, Field(description='The path where the pyproject.toml exists')] + version: Annotated[ + str | None, + Field( + description=( + "The version number a 'dynamic' project version will resolve to. If not provided" + 'a CPPython project will' + ' initialize its SCM plugins to discover any available version' + ) + ), + ] + verbosity: Annotated[int, Field(description='The verbosity level as an integer [0,2]')] = 0 + debug: Annotated[ + bool, Field(description='Debug mode. Additional processing will happen to expose more debug information') + ] = False + + @field_validator('verbosity') + @classmethod + def min_max(cls, value: int) -> int: + """Validator that clamps the input value + + Args: + value: Input to validate + + Returns: + The clamped input value + """ + return min(max(value, 0), 2) + + +class PEP621Data(CPPythonModel): + """Resolved PEP621Configuration data""" + + name: str + version: str + description: str + + +class PEP621Configuration(CPPythonModel): + """CPPython relevant PEP 621 conforming data + + Because only the partial schema is used, we ignore 'extra' attributes + Schema: https://www.python.org/dev/peps/pep-0621/ + """ + + dynamic: Annotated[list[str], Field(description='https://peps.python.org/pep-0621/#dynamic')] = [] + name: Annotated[str, Field(description='https://peps.python.org/pep-0621/#name')] + version: Annotated[str | None, Field(description='https://peps.python.org/pep-0621/#version')] = None + description: Annotated[str, Field(description='https://peps.python.org/pep-0621/#description')] = '' + + @model_validator(mode='after') # type: ignore + @classmethod + def dynamic_data(cls, model: Self) -> Self: + """Validates that dynamic data is represented correctly + + Args: + model: The input model data + + Raises: + ValueError: If dynamic versioning is incorrect + + Returns: + The data + """ + for field in PEP621Configuration.model_fields: + if field == 'dynamic': + continue + value = getattr(model, field) + if field not in model.dynamic: + if value is None: + raise ValueError(f"'{field}' is not a dynamic field. It must be defined") + elif value is not None: + raise ValueError(f"'{field}' is a dynamic field. It must not be defined") + + return model + + +class CPPythonData(CPPythonModel, extra='forbid'): + """Resolved CPPython data with local and global configuration""" + + configuration_path: Path + install_path: Path + tool_path: Path + build_path: Path + current_check: bool + provider_name: TypeName + generator_name: TypeName + scm_name: TypeName + dependencies: list[Requirement] + dependency_groups: dict[str, list[Requirement]] + + provider_data: Annotated[dict[str, Any], Field(description='Resolved provider configuration data')] + generator_data: Annotated[dict[str, Any], Field(description='Resolved generator configuration data')] + + @field_validator('configuration_path', 'install_path', 'tool_path', 'build_path') + @classmethod + def validate_absolute_path(cls, value: Path) -> Path: + """Enforce the input is an absolute path + + Args: + value: The input value + + Raises: + ValueError: Raised if the input is not an absolute path + + Returns: + The validated input value + """ + if not value.is_absolute(): + raise ValueError('Absolute path required') + + return value + + +CPPythonPluginData = NewType('CPPythonPluginData', CPPythonData) + + +class PluginReport(CPPythonModel): + """Report returned by a data plugin's ``plugin_info()`` method. + + Contains the plugin's current configuration, any managed files it writes, + and the content of user-facing template files it can generate. + """ + + configuration: Annotated[ + dict[str, Any], + Field(description='Key-value pairs of the resolved plugin configuration'), + ] = {} + managed_files: Annotated[ + list[Path], + Field(description='Paths to files that are fully managed (auto-generated) by the plugin'), + ] = [] + template_files: Annotated[ + dict[str, str], + Field(description='Mapping of template file names to their current content'), + ] = {} + + +class SyncData(CPPythonModel): + """Data that passes in a plugin sync""" + + provider_name: TypeName + + +class SupportedFeatures(CPPythonModel): + """Plugin feature support""" + + initialization: Annotated[ + bool, Field(description='Whether the plugin supports initialization from an empty state') + ] = False + + +class Information(CPPythonModel): + """Plugin information that complements the packaged project metadata""" + + +class PluginGroupData(CPPythonModel, extra='forbid'): + """Plugin group data""" + + root_directory: Annotated[DirectoryPath, Field(description='The directory of the project')] + tool_directory: Annotated[ + Path, + Field( + description=( + 'Points to the project plugin directory within the tool directory. ' + 'This directory is for project specific cached data.' + ) + ), + ] + + +class Plugin(SynodicPlugin, Protocol): + """CPPython plugin""" + + @abstractmethod + def __init__(self, group_data: PluginGroupData) -> None: + """Initializes the plugin""" + raise NotImplementedError + + @staticmethod + @abstractmethod + def features(directory: DirectoryPath) -> SupportedFeatures: + """Broadcasts the shared features of the plugin to CPPython + + Args: + directory: The root directory where features are evaluated + + Returns: + The supported features + """ + raise NotImplementedError + + @staticmethod + @abstractmethod + def information() -> Information: + """Retrieves plugin information that complements the packaged project metadata + + Returns: + The plugin's information + """ + raise NotImplementedError + + +class DataPluginGroupData(PluginGroupData): + """Data plugin group data""" + + +class CorePluginData(CPPythonModel): + """Core resolved data that will be passed to data plugins""" + + project_data: ProjectData + pep621_data: PEP621Data + cppython_data: CPPythonPluginData + + +class SupportedDataFeatures(SupportedFeatures): + """Data plugin feature support""" + + +class DataPlugin(Plugin, Protocol): + """Abstract plugin type for internal CPPython data""" + + @abstractmethod + def __init__( + self, group_data: DataPluginGroupData, core_data: CorePluginData, configuration_data: dict[str, Any] + ) -> None: + """Initializes the data plugin""" + raise NotImplementedError + + @staticmethod + @abstractmethod + def features(directory: DirectoryPath) -> SupportedFeatures: + """Broadcasts the shared features of the data plugin to CPPython + + Args: + directory: The root directory where features are evaluated + + Returns: + The supported features - `SupportedDataFeatures`. Cast to this type to help us avoid generic typing + """ + raise NotImplementedError + + def plugin_info(self) -> PluginReport: + """Return a report describing this plugin's configuration, managed files, and templates. + + Plugins should override this method to provide meaningful information. + The default implementation returns an empty report. + + Returns: + A :class:`PluginReport` with plugin-specific details. + """ + return PluginReport() + + @classmethod + async def download_tooling(cls, directory: DirectoryPath) -> None: + """Installs the external tooling required by the plugin. Should be overridden if required + + Args: + directory: The directory to download any extra tooling to + """ + + +class CPPythonGlobalConfiguration(CPPythonModel, extra='forbid'): + """Global data extracted by the tool""" + + current_check: Annotated[bool, Field(alias='current-check', description='Checks for a new CPPython version')] = True + + +ProviderData = NewType('ProviderData', dict[str, Any]) +GeneratorData = NewType('GeneratorData', dict[str, Any]) + + +class CPPythonLocalConfiguration(CPPythonModel, extra='forbid'): + """Project-level CPPython configuration + + This configuration is stored in pyproject.toml or cppython.toml. + User-specific overrides can be placed in .cppython.toml (which should be gitignored). + """ + + configuration_path: Annotated[ + Path | None, + Field( + description='The path to the configuration override file. If present, configuration found in the given' + ' directory will be preferred' + ), + ] = None + + install_path: Annotated[ + Path, + Field( + alias='install-path', + description='The global install path for the project. Provider and generator plugins will be' + ' installed here.', + ), + ] = Path.home() / '.cppython' + + tool_path: Annotated[ + Path, + Field( + alias='tool-path', + description='The local tooling path for the project. If the provider or generator need additional file' + ' support, this directory will be used', + ), + ] = Path('tool') + + build_path: Annotated[ + Path, + Field( + alias='build-path', + description='The local build path for the project. This is where the artifacts of the local C++ build' + ' process will be generated.', + ), + ] = Path('build') + + providers: Annotated[ + dict[TypeName, ProviderData], + Field( + description='Named provider configurations. Key is the provider name, value is the provider configuration.' + ), + ] = {} + + generators: Annotated[ + dict[TypeName, GeneratorData], + Field( + description=( + 'Named generator configurations. Key is the generator name, value is the generator configuration.' + ) + ), + ] = {} + + dependencies: Annotated[ + list[str] | None, + Field( + description='A list of dependencies that will be installed. This is a list of pip compatible requirements' + ' strings', + ), + ] = None + + dependency_groups: Annotated[ + dict[str, list[str]] | None, + Field( + alias='dependency-groups', + description='Named groups of dependencies. Key is the group name, value is a list of pip compatible' + ' requirements strings. Similar to PEP 735 dependency groups.', + ), + ] = None + + +class ToolData(CPPythonModel): + """Tool entry of pyproject.toml""" + + cppython: Annotated[CPPythonLocalConfiguration | None, Field(description='CPPython tool data')] = None + + +class PyProject(CPPythonModel): + """pyproject.toml schema""" + + project: Annotated[PEP621Configuration, Field(description='PEP621: https://www.python.org/dev/peps/pep-0621/')] + tool: Annotated[ToolData | None, Field(description='Tool data')] = None + + +class CoreData(CPPythonModel): + """Core resolved data that will be resolved""" + + project_data: ProjectData + cppython_data: CPPythonData + + +@runtime_checkable +class Interface(Protocol): + """Type for interfaces to allow feedback from CPPython""" + + @abstractmethod + def write_pyproject(self) -> None: + """Called when CPPython requires the interface to write out pyproject.toml changes""" + raise NotImplementedError + + @abstractmethod + def write_configuration(self) -> None: + """Called when CPPython requires the interface to write out configuration changes + + This writes to the primary configuration source (pyproject.toml or cppython.toml) + """ + raise NotImplementedError + + @abstractmethod + def write_user_configuration(self) -> None: + """Called when CPPython requires the interface to write out global configuration changes + + This writes to ~/.cppython/config.toml for global user configuration + """ + raise NotImplementedError diff --git a/cppython/data.py b/cppython/data.py new file mode 100644 index 00000000..94ffe370 --- /dev/null +++ b/cppython/data.py @@ -0,0 +1,105 @@ +"""Defines the post-construction data management for CPPython""" + +from dataclasses import dataclass +from logging import Logger + +from packaging.requirements import Requirement + +from cppython.core.plugin_schema.generator import Generator +from cppython.core.plugin_schema.provider import Provider +from cppython.core.plugin_schema.scm import SCM +from cppython.core.schema import CoreData +from cppython.utility.exception import PluginError + + +@dataclass +class Plugins: + """The plugin data for CPPython""" + + generator: Generator + provider: Provider + scm: SCM + + +class Data: + """Contains and manages the project data""" + + def __init__(self, core_data: CoreData, plugins: Plugins, logger: Logger) -> None: + """Initializes the data""" + self._core_data = core_data + self._plugins = plugins + self.logger = logger + self._active_groups: list[str] | None = None + + @property + def plugins(self) -> Plugins: + """The plugin data for CPPython""" + return self._plugins + + def set_active_groups(self, groups: list[str] | None) -> None: + """Set the active dependency groups for the current operation. + + Args: + groups: List of group names to activate, or None for no additional groups + """ + self._active_groups = groups + if groups: + self.logger.info('Active dependency groups: %s', ', '.join(groups)) + + # Validate that requested groups exist + available_groups = set(self._core_data.cppython_data.dependency_groups.keys()) + requested_groups = set(groups) + missing_groups = requested_groups - available_groups + + if missing_groups: + self.logger.warning( + 'Requested dependency groups not found: %s. Available groups: %s', + ', '.join(sorted(missing_groups)), + ', '.join(sorted(available_groups)) if available_groups else 'none', + ) + + def apply_dependency_groups(self, groups: list[str] | None) -> None: + """Validate and log the dependency groups to be used. + + Args: + groups: List of group names to apply, or None for base dependencies only + """ + if groups: + self.set_active_groups(groups) + + def get_active_dependencies(self) -> list: + """Get the combined list of base dependencies and active group dependencies. + + Returns: + Combined list of Requirement objects from base and active groups + """ + dependencies: list[Requirement] = list(self._core_data.cppython_data.dependencies) + + if self._active_groups: + for group_name in self._active_groups: + if group_name in self._core_data.cppython_data.dependency_groups: + dependencies.extend(self._core_data.cppython_data.dependency_groups[group_name]) + + return dependencies + + def sync(self) -> None: + """Gathers sync information from providers and passes it to the generator + + Raises: + PluginError: Plugin error + """ + if (sync_data := self.plugins.provider.sync_data(self.plugins.generator)) is None: + raise PluginError("The provider doesn't support the generator") + + self.plugins.generator.sync(sync_data) + + async def download_provider_tools(self) -> None: + """Download the provider tooling if required""" + base_path = self._core_data.cppython_data.install_path + + path = base_path / self.plugins.provider.name() + + path.mkdir(parents=True, exist_ok=True) + + self.logger.warning('Downloading the %s requirements to %s', self.plugins.provider.name(), path) + await self.plugins.provider.download_tooling(path) diff --git a/cppython/defaults.py b/cppython/defaults.py new file mode 100644 index 00000000..2d732928 --- /dev/null +++ b/cppython/defaults.py @@ -0,0 +1,45 @@ +"""Defines a SCM subclass that is used as the default SCM if no plugin is found or selected""" + +from pydantic import DirectoryPath + +from cppython.core.plugin_schema.scm import ( + SCM, + SCMPluginGroupData, + SupportedSCMFeatures, +) +from cppython.core.schema import Information, SupportedFeatures + + +class DefaultSCM(SCM): + """A default SCM class for when no SCM plugin is selected""" + + def __init__(self, group_data: SCMPluginGroupData) -> None: + """Initializes the default SCM class""" + self.group_data = group_data + + @staticmethod + def features(directory: DirectoryPath) -> SupportedFeatures: + """Broadcasts the shared features of the SCM plugin to CPPython + + Returns: + The supported features - `SupportedGeneratorFeatures`. Cast to this type to help us avoid generic typing + """ + return SupportedSCMFeatures(repository=True) + + @staticmethod + def information() -> Information: + """Returns plugin information + + Returns: + The plugin information + """ + return Information() + + @staticmethod + def version(directory: DirectoryPath) -> str: + """Extracts the system's version metadata + + Returns: + A version + """ + return '1.0.0' diff --git a/cppython/plugins/__init__.py b/cppython/plugins/__init__.py new file mode 100644 index 00000000..501f55f9 --- /dev/null +++ b/cppython/plugins/__init__.py @@ -0,0 +1,7 @@ +"""Plugins for the CPPython project. + +This module contains various plugins that extend the functionality of the CPPython +project. Each plugin integrates with different tools and systems to provide +additional capabilities, such as dependency management, build system integration, +and version control. +""" diff --git a/cppython/plugins/cmake/__init__.py b/cppython/plugins/cmake/__init__.py new file mode 100644 index 00000000..41cf2e16 --- /dev/null +++ b/cppython/plugins/cmake/__init__.py @@ -0,0 +1,6 @@ +"""The CMake generator plugin for CPPython. + +This module implements the CMake generator plugin, which integrates CPPython with +the CMake build system. It includes functionality for resolving configuration data, +writing presets, and synchronizing project data. +""" diff --git a/cppython/plugins/cmake/builder.py b/cppython/plugins/cmake/builder.py new file mode 100644 index 00000000..b68d4e3d --- /dev/null +++ b/cppython/plugins/cmake/builder.py @@ -0,0 +1,389 @@ +"""Plugin builder""" + +from pathlib import Path + +from cppython.plugins.cmake.schema import ( + BuildPreset, + CMakeData, + CMakePresets, + CMakeSyncData, + ConfigurePreset, + TestPreset, +) + + +class Builder: + """Aids in building the information needed for the CMake plugin""" + + def __init__(self) -> None: + """Initialize the builder""" + + @staticmethod + def generate_cppython_preset( + cppython_preset_directory: Path, + provider_preset_file: Path, + provider_data: CMakeSyncData, + project_root: Path, + ) -> CMakePresets: + """Generates the cppython preset which inherits from the provider presets + + Args: + cppython_preset_directory: The tool directory + provider_preset_file: Path to the provider's preset file + provider_data: The provider's synchronization data + project_root: The project root directory (where CMakeLists.txt is located) + + Returns: + A CMakePresets object + """ + configure_presets = [] + + preset_name = 'cppython' + + # Create a default preset that inherits from provider's default preset + default_configure = ConfigurePreset( + name=preset_name, + hidden=True, + description='Injected configuration preset for CPPython', + ) + + if provider_data.toolchain_file: + relative_toolchain = provider_data.toolchain_file.relative_to(project_root, walk_up=True) + default_configure.toolchainFile = '${sourceDir}/' + relative_toolchain.as_posix() + + configure_presets.append(default_configure) + + generated_preset = CMakePresets( + configurePresets=configure_presets, + ) + + return generated_preset + + @staticmethod + def write_cppython_preset( + cppython_preset_directory: Path, + provider_preset_file: Path, + provider_data: CMakeSyncData, + project_root: Path, + ) -> Path: + """Write the cppython presets which inherit from the provider presets + + Args: + cppython_preset_directory: The tool directory + provider_preset_file: Path to the provider's preset file + provider_data: The provider's synchronization data + project_root: The project root directory (where CMakeLists.txt is located) + + Returns: + A file path to the written data + """ + generated_preset = Builder.generate_cppython_preset( + cppython_preset_directory, provider_preset_file, provider_data, project_root + ) + cppython_preset_file = cppython_preset_directory / 'cppython.json' + + initial_preset = None + + # If the file already exists, we need to compare it + if cppython_preset_file.exists(): + with open(cppython_preset_file, encoding='utf-8') as file: + initial_json = file.read() + initial_preset = CMakePresets.model_validate_json(initial_json) + + # Only write the file if the data has changed + if generated_preset != initial_preset: + serialized = generated_preset.model_dump_json(exclude_none=True, by_alias=False, indent=4) + with open(cppython_preset_file, 'w', encoding='utf8') as file: + file.write(serialized) + + return cppython_preset_file + + @staticmethod + def _create_presets( + cmake_data: CMakeData, build_directory: Path + ) -> tuple[list[ConfigurePreset], list[BuildPreset], list[TestPreset]]: + """Create the default configure, build, and test presets for the user. + + Args: + cmake_data: The CMake data to use + build_directory: The build directory to use + + Returns: + A tuple containing the configure presets, build presets, and test presets + """ + user_configure_presets: list[ConfigurePreset] = [] + user_build_presets: list[BuildPreset] = [] + user_test_presets: list[TestPreset] = [] + + name = cmake_data.configuration_name + release_name = name + '-release' + debug_name = name + '-debug' + bench_release_name = name + '-bench-release' + bench_debug_name = name + '-bench-debug' + + user_configure_presets.append( + ConfigurePreset( + name=name, + description='All multi-configuration generators should inherit from this preset', + inherits='cppython', + binaryDir='${sourceDir}/' + build_directory.as_posix(), + cacheVariables={'CMAKE_CONFIGURATION_TYPES': 'Debug;Release'}, + ) + ) + + user_configure_presets.append( + ConfigurePreset( + name=release_name, + description='All single-configuration generators should inherit from this preset', + inherits=name, + cacheVariables={'CMAKE_BUILD_TYPE': 'Release'}, + ) + ) + + user_configure_presets.append( + ConfigurePreset( + name=debug_name, + description='All single-configuration generators should inherit from this preset', + inherits=name, + cacheVariables={'CMAKE_BUILD_TYPE': 'Debug'}, + ) + ) + + user_build_presets.append( + BuildPreset( + name=release_name, + description='An example build preset for release', + configurePreset=release_name, + ) + ) + + user_build_presets.append( + BuildPreset( + name=debug_name, + description='An example build preset for debug', + configurePreset=debug_name, + ) + ) + + # Test presets + user_test_presets.append( + TestPreset( + name=release_name, + description='Run tests for release configuration', + configurePreset=release_name, + ) + ) + + user_test_presets.append( + TestPreset( + name=debug_name, + description='Run tests for debug configuration', + configurePreset=debug_name, + ) + ) + + # Benchmark test presets with label filter + user_test_presets.append( + TestPreset( + name=bench_release_name, + description='Run benchmark tests for release configuration', + configurePreset=release_name, + filter={'include': {'label': 'benchmark'}}, + ) + ) + + user_test_presets.append( + TestPreset( + name=bench_debug_name, + description='Run benchmark tests for debug configuration', + configurePreset=debug_name, + filter={'include': {'label': 'benchmark'}}, + ) + ) + + return user_configure_presets, user_build_presets, user_test_presets + + @staticmethod + def _load_existing_preset(preset_file: Path) -> CMakePresets | None: + """Load existing preset file if it exists. + + Args: + preset_file: Path to the preset file + + Returns: + CMakePresets object if file exists, None otherwise + """ + if not preset_file.exists(): + return None + + with open(preset_file, encoding='utf-8') as file: + initial_json = file.read() + return CMakePresets.model_validate_json(initial_json) + + @staticmethod + def _update_configure_preset(existing_preset: ConfigurePreset, build_directory: Path) -> None: + """Update an existing configure preset to ensure proper inheritance and binary directory. + + Args: + existing_preset: The preset to update + build_directory: The build directory to use + """ + # Update existing preset to ensure it inherits from 'cppython' + if existing_preset.inherits is None: + existing_preset.inherits = 'cppython' # type: ignore[misc] + elif isinstance(existing_preset.inherits, str) and existing_preset.inherits != 'cppython': + existing_preset.inherits = ['cppython', existing_preset.inherits] # type: ignore[misc] + elif isinstance(existing_preset.inherits, list) and 'cppython' not in existing_preset.inherits: + existing_preset.inherits.insert(0, 'cppython') + + # Update binary directory if not set + if not existing_preset.binaryDir: + existing_preset.binaryDir = '${sourceDir}/' + build_directory.as_posix() # type: ignore[misc] + + @staticmethod + def _merge_presets[T: (ConfigurePreset, BuildPreset, TestPreset)]( + existing: list[T] | None, + new_presets: list[T], + ) -> list[T]: + """Merge new presets into an existing list, adding only those not already present. + + Args: + existing: The existing preset list (may be None) + new_presets: The new presets to merge in + + Returns: + The merged list of presets + """ + if existing is None: + return new_presets.copy() + + for preset in new_presets: + if not any(p.name == preset.name for p in existing): + existing.append(preset) + + return existing + + @staticmethod + def _modify_presets( + root_preset: CMakePresets, + user_configure_presets: list[ConfigurePreset], + user_build_presets: list[BuildPreset], + user_test_presets: list[TestPreset], + build_directory: Path, + ) -> None: + """Handle presets in the root preset. + + Args: + root_preset: The root preset to modify + user_configure_presets: The user's configure presets + user_build_presets: The user's build presets + user_test_presets: The user's test presets + build_directory: The build directory to use + """ + if root_preset.configurePresets is None: + root_preset.configurePresets = user_configure_presets.copy() # type: ignore[misc] + else: + # Update or add the user's configure preset + for user_configure_preset in user_configure_presets: + existing_preset = next( + (p for p in root_preset.configurePresets if p.name == user_configure_preset.name), None + ) + if existing_preset: + Builder._update_configure_preset(existing_preset, build_directory) + else: + root_preset.configurePresets.append(user_configure_preset) + + root_preset.buildPresets = Builder._merge_presets(root_preset.buildPresets, user_build_presets) # type: ignore[misc] + root_preset.testPresets = Builder._merge_presets(root_preset.testPresets, user_test_presets) # type: ignore[misc] + + @staticmethod + def _modify_includes(root_preset: CMakePresets, preset_file: Path, cppython_preset_file: Path) -> None: + """Handle include paths in the root preset. + + Args: + root_preset: The root preset to modify + preset_file: Path to the preset file + cppython_preset_file: Path to the cppython preset file to include + """ + # Get the relative path to the cppython preset file + preset_directory = preset_file.parent.absolute() + relative_preset = cppython_preset_file.relative_to(preset_directory, walk_up=True).as_posix() + + # Handle includes + if not root_preset.include: + root_preset.include = [] # type: ignore[misc] + + if str(relative_preset) not in root_preset.include: + root_preset.include.append(str(relative_preset)) + + @staticmethod + def generate_root_preset( + preset_file: Path, cppython_preset_file: Path, cmake_data: CMakeData, build_directory: Path + ) -> CMakePresets: + """Generates the top level root preset with the include reference. + + Args: + preset_file: Preset file to modify + cppython_preset_file: Path to the cppython preset file to include + cmake_data: The CMake data to use + build_directory: The build directory to use + + Returns: + A CMakePresets object + """ + # Create user presets + user_configure_presets, user_build_presets, user_test_presets = Builder._create_presets( + cmake_data, build_directory + ) + + # Load existing preset or create new one + root_preset = Builder._load_existing_preset(preset_file) + if root_preset is None: + root_preset = CMakePresets( + configurePresets=user_configure_presets, + buildPresets=user_build_presets, + testPresets=user_test_presets, + ) + else: + Builder._modify_presets( + root_preset, user_configure_presets, user_build_presets, user_test_presets, build_directory + ) + + Builder._modify_includes(root_preset, preset_file, cppython_preset_file) + + return root_preset + + @staticmethod + def write_root_presets( + preset_file: Path, cppython_preset_file: Path, cmake_data: CMakeData, build_directory: Path + ) -> None: + """Read the top level json file and insert the include reference. + + Receives a relative path to the tool cmake json file + + Raises: + ConfigError: If key files do not exists + + Args: + preset_file: Preset file to modify + cppython_preset_file: Path to the cppython preset file to include + cmake_data: The CMake data to use + build_directory: The build directory to use + """ + initial_root_preset = None + + if preset_file.exists(): + with open(preset_file, encoding='utf-8') as file: + initial_json = file.read() + initial_root_preset = CMakePresets.model_validate_json(initial_json) + + # Ensure that the build_directory is relative to the preset_file, allowing upward traversal + build_directory = build_directory.relative_to(preset_file.parent, walk_up=True) + + root_preset = Builder.generate_root_preset(preset_file, cppython_preset_file, cmake_data, build_directory) + + # Only write the file if the data has changed + if root_preset != initial_root_preset: + with open(preset_file, 'w', encoding='utf-8') as file: + preset = root_preset.model_dump_json(exclude_none=True, indent=4) + file.write(preset) diff --git a/cppython/plugins/cmake/plugin.py b/cppython/plugins/cmake/plugin.py new file mode 100644 index 00000000..76265b23 --- /dev/null +++ b/cppython/plugins/cmake/plugin.py @@ -0,0 +1,254 @@ +"""The CMake generator implementation""" + +from logging import getLogger +from pathlib import Path +from typing import Any + +from cppython.core.plugin_schema.generator import ( + Generator, + GeneratorPluginGroupData, + SupportedGeneratorFeatures, +) +from cppython.core.schema import CorePluginData, Information, PluginReport, SupportedFeatures, SyncData +from cppython.plugins.cmake.builder import Builder +from cppython.plugins.cmake.resolution import resolve_cmake_data +from cppython.plugins.cmake.schema import CMakeSyncData +from cppython.utility.subprocess import run_subprocess + +logger = getLogger('cppython.cmake') + + +class CMakeGenerator(Generator): + """CMake generator""" + + def __init__(self, group_data: GeneratorPluginGroupData, core_data: CorePluginData, data: dict[str, Any]) -> None: + """Initializes the generator""" + self.group_data = group_data + self.core_data = core_data + self.data = resolve_cmake_data(data, core_data) + self.builder = Builder() + + self._cppython_preset_directory = self.core_data.cppython_data.tool_path / 'cppython' + self._provider_directory = self._cppython_preset_directory / 'providers' + + @staticmethod + def features(directory: Path) -> SupportedFeatures: + """Queries if CMake is supported + + Returns: + The supported features - `SupportedGeneratorFeatures`. Cast to this type to help us avoid generic typing + """ + return SupportedGeneratorFeatures() + + @staticmethod + def information() -> Information: + """Queries plugin info + + Returns: + Plugin information + """ + return Information() + + @staticmethod + def sync_types() -> list[type[SyncData]]: + """Returns types in order of preference + + Returns: + The available types + """ + return [CMakeSyncData] + + def sync(self, sync_data: SyncData) -> None: + """Disk sync point + + Args: + sync_data: The input data + """ + match sync_data: + case CMakeSyncData(): + self._cppython_preset_directory.mkdir(parents=True, exist_ok=True) + + cppython_preset_file = self._cppython_preset_directory / 'CPPython.json' + + project_root = self.core_data.project_data.project_root + + cppython_preset_file = self.builder.write_cppython_preset( + self._cppython_preset_directory, cppython_preset_file, sync_data, project_root + ) + + self.builder.write_root_presets( + self.data.preset_file, cppython_preset_file, self.data, self.core_data.cppython_data.build_path + ) + case _: + raise ValueError('Unsupported sync data type') + + def _cmake_command(self) -> str: + """Returns the cmake command to use. + + Returns: + The cmake binary path as a string + """ + if self.data.cmake_binary: + return str(self.data.cmake_binary) + return 'cmake' + + def _ctest_command(self) -> str: + """Returns the ctest command to use. + + Derives the ctest path from the cmake binary path when available. + + Returns: + The ctest binary path as a string + """ + if self.data.cmake_binary: + # ctest is typically in the same directory as cmake + ctest_path = self.data.cmake_binary.parent / 'ctest' + if ctest_path.exists(): + return str(ctest_path) + # Try with .exe on Windows + ctest_exe = self.data.cmake_binary.parent / 'ctest.exe' + if ctest_exe.exists(): + return str(ctest_exe) + return 'ctest' + + def _resolve_configuration(self, configuration: str | None) -> str: + """Resolves the effective CMake preset from CLI argument or default config. + + Args: + configuration: The configuration value passed from the CLI, or None + + Returns: + The resolved CMake preset name + + Raises: + ValueError: If no configuration is available from either CLI or default-configuration config + """ + effective = configuration or self.data.default_configuration + if effective is None: + raise ValueError( + 'CMake generator requires a configuration. ' + "Provide --configuration on the CLI or set 'default-configuration' in [tool.cppython.generators.cmake]." + ) + return effective + + def build(self, configuration: str | None = None) -> None: + """Builds the project using cmake --build with the resolved preset. + + Args: + configuration: Optional CMake preset name. Overrides default-configuration from config. + """ + preset = self._resolve_configuration(configuration) + cmd = [self._cmake_command(), '--build', '--preset', preset] + run_subprocess(cmd, cwd=self.data.preset_file.parent, logger=logger) + + def test(self, configuration: str | None = None) -> None: + """Runs tests using ctest with the resolved preset. + + Args: + configuration: Optional CMake preset name. Overrides default-configuration from config. + """ + preset = self._resolve_configuration(configuration) + cmd = [self._ctest_command(), '--preset', preset] + run_subprocess(cmd, cwd=self.data.preset_file.parent, logger=logger) + + def bench(self, configuration: str | None = None) -> None: + """Runs benchmarks using ctest with the resolved preset. + + Args: + configuration: Optional CMake preset name. Overrides default-configuration from config. + """ + preset = self._resolve_configuration(configuration) + cmd = [self._ctest_command(), '--preset', preset] + run_subprocess(cmd, cwd=self.data.preset_file.parent, logger=logger) + + def run(self, target: str, configuration: str | None = None) -> None: + """Runs a built executable by target name. + + Searches the build directory for the executable matching the target name. + + Args: + target: The name of the build target/executable to run + configuration: Optional CMake preset name. Overrides default-configuration from config. + + Raises: + FileNotFoundError: If the target executable cannot be found + """ + build_path = self.core_data.cppython_data.build_path + + # Search for the executable in the build directory + candidates = list(build_path.rglob(target)) + list(build_path.rglob(f'{target}.exe')) + executables = [c for c in candidates if c.is_file()] + + if not executables: + raise FileNotFoundError(f"Could not find executable '{target}' in build directory: {build_path}") + + executable = executables[0] + run_subprocess([str(executable)], cwd=self.data.preset_file.parent, logger=logger) + + def list_targets(self) -> list[str]: + """Lists discovered build targets/executables in the CMake build directory. + + Searches the build directory for executable files, excluding common + non-target files. + + Returns: + A sorted list of unique target names found. + """ + build_path = self.core_data.cppython_data.build_path + + if not build_path.exists(): + return [] + + # Collect executable files from the build directory + targets: set[str] = set() + for candidate in build_path.rglob('*'): + if candidate.is_file() and (candidate.stat().st_mode & 0o111 or candidate.suffix == '.exe'): + # Use the stem (name without extension) as the target name + targets.add(candidate.stem) + + return sorted(targets) + + def list_targets(self) -> list[str]: + """Lists discovered build targets/executables in the CMake build directory. + + Searches the build directory for executable files, excluding common + non-target files. + + Returns: + A sorted list of unique target names found. + """ + build_path = self.core_data.cppython_data.build_path + + if not build_path.exists(): + return [] + + # Collect executable files from the build directory + targets: set[str] = set() + for candidate in build_path.rglob('*'): + if candidate.is_file() and (candidate.stat().st_mode & 0o111 or candidate.suffix == '.exe'): + # Use the stem (name without extension) as the target name + targets.add(candidate.stem) + + return sorted(targets) + + def plugin_info(self) -> PluginReport: + """Return a report describing the CMake generator's configuration and managed files. + + Returns: + A :class:`PluginReport` with CMake-specific details. + """ + managed = [self._cppython_preset_directory / 'CPPython.json'] + + config: dict[str, object] = { + 'preset_file': str(self.data.preset_file), + 'configuration_name': self.data.configuration_name, + } + if self.data.cmake_binary is not None: + config['cmake_binary'] = str(self.data.cmake_binary) + if self.data.default_configuration is not None: + config['default_configuration'] = self.data.default_configuration + + return PluginReport( + configuration=config, + managed_files=managed, + ) diff --git a/cppython/plugins/cmake/resolution.py b/cppython/plugins/cmake/resolution.py new file mode 100644 index 00000000..e5229c25 --- /dev/null +++ b/cppython/plugins/cmake/resolution.py @@ -0,0 +1,84 @@ +"""Builder to help resolve cmake state""" + +import logging +import os +import shutil +from pathlib import Path +from typing import Any + +from cppython.core.schema import CorePluginData +from cppython.plugins.cmake.schema import CMakeConfiguration, CMakeData + + +def _resolve_cmake_binary(configured_path: Path | None) -> Path | None: + """Resolve the cmake binary path with validation. + + Resolution order: + 1. CMAKE_BINARY environment variable (highest priority) + 2. Configured path from cmake_binary setting + 3. cmake from PATH (fallback) + + If a path is specified (via env or config) but doesn't exist, + a warning is logged and we fall back to PATH lookup. + + Args: + configured_path: The cmake_binary path from configuration, if any + + Returns: + Resolved cmake path, or None if not found anywhere + """ + logger = logging.getLogger('cppython.cmake') + + # Environment variable takes precedence + if env_binary := os.environ.get('CMAKE_BINARY'): + env_path = Path(env_binary) + if env_path.exists(): + return env_path + logger.warning( + 'CMAKE_BINARY environment variable points to non-existent path: %s. Falling back to PATH lookup.', + env_binary, + ) + + # Try configured path + if configured_path: + if configured_path.exists(): + return configured_path + logger.warning( + 'Configured cmake_binary path does not exist: %s. Falling back to PATH lookup.', + configured_path, + ) + + # Fall back to PATH lookup + if cmake_in_path := shutil.which('cmake'): + return Path(cmake_in_path) + + return None + + +def resolve_cmake_data(data: dict[str, Any], core_data: CorePluginData) -> CMakeData: + """Resolves the input data table from defaults to requirements + + Args: + data: The input table + core_data: The core data to help with the resolve + + Returns: + The resolved data + """ + parsed_data = CMakeConfiguration(**data) + + root_directory = core_data.project_data.project_root.absolute() + + modified_preset_file = parsed_data.preset_file + if not modified_preset_file.is_absolute(): + modified_preset_file = root_directory / modified_preset_file + + # Resolve cmake binary: environment variable takes precedence over configuration + cmake_binary = _resolve_cmake_binary(parsed_data.cmake_binary) + + return CMakeData( + preset_file=modified_preset_file, + configuration_name=parsed_data.configuration_name, + cmake_binary=cmake_binary, + default_configuration=parsed_data.default_configuration, + ) diff --git a/cppython/plugins/cmake/schema.py b/cppython/plugins/cmake/schema.py new file mode 100644 index 00000000..4f4adc62 --- /dev/null +++ b/cppython/plugins/cmake/schema.py @@ -0,0 +1,179 @@ +"""CMake plugin schema + +This module defines the schema and data models for integrating the CMake +generator with CPPython. It includes definitions for cache variables, +configuration presets, and synchronization data. +""" + +from enum import StrEnum +from pathlib import Path +from typing import Annotated + +from pydantic import Field + +from cppython.core.schema import CPPythonModel, SyncData + + +class VariableType(StrEnum): + """Defines the types of variables that can be used in CMake cache. + + Args: + Enum: Base class for creating enumerations. + """ + + BOOL = 'BOOL' + PATH = 'PATH' + FILEPATH = 'FILEPATH' + STRING = 'STRING' + INTERNAL = 'INTERNAL' + STATIC = 'STATIC' + UNINITIALIZED = 'UNINITIALIZED' + + +class CacheVariable(CPPythonModel, extra='forbid'): + """Represents a variable in the CMake cache. + + Attributes: + type: The type of the variable (e.g., BOOL, PATH). + value: The value of the variable, which can be a boolean or string. + """ + + type: None | VariableType = None + value: bool | str + + +class ConfigurePreset(CPPythonModel, extra='allow'): + """Partial Configure Preset specification to allow cache variable injection""" + + name: str + description: Annotated[str | None, Field(description='A human-readable description of the preset.')] = None + + hidden: Annotated[bool | None, Field(description='If true, the preset is hidden and cannot be used directly.')] = ( + None + ) + + inherits: Annotated[ + str | list[str] | None, Field(description='The inherits field allows inheriting from other presets.') + ] = None + binaryDir: Annotated[ + str | None, + Field(description='The path to the output binary directory.'), + ] = None + toolchainFile: Annotated[ + str | Path | None, + Field(description='Path to the toolchain file.'), + ] = None + cacheVariables: dict[str, None | bool | str | CacheVariable] | None = None + + +class BuildPreset(CPPythonModel, extra='allow'): + """Partial Build Preset specification for CMake build presets""" + + name: str + description: Annotated[str | None, Field(description='A human-readable description of the preset.')] = None + + hidden: Annotated[bool | None, Field(description='If true, the preset is hidden and cannot be used directly.')] = ( + None + ) + + inherits: Annotated[ + str | list[str] | None, Field(description='The inherits field allows inheriting from other presets.') + ] = None + configurePreset: Annotated[ + str | None, + Field(description='The name of a configure preset to associate with this build preset.'), + ] = None + configuration: Annotated[ + str | None, + Field(description='Build configuration. Equivalent to --config on the command line.'), + ] = None + + +class TestPreset(CPPythonModel, extra='allow'): + """Partial Test Preset specification for CMake test presets (ctest --preset)""" + + name: str + description: Annotated[str | None, Field(description='A human-readable description of the preset.')] = None + + hidden: Annotated[bool | None, Field(description='If true, the preset is hidden and cannot be used directly.')] = ( + None + ) + + inherits: Annotated[ + str | list[str] | None, Field(description='The inherits field allows inheriting from other presets.') + ] = None + configurePreset: Annotated[ + str | None, + Field(description='The name of a configure preset to associate with this test preset.'), + ] = None + configuration: Annotated[ + str | None, + Field(description='Build configuration. Equivalent to --config on the command line.'), + ] = None + filter: Annotated[ + dict | None, + Field(description='Filter for test selection, e.g. include/exclude by label or name.'), + ] = None + + +class CMakePresets(CPPythonModel, extra='allow'): + """The schema for the CMakePresets and CMakeUserPresets files.""" + + version: Annotated[int, Field(description='The version of the JSON schema.')] = 9 + include: Annotated[ + list[str] | None, Field(description='The include field allows inheriting from another preset.') + ] = None + configurePresets: Annotated[list[ConfigurePreset] | None, Field(description='The list of configure presets')] = None + buildPresets: Annotated[list[BuildPreset] | None, Field(description='The list of build presets')] = None + testPresets: Annotated[list[TestPreset] | None, Field(description='The list of test presets')] = None + + +class CMakeSyncData(SyncData): + """The CMake sync data""" + + toolchain_file: Path | None = None + + +class CMakeData(CPPythonModel): + """Resolved CMake data""" + + preset_file: Path + configuration_name: str + cmake_binary: Path | None + default_configuration: str | None = None + + +class CMakeConfiguration(CPPythonModel): + """Configuration for the CMake generator plugin""" + + preset_file: Annotated[ + Path, + Field( + description='The CMakePreset.json file that will be managed by CPPython. Will' + " be searched for the given 'configuration_name'", + ), + ] = Path('CMakePresets.json') + configuration_name: Annotated[ + str, + Field( + description='The CMake configuration preset to look for and override inside the given `preset_file`. ' + 'Additional configurations will be added using this option as the base. For example, given "default", ' + '"default-release" will also be written' + ), + ] = 'default' + cmake_binary: Annotated[ + Path | None, + Field( + description='Path to a specific CMake binary to use. If not specified, uses "cmake" from PATH. ' + 'Can be overridden via CMAKE_BINARY environment variable.' + ), + ] = None + default_configuration: Annotated[ + str | None, + Field( + alias='default-configuration', + description='Default CMake preset name to use for build/test/bench commands. ' + 'When set, the --configuration CLI option is no longer required. ' + 'The CLI --configuration value takes precedence over this default.', + ), + ] = None diff --git a/cppython/plugins/conan/__init__.py b/cppython/plugins/conan/__init__.py new file mode 100644 index 00000000..cd8a30d6 --- /dev/null +++ b/cppython/plugins/conan/__init__.py @@ -0,0 +1 @@ +"""The Conan provider plugin for CPPython.""" diff --git a/cppython/plugins/conan/builder.py b/cppython/plugins/conan/builder.py new file mode 100644 index 00000000..1ea2001c --- /dev/null +++ b/cppython/plugins/conan/builder.py @@ -0,0 +1,199 @@ +"""Construction of Conan data""" + +from pathlib import Path + +from pydantic import DirectoryPath + +from cppython.plugins.conan.schema import ConanDependency, ConanfileGenerationData + + +class Builder: + """Aids in building the information needed for the Conan plugin""" + + def __init__(self) -> None: + """Initialize the builder""" + self._filename = 'conanfile.py' + + @staticmethod + def _create_base_conanfile( + base_file: Path, + dependencies: list[ConanDependency], + dependency_groups: dict[str, list[ConanDependency]], + ) -> None: + """Creates a conanfile_base.py with CPPython managed dependencies. + + Args: + base_file: Path to write the base conanfile + dependencies: List of main dependencies + dependency_groups: Dictionary of dependency groups (e.g., 'test') + """ + test_dependencies = dependency_groups.get('test', []) + + # Generate requirements method content + requires_lines = [] + for dep in dependencies: + requires_lines.append(f' self.requires("{dep.requires()}")') + requires_content = '\n'.join(requires_lines) if requires_lines else ' pass # No requirements' + + # Generate build_requirements method content + test_requires_lines = [] + for dep in test_dependencies: + test_requires_lines.append(f' self.test_requires("{dep.requires()}")') + test_requires_content = ( + '\n'.join(test_requires_lines) if test_requires_lines else ' pass # No test requirements' + ) + + content = f'''"""CPPython managed base ConanFile. + +This file is auto-generated by CPPython. Do not edit manually. +Dependencies and layout are managed through pyproject.toml. +""" + +from conan import ConanFile + + +class CPPythonBase(ConanFile): + """Base ConanFile with CPPython managed dependencies and layout.""" + + def layout(self): + """CPPython managed layout for consistent paths across all platforms. + + Uses explicit folder settings instead of cmake_layout() to avoid + platform/generator-dependent behavior (e.g., build_type subfolders + on single-config generators). + """ + self.folders.build = "." + self.folders.generators = "generators" + + def requirements(self): + """CPPython managed requirements.""" +{requires_content} + + def build_requirements(self): + """CPPython managed build and test requirements.""" +{test_requires_content} +''' + base_file.write_text(content, encoding='utf-8') + + @staticmethod + def _conanfile_content( + name: str, + version: str, + ) -> str: + """Return the conanfile.py template content as a string without writing to disk. + + Args: + name: The project name + version: The project version + + Returns: + The full conanfile.py template string + """ + class_name = name.replace('-', '_').title().replace('_', '') + content = f'''from conan.tools.cmake import CMake, CMakeConfigDeps, CMakeToolchain +from conan.tools.files import copy + +import os + +from conanfile_base import CPPythonBase + + +class {class_name}Package(CPPythonBase): + """Conan recipe for {name}.""" + + name = "{name}" + version = "{version}" + settings = "os", "compiler", "build_type", "arch" + exports = "conanfile_base.py" + + def requirements(self): + """Declare package dependencies. + + CPPython managed dependencies are inherited from CPPythonBase. + Add your custom requirements here. + """ + super().requirements() # Get CPPython managed dependencies + # Add your custom requirements here + + def build_requirements(self): + """Declare build and test dependencies. + + CPPython managed test dependencies are inherited from CPPythonBase. + Add your custom build requirements here. + """ + super().build_requirements() # Get CPPython managed test dependencies + # Add your custom build requirements here + + def layout(self): + """Configure build folder layout. + + CPPython managed layout is inherited from CPPythonBase. + Override if you need custom folder settings. + """ + super().layout() # Get CPPython managed layout + + def generate(self): + deps = CMakeConfigDeps(self) + deps.generate() + tc = CMakeToolchain(self) + tc.user_presets_path = None + tc.generate() + + def build(self): + cmake = CMake(self) + cmake.configure() + cmake.build() + + def package(self): + cmake = CMake(self) + cmake.install() + + def package_info(self): + # Use native CMake config files to preserve FILE_SET information for C++ modules + # This tells CMakeConfigDeps to skip generating files and use the package's native config + self.cpp_info.set_property("cmake_find_mode", "none") + # Point CMakeConfigDeps to the directory containing the native config files + # so conan_cmakedeps_paths.cmake populates the search paths for find_package() + self.cpp_info.builddirs.append(os.path.join("lib", "cmake", self.name)) + + def export_sources(self): + copy(self, "CMakeLists.txt", src=self.recipe_folder, dst=self.export_sources_folder) + copy(self, "src/*", src=self.recipe_folder, dst=self.export_sources_folder) + copy(self, "cmake/*", src=self.recipe_folder, dst=self.export_sources_folder) +''' + return content + + @staticmethod + def _create_conanfile( + conan_file: Path, + name: str, + version: str, + ) -> None: + """Creates a conanfile.py file that inherits from CPPython base.""" + content = Builder._conanfile_content(name, version) + conan_file.write_text(content, encoding='utf-8') + + def generate_conanfile( + self, + directory: DirectoryPath, + data: ConanfileGenerationData, + ) -> None: + """Generate conanfile.py and conanfile_base.py for the project. + + Always generates the base conanfile with managed dependencies. + Only creates conanfile.py if it doesn't exist (never modifies existing files). + + Args: + directory: The project directory + data: Generation data containing dependencies and project info. + """ + directory.mkdir(parents=True, exist_ok=True) + + # Always regenerate the base conanfile with managed dependencies + base_file = directory / 'conanfile_base.py' + self._create_base_conanfile(base_file, data.dependencies, data.dependency_groups) + + # Only create conanfile.py if it doesn't exist + conan_file = directory / self._filename + if not conan_file.exists(): + self._create_conanfile(conan_file, data.name, data.version) diff --git a/cppython/plugins/conan/plugin.py b/cppython/plugins/conan/plugin.py new file mode 100644 index 00000000..ae5772cf --- /dev/null +++ b/cppython/plugins/conan/plugin.py @@ -0,0 +1,532 @@ +"""Conan Provider Plugin + +This module implements the Conan provider plugin for CPPython. It handles +integration with the Conan package manager, including dependency resolution, +installation, and synchronization with other tools. +""" + +import contextlib +import io +import os +from logging import Logger, getLogger +from pathlib import Path +from typing import Any + +from conan.api.conan_api import ConanAPI +from conan.cli.cli import Cli + +from cppython.core.plugin_schema.generator import SyncConsumer +from cppython.core.plugin_schema.provider import Provider, ProviderPluginGroupData, SupportedProviderFeatures +from cppython.core.schema import CorePluginData, Information, PluginReport, SupportedFeatures, SyncData +from cppython.plugins.cmake.plugin import CMakeGenerator +from cppython.plugins.cmake.schema import CMakeSyncData +from cppython.plugins.conan.builder import Builder +from cppython.plugins.conan.resolution import resolve_conan_data, resolve_conan_dependency +from cppython.plugins.conan.schema import ConanData, ConanfileGenerationData +from cppython.plugins.meson.plugin import MesonGenerator +from cppython.plugins.meson.schema import MesonSyncData +from cppython.utility.exception import InstallationVerificationError, NotSupportedError, ProviderInstallationError +from cppython.utility.utility import TypeName + + +class ConanProvider(Provider): + """Conan Provider""" + + def __init__( + self, group_data: ProviderPluginGroupData, core_data: CorePluginData, configuration_data: dict[str, Any] + ) -> None: + """Initializes the provider""" + self.group_data: ProviderPluginGroupData = group_data + self.core_data: CorePluginData = core_data + self.data: ConanData = resolve_conan_data(configuration_data, core_data) + + self.builder = Builder() + # Initialize ConanAPI once and reuse it + self._conan_api = ConanAPI() + # Initialize CLI for command API to work properly + self._cli = Cli(self._conan_api) + self._cli.add_commands() + + self._ensure_default_profiles() + + self._cmake_binary: str | None = None + self._logger = getLogger('cppython.conan') + + def _capture_conan_call(self, args: list[str]) -> None: + """Run a Conan CLI command while capturing stdout/stderr to the logger. + + Args: + args: Command arguments to pass to ``conan_api.command.run``. + + Raises: + Exception: Re-raises any exception from the Conan API after logging. + """ + stdout_capture = io.StringIO() + stderr_capture = io.StringIO() + + try: + with contextlib.redirect_stdout(stdout_capture), contextlib.redirect_stderr(stderr_capture): + self._conan_api.command.run(args) + except Exception: + # Log captured output before re-raising + captured_out = stdout_capture.getvalue() + captured_err = stderr_capture.getvalue() + if captured_out: + for line in captured_out.splitlines(): + self._logger.error('%s', line) + if captured_err: + for line in captured_err.splitlines(): + self._logger.error('%s', line) + raise + else: + captured_out = stdout_capture.getvalue() + captured_err = stderr_capture.getvalue() + if captured_out: + for line in captured_out.splitlines(): + self._logger.debug('%s', line) + if captured_err: + for line in captured_err.splitlines(): + self._logger.debug('%s', line) + + @staticmethod + def features(directory: Path) -> SupportedFeatures: + """Queries conan support + + Args: + directory: The directory to query + + Returns: + Supported features - `SupportedProviderFeatures`. Cast to this type to help us avoid generic typing + """ + return SupportedProviderFeatures() + + @staticmethod + def information() -> Information: + """Returns plugin information + + Returns: + Plugin information + """ + return Information() + + def _install_dependencies(self, *, update: bool = False, groups: list[str] | None = None) -> None: + """Install/update dependencies using Conan CLI. + + Args: + update: If True, check remotes for newer versions/revisions and install those. + If False, use cached versions when available. + groups: Optional list of dependency group names to include + """ + operation = 'update' if update else 'install' + logger = getLogger('cppython.conan') + + try: + # Setup environment and generate conanfile + conanfile_path = self._prepare_installation(groups=groups) + except Exception as e: + raise ProviderInstallationError('conan', f'Failed to prepare {operation} environment: {e}', e) from e + + try: + build_types = self.data.build_types + for build_type in build_types: + logger.info('Installing dependencies for build type: %s', build_type) + self._run_conan_install(conanfile_path, update, build_type, logger) + except Exception as e: + raise ProviderInstallationError('conan', f'Failed to install dependencies: {e}', e) from e + + def _prepare_installation(self, groups: list[str] | None = None) -> Path: + """Prepare the installation environment and generate conanfile. + + Args: + groups: Optional list of dependency group names to include + + Returns: + Path to conanfile.py + """ + # Resolve base dependencies + resolved_dependencies = [resolve_conan_dependency(req) for req in self.core_data.cppython_data.dependencies] + + # Resolve only the requested dependency groups + resolved_dependency_groups = {} + if groups: + for group_name in groups: + if group_name in self.core_data.cppython_data.dependency_groups: + resolved_dependency_groups[group_name] = [ + resolve_conan_dependency(req) + for req in self.core_data.cppython_data.dependency_groups[group_name] + ] + + generation_data = ConanfileGenerationData( + dependencies=resolved_dependencies, + dependency_groups=resolved_dependency_groups, + name=self.core_data.pep621_data.name, + version=self.core_data.pep621_data.version, + ) + + self.builder.generate_conanfile( + self.core_data.project_data.project_root, + generation_data, + ) + + # Ensure build directory exists + self.core_data.cppython_data.build_path.mkdir(parents=True, exist_ok=True) + + # Setup paths + project_root = self.core_data.project_data.project_root + conanfile_path = project_root / 'conanfile.py' + + if not conanfile_path.exists(): + raise FileNotFoundError('Generated conanfile.py not found') + + return conanfile_path + + def _ensure_default_profiles(self) -> None: + """Ensure default Conan profiles exist, creating them if necessary.""" + try: + self._conan_api.profiles.get_default_host() + self._conan_api.profiles.get_default_build() + except Exception: + # If profiles don't exist, create them using profile detect + self._conan_api.command.run(['profile', 'detect']) + + def _run_conan_install(self, conanfile_path: Path, update: bool, build_type: str, logger: Logger) -> None: + """Run conan install command using Conan API with optional build type. + + Args: + conanfile_path: Path to the conanfile.py + update: Whether to check for updates + build_type: Build type (Release, Debug, etc.) or None for default + logger: Logger instance + """ + # Build conan install command arguments + command_args = ['install', str(conanfile_path)] + + # Use build_path as the output folder directly + output_folder = self.core_data.cppython_data.build_path + command_args.extend(['--output-folder', str(output_folder)]) + + # Add build missing flag + command_args.extend(['--build', 'missing']) + + # Add update flag if needed + if update: + command_args.append('--update') + + # Add build type setting if specified + if build_type: + command_args.extend(['-s', f'build_type={build_type}']) + # Enable CMakeConfigDeps (the modern CMake config-mode generator) + command_args.extend(['-c', 'tools.cmake.cmakedeps:new=will_break_next']) + + # Enable 'import std;' support by providing the experimental UUID in the toolchain + # The UUID must be in the toolchain file (before try_compile block) so compiler + # detection can create __CMAKE::CXX23 for projects using 'import std;' + command_args.extend( + [ + '-c', + 'tools.cmake.cmaketoolchain:extra_variables={' + "'CMAKE_EXPERIMENTAL_CXX_IMPORT_STD': 'd0edc3af-4c50-42ea-a356-e2862fe7a444'" + '}', + ] + ) + + # Add cmake binary configuration if specified + if self._cmake_binary: + # Quote the path if it contains spaces + cmake_path = f'"{self._cmake_binary}"' if ' ' in self._cmake_binary else self._cmake_binary + command_args.extend(['-c', f'tools.cmake:cmake_program={cmake_path}']) + + try: + # Use reusable Conan API instance instead of subprocess + # Change to project directory since Conan API might not handle cwd like subprocess + original_cwd = os.getcwd() + try: + os.chdir(str(self.core_data.project_data.project_root)) + self._capture_conan_call(command_args) + finally: + os.chdir(original_cwd) + except Exception as e: + error_msg = str(e) + logger.error('Conan install failed: %s', error_msg, exc_info=True) + raise ProviderInstallationError('conan', error_msg, e) from e + + def verify_installed(self) -> None: + """Verify that Conan-generated artifacts exist on disk. + + Checks for the toolchain/native files that ``conan install`` produces + in the generators output directory. + + Raises: + InstallationVerificationError: If expected artifacts are missing + """ + generators_path = self.core_data.cppython_data.build_path / 'generators' + missing: list[str] = [] + + if not generators_path.is_dir(): + missing.append(f'generators directory ({generators_path})') + else: + # Check for at least one of the expected toolchain files + cmake_toolchain = generators_path / 'conan_toolchain.cmake' + meson_native = generators_path / 'conan_meson_native.ini' + + if not cmake_toolchain.exists() and not meson_native.exists(): + missing.append( + f'toolchain files in {generators_path} (expected conan_toolchain.cmake or conan_meson_native.ini)' + ) + + if missing: + raise InstallationVerificationError('conan', missing) + + def install(self, groups: list[str] | None = None) -> None: + """Installs the provider + + Args: + groups: Optional list of dependency group names to install + """ + self._install_dependencies(update=False, groups=groups) + + def update(self, groups: list[str] | None = None) -> None: + """Updates the provider + + Args: + groups: Optional list of dependency group names to update + """ + self._install_dependencies(update=True, groups=groups) + + @staticmethod + def supported_sync_type(sync_type: type[SyncData]) -> bool: + """Checks if the given sync type is supported by the Conan provider. + + Args: + sync_type: The type of synchronization data to check. + + Returns: + True if the sync type is supported, False otherwise. + """ + return sync_type in CMakeGenerator.sync_types() or sync_type in MesonGenerator.sync_types() + + def sync_data(self, consumer: SyncConsumer) -> SyncData: + """Generates synchronization data for the given consumer. + + Args: + consumer: The input consumer for which synchronization data is generated. + + Returns: + The synchronization data object. + + Raises: + NotSupportedError: If the consumer's sync type is not supported. + """ + for sync_type in consumer.sync_types(): + if sync_type == CMakeSyncData: + return self._sync_with_cmake(consumer) + if sync_type == MesonSyncData: + return self._create_meson_sync_data() + + raise NotSupportedError(f'Unsupported sync types: {consumer.sync_types()}') + + def _sync_with_cmake(self, consumer: SyncConsumer) -> CMakeSyncData: + """Synchronize with CMake generator and create sync data. + + Args: + consumer: The CMake generator consumer + + Returns: + CMakeSyncData configured for Conan integration + """ + # Extract cmake_binary from CMakeGenerator if available + # The cmake_binary is already validated and resolved during CMake data resolution + if isinstance(consumer, CMakeGenerator) and consumer.data.cmake_binary: + self._cmake_binary = str(consumer.data.cmake_binary.resolve()) + + return self._create_cmake_sync_data() + + def _create_cmake_sync_data(self) -> CMakeSyncData: + """Creates CMake synchronization data with Conan toolchain configuration. + + Returns: + CMakeSyncData configured for Conan integration + """ + # The generated conanfile uses explicit layout (self.folders.generators = "generators") + # Combined with --output-folder=build_path, generators are always at build_path/generators/ + conan_toolchain_path = self.core_data.cppython_data.build_path / 'generators' / 'conan_toolchain.cmake' + + return CMakeSyncData( + provider_name=TypeName('conan'), + toolchain_file=conan_toolchain_path, + ) + + def _create_meson_sync_data(self) -> MesonSyncData: + """Creates Meson synchronization data with Conan toolchain configuration. + + Conan's MesonToolchain generator produces ``conan_meson_native.ini`` + and ``conan_meson_cross.ini`` files in the generators directory. + + Returns: + MesonSyncData configured for Conan integration + """ + generators_path = self.core_data.cppython_data.build_path / 'generators' + + native_file = generators_path / 'conan_meson_native.ini' + cross_file = generators_path / 'conan_meson_cross.ini' + + return MesonSyncData( + provider_name=TypeName('conan'), + native_file=native_file if native_file.exists() else None, + cross_file=cross_file if cross_file.exists() else None, + ) + + @classmethod + async def download_tooling(cls, directory: Path) -> None: + """Download external tooling required by the Conan provider. + + Since we're using CMakeToolchain generator instead of cmake-conan provider, + no external tooling needs to be downloaded. + """ + # No external tooling required when using CMakeToolchain + pass + + def publish(self) -> None: + """Publishes the package using conan create workflow. + + Creates packages for all configured build types (e.g., Release, Debug) + to support both single-config and multi-config generators. + """ + project_root = self.core_data.project_data.project_root + conanfile_path = project_root / 'conanfile.py' + logger = getLogger('cppython.conan') + + if not conanfile_path.exists(): + raise FileNotFoundError(f'conanfile.py not found at {conanfile_path}') + + try: + # Create packages for each configured build type + build_types = self.data.build_types + for build_type in build_types: + logger.info('Creating package for build type: %s', build_type) + self._run_conan_create(conanfile_path, build_type, logger) + + # Upload once after all configurations are built + if not self.data.skip_upload: + self._upload_package(logger) + + except Exception as e: + error_msg = str(e) + logger.error('Conan create failed: %s', error_msg, exc_info=True) + raise ProviderInstallationError('conan', error_msg, e) from e + + def _run_conan_create(self, conanfile_path: Path, build_type: str, logger: Logger) -> None: + """Run conan create command for a specific build type. + + Args: + conanfile_path: Path to the conanfile.py + build_type: Build type (Release, Debug, etc.) + logger: Logger instance + """ + # Build conan create command arguments + command_args = ['create', str(conanfile_path)] + + # Add build mode (build everything for publishing) + command_args.extend(['--build', 'missing']) + + # Skip test dependencies during publishing + command_args.extend(['-c', 'tools.graph:skip_test=True']) + command_args.extend(['-c', 'tools.build:skip_test=True']) + + # Enable CMakeConfigDeps (the modern CMake config-mode generator) + command_args.extend(['-c', 'tools.cmake.cmakedeps:new=will_break_next']) + + # Force Ninja Multi-Config generator for C++ module support + # The Visual Studio generator does not support BMI-only compilation + # needed for consuming C++ modules across package boundaries + command_args.extend(['-c', 'tools.cmake.cmaketoolchain:generator=Ninja Multi-Config']) + + # Enable 'import std;' support in the CMake toolchain + # CMAKE_EXPERIMENTAL_CXX_IMPORT_STD must be in the toolchain file (before + # the try_compile block) so compiler detection can create __CMAKE::CXX23. + # Note: CMAKE_CXX_MODULE_STD must NOT be in the toolchain because it would + # cause ABI detection try_compile to fail (chicken-and-egg with __CMAKE::CXX23). + # The UUID is specific to the CMake version and will need updating + # when the CMake version changes until import std graduates from experimental. + command_args.extend( + [ + '-c', + 'tools.cmake.cmaketoolchain:extra_variables={' + "'CMAKE_EXPERIMENTAL_CXX_IMPORT_STD': 'd0edc3af-4c50-42ea-a356-e2862fe7a444'" + '}', + ] + ) + + # Add build type setting + command_args.extend(['-s', f'build_type={build_type}']) + + # Add cmake binary configuration if specified + if self._cmake_binary: + # Quote the path if it contains spaces + cmake_path = f'"{self._cmake_binary}"' if ' ' in self._cmake_binary else self._cmake_binary + command_args.extend(['-c', f'tools.cmake:cmake_program={cmake_path}']) + + # Run conan create using reusable Conan API instance + # Change to project directory since Conan API might not handle cwd like subprocess + original_cwd = os.getcwd() + try: + os.chdir(str(self.core_data.project_data.project_root)) + self._capture_conan_call(command_args) + finally: + os.chdir(original_cwd) + + def _upload_package(self, logger) -> None: + """Upload the package to configured remotes using Conan API.""" + # If no remotes configured, upload to all remotes + if not self.data.remotes: + # Upload to all available remotes + command_args = ['upload', '*', '--all', '--confirm'] + else: + # Upload only to specified remotes + for remote in self.data.remotes: + command_args = ['upload', '*', '--remote', remote, '--all', '--confirm'] + + # Log the command being executed + logger.info('Executing conan upload command: conan %s', ' '.join(command_args)) + + try: + self._capture_conan_call(command_args) + except Exception as e: + error_msg = str(e) + logger.error('Conan upload failed for remote %s: %s', remote, error_msg, exc_info=True) + raise ProviderInstallationError('conan', f'Upload to {remote} failed: {error_msg}', e) from e + return + + # Log the command for uploading to all remotes + logger.info('Executing conan upload command: conan %s', ' '.join(command_args)) + + try: + self._capture_conan_call(command_args) + except Exception as e: + error_msg = str(e) + logger.error('Conan upload failed: %s', error_msg, exc_info=True) + raise ProviderInstallationError('conan', error_msg, e) from e + + def plugin_info(self) -> PluginReport: + """Return a report describing the Conan provider's configuration, managed files, and templates. + + Returns: + A :class:`PluginReport` with Conan-specific details. + """ + project_root = self.core_data.project_data.project_root + + template_content = Builder._conanfile_content( + self.core_data.pep621_data.name, + self.core_data.pep621_data.version, + ) + + return PluginReport( + configuration={ + 'build_types': self.data.build_types, + 'remotes': self.data.remotes, + 'profile_dir': str(self.data.profile_dir), + 'skip_upload': self.data.skip_upload, + }, + managed_files=[project_root / 'conanfile_base.py'], + template_files={'conanfile.py': template_content}, + ) diff --git a/cppython/plugins/conan/resolution.py b/cppython/plugins/conan/resolution.py new file mode 100644 index 00000000..d8dd98d2 --- /dev/null +++ b/cppython/plugins/conan/resolution.py @@ -0,0 +1,123 @@ +"""Provides functionality to resolve Conan-specific data for the CPPython project.""" + +from pathlib import Path +from typing import Any + +from packaging.requirements import Requirement + +from cppython.core.exception import ConfigException +from cppython.core.schema import CorePluginData +from cppython.plugins.conan.schema import ( + ConanConfiguration, + ConanData, + ConanDependency, + ConanVersion, + ConanVersionRange, +) + + +def _handle_single_specifier(name: str, specifier) -> ConanDependency: + """Handle a single version specifier.""" + MINIMUM_VERSION_PARTS = 2 + + operator_handlers = { + '==': lambda v: ConanDependency(name=name, version=ConanVersion.from_string(v)), + '>=': lambda v: ConanDependency(name=name, version_range=ConanVersionRange(expression=f'>={v}')), + '>': lambda v: ConanDependency(name=name, version_range=ConanVersionRange(expression=f'>{v}')), + '<': lambda v: ConanDependency(name=name, version_range=ConanVersionRange(expression=f'<{v}')), + '<=': lambda v: ConanDependency(name=name, version_range=ConanVersionRange(expression=f'<={v}')), + '!=': lambda v: ConanDependency(name=name, version_range=ConanVersionRange(expression=f'!={v}')), + } + + if specifier.operator in operator_handlers: + return operator_handlers[specifier.operator](specifier.version) + elif specifier.operator == '~=': + # Compatible release - convert to Conan tilde syntax + version_parts = specifier.version.split('.') + if len(version_parts) >= MINIMUM_VERSION_PARTS: + conan_version = '.'.join(version_parts[:MINIMUM_VERSION_PARTS]) + return ConanDependency(name=name, version_range=ConanVersionRange(expression=f'~{conan_version}')) + else: + return ConanDependency(name=name, version_range=ConanVersionRange(expression=f'>={specifier.version}')) + else: + raise ConfigException( + f"Unsupported single specifier '{specifier.operator}'. Supported: '==', '>=', '>', '<', '<=', '!=', '~='" + ) + + +def resolve_conan_dependency(requirement: Requirement) -> ConanDependency: + """Resolves a Conan dependency from a Python requirement string. + + Converts Python packaging requirements to Conan version specifications: + - package>=1.0.0 -> package/[>=1.0.0] + - package==1.0.0 -> package/1.0.0 + - package~=1.2.0 -> package/[~1.2] + - package>=1.0,<2.0 -> package/[>=1.0 <2.0] + """ + specifiers = requirement.specifier + + # Handle no version specifiers + if not specifiers: + return ConanDependency(name=requirement.name) + + # Handle single specifier (most common case) + if len(specifiers) == 1: + return _handle_single_specifier(requirement.name, next(iter(specifiers))) + + # Handle multiple specifiers - convert to Conan range syntax + range_parts: list[str] = [] + + # Define order for operators to ensure consistent output + operator_order = ['>=', '>', '<=', '<', '!='] + + # Group specifiers by operator to ensure consistent ordering + specifier_groups = {op: [] for op in operator_order} + + for specifier in specifiers: + if specifier.operator in ('>=', '>', '<', '<=', '!='): + specifier_groups[specifier.operator].append(specifier.version) + elif specifier.operator == '==': + # Multiple == operators would be contradictory + raise ConfigException("Multiple '==' specifiers are contradictory. Use a single '==' or range operators.") + elif specifier.operator == '~=': + # ~= with other operators is complex, for now treat as >= + specifier_groups['>='].append(specifier.version) + else: + raise ConfigException( + f"Unsupported specifier '{specifier.operator}' in multi-specifier requirement. " + f"Supported: '>=', '>', '<', '<=', '!='" + ) + + # Build range parts in consistent order + for operator in operator_order: + for version in specifier_groups[operator]: + range_parts.append(f'{operator}{version}') + + # Join range parts with spaces (Conan AND syntax) + version_range = ' '.join(range_parts) + return ConanDependency(name=requirement.name, version_range=ConanVersionRange(expression=version_range)) + + +def resolve_conan_data(data: dict[str, Any], core_data: CorePluginData) -> ConanData: + """Resolves the conan data + + Args: + data: The data to resolve + core_data: The core plugin data + + Returns: + The resolved conan data + """ + parsed_data = ConanConfiguration(**data) + + profile_dir = Path(parsed_data.profile_dir) + + if not profile_dir.is_absolute(): + profile_dir = core_data.cppython_data.tool_path / profile_dir + + return ConanData( + remotes=parsed_data.remotes, + skip_upload=parsed_data.skip_upload, + profile_dir=profile_dir, + build_types=parsed_data.build_types, + ) diff --git a/cppython/plugins/conan/schema.py b/cppython/plugins/conan/schema.py new file mode 100644 index 00000000..d2a6dd5a --- /dev/null +++ b/cppython/plugins/conan/schema.py @@ -0,0 +1,341 @@ +"""Conan plugin schema + +This module defines Pydantic models used for integrating the Conan +package manager with the CPPython environment. The classes within +provide structured configuration and data needed by the Conan Provider. +""" + +import re +from pathlib import Path +from typing import Annotated + +from pydantic import Field, field_validator + +from cppython.core.schema import CPPythonModel + + +class ConanVersion(CPPythonModel): + """Represents a single Conan version with optional pre-release suffix.""" + + major: int + minor: int + patch: int | None = None + prerelease: str | None = None + + @field_validator('major', 'minor', mode='before') # type: ignore + @classmethod + def validate_version_parts(cls, v: int) -> int: + """Validate version parts are non-negative integers.""" + if v < 0: + raise ValueError('Version parts must be non-negative') + return v + + @field_validator('patch', mode='before') # type: ignore + @classmethod + def validate_patch(cls, v: int | None) -> int | None: + """Validate patch is non-negative integer or None.""" + if v is not None and v < 0: + raise ValueError('Version parts must be non-negative') + return v + + @field_validator('prerelease', mode='before') # type: ignore + @classmethod + def validate_prerelease(cls, v: str | None) -> str | None: + """Validate prerelease is not an empty string.""" + if v is not None and not v.strip(): + raise ValueError('Pre-release cannot be empty string') + return v + + def __str__(self) -> str: + """String representation of the version.""" + version = f'{self.major}.{self.minor}.{self.patch}' if self.patch is not None else f'{self.major}.{self.minor}' + + if self.prerelease: + version += f'-{self.prerelease}' + return version + + @classmethod + def from_string(cls, version_str: str) -> ConanVersion: + """Parse a version string into a ConanVersion.""" + if '-' in version_str: + version_part, prerelease = version_str.split('-', 1) + else: + version_part = version_str + prerelease = None + + parts = version_part.split('.') + + # Parse parts based on what's actually provided + MAJOR_INDEX = 0 + MINOR_INDEX = 1 + PATCH_INDEX = 2 + + major = int(parts[MAJOR_INDEX]) + minor = int(parts[MINOR_INDEX]) if len(parts) > MINOR_INDEX else 0 + patch = int(parts[PATCH_INDEX]) if len(parts) > PATCH_INDEX else None + + return cls( + major=major, + minor=minor, + patch=patch, + prerelease=prerelease, + ) + + +class ConanVersionRange(CPPythonModel): + """Represents a Conan version range expression like '>=1.0 <2.0' or complex expressions.""" + + expression: str + + @field_validator('expression') # type: ignore + @classmethod + def validate_expression(cls, v: str) -> str: + """Validate the version range expression contains valid operators.""" + if not v.strip(): + raise ValueError('Version range expression cannot be empty') + + # Basic validation - ensure it contains valid operators + valid_operators = {'>=', '>', '<=', '<', '!=', '~', '||', '&&'} + + # Split by spaces and logical operators to get individual components + tokens = re.split(r'(\|\||&&|\s+)', v) + + for token in tokens: + current_token = token.strip() + if not current_token or current_token in {'||', '&&'}: + continue + + # Check if token starts with a valid operator + has_valid_operator = any(current_token.startswith(op) for op in valid_operators) + if not has_valid_operator: + raise ValueError(f'Invalid operator in version range: {current_token}') + + return v + + def __str__(self) -> str: + """Return the version range expression.""" + return self.expression + + +class ConanUserChannel(CPPythonModel): + """Represents a Conan user/channel pair.""" + + user: str + channel: str | None = None + + @field_validator('user') # type: ignore + @classmethod + def validate_user(cls, v: str) -> str: + """Validate user is not empty.""" + if not v.strip(): + raise ValueError('User cannot be empty') + return v.strip() + + @field_validator('channel') # type: ignore + @classmethod + def validate_channel(cls, v: str | None) -> str | None: + """Validate channel is not an empty string.""" + if v is not None and not v.strip(): + raise ValueError('Channel cannot be empty string') + return v.strip() if v else None + + def __str__(self) -> str: + """String representation for use in requires().""" + if self.channel: + return f'{self.user}/{self.channel}' + return f'{self.user}/_' + + +class ConanRevision(CPPythonModel): + """Represents a Conan revision identifier.""" + + revision: str + + @field_validator('revision') # type: ignore + @classmethod + def validate_revision(cls, v: str) -> str: + """Validate revision is not empty.""" + if not v.strip(): + raise ValueError('Revision cannot be empty') + return v.strip() + + def __str__(self) -> str: + """Return the revision identifier.""" + return self.revision + + +class ConanDependency(CPPythonModel): + """Dependency information following Conan's full version specification. + + Supports: + - Exact versions: package/1.0.0 + - Pre-release versions: package/1.0.0-alpha1 + - Version ranges: package/[>1.0 <2.0] + - Revisions: package/1.0.0#revision + - User/channel: package/1.0.0@user/channel + - Complex expressions: package/[>=1.0 <2.0 || >=3.0] + - Pre-release handling: resolve_prereleases setting + """ + + name: str + version: ConanVersion | None = None + version_range: ConanVersionRange | None = None + user_channel: ConanUserChannel | None = None + revision: ConanRevision | None = None + + # Pre-release handling + resolve_prereleases: bool | None = None + + def requires(self) -> str: + """Generate the requires attribute for Conan following the full specification. + + Examples: + - package -> package + - package/1.0.0 -> package/1.0.0 + - package/1.0.0-alpha1 -> package/1.0.0-alpha1 + - package/[>=1.0 <2.0] -> package/[>=1.0 <2.0] + - package/1.0.0@user/channel -> package/1.0.0@user/channel + - package/1.0.0#revision -> package/1.0.0#revision + - package/1.0.0@user/channel#revision -> package/1.0.0@user/channel#revision + """ + result = self.name + + # Add version or version range + if self.version_range: + # Complex version range + result += f'/[{self.version_range}]' + elif self.version: + # Simple version (can include pre-release suffixes) + result += f'/{self.version}' + + # Add user/channel + if self.user_channel: + result += f'@{self.user_channel}' + + # Add revision + if self.revision: + result += f'#{self.revision}' + + return result + + @classmethod + def from_conan_reference(cls, reference: str) -> ConanDependency: + """Parse a Conan reference string into a ConanDependency. + + Examples: + - package -> ConanDependency(name='package') + - package/1.0.0 -> ConanDependency(name='package', version=ConanVersion.from_string('1.0.0')) + - package/[>=1.0 <2.0] -> ConanDependency(name='package', version_range=ConanVersionRange('>=1.0 <2.0')) + - package/1.0.0@user/channel -> ConanDependency(name='package', version=..., user_channel=ConanUserChannel(...)) + - package/1.0.0#revision -> ConanDependency(name='package', version=..., revision=ConanRevision('revision')) + """ + # Split revision first (everything after #) + revision_obj = None + if '#' in reference: + reference, revision_str = reference.rsplit('#', 1) + revision_obj = ConanRevision(revision=revision_str) + + # Split user/channel (everything after @) + user_channel_obj = None + if '@' in reference: + reference, user_channel_str = reference.rsplit('@', 1) + if '/' in user_channel_str: + user, channel = user_channel_str.split('/', 1) + if channel == '_': + channel = None + else: + user = user_channel_str + channel = None + user_channel_obj = ConanUserChannel(user=user, channel=channel) + + # Split name and version + name = reference + version_obj = None + version_range_obj = None + + if '/' in reference: + name, version_part = reference.split('/', 1) + + # Check if it's a version range (enclosed in brackets) + if version_part.startswith('[') and version_part.endswith(']'): + version_range_obj = ConanVersionRange(expression=version_part[1:-1]) # Remove brackets + else: + version_obj = ConanVersion.from_string(version_part) + + return cls( + name=name, + version=version_obj, + version_range=version_range_obj, + user_channel=user_channel_obj, + revision=revision_obj, + ) + + def is_prerelease(self) -> bool: + """Check if this dependency specifies a pre-release version. + + Pre-release versions contain hyphens followed by pre-release identifiers + like: 1.0.0-alpha1, 1.0.0-beta2, 1.0.0-rc1, 1.0.0-dev, etc. + """ + # Check version object for pre-release + if self.version and self.version.prerelease: + prerelease_keywords = {'alpha', 'beta', 'rc', 'dev', 'pre', 'snapshot'} + return any(keyword in self.version.prerelease.lower() for keyword in prerelease_keywords) + + # Also check version_range for pre-release patterns + if self.version_range and '-' in self.version_range.expression: + prerelease_keywords = {'alpha', 'beta', 'rc', 'dev', 'pre', 'snapshot'} + return any(keyword in self.version_range.expression.lower() for keyword in prerelease_keywords) + + return False + + +class ConanData(CPPythonModel): + """Resolved conan data""" + + remotes: list[str] + skip_upload: bool + profile_dir: Path + build_types: list[str] + + +class ConanfileGenerationData(CPPythonModel): + """Data required for generating conanfile.py and conanfile_base.py. + + Groups related parameters for conanfile generation to reduce function argument count. + """ + + dependencies: list[ConanDependency] + dependency_groups: dict[str, list[ConanDependency]] + name: str + version: str + + +class ConanConfiguration(CPPythonModel): + """Conan provider configuration""" + + remotes: Annotated[ + list[str], + Field(description='List of remotes to upload to. If empty, uploads to all available remotes.'), + ] = ['conancenter'] + skip_upload: Annotated[ + bool, + Field(description='If true, skip uploading packages to a remote during publishing.'), + ] = False + profile_dir: Annotated[ + str, + Field( + description='Directory containing Conan profiles. Profiles will be looked up relative to this directory. ' + 'If profiles do not exist in this directory, Conan will fall back to default profiles. ' + "If a relative path is provided, it will be resolved relative to the tool's working directory." + ), + ] = 'profiles' + build_types: Annotated[ + list[str], + Field( + alias='build-types', + description='List of CMake build types to install dependencies for. ' + 'For multi-config generators (Visual Studio), use both Release and Debug. ' + 'For single-config generators or build backends like scikit-build-core, ' + 'use only the build type you need (e.g., ["Release"]).', + ), + ] = ['Release', 'Debug'] diff --git a/cppython/plugins/git/__init__.py b/cppython/plugins/git/__init__.py new file mode 100644 index 00000000..557ca36c --- /dev/null +++ b/cppython/plugins/git/__init__.py @@ -0,0 +1,6 @@ +"""The Git SCM plugin for CPPython. + +This module implements the Git SCM plugin, which provides version control +functionality using Git. It includes features for extracting repository +information, handling version metadata, and managing project descriptions. +""" diff --git a/cppython/plugins/git/plugin.py b/cppython/plugins/git/plugin.py new file mode 100644 index 00000000..cc2fc176 --- /dev/null +++ b/cppython/plugins/git/plugin.py @@ -0,0 +1,61 @@ +"""Git SCM Plugin""" + +from pathlib import Path + +from dulwich.errors import NotGitRepository +from dulwich.repo import Repo + +from cppython.core.plugin_schema.scm import ( + SCM, + SCMPluginGroupData, + SupportedSCMFeatures, +) +from cppython.core.schema import Information, SupportedFeatures + + +class GitSCM(SCM): + """Git implementation hooks""" + + def __init__(self, group_data: SCMPluginGroupData) -> None: + """Initializes the plugin""" + self.group_data = group_data + + @staticmethod + def features(directory: Path) -> SupportedFeatures: + """Broadcasts the shared features of the SCM plugin to CPPython + + Args: + directory: The root directory where features are evaluated + + Returns: + The supported features - `SupportedSCMFeatures`. Cast to this type to help us avoid generic typing + """ + is_repository = True + try: + Repo(str(directory)) + except NotGitRepository: + is_repository = False + + return SupportedSCMFeatures(repository=is_repository) + + @staticmethod + def information() -> Information: + """Extracts the system's version metadata + + Returns: + A version + """ + return Information() + + def version(self, directory: Path) -> str: + """Extracts the system's version metadata + + Returns: + The git version + """ + return '' + + @staticmethod + def description() -> str | None: + """Requests extraction of the project description""" + return None diff --git a/cppython/plugins/meson/__init__.py b/cppython/plugins/meson/__init__.py new file mode 100644 index 00000000..6f6c29d6 --- /dev/null +++ b/cppython/plugins/meson/__init__.py @@ -0,0 +1,6 @@ +"""The Meson generator plugin for CPPython. + +This module implements the Meson generator plugin, which integrates CPPython with +the Meson build system. It includes functionality for resolving configuration data, +writing native/cross files, and synchronizing project data. +""" diff --git a/cppython/plugins/meson/builder.py b/cppython/plugins/meson/builder.py new file mode 100644 index 00000000..ebd87831 --- /dev/null +++ b/cppython/plugins/meson/builder.py @@ -0,0 +1,139 @@ +"""Plugin builder for Meson native/cross file management.""" + +import configparser +import io +from pathlib import Path + +from cppython.plugins.meson.schema import MesonSyncData + + +class Builder: + """Aids in building the information needed for the Meson plugin. + + Manages generation and writing of Meson native and cross files + that configure dependency paths from providers. + """ + + def __init__(self) -> None: + """Initialize the builder.""" + + @staticmethod + def generate_native_file(sync_data: MesonSyncData, project_root: Path) -> str: + """Generates a Meson native file that references provider-managed dependencies. + + The native file points Meson to the provider's dependency paths via + ``pkg_config_path`` and ``cmake_prefix_path`` in the ``[built-in options]`` + section. If the provider supplies its own native file, an include + directive is used instead. + + Args: + sync_data: The provider's synchronization data + project_root: The project root directory + + Returns: + The native file content as a string + """ + config = configparser.ConfigParser() + # Preserve key casing + config.optionxform = str # type: ignore[assignment] + + if sync_data.native_file: + # Reference the provider's native file via properties + config['properties'] = { + 'cppython_provider': f"'{sync_data.provider_name}'", + 'cppython_native_file': f"'{sync_data.native_file.as_posix()}'", + } + + output = io.StringIO() + config.write(output) + return output.getvalue() + + @staticmethod + def generate_cross_file(sync_data: MesonSyncData, project_root: Path) -> str: + """Generates a Meson cross file that references provider-managed toolchain. + + Args: + sync_data: The provider's synchronization data + project_root: The project root directory + + Returns: + The cross file content as a string + """ + config = configparser.ConfigParser() + config.optionxform = str # type: ignore[assignment] + + if sync_data.cross_file: + config['properties'] = { + 'cppython_provider': f"'{sync_data.provider_name}'", + 'cppython_cross_file': f"'{sync_data.cross_file.as_posix()}'", + } + + output = io.StringIO() + config.write(output) + return output.getvalue() + + @staticmethod + def write_native_file(directory: Path, sync_data: MesonSyncData, project_root: Path) -> Path | None: + """Write a CPPython-managed native file to disk. + + Only writes if the provider supplied a native file. The generated file + is written to ``{directory}/cppython_native.ini`` and is only updated + if the content has changed. + + Args: + directory: The tool directory to write the file to + sync_data: The provider's synchronization data + project_root: The project root directory + + Returns: + Path to the written native file, or None if no native file was provided + """ + if not sync_data.native_file: + return None + + directory.mkdir(parents=True, exist_ok=True) + native_file_path = directory / 'cppython_native.ini' + + content = Builder.generate_native_file(sync_data, project_root) + + # Only write if content changed + if native_file_path.exists(): + existing = native_file_path.read_text(encoding='utf-8') + if existing == content: + return native_file_path + + native_file_path.write_text(content, encoding='utf-8') + return native_file_path + + @staticmethod + def write_cross_file(directory: Path, sync_data: MesonSyncData, project_root: Path) -> Path | None: + """Write a CPPython-managed cross file to disk. + + Only writes if the provider supplied a cross file. The generated file + is written to ``{directory}/cppython_cross.ini`` and is only updated + if the content has changed. + + Args: + directory: The tool directory to write the file to + sync_data: The provider's synchronization data + project_root: The project root directory + + Returns: + Path to the written cross file, or None if no cross file was provided + """ + if not sync_data.cross_file: + return None + + directory.mkdir(parents=True, exist_ok=True) + cross_file_path = directory / 'cppython_cross.ini' + + content = Builder.generate_cross_file(sync_data, project_root) + + # Only write if content changed + if cross_file_path.exists(): + existing = cross_file_path.read_text(encoding='utf-8') + if existing == content: + return cross_file_path + + cross_file_path.write_text(content, encoding='utf-8') + return cross_file_path diff --git a/cppython/plugins/meson/plugin.py b/cppython/plugins/meson/plugin.py new file mode 100644 index 00000000..4710f9ef --- /dev/null +++ b/cppython/plugins/meson/plugin.py @@ -0,0 +1,219 @@ +"""The Meson generator implementation""" + +from logging import getLogger +from pathlib import Path +from typing import Any + +from cppython.core.plugin_schema.generator import ( + Generator, + GeneratorPluginGroupData, + SupportedGeneratorFeatures, +) +from cppython.core.schema import CorePluginData, Information, SupportedFeatures, SyncData +from cppython.plugins.meson.builder import Builder +from cppython.plugins.meson.resolution import resolve_meson_data +from cppython.plugins.meson.schema import MesonSyncData +from cppython.utility.subprocess import run_subprocess + +logger = getLogger('cppython.meson') + + +class MesonGenerator(Generator): + """Meson generator""" + + def __init__(self, group_data: GeneratorPluginGroupData, core_data: CorePluginData, data: dict[str, Any]) -> None: + """Initializes the generator.""" + self.group_data = group_data + self.core_data = core_data + self.data = resolve_meson_data(data, core_data) + self.builder = Builder() + + self._cppython_meson_directory = self.core_data.cppython_data.tool_path / 'cppython' / 'meson' + + # Track injected native/cross files for use in meson setup + self._native_file: Path | None = None + self._cross_file: Path | None = None + + @staticmethod + def features(directory: Path) -> SupportedFeatures: + """Queries if Meson is supported. + + Args: + directory: The root directory where features are evaluated + + Returns: + The supported features + """ + return SupportedGeneratorFeatures() + + @staticmethod + def information() -> Information: + """Queries plugin info. + + Returns: + Plugin information + """ + return Information() + + @staticmethod + def sync_types() -> list[type[SyncData]]: + """Returns types in order of preference. + + Returns: + The available types + """ + return [MesonSyncData] + + def sync(self, sync_data: SyncData) -> None: + """Disk sync point. + + Receives sync data from the provider and writes native/cross files + that will be passed to ``meson setup``. + + Args: + sync_data: The input data + """ + match sync_data: + case MesonSyncData(): + project_root = self.core_data.project_data.project_root + + self._native_file = self.builder.write_native_file( + self._cppython_meson_directory, sync_data, project_root + ) + self._cross_file = self.builder.write_cross_file( + self._cppython_meson_directory, sync_data, project_root + ) + case _: + raise ValueError('Unsupported sync data type') + + def _meson_command(self) -> str: + """Returns the meson command to use. + + Returns: + The meson binary path as a string + """ + if self.data.meson_binary: + return str(self.data.meson_binary) + return 'meson' + + def _build_dir(self) -> Path: + """Returns the absolute path to the meson build directory. + + Returns: + The build directory path + """ + return self.data.build_file.parent / self.data.build_directory + + def _ensure_setup(self) -> None: + """Ensure the meson build directory is configured. + + Runs ``meson setup`` if the build directory doesn't exist yet, + or ``meson setup --reconfigure`` if it does. + """ + build_dir = self._build_dir() + source_dir = self.data.build_file.parent + + cmd = [self._meson_command(), 'setup'] + + # Add native file if available + if self._native_file and self._native_file.exists(): + cmd.extend(['--native-file', str(self._native_file)]) + + # Add cross file if available + if self._cross_file and self._cross_file.exists(): + cmd.extend(['--cross-file', str(self._cross_file)]) + + if build_dir.exists(): + cmd.append('--reconfigure') + + cmd.extend([str(build_dir), str(source_dir)]) + + run_subprocess(cmd, cwd=source_dir, logger=logger) + + def _effective_build_dir(self, configuration: str | None) -> Path: + """Returns the build directory, optionally overridden by a configuration name. + + Args: + configuration: If provided, used as the build directory name instead of the + configured ``build_directory``. + + Returns: + The absolute path to the build directory + """ + directory = configuration if configuration else self.data.build_directory + return self.data.build_file.parent / directory + + def build(self, configuration: str | None = None) -> None: + """Builds the project using meson compile. + + Args: + configuration: Optional build directory name override. + """ + self._ensure_setup() + build_dir = self._effective_build_dir(configuration) + cmd = [self._meson_command(), 'compile', '-C', str(build_dir)] + run_subprocess(cmd, cwd=self.data.build_file.parent, logger=logger) + + def test(self, configuration: str | None = None) -> None: + """Runs tests using meson test. + + Args: + configuration: Optional build directory name override. + """ + build_dir = self._effective_build_dir(configuration) + cmd = [self._meson_command(), 'test', '-C', str(build_dir)] + run_subprocess(cmd, cwd=self.data.build_file.parent, logger=logger) + + def bench(self, configuration: str | None = None) -> None: + """Runs benchmarks using meson test --benchmark. + + Args: + configuration: Optional build directory name override. + """ + build_dir = self._effective_build_dir(configuration) + cmd = [self._meson_command(), 'test', '--benchmark', '-C', str(build_dir)] + run_subprocess(cmd, cwd=self.data.build_file.parent, logger=logger) + + def run(self, target: str, configuration: str | None = None) -> None: + """Runs a built executable by target name. + + Searches the build directory for the executable matching the target name. + + Args: + target: The name of the build target/executable to run + configuration: Optional build directory name override. + + Raises: + FileNotFoundError: If the target executable cannot be found + """ + build_dir = self._effective_build_dir(configuration) + + # Search for the executable in the build directory + candidates = list(build_dir.rglob(target)) + list(build_dir.rglob(f'{target}.exe')) + executables = [c for c in candidates if c.is_file()] + + if not executables: + raise FileNotFoundError(f"Could not find executable '{target}' in build directory: {build_dir}") + + executable = executables[0] + run_subprocess([str(executable)], cwd=self.data.build_file.parent, logger=logger) + + def list_targets(self) -> list[str]: + """Lists discovered build targets/executables in the Meson build directory. + + Searches the build directory for executable files. + + Returns: + A sorted list of unique target names found. + """ + build_dir = self._build_dir() + + if not build_dir.exists(): + return [] + + targets: set[str] = set() + for candidate in build_dir.rglob('*'): + if candidate.is_file() and (candidate.stat().st_mode & 0o111 or candidate.suffix == '.exe'): + targets.add(candidate.stem) + + return sorted(targets) diff --git a/cppython/plugins/meson/resolution.py b/cppython/plugins/meson/resolution.py new file mode 100644 index 00000000..60056fc0 --- /dev/null +++ b/cppython/plugins/meson/resolution.py @@ -0,0 +1,81 @@ +"""Builder to help resolve meson state""" + +import logging +import os +import shutil +from pathlib import Path +from typing import Any + +from cppython.core.schema import CorePluginData +from cppython.plugins.meson.schema import MesonConfiguration, MesonData + + +def _resolve_meson_binary(configured_path: Path | None) -> Path | None: + """Resolve the meson binary path with validation. + + Resolution order: + 1. MESON_BINARY environment variable (highest priority) + 2. Configured path from meson_binary setting + 3. meson from PATH (fallback) + + If a path is specified (via env or config) but doesn't exist, + a warning is logged and we fall back to PATH lookup. + + Args: + configured_path: The meson_binary path from configuration, if any + + Returns: + Resolved meson path, or None if not found anywhere + """ + logger = logging.getLogger('cppython.meson') + + # Environment variable takes precedence + if env_binary := os.environ.get('MESON_BINARY'): + env_path = Path(env_binary) + if env_path.exists(): + return env_path + logger.warning( + 'MESON_BINARY environment variable points to non-existent path: %s. Falling back to PATH lookup.', + env_binary, + ) + + # Try configured path + if configured_path: + if configured_path.exists(): + return configured_path + logger.warning( + 'Configured meson_binary path does not exist: %s. Falling back to PATH lookup.', + configured_path, + ) + + # Fall back to PATH lookup + if meson_in_path := shutil.which('meson'): + return Path(meson_in_path) + + return None + + +def resolve_meson_data(data: dict[str, Any], core_data: CorePluginData) -> MesonData: + """Resolves the input data table from defaults to requirements. + + Args: + data: The input table + core_data: The core data to help with the resolve + + Returns: + The resolved data + """ + parsed_data = MesonConfiguration(**data) + + root_directory = core_data.project_data.project_root.absolute() + + modified_build_file = parsed_data.build_file + if not modified_build_file.is_absolute(): + modified_build_file = root_directory / modified_build_file + + # Resolve meson binary: environment variable takes precedence over configuration + meson_binary = _resolve_meson_binary(parsed_data.meson_binary) + + return MesonData( + build_file=modified_build_file, build_directory=parsed_data.build_directory, meson_binary=meson_binary + ) diff --git a/cppython/plugins/meson/schema.py b/cppython/plugins/meson/schema.py new file mode 100644 index 00000000..0fa1ce4d --- /dev/null +++ b/cppython/plugins/meson/schema.py @@ -0,0 +1,73 @@ +"""Meson plugin schema + +This module defines the schema and data models for integrating the Meson +generator with CPPython. It includes definitions for configuration, +synchronization data, and resolved runtime data. +""" + +from pathlib import Path +from typing import Annotated + +from pydantic import Field + +from cppython.core.schema import CPPythonModel, SyncData + + +class MesonSyncData(SyncData): + """The Meson sync data exchanged between providers and the Meson generator. + + Providers populate these fields with paths to native/cross files + that configure Meson to find provider-managed dependencies. + """ + + native_file: Annotated[ + Path | None, + Field( + description='Path to a Meson native file for same-platform builds. ' + 'Contains pkg-config paths, dependency directories, and build options.' + ), + ] = None + cross_file: Annotated[ + Path | None, + Field( + description='Path to a Meson cross file for cross-compilation. ' + 'Contains host/target machine definitions and toolchain configuration.' + ), + ] = None + + +class MesonData(CPPythonModel): + """Resolved Meson data used at runtime by the generator plugin.""" + + build_file: Path + build_directory: str + meson_binary: Path | None + + +class MesonConfiguration(CPPythonModel): + """Configuration for the Meson generator plugin. + + User-facing configuration from ``[tool.cppython.generators.meson]``. + """ + + build_file: Annotated[ + Path, + Field( + description='The meson.build file that defines the project. ' + 'Relative paths are resolved against the project root.', + ), + ] = Path('meson.build') + build_directory: Annotated[ + str, + Field( + description='The Meson build directory name. This is passed to ' + '"meson setup" as the build directory argument.', + ), + ] = 'builddir' + meson_binary: Annotated[ + Path | None, + Field( + description='Path to a specific Meson binary to use. If not specified, uses "meson" from PATH. ' + 'Can be overridden via MESON_BINARY environment variable.' + ), + ] = None diff --git a/cppython/plugins/pdm/__init__.py b/cppython/plugins/pdm/__init__.py new file mode 100644 index 00000000..2db3d2b2 --- /dev/null +++ b/cppython/plugins/pdm/__init__.py @@ -0,0 +1,6 @@ +"""The PDM interface plugin for CPPython. + +This module implements the PDM interface plugin, which integrates CPPython with +the PDM tool. It includes functionality for handling post-install actions, +writing configuration data, and managing project-specific settings. +""" diff --git a/cppython/plugins/pdm/plugin.py b/cppython/plugins/pdm/plugin.py new file mode 100644 index 00000000..674f4dd0 --- /dev/null +++ b/cppython/plugins/pdm/plugin.py @@ -0,0 +1,107 @@ +"""Implementation of the PDM Interface Plugin""" + +from argparse import Namespace +from logging import getLogger +from typing import Any + +from pdm.cli.commands.base import BaseCommand +from pdm.core import Core +from pdm.project.core import Project +from pdm.signals import post_install + +from cppython.console.entry import app +from cppython.core.schema import Interface, ProjectConfiguration +from cppython.project import Project as CPPythonProject + + +class CPPythonPlugin(Interface): + """Implementation of the PDM Interface Plugin""" + + def __init__(self, core: Core) -> None: + """Initializes the plugin""" + post_install.connect(self.on_post_install, weak=False) + self.logger = getLogger('cppython.interface.pdm') + self._core = core + + # Register the cpp command + register_commands(core) + + def write_pyproject(self) -> None: + """Called when CPPython requires the interface to write out pyproject.toml changes""" + self._core.ui.echo('Writing out pyproject.toml') + # TODO: Implement writing to pyproject.toml through PDM + + def write_configuration(self) -> None: + """Called when CPPython requires the interface to write out configuration changes""" + self._core.ui.echo('Writing out configuration') + # TODO: Implement writing to cppython.toml + + def write_user_configuration(self) -> None: + """Called when CPPython requires the interface to write out user-specific configuration changes""" + self._core.ui.echo('Writing out user configuration') + # TODO: Implement writing to .cppython.toml + + def on_post_install(self, project: Project, dry_run: bool, **_kwargs: Any) -> None: + """Called after a pdm install command is called + + Args: + project: The input PDM project + dry_run: If true, won't perform any actions + _kwargs: Sink for unknown arguments + """ + root = project.root.absolute() + pdm_pyproject = project.pyproject.open_for_read() + + # Attach configuration for CPPythonPlugin callbacks + version = project.pyproject.metadata.get('version') + verbosity = project.core.ui.verbosity + + project_configuration = ProjectConfiguration(project_root=root, verbosity=verbosity, version=version) + + try: + cppython_project = CPPythonProject(project_configuration, self, pdm_pyproject) + + if not dry_run: + cppython_project.install() + except Exception: + self.logger.debug('CPPython: Error during post-install hook', exc_info=True) + + +class CPPythonCommand(BaseCommand): + """PDM command to invoke CPPython directly""" + + name = 'cpp' + description = 'Run CPPython commands' + + def add_arguments(self, parser) -> None: + """Add command arguments - delegate to Typer for argument parsing""" + # Add a catch-all for remaining arguments to pass to Typer + parser.add_argument('args', nargs='*', help='CPPython command arguments') + + def handle(self, project: Project, options: Namespace) -> None: + """Handle the command by delegating to the Typer app + + Args: + project: The PDM project + options: Command line options + """ + # Get the command arguments from options + args = getattr(options, 'args', []) + + try: + # Invoke cppython directly with the provided arguments + app(args) + except SystemExit: + # Typer/Click uses SystemExit for normal completion, don't propagate it + pass + except Exception as e: + project.core.ui.echo(f'Error running CPPython command: {e}', style='error') + + +def register_commands(core: Core) -> None: + """Register the CPPython command with PDM + + Args: + core: The PDM core instance + """ + core.register_command(CPPythonCommand) diff --git a/cppython/plugins/vcpkg/__init__.py b/cppython/plugins/vcpkg/__init__.py new file mode 100644 index 00000000..4758507f --- /dev/null +++ b/cppython/plugins/vcpkg/__init__.py @@ -0,0 +1,7 @@ +"""The vcpkg provider plugin for CPPython. + +This module implements the vcpkg provider plugin, which manages C++ dependencies +using the vcpkg package manager. It includes functionality for resolving +configuration data, generating manifests, and handling installation and updates +of dependencies. +""" diff --git a/cppython/plugins/vcpkg/plugin.py b/cppython/plugins/vcpkg/plugin.py new file mode 100644 index 00000000..f79eb2ee --- /dev/null +++ b/cppython/plugins/vcpkg/plugin.py @@ -0,0 +1,311 @@ +"""The vcpkg provider implementation""" + +import subprocess +from logging import getLogger +from os import name as system_name +from pathlib import Path, PosixPath, WindowsPath +from typing import Any + +from cppython.core.plugin_schema.generator import SyncConsumer +from cppython.core.plugin_schema.provider import ( + Provider, + ProviderPluginGroupData, + SupportedProviderFeatures, +) +from cppython.core.schema import CorePluginData, Information, SupportedFeatures, SyncData +from cppython.plugins.cmake.plugin import CMakeGenerator +from cppython.plugins.cmake.schema import CMakeSyncData +from cppython.plugins.meson.plugin import MesonGenerator +from cppython.plugins.meson.schema import MesonSyncData +from cppython.plugins.vcpkg.resolution import generate_manifest, resolve_vcpkg_data +from cppython.plugins.vcpkg.schema import VcpkgData +from cppython.utility.exception import ( + InstallationVerificationError, + NotSupportedError, + ProviderInstallationError, + ProviderToolingError, +) +from cppython.utility.subprocess import run_subprocess +from cppython.utility.utility import TypeName + +logger = getLogger('cppython.vcpkg') + + +class VcpkgProvider(Provider): + """vcpkg Provider""" + + def __init__( + self, group_data: ProviderPluginGroupData, core_data: CorePluginData, configuration_data: dict[str, Any] + ) -> None: + """Initializes the provider""" + self.group_data: ProviderPluginGroupData = group_data + self.core_data: CorePluginData = core_data + self.data: VcpkgData = resolve_vcpkg_data(configuration_data, core_data) + + @staticmethod + def features(directory: Path) -> SupportedFeatures: + """Queries vcpkg support + + Args: + directory: The directory to query + + Returns: + Supported features - `SupportedProviderFeatures`. Cast to this type to help us avoid generic typing + """ + return SupportedProviderFeatures() + + @staticmethod + def supported_sync_type(sync_type: type[SyncData]) -> bool: + """Checks if the given sync type is supported by the vcpkg provider. + + Args: + sync_type: The type of synchronization data to check. + + Returns: + True if the sync type is supported, False otherwise. + """ + return sync_type in CMakeGenerator.sync_types() or sync_type in MesonGenerator.sync_types() + + @staticmethod + def information() -> Information: + """Returns plugin information + + Returns: + Plugin information + """ + return Information() + + @classmethod + def _update_provider(cls, path: Path) -> None: + """Calls the vcpkg tool install script + + Args: + path: The path where the script is located + """ + try: + if system_name == 'nt': + run_subprocess( + [str(WindowsPath('bootstrap-vcpkg.bat')), '-disableMetrics'], + cwd=path, + logger=logger, + shell=True, + ) + elif system_name == 'posix': + run_subprocess( + ['./' + str(PosixPath('bootstrap-vcpkg.sh')), '-disableMetrics'], + cwd=path, + logger=logger, + shell=True, + ) + except subprocess.CalledProcessError as e: + raise ProviderToolingError('vcpkg', 'bootstrap the vcpkg repository', str(e), e) from e + + def sync_data(self, consumer: SyncConsumer) -> SyncData: + """Gathers a data object for the given generator. + + Args: + consumer: The input consumer + + Raises: + NotSupportedError: If not supported + + Returns: + The sync data object + """ + for sync_type in consumer.sync_types(): + if sync_type == CMakeSyncData: + return self._create_cmake_sync_data() + if sync_type == MesonSyncData: + return self._create_meson_sync_data() + + raise NotSupportedError('OOF') + + def _create_cmake_sync_data(self) -> CMakeSyncData: + """Creates CMake synchronization data with vcpkg configuration. + + Returns: + CMakeSyncData configured for vcpkg integration + """ + # Create CMakeSyncData with vcpkg configuration + vcpkg_cmake_path = self.core_data.cppython_data.install_path / 'scripts/buildsystems/vcpkg.cmake' + + return CMakeSyncData( + provider_name=TypeName('vcpkg'), + toolchain_file=vcpkg_cmake_path, + ) + + def _create_meson_sync_data(self) -> MesonSyncData: + """Creates Meson synchronization data with vcpkg configuration. + + vcpkg exposes installed dependencies via pkg-config. The native file + points Meson's ``pkg_config_path`` to vcpkg's installed pkgconfig directory. + + Returns: + MesonSyncData configured for vcpkg integration + """ + # vcpkg installs pkg-config files under installed//lib/pkgconfig + # We point Meson to the installed directory via a native file reference + # The native file itself is generated by the Meson builder during sync + vcpkg_pkgconfig_path = self.core_data.cppython_data.install_path / 'installed' + + # Create a native file path in the tool directory + native_file = self.core_data.cppython_data.tool_path / 'cppython' / 'meson' / 'vcpkg_native.ini' + + # Write a minimal native file pointing to vcpkg's pkg-config + native_file.parent.mkdir(parents=True, exist_ok=True) + content = f"[built-in options]\npkg_config_path = '{vcpkg_pkgconfig_path.as_posix()}'\n" + native_file.write_text(content, encoding='utf-8') + + return MesonSyncData( + provider_name=TypeName('vcpkg'), + native_file=native_file, + ) + + @classmethod + def tooling_downloaded(cls, path: Path) -> bool: + """Returns whether the provider tooling needs to be downloaded + + Args: + path: The directory to check for downloaded tooling + + Returns: + Whether the tooling has been downloaded or not + """ + try: + subprocess.run( + ['git', 'rev-parse', '--is-inside-work-tree'], + cwd=path, + check=True, + capture_output=True, + ) + except subprocess.CalledProcessError: + return False + + return True + + @classmethod + async def download_tooling(cls, directory: Path) -> None: + """Installs the external tooling required by the provider + + Args: + directory: The directory to download any extra tooling to + """ + if cls.tooling_downloaded(directory): + try: + logger.debug("Updating the vcpkg repository at '%s'", directory.absolute()) + + # The entire history is need for vcpkg 'baseline' information + run_subprocess( + ['git', 'fetch', 'origin'], + cwd=directory, + logger=logger, + ) + run_subprocess( + ['git', 'pull'], + cwd=directory, + logger=logger, + ) + except subprocess.CalledProcessError as e: + raise ProviderToolingError('vcpkg', 'update the vcpkg repository', str(e), e) from e + else: + try: + logger.debug("Cloning the vcpkg repository to '%s'", directory.absolute()) + + # The entire history is need for vcpkg 'baseline' information + run_subprocess( + ['git', 'clone', 'https://github.com/microsoft/vcpkg', '.'], + cwd=directory, + logger=logger, + ) + + except subprocess.CalledProcessError as e: + raise ProviderToolingError('vcpkg', 'clone the vcpkg repository', str(e), e) from e + + cls._update_provider(directory) + + def verify_installed(self) -> None: + """Verify that vcpkg tooling and installed packages exist on disk. + + Checks that the vcpkg repository has been cloned and that the install + directory contains packages from a prior ``install()`` call. + + Raises: + InstallationVerificationError: If required artifacts are missing + """ + missing: list[str] = [] + + # Check that vcpkg tooling has been downloaded + tooling_path = self.core_data.cppython_data.install_path + if not self.tooling_downloaded(tooling_path): + missing.append(f'vcpkg repository ({tooling_path})') + + # Check that packages have been installed + install_directory = self.data.install_directory + if not install_directory.is_dir() or not any(install_directory.iterdir()): + missing.append(f'installed packages directory ({install_directory})') + + if missing: + raise InstallationVerificationError('vcpkg', missing) + + def install(self, groups: list[str] | None = None) -> None: + """Called when dependencies need to be installed from a lock file. + + Args: + groups: Optional list of dependency group names to install (currently not used by vcpkg) + """ + manifest_directory = self.core_data.project_data.project_root + manifest = generate_manifest(self.core_data, self.data) + + # Write out the manifest + serialized = manifest.model_dump_json(exclude_none=True, by_alias=True, indent=4) + with open(manifest_directory / 'vcpkg.json', 'w', encoding='utf8') as file: + file.write(serialized) + + executable = self.core_data.cppython_data.install_path / 'vcpkg' + install_directory = self.data.install_directory + build_path = self.core_data.cppython_data.build_path + + try: + run_subprocess( + [str(executable), 'install', f'--x-install-root={str(install_directory)}'], + cwd=str(build_path), + logger=logger, + ) + except subprocess.CalledProcessError as e: + raise ProviderInstallationError('vcpkg', f'install project dependencies: {e}', e) from e + + def update(self, groups: list[str] | None = None) -> None: + """Called when dependencies need to be updated and written to the lock file. + + Args: + groups: Optional list of dependency group names to update (currently not used by vcpkg) + """ + manifest_directory = self.core_data.project_data.project_root + + manifest = generate_manifest(self.core_data, self.data) + + # Write out the manifest + serialized = manifest.model_dump_json(exclude_none=True, by_alias=True, indent=4) + with open(manifest_directory / 'vcpkg.json', 'w', encoding='utf8') as file: + file.write(serialized) + + executable = self.core_data.cppython_data.install_path / 'vcpkg' + install_directory = self.data.install_directory + build_path = self.core_data.cppython_data.build_path + + try: + run_subprocess( + [str(executable), 'install', f'--x-install-root={str(install_directory)}'], + cwd=str(build_path), + logger=logger, + ) + except subprocess.CalledProcessError as e: + raise ProviderInstallationError('vcpkg', f'update project dependencies: {e}', e) from e + + def publish(self) -> None: + """Called when the project needs to be published. + + Raises: + NotImplementedError: vcpkg does not support publishing + """ + raise NotImplementedError('vcpkg does not support publishing') diff --git a/cppython/plugins/vcpkg/resolution.py b/cppython/plugins/vcpkg/resolution.py new file mode 100644 index 00000000..1725f46d --- /dev/null +++ b/cppython/plugins/vcpkg/resolution.py @@ -0,0 +1,112 @@ +"""Builder to help build vcpkg state""" + +from subprocess import CalledProcessError, check_output +from typing import Any + +from packaging.requirements import Requirement + +from cppython.core.exception import ConfigException +from cppython.core.schema import CorePluginData +from cppython.plugins.vcpkg.schema import ( + Manifest, + VcpkgConfiguration, + VcpkgData, + VcpkgDependency, +) + + +def generate_manifest(core_data: CorePluginData, data: VcpkgData) -> Manifest: + """From the input configuration data, construct a Vcpkg specific Manifest type + + Args: + core_data: The core data to help with the resolve + data: Converted vcpkg data + + Returns: + The manifest + """ + # If builtin_baseline is None, we set it to the current commit of the cloned vcpkg repository + if data.builtin_baseline is None: + try: + cwd = core_data.cppython_data.install_path + + # Get the current commit hash from the vcpkg repository + result = check_output(['git', 'rev-parse', 'HEAD'], cwd=cwd) + data.builtin_baseline = result.decode('utf-8').strip() + except (CalledProcessError, FileNotFoundError) as e: + raise ConfigException('Failed to get the current commit hash from the vcpkg repository.') from e + + return Manifest( + name=core_data.pep621_data.name, + version_string=core_data.pep621_data.version, + dependencies=data.dependencies, + builtin_baseline=data.builtin_baseline, + ) + + +def resolve_vcpkg_data(data: dict[str, Any], core_data: CorePluginData) -> VcpkgData: + """Resolves the input data table from defaults to requirements + + Args: + data: The input table + core_data: The core data to help with the resolve + + Returns: + The resolved data + """ + parsed_data = VcpkgConfiguration(**data) + + root_directory = core_data.project_data.project_root.absolute() + + modified_install_directory = parsed_data.install_directory + + # Add the project location to all relative paths + if not modified_install_directory.is_absolute(): + modified_install_directory = root_directory / modified_install_directory + + # Create directories + modified_install_directory.mkdir(parents=True, exist_ok=True) + + vcpkg_dependencies: list[VcpkgDependency] = [] + for requirement in core_data.cppython_data.dependencies: + resolved_dependency = resolve_vcpkg_dependency(requirement) + vcpkg_dependencies.append(resolved_dependency) + + return VcpkgData( + install_directory=modified_install_directory, + dependencies=vcpkg_dependencies, + builtin_baseline=parsed_data.builtin_baseline, + ) + + +def resolve_vcpkg_dependency(requirement: Requirement) -> VcpkgDependency: + """Resolve a VcpkgDependency from a packaging requirement. + + Args: + requirement: A packaging requirement object. + + Returns: + A resolved VcpkgDependency object. + """ + specifiers = requirement.specifier + + # If the length of specifiers is greater than one, raise a configuration error + if len(specifiers) > 1: + raise ConfigException('Multiple specifiers are not supported. Please provide a single specifier.') + + # Extract the version from the single specifier + min_version = None + if len(specifiers) == 1: + specifier = next(iter(specifiers)) + if specifier.operator != '>=': + raise ConfigException(f"Unsupported specifier '{specifier.operator}'. Only '>=' is supported.") + min_version = specifier.version + + return VcpkgDependency( + name=requirement.name, + default_features=True, + features=[], + version_ge=min_version, + platform=None, + host=False, + ) diff --git a/cppython/plugins/vcpkg/schema.py b/cppython/plugins/vcpkg/schema.py new file mode 100644 index 00000000..e8d497a2 --- /dev/null +++ b/cppython/plugins/vcpkg/schema.py @@ -0,0 +1,81 @@ +"""Definitions for the plugin""" + +from pathlib import Path +from typing import Annotated + +from pydantic import Field, HttpUrl + +from cppython.core.schema import CPPythonModel + + +class VcpkgDependency(CPPythonModel): + """Vcpkg dependency type""" + + name: Annotated[str, Field(description='The name of the dependency.')] + default_features: Annotated[ + bool, + Field( + alias='default-features', + description='Whether to use the default features of the dependency. Defaults to true.', + ), + ] = True + features: Annotated[ + list[str], + Field(description='A list of additional features to require for the dependency.'), + ] = [] + version_ge: Annotated[ + str | None, + Field( + alias='version>=', + description='The minimum required version of the dependency, optionally with a port-version suffix.', + ), + ] = None + platform: Annotated[ + str | None, + Field(description='A platform expression specifying the platforms where the dependency applies.'), + ] = None + host: Annotated[ + bool, + Field(description='Whether the dependency is required for the host machine instead of the target.'), + ] = False + + +class VcpkgData(CPPythonModel): + """Resolved vcpkg data""" + + install_directory: Path + dependencies: list[VcpkgDependency] + builtin_baseline: str | None + + +class VcpkgConfiguration(CPPythonModel): + """vcpkg provider configuration""" + + install_directory: Annotated[ + Path, + Field( + alias='install-directory', + description='The directory where vcpkg artifacts will be installed.', + ), + ] = Path('build') + + builtin_baseline: Annotated[ + str | None, + Field( + alias='builtin-baseline', + description='A shortcut for specifying the baseline for version resolution in the default registry.', + ), + ] = None + + +class Manifest(CPPythonModel): + """The manifest schema""" + + name: Annotated[str, Field(description='The project name')] + + version_string: Annotated[str, Field(alias='version-string', description='The arbitrary version string')] + + description: Annotated[str, Field(description='The project description')] = '' + homepage: Annotated[HttpUrl | None, Field(description='Homepage URL')] = None + dependencies: Annotated[list[VcpkgDependency], Field(description='List of dependencies')] = [] + builtin_baseline: Annotated[str, Field(alias='builtin-baseline', description='The arbitrary version string')] diff --git a/cppython/project.py b/cppython/project.py new file mode 100644 index 00000000..04114745 --- /dev/null +++ b/cppython/project.py @@ -0,0 +1,321 @@ +"""Manages data flow to and from plugins""" + +import asyncio +import logging +from typing import Any + +from cppython.builder import Builder +from cppython.core.exception import ConfigException +from cppython.core.resolution import resolve_model +from cppython.core.schema import Interface, ProjectConfiguration, PyProject, SyncData +from cppython.schema import API +from cppython.utility.output import NULL_SESSION, SessionProtocol + + +class Project(API): + """The object that should be constructed at each entry_point""" + + def __init__( + self, + project_configuration: ProjectConfiguration, + interface: Interface, + pyproject_data: dict[str, Any], + *, + session: SessionProtocol | None = None, + ) -> None: + """Initializes the project + + Args: + project_configuration: Project-wide configuration + interface: Interface for callbacks to write configuration changes + pyproject_data: Merged configuration data from all sources + session: Output session for spinner / log file management (defaults to no-op) + """ + self._enabled = False + self._interface = interface + self._session: SessionProtocol = session or NULL_SESSION + self.logger = logging.getLogger('cppython') + + # Early exit: if no CPPython configuration table, do nothing silently + tool_data = pyproject_data.get('tool') + if not tool_data or not isinstance(tool_data, dict) or not tool_data.get('cppython'): + return + + builder = Builder(project_configuration, self.logger) + + self.logger.info('Initializing project') + + try: + pyproject = resolve_model(PyProject, pyproject_data) + except ConfigException as error: + # Log the exception message explicitly + self.logger.error('Configuration error:\n%s', error, exc_info=False) + raise SystemExit('Error: Invalid configuration. Please check your pyproject.toml.') from None + + if not pyproject.tool or not pyproject.tool.cppython: + self.logger.info("The pyproject.toml file doesn't contain the `tool.cppython` table") + return + + self._data = builder.build(pyproject.project, pyproject.tool.cppython) + + self._enabled = True + + self.logger.info('Initialized project successfully') + + @property + def enabled(self) -> bool: + """Queries if the project was is initialized for full functionality + + Returns: + The query result + """ + return self._enabled + + @property + def session(self) -> SessionProtocol: + """The output session for spinner / log file management.""" + return self._session + + @session.setter + def session(self, value: SessionProtocol) -> None: + self._session = value + + def info(self) -> dict[str, Any]: + """Return project and plugin information. + + Returns: + A dictionary containing: + - ``provider``: name and :class:`PluginReport` for the active provider plugin + - ``generator``: name and :class:`PluginReport` for the active generator plugin + """ + if not self._enabled: + self.logger.info('Skipping info because the project is not enabled') + return {} + + return { + 'provider': { + 'name': self._data.plugins.provider.name(), + 'report': self._data.plugins.provider.plugin_info(), + }, + 'generator': { + 'name': self._data.plugins.generator.name(), + 'report': self._data.plugins.generator.plugin_info(), + }, + } + + def install(self, groups: list[str] | None = None) -> None: + """Installs project dependencies + + Args: + groups: Optional list of dependency groups to install in addition to base dependencies + + Raises: + Exception: Provider-specific exceptions are propagated with full context + """ + if not self._enabled: + self.logger.info('Skipping install because the project is not enabled') + return + + self.logger.info('Installing tools') + + with self._session.spinner('Downloading provider tools...'): + asyncio.run(self._data.download_provider_tools()) + + self.logger.info('Installing project') + + # Log active groups + if groups: + self.logger.info('Installing with dependency groups: %s', ', '.join(groups)) + + self.logger.info('Installing %s provider', self._data.plugins.provider.name()) + + # Validate and log active groups + self._data.apply_dependency_groups(groups) + + # Sync before install to allow provider to access generator's resolved configuration + with self._session.spinner('Syncing project data...'): + self._data.sync() + + with self._session.spinner('Installing dependencies...'): + self._data.plugins.provider.install(groups=groups) + + def update(self, groups: list[str] | None = None) -> None: + """Updates project dependencies + + Args: + groups: Optional list of dependency groups to update in addition to base dependencies + + Raises: + Exception: Provider-specific exception + """ + if not self._enabled: + self.logger.info('Skipping update because the project is not enabled') + return + + self.logger.info('Updating tools') + + with self._session.spinner('Downloading provider tools...'): + asyncio.run(self._data.download_provider_tools()) + + self.logger.info('Updating project') + + # Log active groups + if groups: + self.logger.info('Updating with dependency groups: %s', ', '.join(groups)) + + self.logger.info('Updating %s provider', self._data.plugins.provider.name()) + + # Validate and log active groups + self._data.apply_dependency_groups(groups) + + with self._session.spinner('Syncing project data...'): + self._data.sync() + + with self._session.spinner('Updating dependencies...'): + self._data.plugins.provider.update(groups=groups) + + def publish(self) -> None: + """Publishes the project + + Raises: + Exception: Provider-specific exception + """ + if not self._enabled: + self.logger.info('Skipping publish because the project is not enabled') + return + + self.logger.info('Publishing project') + + with self._session.spinner('Syncing project data...'): + self._data.sync() + + with self._session.spinner('Publishing package...'): + self._data.plugins.provider.publish() + + def prepare_build(self) -> SyncData | None: + """Prepare for a PEP 517 build without installing C++ dependencies. + + Syncs generated files (presets, native files) and verifies that a prior + ``install()`` call has produced the expected provider artifacts. This is + used by the build backend so that ``pdm build`` / ``pip wheel`` can + delegate to scikit-build-core or meson-python without re-running the + full provider install workflow. + + Returns: + The sync data from the provider, or None if the project is not enabled + + Raises: + InstallationVerificationError: If provider artifacts are missing + """ + if not self._enabled: + self.logger.info('Skipping prepare_build because the project is not enabled') + return None + + self.logger.info('Preparing build environment') + + # Sync config files so the generator has up-to-date presets / native files + self._data.sync() + + # Verify that a prior install() produced the expected artifacts + self._data.plugins.provider.verify_installed() + + # Return sync data for the build backend to inject into config_settings + return self._data.plugins.provider.sync_data(self._data.plugins.generator) + + def build(self, configuration: str | None = None) -> None: + """Builds the project + + Assumes dependencies have been installed via `install`. + Syncs generated files to ensure they are up-to-date, then executes the build. + + Args: + configuration: Optional named configuration to use + """ + if not self._enabled: + self.logger.info('Skipping build because the project is not enabled') + return + + self.logger.info('Building project') + + with self._session.spinner('Syncing project data...'): + self._data.sync() + + with self._session.spinner('Building project...'): + self._data.plugins.generator.build(configuration=configuration) + + def test(self, configuration: str | None = None) -> None: + """Runs project tests + + Assumes dependencies have been installed via `install`. + Syncs generated files to ensure they are up-to-date, then executes tests. + + Args: + configuration: Optional named configuration to use + """ + if not self._enabled: + self.logger.info('Skipping test because the project is not enabled') + return + + self.logger.info('Running tests') + + with self._session.spinner('Syncing project data...'): + self._data.sync() + + with self._session.spinner('Running tests...'): + self._data.plugins.generator.test(configuration=configuration) + + def bench(self, configuration: str | None = None) -> None: + """Runs project benchmarks + + Assumes dependencies have been installed via `install`. + Syncs generated files to ensure they are up-to-date, then executes benchmarks. + + Args: + configuration: Optional named configuration to use + """ + if not self._enabled: + self.logger.info('Skipping bench because the project is not enabled') + return + + self.logger.info('Running benchmarks') + + with self._session.spinner('Syncing project data...'): + self._data.sync() + + with self._session.spinner('Running benchmarks...'): + self._data.plugins.generator.bench(configuration=configuration) + + def run(self, target: str, configuration: str | None = None) -> None: + """Runs a built executable + + Assumes dependencies have been installed via `install`. + Syncs generated files to ensure they are up-to-date, then executes the target. + + Args: + target: The name of the build target to run + configuration: Optional named configuration to use + """ + if not self._enabled: + self.logger.info('Skipping run because the project is not enabled') + return + + self.logger.info('Running target: %s', target) + + with self._session.spinner('Syncing project data...'): + self._data.sync() + + with self._session.spinner(f'Running {target}...'): + self._data.plugins.generator.run(target, configuration=configuration) + + def list_targets(self) -> list[str]: + """Lists discovered build targets/executables. + + Returns: + A list of target names found in the build directory, or an empty list + if the project is not enabled. + """ + if not self._enabled: + self.logger.info('Skipping list_targets because the project is not enabled') + return [] + + return self._data.plugins.generator.list_targets() diff --git a/cppython/py.typed b/cppython/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/cppython/schema.py b/cppython/schema.py new file mode 100644 index 00000000..59d1bc88 --- /dev/null +++ b/cppython/schema.py @@ -0,0 +1,82 @@ +"""Project schema specifications""" + +from abc import abstractmethod +from typing import Any, Protocol + + +class API(Protocol): + """Project API specification""" + + @abstractmethod + def info(self) -> dict[str, Any]: + """Return project and template information. + + Returns: + A dictionary with project metadata and template status. + """ + raise NotImplementedError() + + @abstractmethod + def install(self, groups: list[str] | None = None) -> None: + """Installs project dependencies + + Args: + groups: Optional list of dependency groups to install + """ + raise NotImplementedError() + + @abstractmethod + def update(self, groups: list[str] | None = None) -> None: + """Updates project dependencies + + Args: + groups: Optional list of dependency groups to update + """ + raise NotImplementedError() + + @abstractmethod + def build(self, configuration: str | None = None) -> None: + """Builds the project + + Args: + configuration: Optional named configuration to use. Interpretation is generator-specific + (e.g. CMake preset name, Meson build directory). + """ + raise NotImplementedError() + + @abstractmethod + def test(self, configuration: str | None = None) -> None: + """Runs project tests + + Args: + configuration: Optional named configuration to use. Interpretation is generator-specific. + """ + raise NotImplementedError() + + @abstractmethod + def bench(self, configuration: str | None = None) -> None: + """Runs project benchmarks + + Args: + configuration: Optional named configuration to use. Interpretation is generator-specific. + """ + raise NotImplementedError() + + @abstractmethod + def run(self, target: str, configuration: str | None = None) -> None: + """Runs a built executable + + Args: + target: The name of the build target to run + configuration: Optional named configuration to use. Interpretation is generator-specific. + """ + raise NotImplementedError() + + @abstractmethod + def list_targets(self) -> list[str]: + """Lists discovered build targets/executables. + + Returns: + A list of target names found in the build directory. + """ + raise NotImplementedError() diff --git a/cppython/test/__init__.py b/cppython/test/__init__.py new file mode 100644 index 00000000..bbf00b9e --- /dev/null +++ b/cppython/test/__init__.py @@ -0,0 +1,7 @@ +"""Testing utilities for the CPPython project. + +This module provides various utilities and mock implementations to facilitate +the testing of CPPython plugins and core functionalities. It includes shared +test types, fixtures, and mock classes that simulate real-world scenarios and +edge cases. +""" diff --git a/cppython/test/data/__init__.py b/cppython/test/data/__init__.py new file mode 100644 index 00000000..071166f5 --- /dev/null +++ b/cppython/test/data/__init__.py @@ -0,0 +1 @@ +"""Data for testing plugins""" diff --git a/cppython/test/data/mocks.py b/cppython/test/data/mocks.py new file mode 100644 index 00000000..cbb71267 --- /dev/null +++ b/cppython/test/data/mocks.py @@ -0,0 +1,57 @@ +"""Mocked types and data for testing plugins""" + +from collections.abc import Sequence + +from cppython.core.plugin_schema.generator import Generator +from cppython.core.plugin_schema.provider import Provider +from cppython.core.plugin_schema.scm import SCM +from cppython.test.mock.generator import MockGenerator +from cppython.test.mock.provider import MockProvider +from cppython.test.mock.scm import MockSCM + + +def _mock_provider_list() -> Sequence[type[Provider]]: + """Mocked list of providers + + Returns: + A list of mock providers + """ + variants = [] + + # Default + variants.append(MockProvider) + + return variants + + +def _mock_generator_list() -> Sequence[type[Generator]]: + """Mocked list of generators + + Returns: + List of mock generators + """ + variants = [] + + # Default + variants.append(MockGenerator) + + return variants + + +def _mock_scm_list() -> Sequence[type[SCM]]: + """Mocked list of SCMs + + Returns: + List of mock SCMs + """ + variants = [] + + # Default + variants.append(MockSCM) + + return variants + + +provider_variants = _mock_provider_list() +generator_variants = _mock_generator_list() +scm_variants = _mock_scm_list() diff --git a/cppython/test/mock/__init__.py b/cppython/test/mock/__init__.py new file mode 100644 index 00000000..b7a7809d --- /dev/null +++ b/cppython/test/mock/__init__.py @@ -0,0 +1,7 @@ +"""Mock implementations for testing CPPython plugins. + +This module provides mock implementations of various CPPython plugin interfaces, +enabling comprehensive testing of plugin behavior. The mocks include providers, +generators, and SCMs, each designed to simulate real-world scenarios and edge +cases. +""" diff --git a/cppython/test/mock/generator.py b/cppython/test/mock/generator.py new file mode 100644 index 00000000..48e0edea --- /dev/null +++ b/cppython/test/mock/generator.py @@ -0,0 +1,78 @@ +"""Shared definitions for testing.""" + +from typing import Any + +from pydantic import DirectoryPath + +from cppython.core.plugin_schema.generator import ( + Generator, + GeneratorPluginGroupData, + SupportedGeneratorFeatures, +) +from cppython.core.schema import CorePluginData, CPPythonModel, Information, SupportedFeatures, SyncData + + +class MockSyncData(SyncData): + """A Mock data type""" + + +class MockGeneratorData(CPPythonModel): + """Dummy data""" + + +class MockGenerator(Generator): + """A mock generator class for behavior testing""" + + def __init__( + self, group_data: GeneratorPluginGroupData, core_data: CorePluginData, configuration_data: dict[str, Any] + ) -> None: + """Initializes the mock generator""" + self.group_data = group_data + self.core_data = core_data + self.configuration_data = MockGeneratorData(**configuration_data) + + @staticmethod + def features(directory: DirectoryPath) -> SupportedFeatures: + """Broadcasts the shared features of the generator plugin to CPPython + + Returns: + The supported features - `SupportedGeneratorFeatures`. Cast to this type to help us avoid generic typing + """ + return SupportedGeneratorFeatures() + + @staticmethod + def information() -> Information: + """Returns plugin information + + Returns: + The plugin information + """ + return Information() + + @staticmethod + def sync_types() -> list[type[SyncData]]: + """Returns the supported synchronization data types for the mock generator. + + Returns: + A list of supported synchronization data types. + """ + return [MockSyncData] + + def sync(self, sync_data: SyncData) -> None: + """Synchronizes generator files and state with the providers input""" + + def build(self, configuration: str | None = None) -> None: + """No-op build for testing""" + + def test(self, configuration: str | None = None) -> None: + """No-op test for testing""" + + def bench(self, configuration: str | None = None) -> None: + """No-op bench for testing""" + + def run(self, target: str, configuration: str | None = None) -> None: + """No-op run for testing""" + + def list_targets(self) -> list[str]: + """No-op list_targets for testing""" + return [] diff --git a/cppython/test/mock/interface.py b/cppython/test/mock/interface.py new file mode 100644 index 00000000..983b73c4 --- /dev/null +++ b/cppython/test/mock/interface.py @@ -0,0 +1,16 @@ +"""Mock interface definitions""" + +from cppython.core.schema import Interface + + +class MockInterface(Interface): + """A mock interface class for behavior testing""" + + def write_pyproject(self) -> None: + """Implementation of Interface function""" + + def write_configuration(self) -> None: + """Implementation of Interface function""" + + def write_user_configuration(self) -> None: + """Implementation of Interface function""" diff --git a/cppython/test/mock/provider.py b/cppython/test/mock/provider.py new file mode 100644 index 00000000..1735fd03 --- /dev/null +++ b/cppython/test/mock/provider.py @@ -0,0 +1,110 @@ +"""Mock provider definitions""" + +from typing import Any + +from pydantic import DirectoryPath + +from cppython.core.plugin_schema.generator import SyncConsumer +from cppython.core.plugin_schema.provider import ( + Provider, + ProviderPluginGroupData, + SupportedProviderFeatures, +) +from cppython.core.schema import CorePluginData, CPPythonModel, Information, SupportedFeatures, SyncData +from cppython.test.mock.generator import MockSyncData + + +class MockProviderData(CPPythonModel): + """Dummy data""" + + +class MockProvider(Provider): + """A mock provider class for behavior testing""" + + downloaded: DirectoryPath | None = None + + def __init__( + self, group_data: ProviderPluginGroupData, core_data: CorePluginData, configuration_data: dict[str, Any] + ) -> None: + """Initializes the mock provider""" + self.group_data = group_data + self.core_data = core_data + self.configuration_data = MockProviderData(**configuration_data) + + @staticmethod + def features(directory: DirectoryPath) -> SupportedFeatures: + """Broadcasts the shared features of the Provider plugin to CPPython + + Returns: + The supported features - `SupportedProviderFeatures`. Cast to this type to help us avoid generic typing + """ + return SupportedProviderFeatures() + + @staticmethod + def information() -> Information: + """Returns plugin information + + Returns: + The plugin information + """ + return Information() + + @staticmethod + def supported_sync_type(sync_type: type[SyncData]) -> bool: + """Broadcasts supported types + + Args: + sync_type: The input type + + Returns: + Support + """ + return sync_type == MockSyncData + + def sync_data(self, consumer: SyncConsumer) -> SyncData | None: + """Gathers synchronization data + + Args: + consumer: The input consumer + + Returns: + The sync data object + """ + # This is a mock class, so any generator sync type is OK + for sync_type in consumer.sync_types(): + match sync_type: + case underlying_type if underlying_type is MockSyncData: + return MockSyncData(provider_name=self.name()) + + return None + + @classmethod + async def download_tooling(cls, directory: DirectoryPath) -> None: + """Downloads the provider tooling""" + cls.downloaded = directory + + def verify_installed(self) -> None: + """Verify that mock provider artifacts exist. + + Always passes since this is a mock. + """ + + def install(self, groups: list[str] | None = None) -> None: + """Installs the provider + + Args: + groups: Optional list of dependency group names to install + """ + pass + + def update(self, groups: list[str] | None = None) -> None: + """Updates the provider + + Args: + groups: Optional list of dependency group names to update + """ + pass + + def publish(self) -> None: + """Updates the provider""" + pass diff --git a/cppython/test/mock/scm.py b/cppython/test/mock/scm.py new file mode 100644 index 00000000..c2c9cee7 --- /dev/null +++ b/cppython/test/mock/scm.py @@ -0,0 +1,50 @@ +"""Mock SCM definitions""" + +from pydantic import DirectoryPath + +from cppython.core.plugin_schema.scm import ( + SCM, + SCMPluginGroupData, + SupportedSCMFeatures, +) +from cppython.core.schema import Information, SupportedFeatures + + +class MockSCM(SCM): + """A mock generator class for behavior testing""" + + def __init__(self, group_data: SCMPluginGroupData) -> None: + """Initializes the mock generator""" + self.group_data = group_data + + @staticmethod + def features(directory: DirectoryPath) -> SupportedFeatures: + """Broadcasts the shared features of the SCM plugin to CPPython + + Args: + directory: The root directory where features are evaluated + + Returns: + The supported features - `SupportedSCMFeatures`. Cast to this type to help us avoid generic typing + """ + return SupportedSCMFeatures(repository=True) + + @staticmethod + def information() -> Information: + """Returns plugin information + + Returns: + The plugin information + """ + return Information() + + def version(self, directory: DirectoryPath) -> str: + """Extracts the system's version metadata + + Args: + directory: The input directory + + Returns: + A version + """ + return '1.0.0' diff --git a/cppython/test/pytest/__init__.py b/cppython/test/pytest/__init__.py new file mode 100644 index 00000000..85c2f098 --- /dev/null +++ b/cppython/test/pytest/__init__.py @@ -0,0 +1 @@ +"""Pytest integration and testing framework for CPPython.""" diff --git a/cppython/test/pytest/contracts.py b/cppython/test/pytest/contracts.py new file mode 100644 index 00000000..4208b2a5 --- /dev/null +++ b/cppython/test/pytest/contracts.py @@ -0,0 +1,219 @@ +"""Plugin test contracts that define standard test requirements. + +This module contains abstract base classes that define the testing contracts +for each plugin type. Each plugin implementation should inherit from the +appropriate contract class exactly once to ensure they fulfill the required +testing obligations. + +These contracts combine the core fixtures with plugin-type-specific requirements. +""" + +import asyncio +from abc import ABC +from importlib.metadata import entry_points +from pathlib import Path +from typing import Any, LiteralString + +import pytest + +from cppython.core.plugin_schema.generator import Generator +from cppython.core.plugin_schema.provider import Provider +from cppython.core.plugin_schema.scm import SCM +from cppython.core.schema import ( + CorePluginData, + DataPluginGroupData, + Plugin, + ProjectConfiguration, +) +from cppython.test.pytest.mixins import ( + GeneratorPluginTestMixin, + ProviderPluginTestMixin, + SCMPluginTestMixin, +) +from cppython.utility.utility import canonicalize_type + + +class _PluginValidation: + """Common validation tests that can be applied to any plugin. + + These are generic tests that validate basic plugin behavior regardless + of the specific plugin type. Test classes can inherit this to get + standard validation tests. + """ + + @staticmethod + def test_feature_extraction(plugin_type: type[Plugin], project_configuration: ProjectConfiguration) -> None: + """Test the feature extraction of a plugin. + + Args: + plugin_type: The type of plugin to test. + project_configuration: The project configuration to use for testing. + """ + assert plugin_type.features(project_configuration.project_root) + + @staticmethod + def test_information(plugin_type: type[Plugin]) -> None: + """Test the information method of a plugin. + + Args: + plugin_type: The type of the plugin to test. + """ + assert plugin_type.information() + + @staticmethod + def test_plugin_name_extraction(plugin_type: type[Plugin]) -> None: + """Verifies the class name allows name extraction + + Args: + plugin_type: The type to register + """ + assert plugin_type.group() + assert len(plugin_type.group()) + assert plugin_type.name() + assert len(plugin_type.name()) + + +class _DataPluginValidation(_PluginValidation): + """Validation tests specific to data plugins. + + These tests validate that data plugins can handle various configuration + scenarios properly. + """ + + @staticmethod + def test_empty_data_construction( + plugin_type: type[Any], + plugin_group_data: DataPluginGroupData, + core_plugin_data: CorePluginData, + ) -> None: + """All data plugins should be able to be constructed with empty data + + Args: + plugin_type: The plugin type to test + plugin_group_data: Plugin group configuration + core_plugin_data: Core plugin data + """ + plugin = plugin_type(plugin_group_data, core_plugin_data, {}) + assert plugin, 'The plugin should be able to be constructed with empty data' + + +class ProviderUnitTestContract[T: Provider](ProviderPluginTestMixin[T], _DataPluginValidation, ABC): + """Test contract for Provider plugins. + + Each Provider plugin should have exactly one test class that inherits from this + to ensure it fulfills all Provider testing requirements. + """ + + +class ProviderIntegrationTestContract[T: Provider](ProviderPluginTestMixin[T], ABC): + """Integration test contract for Provider plugins. + + Providers that need integration testing should inherit from this contract. + This includes tests that require actual tool installation and execution. + """ + + @staticmethod + @pytest.fixture(autouse=True, scope='session') + def _fixture_install_dependency(plugin_type: type[T], install_path: Path) -> None: + """Forces the provider tool download to only happen once per test session""" + path = install_path / canonicalize_type(plugin_type).name + path.mkdir(parents=True, exist_ok=True) + asyncio.run(plugin_type.download_tooling(path)) + + @staticmethod + def test_entry_point_registration(plugin_type: type[T], plugin_group_name: LiteralString) -> None: + """Verify that the provider plugin was registered with entry points""" + if plugin_type.name() == 'mock': + pytest.skip('Mocked plugin type') + + registered_types = [] + for entry in list(entry_points(group=f'{plugin_group_name}.{plugin_type.group()}')): + registered_types.append(entry.load()) + + assert plugin_type in registered_types + + @staticmethod + def test_install(plugin: T) -> None: + """Ensure that the provider install command functions""" + plugin.install() + + @staticmethod + def test_verify_installed_after_install(plugin: T) -> None: + """Ensure that verify_installed passes after a successful install""" + plugin.install() + # Should not raise + plugin.verify_installed() + + @staticmethod + def test_update(plugin: T) -> None: + """Ensure that the provider update command functions""" + plugin.update() + + @staticmethod + def test_group_name(plugin_type: type[T]) -> None: + """Verify that the provider group name is correct""" + assert canonicalize_type(plugin_type).group == 'provider' + + +class GeneratorUnitTestContract[T: Generator](GeneratorPluginTestMixin[T], _DataPluginValidation, ABC): + """Test contract for Generator plugins. + + Each Generator plugin should have exactly one test class that inherits from this + to ensure it fulfills all Generator testing requirements. + """ + + +class GeneratorIntegrationTestContract[T: Generator](GeneratorPluginTestMixin[T], ABC): + """Integration test contract for Generator plugins. + + Generators that need integration testing should inherit from this contract. + """ + + @staticmethod + def test_entry_point_registration(plugin_type: type[T], plugin_group_name: LiteralString) -> None: + """Verify that the generator plugin was registered with entry points""" + if plugin_type.name() == 'mock': + pytest.skip('Mocked plugin type') + + registered_types = [] + for entry in list(entry_points(group=f'{plugin_group_name}.{plugin_type.group()}')): + registered_types.append(entry.load()) + + assert plugin_type in registered_types + + @staticmethod + def test_group_name(plugin_type: type[T]) -> None: + """Verify that the generator group name is correct""" + assert canonicalize_type(plugin_type).group == 'generator' + + +class SCMUnitTestContract[T: SCM](SCMPluginTestMixin[T], _PluginValidation, ABC): + """Test contract for SCM plugins. + + Each SCM plugin should have exactly one test class that inherits from this + to ensure it fulfills all SCM testing requirements. + """ + + +class SCMIntegrationTestContract[T: SCM](SCMPluginTestMixin[T], ABC): + """Integration test contract for SCM plugins. + + SCM plugins that need integration testing should inherit from this contract. + """ + + @staticmethod + def test_entry_point_registration(plugin_type: type[T], plugin_group_name: LiteralString) -> None: + """Verify that the SCM plugin was registered with entry points""" + if plugin_type.name() == 'mock': + pytest.skip('Mocked plugin type') + + registered_types = [] + for entry in list(entry_points(group=f'{plugin_group_name}.{plugin_type.group()}')): + registered_types.append(entry.load()) + + assert plugin_type in registered_types + + @staticmethod + def test_group_name(plugin_type: type[T]) -> None: + """Verify that the SCM group name is correct""" + assert canonicalize_type(plugin_type).group == 'scm' diff --git a/cppython/test/pytest/fixtures.py b/cppython/test/pytest/fixtures.py new file mode 100644 index 00000000..23fb3ca0 --- /dev/null +++ b/cppython/test/pytest/fixtures.py @@ -0,0 +1,252 @@ +"""Global fixtures for the test suite""" + +# from pathlib import Path +from pathlib import Path + +import pytest + +from cppython.core.plugin_schema.generator import Generator +from cppython.core.plugin_schema.provider import Provider +from cppython.core.plugin_schema.scm import SCM +from cppython.core.resolution import ( + PluginBuildData, + PluginCPPythonData, + resolve_cppython, + resolve_pep621, + resolve_project_configuration, +) +from cppython.core.schema import ( + CoreData, + CPPythonData, + CPPythonGlobalConfiguration, + CPPythonLocalConfiguration, + GeneratorData, + PEP621Configuration, + PEP621Data, + ProjectConfiguration, + ProjectData, + ProviderData, + PyProject, + ToolData, +) +from cppython.utility.utility import TypeName + + +@pytest.fixture( + name='install_path', + scope='session', +) +def fixture_install_path(tmp_path_factory: pytest.TempPathFactory) -> Path: + """Creates temporary install location + + Args: + tmp_path_factory: Factory for centralized temporary directories + + Returns: + A temporary directory + """ + path = tmp_path_factory.getbasetemp() + path.mkdir(parents=True, exist_ok=True) + return path + + +@pytest.fixture( + name='pep621_configuration', + scope='session', +) +def fixture_pep621_configuration() -> PEP621Configuration: + """Fixture defining all testable variations of PEP621 + + Returns: + PEP621 variant + """ + return PEP621Configuration(name='unnamed', version='1.0.0') + + +@pytest.fixture( + name='pep621_data', +) +def fixture_pep621_data( + pep621_configuration: PEP621Configuration, project_configuration: ProjectConfiguration +) -> PEP621Data: + """Resolved project table fixture + + Args: + pep621_configuration: The input configuration to resolve + project_configuration: The project configuration to help with the resolve + + Returns: + The resolved project table + """ + return resolve_pep621(pep621_configuration, project_configuration, None) + + +@pytest.fixture( + name='cppython_local_configuration', +) +def fixture_cppython_local_configuration(install_path: Path) -> CPPythonLocalConfiguration: + """Fixture defining all testable variations of CPPythonData + + Args: + install_path: The temporary install directory + + Returns: + Variation of CPPython data + """ + cppython_local_configuration = CPPythonLocalConfiguration( + install_path=install_path, + providers={TypeName('mock'): ProviderData({})}, + generators={TypeName('mock'): GeneratorData({})}, + ) + + return cppython_local_configuration + + +@pytest.fixture( + name='cppython_global_configuration', +) +def fixture_cppython_global_configuration() -> CPPythonGlobalConfiguration: + """Fixture defining all testable variations of CPPythonData + + Returns: + Variation of CPPython data + """ + return CPPythonGlobalConfiguration() + + +@pytest.fixture( + name='plugin_build_data', + scope='session', +) +def fixture_plugin_build_data( + provider_type: type[Provider], + generator_type: type[Generator], + scm_type: type[SCM], +) -> PluginBuildData: + """Fixture for constructing resolved CPPython table data + + Args: + provider_type: The provider type + generator_type: The generator type + scm_type: The scm type + + Returns: + The plugin build data + """ + return PluginBuildData(generator_type=generator_type, provider_type=provider_type, scm_type=scm_type) + + +@pytest.fixture( + name='plugin_cppython_data', + scope='session', +) +def fixture_plugin_cppython_data( + provider_type: type[Provider], + generator_type: type[Generator], + scm_type: type[SCM], +) -> PluginCPPythonData: + """Fixture for constructing resolved CPPython table data + + Args: + provider_type: The provider type + generator_type: The generator type + scm_type: The scm type + + Returns: + The plugin data for CPPython resolution + """ + return PluginCPPythonData( + generator_name=generator_type.name(), provider_name=provider_type.name(), scm_name=scm_type.name() + ) + + +@pytest.fixture( + name='cppython_data', +) +def fixture_cppython_data( + cppython_local_configuration: CPPythonLocalConfiguration, + cppython_global_configuration: CPPythonGlobalConfiguration, + project_data: ProjectData, + plugin_cppython_data: PluginCPPythonData, +) -> CPPythonData: + """Fixture for constructing resolved CPPython table data + + Args: + cppython_local_configuration: The local configuration to resolve + cppython_global_configuration: The global configuration to resolve + project_data: The project data to help with the resolve + plugin_cppython_data: Plugin data for CPPython resolution + + Returns: + The resolved CPPython table + """ + return resolve_cppython( + cppython_local_configuration, + cppython_global_configuration, + project_data, + plugin_cppython_data, + ) + + +@pytest.fixture( + name='core_data', +) +def fixture_core_data(cppython_data: CPPythonData, project_data: ProjectData) -> CoreData: + """Fixture for creating the wrapper CoreData type + + Args: + cppython_data: CPPython data + project_data: The project data + + Returns: + Wrapper Core Type + """ + return CoreData(cppython_data=cppython_data, project_data=project_data) + + +@pytest.fixture( + name='project_configuration', +) +def fixture_project_configuration(tmp_path_factory: pytest.TempPathFactory) -> ProjectConfiguration: + """Project configuration fixture. + + Here we provide overrides on the input variants so that we can use a temporary directory for testing purposes. + + Returns: + Configuration with temporary directory capabilities + """ + workspace_path = tmp_path_factory.mktemp('workspace-') + return ProjectConfiguration(project_root=workspace_path, version='0.1.0') + + +@pytest.fixture( + name='project_data', +) +def fixture_project_data(project_configuration: ProjectConfiguration) -> ProjectData: + """Fixture that creates a project space at 'workspace/test_project/pyproject.toml' + + Args: + project_configuration: Project data + + Returns: + A project data object that has populated a function level temporary directory + """ + return resolve_project_configuration(project_configuration) + + +@pytest.fixture(name='project') +def fixture_project( + cppython_local_configuration: CPPythonLocalConfiguration, + pep621_configuration: PEP621Configuration, +) -> PyProject: + """Parameterized construction of PyProject data + + Args: + cppython_local_configuration: The parameterized cppython table + pep621_configuration: The project table + + Returns: + All the data as one object + """ + tool = ToolData(cppython=cppython_local_configuration) + return PyProject(project=pep621_configuration, tool=tool) diff --git a/cppython/test/pytest/mixins.py b/cppython/test/pytest/mixins.py new file mode 100644 index 00000000..848ddf0a --- /dev/null +++ b/cppython/test/pytest/mixins.py @@ -0,0 +1,370 @@ +"""Core test mixins and utilities that can be used by any test class. + +This module provides the foundational testing infrastructure that all test classes +can inherit from or use directly. These are meant to be mixed into test classes +as needed, not inherited in a strict hierarchy. +""" + +from abc import ABC, abstractmethod +from typing import Any, LiteralString + +import pytest + +from cppython.core.plugin_schema.generator import Generator, GeneratorPluginGroupData +from cppython.core.plugin_schema.provider import Provider, ProviderPluginGroupData +from cppython.core.plugin_schema.scm import SCM, SCMPluginGroupData +from cppython.core.resolution import ( + resolve_cppython_plugin, + resolve_generator, + resolve_provider, + resolve_scm, +) +from cppython.core.schema import ( + CorePluginData, + CPPythonData, + CPPythonPluginData, + DataPlugin, + DataPluginGroupData, + PEP621Data, + Plugin, + PluginGroupData, + ProjectData, +) +from cppython.test.data.mocks import generator_variants, provider_variants, scm_variants + + +class TestMixin[T: Plugin](ABC): + """Core mixin that provides basic plugin construction capabilities. + + Any test class can inherit from this to get access to standard plugin + construction fixtures. This is the base layer that provides the minimal + infrastructure needed for plugin testing. + """ + + @abstractmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type(self) -> type[T]: + """A required testing hook that allows type generation + + This must be implemented by any concrete test class to specify + which plugin type is being tested. + """ + raise NotImplementedError('Override this fixture') + + @abstractmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data(self) -> dict[str, Any]: + """A required testing hook that allows plugin configuration data generation + + This must be implemented by any concrete test class to provide + the configuration data for the plugin being tested. + """ + raise NotImplementedError('Override this fixture') + + @staticmethod + @pytest.fixture(name='plugin_group_name', scope='session') + def fixture_plugin_group_name() -> LiteralString: + """A required testing hook that allows plugin group name generation + + Returns: + The plugin group name + """ + return 'cppython' + + @staticmethod + @pytest.fixture(name='cppython_plugin_data') + def fixture_cppython_plugin_data(cppython_data: CPPythonData, plugin_type: type[T]) -> CPPythonPluginData: + """Fixture for created the plugin CPPython table + + Args: + cppython_data: The CPPython table to help the resolve + plugin_type: The data plugin type + + Returns: + The plugin specific CPPython table information + """ + return resolve_cppython_plugin(cppython_data, plugin_type) + + @staticmethod + @pytest.fixture(name='core_plugin_data') + def fixture_core_plugin_data( + cppython_plugin_data: CPPythonPluginData, project_data: ProjectData, pep621_data: PEP621Data + ) -> CorePluginData: + """Fixture for creating the wrapper CoreData type + + Args: + cppython_plugin_data: CPPython data + project_data: The project data + pep621_data: Project table data + + Returns: + Wrapper Core Type + """ + return CorePluginData(cppython_data=cppython_plugin_data, project_data=project_data, pep621_data=pep621_data) + + +class PluginTestMixin[T: Plugin](TestMixin[T], ABC): + """Plugin construction mixin for simple plugins. + + Provides plugin instance creation for plugins that don't need complex + configuration data (like SCM plugins). + """ + + @staticmethod + @pytest.fixture(name='plugin') + def fixture_plugin(plugin_type: type[T], plugin_group_data: PluginGroupData) -> T: + """Create a basic plugin instance + + Args: + plugin_type: Plugin type + plugin_group_data: The data group configuration + + Returns: + A newly constructed plugin + """ + return plugin_type(plugin_group_data) + + +class DataPluginTestMixin[T: DataPlugin](TestMixin[T], ABC): + """Data plugin construction mixin for complex plugins. + + Provides plugin instance creation for plugins that need rich configuration + data (like Provider and Generator plugins). + """ + + @staticmethod + @pytest.fixture(name='plugin') + def fixture_plugin( + plugin_type: type[T], + plugin_group_data: DataPluginGroupData, + core_plugin_data: CorePluginData, + plugin_data: dict[str, Any], + ) -> T: + """Create a data plugin instance + + Args: + plugin_type: Plugin type + plugin_group_data: The data group configuration + core_plugin_data: The core metadata + plugin_data: The data table + + Returns: + A newly constructed provider + """ + return plugin_type(plugin_group_data, core_plugin_data, plugin_data) + + +class ProviderPluginTestMixin[T: Provider](DataPluginTestMixin[T], ABC): + """Data plugin construction mixin specifically for Provider plugins. + + Provides all necessary fixtures for Provider plugin testing, including + the plugin_group_data fixture that creates ProviderPluginGroupData. + """ + + @staticmethod + @pytest.fixture(name='plugin_group_data') + def fixture_plugin_group_data( + project_data: ProjectData, cppython_plugin_data: CPPythonPluginData + ) -> ProviderPluginGroupData: + """Generate Provider plugin configuration data + + Args: + project_data: The project data + cppython_plugin_data: CPPython plugin data + + Returns: + Provider plugin group data + """ + return resolve_provider(project_data=project_data, cppython_data=cppython_plugin_data) + + # Cross-plugin testing fixtures for ensuring compatibility + @staticmethod + @pytest.fixture(name='provider_type', scope='session') + def fixture_provider_type(plugin_type: type[T]) -> type[T]: + """Return this provider type for cross-plugin testing + + Args: + plugin_type: The provider plugin type being tested + + Returns: + The same provider type + """ + return plugin_type + + @staticmethod + @pytest.fixture(name='generator_type', scope='session') + def fixture_generator_type(request: pytest.FixtureRequest) -> type[Generator]: + """Provide generator variants for cross-plugin testing + + Args: + request: Pytest fixture request + + Returns: + Generator type for testing + """ + # Use the first generator variant for testing + return generator_variants[0] + + @staticmethod + @pytest.fixture(name='scm_type', scope='session') + def fixture_scm_type(request: pytest.FixtureRequest) -> type[SCM]: + """Provide SCM variants for cross-plugin testing + + Args: + request: Pytest fixture request + + Returns: + SCM type for testing + """ + # Use the first SCM variant for testing + return scm_variants[0] + + @staticmethod + @pytest.fixture(name='plugin_configuration_type', scope='session') + def fixture_plugin_configuration_type() -> type[ProviderPluginGroupData]: + """Required hook for Provider plugin configuration data generation""" + return ProviderPluginGroupData + + +class GeneratorPluginTestMixin[T: Generator](DataPluginTestMixin[T], ABC): + """Data plugin construction mixin specifically for Generator plugins. + + Provides all necessary fixtures for Generator plugin testing, including + the plugin_group_data fixture that creates GeneratorPluginGroupData. + """ + + @staticmethod + @pytest.fixture(name='plugin_group_data') + def fixture_plugin_group_data( + project_data: ProjectData, cppython_plugin_data: CPPythonPluginData + ) -> GeneratorPluginGroupData: + """Generate Generator plugin configuration data + + Args: + project_data: The project data + cppython_plugin_data: CPPython plugin data + + Returns: + Generator plugin group data + """ + return resolve_generator(project_data=project_data, cppython_data=cppython_plugin_data) + + # Cross-plugin testing fixtures for ensuring compatibility + @staticmethod + @pytest.fixture(name='provider_type', scope='session') + def fixture_provider_type(request: pytest.FixtureRequest) -> type[Provider]: + """Provide provider variants for cross-plugin testing + + Args: + request: Pytest fixture request + + Returns: + Provider type for testing + """ + # Use the first provider variant for testing + return provider_variants[0] + + @staticmethod + @pytest.fixture(name='generator_type', scope='session') + def fixture_generator_type(plugin_type: type[T]) -> type[T]: + """Return this generator type for cross-plugin testing + + Args: + plugin_type: The generator plugin type being tested + + Returns: + The same generator type + """ + return plugin_type + + @staticmethod + @pytest.fixture(name='scm_type', scope='session') + def fixture_scm_type(request: pytest.FixtureRequest) -> type[SCM]: + """Provide SCM variants for cross-plugin testing + + Args: + request: Pytest fixture request + + Returns: + SCM type for testing + """ + # Use the first SCM variant for testing + return scm_variants[0] + + @staticmethod + @pytest.fixture(name='plugin_configuration_type', scope='session') + def fixture_plugin_configuration_type() -> type[GeneratorPluginGroupData]: + """Required hook for Generator plugin configuration data generation""" + return GeneratorPluginGroupData + + +class SCMPluginTestMixin[T: SCM](PluginTestMixin[T], ABC): + """Plugin construction mixin specifically for SCM plugins. + + Provides all necessary fixtures for SCM plugin testing, including + the plugin_group_data fixture that creates SCMPluginGroupData. + """ + + @staticmethod + @pytest.fixture(name='plugin_group_data') + def fixture_plugin_group_data( + project_data: ProjectData, cppython_plugin_data: CPPythonPluginData + ) -> SCMPluginGroupData: + """Generate SCM plugin configuration data + + Args: + project_data: The project data + cppython_plugin_data: CPPython plugin data + + Returns: + SCM plugin group data + """ + return resolve_scm(project_data=project_data, cppython_data=cppython_plugin_data) + + # Cross-plugin testing fixtures for ensuring compatibility + @staticmethod + @pytest.fixture(name='provider_type', scope='session') + def fixture_provider_type(request: pytest.FixtureRequest) -> type[Provider]: + """Provide provider variants for cross-plugin testing + + Args: + request: Pytest fixture request + + Returns: + Provider type for testing + """ + # Use the first provider variant for testing + return provider_variants[0] + + @staticmethod + @pytest.fixture(name='generator_type', scope='session') + def fixture_generator_type(request: pytest.FixtureRequest) -> type[Generator]: + """Provide generator variants for cross-plugin testing + + Args: + request: Pytest fixture request + + Returns: + Generator type for testing + """ + # Use the first generator variant for testing + return generator_variants[0] + + @staticmethod + @pytest.fixture(name='scm_type', scope='session') + def fixture_scm_type(plugin_type: type[T]) -> type[T]: + """Return this SCM type for cross-plugin testing + + Args: + plugin_type: The SCM plugin type being tested + + Returns: + The same SCM type + """ + return plugin_type + + @staticmethod + @pytest.fixture(name='plugin_configuration_type', scope='session') + def fixture_plugin_configuration_type() -> type[SCMPluginGroupData]: + """Required hook for SCM plugin configuration data generation""" + return SCMPluginGroupData diff --git a/cppython/utility/__init__.py b/cppython/utility/__init__.py new file mode 100644 index 00000000..9aead6ad --- /dev/null +++ b/cppython/utility/__init__.py @@ -0,0 +1,6 @@ +"""Utility functions for the CPPython project. + +This module contains various utility functions that assist with different +aspects of the CPPython project. The utilities include subprocess management, +exception handling, and type canonicalization. +""" diff --git a/cppython/utility/exception.py b/cppython/utility/exception.py new file mode 100644 index 00000000..89744cc6 --- /dev/null +++ b/cppython/utility/exception.py @@ -0,0 +1,105 @@ +"""Exception definitions""" + + +class PluginError(Exception): + """Raised when there is a plugin error.""" + + def __init__(self, error: str) -> None: + """Initializes the error. + + Args: + error: The error message + """ + self.error = error + super().__init__(error) + + +class NotSupportedError(Exception): + """Raised when something is not supported.""" + + def __init__(self, error: str) -> None: + """Initializes the error. + + Args: + error: The error message + """ + self.error = error + super().__init__(error) + + +class ProviderInstallationError(Exception): + """Raised when provider installation fails.""" + + def __init__(self, provider_name: str, error: str, original_error: Exception | None = None) -> None: + """Initializes the error. + + Args: + provider_name: The name of the provider that failed + error: The error message + original_error: The original exception that caused this error + """ + self.provider_name = provider_name + self.error = error + self.original_error = original_error + super().__init__(f"Provider '{provider_name}' installation failed: {error}") + + +class ProviderConfigurationError(Exception): + """Raised when provider configuration is invalid.""" + + def __init__(self, provider_name: str, error: str, configuration_key: str | None = None) -> None: + """Initializes the error. + + Args: + provider_name: The name of the provider with invalid configuration + error: The error message + configuration_key: The specific configuration key that caused the error + """ + self.provider_name = provider_name + self.error = error + self.configuration_key = configuration_key + + message = f"Provider '{provider_name}' configuration error" + if configuration_key: + message += f" in '{configuration_key}'" + message += f': {error}' + super().__init__(message) + + +class InstallationVerificationError(Exception): + """Raised when provider artifacts are missing and the user needs to run install first.""" + + def __init__(self, provider_name: str, missing_artifacts: list[str]) -> None: + """Initializes the error. + + Args: + provider_name: The name of the provider whose artifacts are missing + missing_artifacts: List of descriptions of what is missing + """ + self.provider_name = provider_name + self.missing_artifacts = missing_artifacts + + artifact_list = ', '.join(missing_artifacts) + super().__init__( + f"Provider '{provider_name}' artifacts not found: {artifact_list}. " + f"Run 'cppython install' or 'pdm install' before building." + ) + + +class ProviderToolingError(Exception): + """Raised when provider tooling operations fail.""" + + def __init__(self, provider_name: str, operation: str, error: str, original_error: Exception | None = None) -> None: + """Initializes the error. + + Args: + provider_name: The name of the provider that failed + operation: The operation that failed (e.g., 'download', 'bootstrap', 'install') + error: The error message + original_error: The original exception that caused this error + """ + self.provider_name = provider_name + self.operation = operation + self.error = error + self.original_error = original_error + super().__init__(f"Provider '{provider_name}' {operation} failed: {error}") diff --git a/cppython/utility/filesystem.py b/cppython/utility/filesystem.py new file mode 100644 index 00000000..36aa17f4 --- /dev/null +++ b/cppython/utility/filesystem.py @@ -0,0 +1,20 @@ +"""Helpers for working with the filesystem.""" + +import os +import tempfile +from collections.abc import Generator +from contextlib import contextmanager +from pathlib import Path + + +@contextmanager +def isolated_filesystem() -> Generator[Path]: + """Change the current working directory to the given path for the duration of the test.""" + old_cwd = os.getcwd() + + try: + with tempfile.TemporaryDirectory() as temp_directory: + os.chdir(temp_directory) + yield Path(temp_directory) + finally: + os.chdir(old_cwd) diff --git a/cppython/utility/output.py b/cppython/utility/output.py new file mode 100644 index 00000000..44a6b3ac --- /dev/null +++ b/cppython/utility/output.py @@ -0,0 +1,235 @@ +"""Session-scoped output management for CPPython. + +Provides :class:`OutputSession` (one per CLI invocation / build backend call) +which owns a single temporary log file, and :class:`SpinnerContext` (one per +logical operation) which drives a Rich spinner. + +Usage:: + + with OutputSession(console, verbose=False) as session: + with session.spinner('Installing dependencies...'): + provider.install() + with session.spinner('Building project...'): + generator.build() + # session.__exit__ prints the log file path + +When no session is needed (e.g. tests, library usage), use +:data:`NULL_SESSION` which is always available, requires no ``with`` block, +and whose :meth:`spinner` returns a silent no-op context manager. +""" + +import contextlib +import logging +import tempfile +from pathlib import Path +from types import TracebackType +from typing import Protocol + +from rich.console import Console +from rich.status import Status + +# --------------------------------------------------------------------------- +# Spinner +# --------------------------------------------------------------------------- + + +class SpinnerContext: + """A context manager that shows a Rich spinner for a single operation. + + When *verbose* is ``True`` the spinner is not shown — phase transitions + are printed as plain text lines instead. + """ + + def __init__(self, description: str, console: Console, verbose: bool) -> None: + """Initialize the spinner context. + + Args: + description: Text shown next to the spinner. + console: The Rich console used for output. + verbose: When ``True``, print plain text instead of a spinner. + """ + self._description = description + self._console = console + self._verbose = verbose + self._status: Status | None = None + + def __enter__(self) -> SpinnerContext: + """Start the spinner or print the phase header in verbose mode.""" + if self._verbose: + self._console.print(f'[bold]> {self._description}[/bold]') + else: + self._status = self._console.status(self._description, spinner='dots') + self._status.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Stop the spinner if it is running.""" + if self._status is not None: + self._status.stop() + self._status = None + + def update(self, message: str) -> None: + """Change the spinner / status text.""" + if self._verbose: + self._console.print(f' {message}') + elif self._status is not None: + self._status.update(message) + + +# --------------------------------------------------------------------------- +# Session protocol — allows Project to depend on an interface, not a class +# --------------------------------------------------------------------------- + + +class SessionProtocol(Protocol): + """Minimal interface that :class:`Project` depends on.""" + + def spinner(self, description: str) -> contextlib.AbstractContextManager[SpinnerContext | None]: + """Return a context manager that optionally shows a spinner.""" + ... + + +# --------------------------------------------------------------------------- +# Null (no-op) session — used when no output management is desired +# --------------------------------------------------------------------------- + + +class _NullSession: + """A session that does nothing. Always safe to call without a ``with`` block.""" + + @contextlib.contextmanager + def spinner(self, description: str): + """Yield immediately — no spinner, no output.""" + yield None + + +NULL_SESSION: SessionProtocol = _NullSession() +"""Singleton no-op session. Assign to ``Project.session`` when you don't +want spinners or a log file (tests, library usage, etc.).""" + + +# --------------------------------------------------------------------------- +# Real output session +# --------------------------------------------------------------------------- + + +class OutputSession: + """Session-scoped output controller. + + Owns a single temporary log file for the entire invocation. All + ``cppython.*`` logger output is routed to this file via a + :class:`logging.FileHandler`. In quiet mode (default) the + ``RichHandler`` on the logger is temporarily removed so nothing is + printed to the terminal — only the spinner is visible. + + On exit the path to the log file is printed. If an exception is + propagating a red error banner is shown as well. + """ + + def __init__(self, console: Console, *, verbose: bool) -> None: + """Initialize the output session. + + Args: + console: The Rich console used for spinner and summary output. + verbose: When ``True``, bypass spinner and print phase headers. + """ + self._console = console + self._verbose = verbose + self._file_handler: logging.FileHandler | None = None + self._removed_handlers: list[logging.Handler] = [] + self._log_path: Path | None = None + self._original_level: int | None = None + + # -- context manager ---------------------------------------------------- + + def __enter__(self) -> OutputSession: + """Enter the session: create the log file and configure logging.""" + # Create the session log file + fd = tempfile.NamedTemporaryFile( # noqa: SIM115 + mode='w', + suffix='.log', + prefix='cppython-', + delete=False, + ) + self._log_path = Path(fd.name) + fd.close() # We'll let the FileHandler manage the FD + + # Attach a file handler to the root cppython logger + root_logger = logging.getLogger('cppython') + self._file_handler = logging.FileHandler(str(self._log_path), mode='w', encoding='utf-8') + self._file_handler.setLevel(logging.DEBUG) + self._file_handler.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s %(message)s')) + root_logger.addHandler(self._file_handler) + + # Ensure the root logger level is low enough to let DEBUG through to the file + if root_logger.level > logging.DEBUG: + self._original_level = root_logger.level + root_logger.setLevel(logging.DEBUG) + else: + self._original_level = None + + # In quiet mode suppress all console handlers so only the spinner shows + if not self._verbose: + for handler in list(root_logger.handlers): + if handler is not self._file_handler: + root_logger.removeHandler(handler) + self._removed_handlers.append(handler) + + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Tear down logging, restore handlers, and print the session summary.""" + root_logger = logging.getLogger('cppython') + + # Remove and close the file handler + if self._file_handler is not None: + root_logger.removeHandler(self._file_handler) + self._file_handler.close() + self._file_handler = None + + # Restore previously removed console handlers + for handler in self._removed_handlers: + root_logger.addHandler(handler) + self._removed_handlers.clear() + + # Restore original log level + if self._original_level is not None: + root_logger.setLevel(self._original_level) + self._original_level = None + + # Print the result + if exc_type is not None: + self._console.print(f'[bold red]Error:[/bold red] {exc_val}') + else: + self._console.print('[bold green]Done[/bold green]') + + if self._log_path is not None: + self._console.print(f'Full log: {self._log_path}') + + # -- public API --------------------------------------------------------- + + @property + def log_path(self) -> Path | None: + """Path to the session log file, or ``None`` before ``__enter__``.""" + return self._log_path + + def spinner(self, description: str) -> SpinnerContext: + """Create a spinner context for a logical operation. + + Args: + description: Text shown next to the spinner. + + Returns: + A :class:`SpinnerContext` context manager. + """ + return SpinnerContext(description, self._console, self._verbose) diff --git a/cppython/utility/plugin.py b/cppython/utility/plugin.py new file mode 100644 index 00000000..1c404c6a --- /dev/null +++ b/cppython/utility/plugin.py @@ -0,0 +1,36 @@ +"""Defines the base plugin type and related types.""" + +from typing import Protocol + +from cppython.utility.utility import TypeGroup, TypeID, TypeName, canonicalize_name + + +class Plugin(Protocol): + """A protocol for defining a plugin type""" + + @classmethod + def id(cls) -> TypeID: + """The type identifier for the plugin + + Returns: + The type identifier + """ + return canonicalize_name(cls.__name__) + + @classmethod + def name(cls) -> TypeName: + """The name of the plugin + + Returns: + The name + """ + return cls.id().name + + @classmethod + def group(cls) -> TypeGroup: + """The group of the plugin + + Returns: + The group + """ + return cls.id().group diff --git a/cppython/utility/py.typed b/cppython/utility/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/cppython/utility/subprocess.py b/cppython/utility/subprocess.py new file mode 100644 index 00000000..0dace02f --- /dev/null +++ b/cppython/utility/subprocess.py @@ -0,0 +1,77 @@ +"""Unified subprocess execution for CPPython plugins. + +All plugin subprocess calls should go through :func:`run_subprocess` so that +stdout/stderr is captured, logged to the session log file, and hidden from +the terminal unless verbose mode is enabled. +""" + +import subprocess +from logging import Logger +from pathlib import Path + + +def run_subprocess( + cmd: list[str], + *, + cwd: Path | str | None = None, + logger: Logger, + **kwargs, +) -> subprocess.CompletedProcess[str]: + """Run a subprocess with captured output that is routed to the logger. + + Stdout and stderr are always captured (never printed to the terminal + directly). Each non-empty line is logged at ``DEBUG`` level so it + appears in the session log file. On failure the full output is logged + at ``ERROR`` level and the :class:`subprocess.CalledProcessError` is + re-raised. + + Args: + cmd: The command and arguments to execute. + cwd: Working directory for the subprocess. + logger: Logger instance used to record output. + **kwargs: Additional keyword arguments forwarded to + :func:`subprocess.run`. ``capture_output``, ``text``, and + ``check`` are always overridden. + + Returns: + The completed process result. + + Raises: + subprocess.CalledProcessError: If the process exits with a non-zero + return code. + """ + # Force capture so output never leaks to the terminal + kwargs.pop('capture_output', None) + kwargs.pop('text', None) + kwargs.pop('check', None) + + logger.debug('Running: %s', ' '.join(cmd)) + + try: + result = subprocess.run( + cmd, + cwd=cwd, + capture_output=True, + text=True, + check=True, + **kwargs, + ) + except subprocess.CalledProcessError as exc: + # Log everything we have so the session log file is useful + if exc.stdout: + for line in exc.stdout.splitlines(): + logger.error('%s', line) + if exc.stderr: + for line in exc.stderr.splitlines(): + logger.error('%s', line) + raise + + # Log successful output at debug level + if result.stdout: + for line in result.stdout.splitlines(): + logger.debug('%s', line) + if result.stderr: + for line in result.stderr.splitlines(): + logger.debug('%s', line) + + return result diff --git a/cppython/utility/utility.py b/cppython/utility/utility.py new file mode 100644 index 00000000..dd76f574 --- /dev/null +++ b/cppython/utility/utility.py @@ -0,0 +1,44 @@ +"""Utility definitions""" + +import re +from typing import Any, NamedTuple, NewType + +TypeName = NewType('TypeName', str) +TypeGroup = NewType('TypeGroup', str) + + +class TypeID(NamedTuple): + """Represents a type ID with a name and group.""" + + name: TypeName + group: TypeGroup + + +_canonicalize_regex = re.compile(r'((?<=[a-z])[A-Z]|(? TypeID: + """Extracts the type identifier from an input string + + Args: + name: The string to parse + + Returns: + The type identifier + """ + sub = re.sub(_canonicalize_regex, r' \1', name) + values = sub.split(' ') + result = ''.join(values[:-1]) + return TypeID(TypeName(result.lower()), TypeGroup(values[-1].lower())) + + +def canonicalize_type(input_type: type[Any]) -> TypeID: + """Extracts the plugin identifier from a type + + Args: + input_type: The input type to resolve + + Returns: + The type identifier + """ + return canonicalize_name(input_type.__name__) diff --git a/docs/build-backend/configuration.md b/docs/build-backend/configuration.md new file mode 100644 index 00000000..d6a567db --- /dev/null +++ b/docs/build-backend/configuration.md @@ -0,0 +1,172 @@ +# Configuration Reference + +Complete configuration options for the `cppython.build` backend. + +## Build System + +```toml +[build-system] +requires = ["cppython[conan, cmake]"] +build-backend = "cppython.build" +``` + +### Extras + +| Extra | Description | +|-------|-------------| +| `conan` | Conan package manager support | +| `cmake` | CMake generator support | +| `git` | Git SCM for dynamic versioning | + +## CPPython Options + +These options are configured under `[tool.cppython]`. + +### install-path + +Path where provider tools and cached dependencies are stored. + +```toml +[tool.cppython] +install-path = "~/.cppython" # Default +``` + +- **Type**: Path +- **Default**: `~/.cppython` +- Relative paths are resolved from the project root +- Use absolute path for build isolation compatibility + +### tool-path + +Local directory for CPPython-generated files (presets, etc.). + +```toml +[tool.cppython] +tool-path = "tool" # Default +``` + +- **Type**: Path +- **Default**: `tool` + +### build-path + +Directory for build artifacts. + +```toml +[tool.cppython] +build-path = "build" # Default +``` + +- **Type**: Path +- **Default**: `build` + +### dependencies + +List of C++ dependencies in PEP 508-style format. + +```toml +[tool.cppython] +dependencies = [ + "fmt>=11.0.0", + "boost>=1.84.0", +] +``` + +- **Type**: List of strings +- Syntax follows provider conventions (Conan uses `name>=version`) + +### dependency-groups + +Named groups of dependencies for optional features. + +```toml +[tool.cppython.dependency-groups] +test = ["gtest>=1.14.0"] +dev = ["benchmark>=1.8.0"] +``` + +- **Type**: Dictionary of string lists + +## Provider Configuration + +### Conan + +```toml +[tool.cppython.providers.conan] +# Conan-specific options +``` + +See [Conan Plugin Configuration](../plugins/conan/configuration.md) for details. + +## Generator Configuration + +### CMake + +```toml +[tool.cppython.generators.cmake] +preset_file = "CMakePresets.json" # Default +configuration_name = "default" # Default +``` + +## scikit-build-core Passthrough + +All `[tool.scikit-build]` options are passed directly to scikit-build-core: + +```toml +[tool.scikit-build] +cmake.build-type = "Release" +cmake.args = ["-DSOME_OPTION=ON"] +wheel.packages = ["src/my_package"] +logging.level = "WARNING" +``` + +CPPython only adds: + +- `cmake.define.CMAKE_TOOLCHAIN_FILE` (from provider) + +All other settings are untouched. + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `CMAKE_TOOLCHAIN_FILE` | Overrides CPPython's injected toolchain (user takes precedence) | +| `CONAN_HOME` | Conan cache location | + +## Example: Full Configuration + +```toml +[build-system] +requires = ["cppython[conan, cmake, git]"] +build-backend = "cppython.build" + +[project] +name = "my_extension" +version = "1.0.0" +requires-python = ">=3.10" + +[tool.scikit-build] +cmake.build-type = "Release" +cmake.args = ["-DBUILD_TESTING=OFF"] +wheel.packages = ["src/my_extension"] +wheel.install-dir = "my_extension" + +[tool.cppython] +install-path = "~/.cppython" +tool-path = "tool" +build-path = "build" + +dependencies = [ + "fmt>=11.0.0", + "spdlog>=1.14.0", +] + +[tool.cppython.dependency-groups] +test = ["gtest>=1.14.0", "benchmark>=1.8.0"] + +[tool.cppython.generators.cmake] +preset_file = "CMakePresets.json" +configuration_name = "default" + +[tool.cppython.providers.conan] +``` diff --git a/docs/build-backend/index.md b/docs/build-backend/index.md new file mode 100644 index 00000000..c66433b5 --- /dev/null +++ b/docs/build-backend/index.md @@ -0,0 +1,244 @@ +# Build Backend + +CPPython provides a PEP 517 build backend that wraps [scikit-build-core](https://scikit-build-core.readthedocs.io/), enabling seamless building of Python extension modules with C++ dependencies managed by CPPython. + +## Overview + +The `cppython.build` backend automatically: + +1. Runs the CPPython provider workflow (Conan/vcpkg) to install C++ dependencies +2. Extracts the generated toolchain file +3. Injects `CMAKE_TOOLCHAIN_FILE` into scikit-build-core +4. Delegates the actual wheel building to scikit-build-core + +This allows you to define C++ dependencies in `[tool.cppython]` and have them automatically available when building Python extensions. + +## Quick Start + +Set `cppython.build` as your build backend in `pyproject.toml`: + +```toml +[build-system] +requires = ["cppython[conan, cmake]"] +build-backend = "cppython.build" + +[project] +name = "my_extension" +version = "1.0.0" + +[tool.scikit-build] +cmake.build-type = "Release" + +[tool.cppython] +dependencies = ["fmt>=11.0.0", "nanobind>=2.4.0"] + +[tool.cppython.generators.cmake] + +[tool.cppython.providers.conan] +``` + +Then build with: + +```bash +pip wheel . +``` + +## Configuration + +### Build System Requirements + +The `build-system.requires` should include CPPython with the appropriate extras: + +```toml +[build-system] +requires = ["cppython[conan, cmake]"] +build-backend = "cppython.build" +``` + +Available extras: + +- `conan` - Conan package manager support +- `cmake` - CMake build system +- `git` - Git SCM support for version detection + +### CPPython Configuration + +Configure C++ dependencies under `[tool.cppython]`: + +```toml +[tool.cppython] +install-path = "install" # Where provider tools are cached + +dependencies = [ + "fmt>=11.0.0", +] + +[tool.cppython.generators.cmake] +# CMake generator options + +[tool.cppython.providers.conan] +# Conan provider options +``` + +### scikit-build-core Configuration + +All standard `[tool.scikit-build]` options are supported and passed through: + +```toml +[tool.scikit-build] +cmake.build-type = "Release" +wheel.packages = ["src/my_package"] +``` + +CPPython only injects `CMAKE_TOOLCHAIN_FILE` - all other scikit-build-core settings remain under your control. + +## How It Works + +### Build Workflow + +``` +pip wheel . / pdm build + │ + ▼ +┌─────────────────────────────────────┐ +│ cppython.build │ +├─────────────────────────────────────┤ +│ 1. Load pyproject.toml │ +│ 2. Initialize CPPython Project │ +│ 3. Run provider.install() │ +│ └─► Conan/vcpkg installs deps │ +│ 4. Extract toolchain file path │ +│ 5. Inject CMAKE_TOOLCHAIN_FILE │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ scikit_build_core.build │ +├─────────────────────────────────────┤ +│ 1. Configure CMake with toolchain │ +│ 2. Build extension module │ +│ 3. Package into wheel │ +└─────────────────────────────────────┘ +``` + +### Toolchain Injection + +The provider (e.g., Conan) generates a toolchain file containing paths to all installed dependencies. CPPython extracts this path and passes it to scikit-build-core via: + +``` +cmake.define.CMAKE_TOOLCHAIN_FILE=/path/to/conan_toolchain.cmake +``` + +This allows CMake's `find_package()` to locate all CPPython-managed dependencies. + +## Example Project + +A complete example is available at `examples/conan_cmake/extension/`. + +### Project Structure + +``` +my_extension/ +├── CMakeLists.txt +├── pyproject.toml +└── src/ + └── my_extension/ + ├── __init__.py + └── _core.cpp +``` + +### CMakeLists.txt + +```cmake +cmake_minimum_required(VERSION 3.15...3.30) +project(my_extension LANGUAGES CXX) + +find_package(Python REQUIRED COMPONENTS Interpreter Development.Module) +find_package(nanobind REQUIRED) +find_package(fmt REQUIRED) + +nanobind_add_module(_core src/my_extension/_core.cpp) +target_link_libraries(_core PRIVATE fmt::fmt) +target_compile_features(_core PRIVATE cxx_std_17) + +install(TARGETS _core DESTINATION my_extension) +``` + +### pyproject.toml + +```toml +[build-system] +requires = ["cppython[conan, cmake]"] +build-backend = "cppython.build" + +[project] +name = "my_extension" +version = "1.0.0" + +[tool.scikit-build] +cmake.build-type = "Release" +wheel.packages = ["src/my_extension"] + +[tool.cppython] +dependencies = ["fmt>=11.0.0", "nanobind>=2.4.0"] + +[tool.cppython.generators.cmake] + +[tool.cppython.providers.conan] +``` + +### C++ Source + +```cpp +#include +#include + +namespace nb = nanobind; + +std::string greet(const std::string& name) { + return fmt::format("Hello, {}!", name); +} + +NB_MODULE(_core, m) { + m.def("greet", &greet, nb::arg("name")); +} +``` + +## Comparison with Alternatives + +| Approach | C++ Deps | Python Bindings | Build Backend | +|----------|----------|-----------------|---------------| +| **CPPython + scikit-build-core** | Conan/vcpkg | Any (nanobind, pybind11) | `cppython.build` | +| scikit-build-core alone | Manual/system | Any | `scikit_build_core.build` | +| meson-python | Manual/system | Any | `mesonpy` | + +CPPython's advantage is automated C++ dependency management - you declare dependencies in `pyproject.toml` and they're installed automatically during the build. + +## Troubleshooting + +### Dependencies not found by CMake + +Ensure your `CMakeLists.txt` uses `find_package()` for each dependency: + +```cmake +find_package(fmt REQUIRED) +target_link_libraries(my_target PRIVATE fmt::fmt) +``` + +### Build isolation issues + +If dependencies aren't being found in isolated builds, ensure `install-path` in `[tool.cppython]` uses an absolute path for caching: + +```toml +[tool.cppython] +install-path = "~/.cppython" # Persists across builds +``` + +### Viewing build logs + +Set scikit-build-core to verbose mode: + +```toml +[tool.scikit-build] +logging.level = "DEBUG" +``` diff --git a/docs/build-backend/integration.md b/docs/build-backend/integration.md new file mode 100644 index 00000000..a97b185b --- /dev/null +++ b/docs/build-backend/integration.md @@ -0,0 +1,220 @@ +# Integration Guide + +How to integrate the `cppython.build` backend into your Python extension project. + +## Migrating from scikit-build-core + +If you have an existing scikit-build-core project, migration is straightforward. + +### Before (scikit-build-core only) + +```toml +[build-system] +requires = ["scikit-build-core"] +build-backend = "scikit_build_core.build" + +[tool.scikit-build] +cmake.build-type = "Release" +``` + +With manual C++ dependency management (system packages, git submodules, etc.). + +### After (CPPython + scikit-build-core) + +```toml +[build-system] +requires = ["cppython[conan, cmake]"] +build-backend = "cppython.build" + +[tool.scikit-build] +cmake.build-type = "Release" + +[tool.cppython] +dependencies = ["fmt>=11.0.0", "nanobind>=2.4.0"] + +[tool.cppython.generators.cmake] + +[tool.cppython.providers.conan] +``` + +### CMakeLists.txt Changes + +Remove manual dependency fetching: + +```cmake +# Before: FetchContent, git submodules, find_package with hints +FetchContent_Declare(fmt GIT_REPOSITORY ...) +FetchContent_MakeAvailable(fmt) + +# After: Just find_package (Conan toolchain provides paths) +find_package(fmt REQUIRED) +``` + +## Using with PDM + +CPPython integrates with PDM for development workflow. + +### Development Setup + +```toml +[tool.pdm] +distribution = true + +[build-system] +requires = ["cppython[conan, cmake]"] +build-backend = "cppython.build" +``` + +### Commands + +```bash +# Install Python dependencies + build extension +pdm install + +# Build wheel +pdm build + +# Development with editable install +pdm install --dev +``` + +## Build Isolation + +### Default Behavior + +`pip wheel .` and `pdm build` use isolated build environments. CPPython handles this by: + +1. Installing C++ dependencies to `install-path` (outside isolation) +2. Generating toolchain in the build directory +3. Passing absolute paths to scikit-build-core + +### Caching Dependencies + +For faster builds, use a persistent `install-path`: + +```toml +[tool.cppython] +install-path = "~/.cppython" # Shared across projects +``` + +### Disabling Isolation (Development) + +For faster iteration during development: + +```bash +pip wheel . --no-build-isolation +``` + +This uses your current environment's CPPython installation. + +## CI/CD Integration + +### GitHub Actions + +```yaml +name: Build + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install build dependencies + run: pip install build + + - name: Build wheel + run: python -m build + + - name: Upload wheel + uses: actions/upload-artifact@v4 + with: + name: wheel + path: dist/*.whl +``` + +### Caching Conan Packages + +```yaml + - name: Cache Conan packages + uses: actions/cache@v4 + with: + path: ~/.conan2 + key: conan-${{ runner.os }}-${{ hashFiles('pyproject.toml') }} +``` + +## Multi-Platform Builds + +### cibuildwheel + +CPPython works with cibuildwheel for building wheels across platforms: + +```toml +# pyproject.toml +[tool.cibuildwheel] +build-verbosity = 1 + +[tool.cibuildwheel.linux] +before-all = "pip install conan && conan profile detect" + +[tool.cibuildwheel.macos] +before-all = "pip install conan && conan profile detect" + +[tool.cibuildwheel.windows] +before-all = "pip install conan && conan profile detect" +``` + +## Editable Installs + +scikit-build-core's editable mode works with CPPython: + +```bash +pip install -e . --no-build-isolation +``` + +For automatic rebuilds on import: + +```toml +[tool.scikit-build] +editable.rebuild = true +editable.verbose = true +``` + +## Combining with Non-Python Projects + +If your repository contains both a Python extension and a standalone C++ project: + +``` +my_project/ +├── CMakeLists.txt # Standalone C++ build +├── CMakePresets.json # CPPython manages presets +├── pyproject.toml # Python extension config +├── src/ +│ ├── lib/ # C++ library +│ └── python/ # Python bindings +└── tool/ # CPPython generated files +``` + +### Dual Workflow + +**Python extension** (uses `cppython.build`): + +```bash +pip wheel . +``` + +**Standalone C++ build** (uses CMakePresets): + +```bash +cmake --preset=default +cmake --build build +``` + +Both workflows share the same Conan-managed dependencies through CPPython's CMake preset integration. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..f142e1cb --- /dev/null +++ b/docs/index.md @@ -0,0 +1 @@ +# CPPython diff --git a/docs/plugins/conan/configuration.md b/docs/plugins/conan/configuration.md new file mode 100644 index 00000000..b11b656a --- /dev/null +++ b/docs/plugins/conan/configuration.md @@ -0,0 +1,7 @@ +# Configuration + +```toml +[tool.cppython.plugins.conan] +remotes = ["remote-name"] # Optional: remotes for publishing +skip_upload = false # Optional: skip upload during publish +``` diff --git a/docs/plugins/conan/index.md b/docs/plugins/conan/index.md new file mode 100644 index 00000000..b5a62dee --- /dev/null +++ b/docs/plugins/conan/index.md @@ -0,0 +1,3 @@ +# Conan Plugin + +The Conan plugin integrates with the Conan C++ package manager for dependency management and package publishing. diff --git a/docs/plugins/conan/integration.md b/docs/plugins/conan/integration.md new file mode 100644 index 00000000..988471b2 --- /dev/null +++ b/docs/plugins/conan/integration.md @@ -0,0 +1,50 @@ +# Conanfile Integration + +CPPython uses a dual-file approach. + +## `conanfile_base.py` - Auto-generated + +**Auto-generated** from your `pyproject.toml` dependencies. **Do not edit manually** - regenerated on every install/update/publish. + +Contains a `CPPythonBase` class with dependencies from `[tool.cppython.dependencies]` and test dependencies from `[tool.cppython.dependency-groups.test]`. + +## `conanfile.py` - User-customizable + +**Created once** if it doesn't exist, then **never modified** by CPPython. Inherits from `CPPythonBase`. + +You can customize this file for package metadata like name, version, and settings, additional dependencies beyond `pyproject.toml`, build configuration and CMake integration, and package and export logic. + +**Key requirement** - Always call `super().requirements()` and `super().build_requirements()` to inherit managed dependencies. + +## Workflow + +- First run - Both files are generated +- Subsequent runs - Only `conanfile_base.py` is regenerated; `conanfile.py` is preserved +- Adding dependencies - Update `pyproject.toml` and run `cppython install` + +## Migrating Existing Projects + +If you have an existing `conanfile.py`, back it up and run `cppython install` to generate both files. Then update your conanfile to inherit from `CPPythonBase`. + +```python +from conan.tools.cmake import CMake, CMakeConfigDeps, CMakeToolchain, cmake_layout +from conanfile_base import CPPythonBase # Import the base class + + +class YourPackage(CPPythonBase): # Inherit from CPPythonBase instead of ConanFile + name = "your-package" + version = "1.0.0" + settings = "os", "compiler", "build_type", "arch" + exports = "conanfile_base.py" # Export the base file + + def requirements(self): + super().requirements() # Get CPPython managed dependencies + # Add your custom requirements here + # self.requires("custom-lib/1.0.0") + + def build_requirements(self): + super().build_requirements() # Get CPPython managed test dependencies + # Add your custom build requirements here + + # ... rest of your configuration +``` diff --git a/examples/conan_cmake/extension/CMakeLists.txt b/examples/conan_cmake/extension/CMakeLists.txt new file mode 100644 index 00000000..2f8800e9 --- /dev/null +++ b/examples/conan_cmake/extension/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 4.0) +project(example_extension LANGUAGES CXX) + +find_package(Python REQUIRED COMPONENTS Interpreter Development.Module) +find_package(nanobind REQUIRED) + +find_package(fmt REQUIRED) + +# Create the Python extension module using nanobind +nanobind_add_module(_core src/example_extension/_core.cpp) + +# Link against fmt library +target_link_libraries(_core PRIVATE fmt::fmt) + +# Set C++ standard (nanobind requires C++17+) +target_compile_features(_core PRIVATE cxx_std_17) + +# Install the module +install(TARGETS _core DESTINATION example_extension) diff --git a/examples/conan_cmake/extension/CMakePresets.json b/examples/conan_cmake/extension/CMakePresets.json new file mode 100644 index 00000000..8cbd5d78 --- /dev/null +++ b/examples/conan_cmake/extension/CMakePresets.json @@ -0,0 +1,10 @@ +{ + "version": 9, + "configurePresets": [ + { + "name": "default", + "hidden": true, + "description": "Base preset for all configurations" + } + ] +} \ No newline at end of file diff --git a/examples/conan_cmake/extension/README.md b/examples/conan_cmake/extension/README.md new file mode 100644 index 00000000..6e6e5adc --- /dev/null +++ b/examples/conan_cmake/extension/README.md @@ -0,0 +1,18 @@ +# Example Python Extension with CPPython + +A Python extension module built with CPPython's `cppython.build` backend, using Conan-managed C++ dependencies (nanobind, fmt) and scikit-build-core. + +## Building + +```bash +pip wheel . +``` + +## Usage + +```python +import example_extension + +print(example_extension.format_greeting("World")) +# Output: Hello, World! This message was formatted by fmt. +``` diff --git a/examples/conan_cmake/extension/pyproject.toml b/examples/conan_cmake/extension/pyproject.toml new file mode 100644 index 00000000..7d41ebb3 --- /dev/null +++ b/examples/conan_cmake/extension/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "example_extension" +version = "1.0.0" +description = "A Python extension module using CPPython with Conan-managed dependencies" +license = { text = "MIT" } +authors = [{ name = "Synodic Software", email = "contact@synodic.software" }] +requires-python = ">=3.10" + +[build-system] +requires = ["cppython[conan, cmake, git]", "nanobind>=2.4.0"] +build-backend = "cppython.build" + +[tool.scikit-build] +# scikit-build-core configuration +cmake.build-type = "Release" +wheel.packages = ["src/example_extension"] + +[tool.cppython] +# CPPython will install these C++ dependencies via Conan +# and inject the toolchain file into scikit-build-core +install-path = "install" + +dependencies = ["fmt>=11.0.2"] + +[tool.cppython.generators.cmake] +# Use the CMakePresets.json in this directory + +[tool.cppython.providers.conan] +# Conan provider configuration +# Only install Release build type for scikit-build-core (single-config builds) +build-types = ["Release"] diff --git a/examples/conan_cmake/extension/src/example_extension/__init__.py b/examples/conan_cmake/extension/src/example_extension/__init__.py new file mode 100644 index 00000000..9c0834c5 --- /dev/null +++ b/examples/conan_cmake/extension/src/example_extension/__init__.py @@ -0,0 +1,5 @@ +"""Example Python extension module with C++ backend.""" + +from example_extension._core import add_numbers, format_greeting + +__all__ = ['format_greeting', 'add_numbers'] diff --git a/examples/conan_cmake/extension/src/example_extension/_core.cpp b/examples/conan_cmake/extension/src/example_extension/_core.cpp new file mode 100644 index 00000000..1a8b2116 --- /dev/null +++ b/examples/conan_cmake/extension/src/example_extension/_core.cpp @@ -0,0 +1,46 @@ +/** + * Example Python extension module using nanobind and fmt. + * + * This demonstrates how CPPython manages C++ dependencies (fmt, nanobind) + * via Conan, then scikit-build-core builds the Python extension. + */ + +#include +#include +#include + +namespace nb = nanobind; + +/** + * Format a greeting message using the fmt library. + * + * @param name The name to greet + * @return A formatted greeting string + */ +std::string format_greeting(const std::string &name) +{ + return fmt::format("Hello, {}! This message was formatted by fmt.", name); +} + +/** + * Add two numbers together. + * + * @param a First number + * @param b Second number + * @return Sum of a and b + */ +int add_numbers(int a, int b) +{ + return a + b; +} + +NB_MODULE(_core, m) +{ + m.doc() = "Example extension module built with CPPython + scikit-build-core"; + + m.def("format_greeting", &format_greeting, nb::arg("name"), + "Format a greeting message using the fmt library"); + + m.def("add_numbers", &add_numbers, nb::arg("a"), nb::arg("b"), + "Add two numbers together"); +} diff --git a/examples/conan_cmake/library/CMakeLists.txt b/examples/conan_cmake/library/CMakeLists.txt new file mode 100644 index 00000000..bcc1214f --- /dev/null +++ b/examples/conan_cmake/library/CMakeLists.txt @@ -0,0 +1,62 @@ +cmake_minimum_required(VERSION 4.0) + +project(mathutils + VERSION 1.0.0 + DESCRIPTION "A modern math utilities library" + LANGUAGES CXX +) + +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +# Dependencies +find_package(fmt REQUIRED) + +add_library(mathutils) +add_library(mathutils::mathutils ALIAS mathutils) + +target_sources(mathutils + PUBLIC + FILE_SET CXX_MODULES FILES + src/mathutils.ixx +) + +target_compile_features(mathutils PUBLIC cxx_std_23) +target_link_libraries(mathutils PUBLIC fmt::fmt) + +# Generate package config files +write_basic_package_version_file( + mathutilsConfigVersion.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion +) + +configure_package_config_file( + cmake/mathutilsConfig.cmake.in + mathutilsConfig.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mathutils +) + +install( + TARGETS mathutils + EXPORT mathutilsTargets + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + FILE_SET CXX_MODULES + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mathutils +) + +# Export and package configuration +install( + EXPORT mathutilsTargets + FILE mathutilsTargets.cmake + NAMESPACE mathutils:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mathutils +) + +install( + FILES + ${CMAKE_CURRENT_BINARY_DIR}/mathutilsConfig.cmake + ${CMAKE_CURRENT_BINARY_DIR}/mathutilsConfigVersion.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/mathutils +) \ No newline at end of file diff --git a/examples/conan_cmake/library/cmake/mathutilsConfig.cmake.in b/examples/conan_cmake/library/cmake/mathutilsConfig.cmake.in new file mode 100644 index 00000000..cfe24c19 --- /dev/null +++ b/examples/conan_cmake/library/cmake/mathutilsConfig.cmake.in @@ -0,0 +1,8 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +find_dependency(fmt REQUIRED) + +include("${CMAKE_CURRENT_LIST_DIR}/mathutilsTargets.cmake") + +check_required_components(mathutils) diff --git a/examples/conan_cmake/library/pdm.lock b/examples/conan_cmake/library/pdm.lock new file mode 100644 index 00000000..50fb9249 --- /dev/null +++ b/examples/conan_cmake/library/pdm.lock @@ -0,0 +1,9 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = ["inherit_metadata"] +targets = [] +lock_version = "4.5.0" +content_hash = "sha256:a654322e4e66da5a00a43084dcf782a21de755b09b24f0680bcb42a2c1beff8e" diff --git a/examples/conan_cmake/library/pyproject.toml b/examples/conan_cmake/library/pyproject.toml new file mode 100644 index 00000000..bf0edffe --- /dev/null +++ b/examples/conan_cmake/library/pyproject.toml @@ -0,0 +1,26 @@ +[project] +description = "A simple C++ library example using conan with CPPython" +name = "mathutils" +version = "1.0.0" + +license = { text = "MIT" } + +authors = [{ name = "Synodic Software", email = "contact@synodic.software" }] + +requires-python = ">=3.14" + +dependencies = ["cppython[conan, cmake, git]>=0.9.0"] + + +[tool.cppython] +install-path = "install" + +dependencies = ["fmt>=12.1.0"] + + +[tool.cppython.generators.cmake] + +[tool.cppython.providers.conan] + +[tool.pdm] +distribution = false diff --git a/examples/conan_cmake/library/src/mathutils.ixx b/examples/conan_cmake/library/src/mathutils.ixx new file mode 100644 index 00000000..30685354 --- /dev/null +++ b/examples/conan_cmake/library/src/mathutils.ixx @@ -0,0 +1,58 @@ +module; +#include +#include + +export module mathutils; + +export namespace mathutils +{ + /** + * Add two numbers and return the result with formatted output + * @param a First number + * @param b Second number + * @return Sum of a and b + */ + double add(double a, double b); + + /** + * Multiply two numbers and return the result with formatted output + * @param a First number + * @param b Second number + * @return Product of a and b + */ + double multiply(double a, double b); + + /** + * Print a formatted calculation result + * @param operation The operation performed + * @param a First operand + * @param b Second operand + * @param result The result of the operation + */ + void print_result(const char *operation, double a, double b, double result); +} + +// Implementation +namespace mathutils +{ + double add(double a, double b) + { + double result = a + b; + print_result("addition", a, b, result); + return result; + } + + double multiply(double a, double b) + { + double result = a * b; + print_result("multiplication", a, b, result); + return result; + } + + void print_result(const char *operation, double a, double b, double result) + { + fmt::print(fg(fmt::terminal_color::green), + "MathUtils {}: ({}, {}) = {}\n", + operation, a, b, result); + } +} diff --git a/examples/conan_cmake/library/test_package/CMakeLists.txt b/examples/conan_cmake/library/test_package/CMakeLists.txt new file mode 100644 index 00000000..b4d7f732 --- /dev/null +++ b/examples/conan_cmake/library/test_package/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 4.0) + +project(MathUtilsConsumer LANGUAGES CXX) + +# Enable 'import std;' support (requires __CMAKE::CXX23 from compiler detection) +set(CMAKE_CXX_MODULE_STD ON) + +find_package(mathutils REQUIRED) + +add_executable(consumer main.cpp) +target_link_libraries(consumer PRIVATE mathutils::mathutils) diff --git a/examples/conan_cmake/library/test_package/conanfile.py b/examples/conan_cmake/library/test_package/conanfile.py new file mode 100644 index 00000000..25d24a9b --- /dev/null +++ b/examples/conan_cmake/library/test_package/conanfile.py @@ -0,0 +1,40 @@ +"""Test package for mathutils library.""" + +import os + +from conan import ConanFile +from conan.tools.build import can_run +from conan.tools.cmake import CMake, CMakeConfigDeps, CMakeToolchain, cmake_layout + + +class MathUtilsTestConan(ConanFile): + """Test package for mathutils library.""" + + settings = 'os', 'compiler', 'build_type', 'arch' + + def requirements(self): + """Add the tested package as a requirement.""" + self.requires(self.tested_reference_str) + + def layout(self): + """Set the CMake layout.""" + cmake_layout(self) + + def generate(self): + """Generate CMake dependencies and toolchain.""" + deps = CMakeConfigDeps(self) + deps.generate() + tc = CMakeToolchain(self) + tc.generate() + + def build(self): + """Build the test package.""" + cmake = CMake(self) + cmake.configure() + cmake.build() + + def test(self): + """Run the test.""" + if can_run(self): + cmd = os.path.join(self.cpp.build.bindir, 'consumer') + self.run(cmd, env='conanrun') diff --git a/examples/conan_cmake/library/test_package/main.cpp b/examples/conan_cmake/library/test_package/main.cpp new file mode 100644 index 00000000..b19807ef --- /dev/null +++ b/examples/conan_cmake/library/test_package/main.cpp @@ -0,0 +1,13 @@ +import mathutils; +import std; + +int main() +{ + std::cout << "Testing MathUtils library..." << std::endl; + + std::cout << "add(5, 3) = " << mathutils::add(5.0, 3.0) << std::endl; + std::cout << "multiply(4, 2.5) = " << mathutils::multiply(4.0, 2.5) << std::endl; + + std::cout << "MathUtils tests completed successfully!" << std::endl; + return 0; +} diff --git a/examples/conan_cmake/simple/CMakeLists.txt b/examples/conan_cmake/simple/CMakeLists.txt new file mode 100644 index 00000000..e728aa4d --- /dev/null +++ b/examples/conan_cmake/simple/CMakeLists.txt @@ -0,0 +1,10 @@ +cmake_minimum_required(VERSION 3.24) + +project(FormatOutput LANGUAGES CXX C) + +set(CMAKE_CXX_STANDARD 14) + +find_package(fmt REQUIRED) + +add_executable(main src/main.cpp) +target_link_libraries(main PRIVATE fmt::fmt) \ No newline at end of file diff --git a/examples/conan_cmake/simple/README.md b/examples/conan_cmake/simple/README.md new file mode 100644 index 00000000..e69de29b diff --git a/examples/conan_cmake/simple/pdm.lock b/examples/conan_cmake/simple/pdm.lock new file mode 100644 index 00000000..6a5ae656 --- /dev/null +++ b/examples/conan_cmake/simple/pdm.lock @@ -0,0 +1,437 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = [] +lock_version = "4.5.0" +content_hash = "sha256:a654322e4e66da5a00a43084dcf782a21de755b09b24f0680bcb42a2c1beff8e" + +[[metadata.targets]] +requires_python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +summary = "" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "certifi" +version = "2025.7.9" +summary = "" +files = [ + {file = "certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39"}, + {file = "certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +summary = "" +files = [ + {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, + {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, +] + +[[package]] +name = "click" +version = "8.2.1" +summary = "" +dependencies = [ + "colorama; sys_platform == \"win32\"", +] +files = [ + {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, + {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, +] + +[[package]] +name = "cmake" +version = "4.0.3" +summary = "" +files = [ + {file = "cmake-4.0.3-py3-none-macosx_10_10_universal2.whl", hash = "sha256:f2adfb459747025f40f9d3bdd1f3a485d43e866c0c4eb66373d1fcd666b13e4a"}, + {file = "cmake-4.0.3-py3-none-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:04c40c92fdcaa96c66a5731b5b3fbbdf87da99cc68fdd30ff30b90c34d222986"}, + {file = "cmake-4.0.3-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d41b83d061bcc375a7a5f2942ba523a7563368d296d91260f9d8a53a10f5e5e5"}, + {file = "cmake-4.0.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:434f84fdf1e21578974876b8414dc47afeaea62027d9adc37a943a6bb08eb053"}, + {file = "cmake-4.0.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beec48371a4b906fe398758ded5df57fc16e9bb14fd34244d9d66ee35862fb9f"}, + {file = "cmake-4.0.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47dc28bee6cfb4de00c7cf7e87d565b5c86eb4088da81b60a49e214fcdd4ffda"}, + {file = "cmake-4.0.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e10fdc972b3211915b65cc89e8cd24e1a26c9bd684ee71c3f369fb488f2c4388"}, + {file = "cmake-4.0.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d840e780c48c5df1330879d50615176896e8e6eee554507d21ce8e2f1a5f0ff8"}, + {file = "cmake-4.0.3-py3-none-manylinux_2_31_armv7l.whl", hash = "sha256:6ef63bbabcbe3b89c1d80547913b6caceaad57987a27e7afc79ebc88ecd829e4"}, + {file = "cmake-4.0.3-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:67103f2bcce8f57b8705ba8e353f18fdc3684a346eee97dc5f94d11575a424c6"}, + {file = "cmake-4.0.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:880a1e1ae26d440d7e4f604fecbf839728ca7b096c870f2e7359855cc4828532"}, + {file = "cmake-4.0.3-py3-none-musllinux_1_1_ppc64le.whl", hash = "sha256:c403b660bbff1fd4d7f1c5d9e015ea27566e49ca9461e260c9758f2fd4e5e813"}, + {file = "cmake-4.0.3-py3-none-musllinux_1_1_s390x.whl", hash = "sha256:2a66ecdd4c3238484cb0c377d689c086a9b8b533e25329f73d21bd1c38f1ae86"}, + {file = "cmake-4.0.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:004e58b1a1a384c2ca799c9c41ac4ed86ac3b80129462992c43c1121f8729ffd"}, + {file = "cmake-4.0.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:133dbc33f995cb97a4456d83d67fa0a7a798f53f979454359140588baa928f43"}, + {file = "cmake-4.0.3-py3-none-win32.whl", hash = "sha256:3e07bdd14e69ea67d1e67a4f5225ac2fd91ee9e349c440143cdddd7368be1f46"}, + {file = "cmake-4.0.3-py3-none-win_amd64.whl", hash = "sha256:9a349ff2b4a7c63c896061676bc0f4e6994f373d54314d79ba3608ee7fa75442"}, + {file = "cmake-4.0.3-py3-none-win_arm64.whl", hash = "sha256:94a52e67b264a51089907c9e74ca5a9e2f3e65c57c457e0f40f02629a0de74d8"}, + {file = "cmake-4.0.3.tar.gz", hash = "sha256:215732f09ea8a7088fe1ab46bbd61669437217278d709fd3851bf8211e8c59e3"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +summary = "" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "conan" +version = "2.18.1" +summary = "" +dependencies = [ + "colorama", + "distro; platform_system == \"FreeBSD\" or sys_platform == \"linux\"", + "fasteners", + "jinja2", + "patch-ng", + "python-dateutil", + "pyyaml", + "requests", + "urllib3", +] +files = [ + {file = "conan-2.18.1.tar.gz", hash = "sha256:5d8e9fac7614de9297933f65de8f17db14851a871cebc962f4856b7c294f43c5"}, +] + +[[package]] +name = "cppython" +version = "0.9.2" +summary = "" +dependencies = [ + "packaging", + "pydantic", + "requests", + "typer", + "types-requests", +] +files = [ + {file = "cppython-0.9.2-py3-none-any.whl", hash = "sha256:b43997b0d7237e4098501bf73f5d25576f5ef08ec7ec9fe4a111be5654cf4dca"}, + {file = "cppython-0.9.2.tar.gz", hash = "sha256:d8468a612f663d47074059811a4a53c6489d4e7a2d20ad15d811b4133377f072"}, +] + +[[package]] +name = "cppython" +version = "0.9.2" +extras = ["cmake"] +summary = "" +dependencies = [ + "cmake", + "cppython==0.9.2", +] +files = [ + {file = "cppython-0.9.2-py3-none-any.whl", hash = "sha256:b43997b0d7237e4098501bf73f5d25576f5ef08ec7ec9fe4a111be5654cf4dca"}, + {file = "cppython-0.9.2.tar.gz", hash = "sha256:d8468a612f663d47074059811a4a53c6489d4e7a2d20ad15d811b4133377f072"}, +] + +[[package]] +name = "cppython" +version = "0.9.2" +extras = ["conan"] +summary = "" +dependencies = [ + "conan", + "cppython==0.9.2", + "libcst", +] +files = [ + {file = "cppython-0.9.2-py3-none-any.whl", hash = "sha256:b43997b0d7237e4098501bf73f5d25576f5ef08ec7ec9fe4a111be5654cf4dca"}, + {file = "cppython-0.9.2.tar.gz", hash = "sha256:d8468a612f663d47074059811a4a53c6489d4e7a2d20ad15d811b4133377f072"}, +] + +[[package]] +name = "cppython" +version = "0.9.2" +extras = ["git"] +summary = "" +dependencies = [ + "cppython==0.9.2", + "dulwich", +] +files = [ + {file = "cppython-0.9.2-py3-none-any.whl", hash = "sha256:b43997b0d7237e4098501bf73f5d25576f5ef08ec7ec9fe4a111be5654cf4dca"}, + {file = "cppython-0.9.2.tar.gz", hash = "sha256:d8468a612f663d47074059811a4a53c6489d4e7a2d20ad15d811b4133377f072"}, +] + +[[package]] +name = "distro" +version = "1.8.0" +summary = "" +files = [ + {file = "distro-1.8.0-py3-none-any.whl", hash = "sha256:99522ca3e365cac527b44bde033f64c6945d90eb9f769703caaec52b09bbd3ff"}, + {file = "distro-1.8.0.tar.gz", hash = "sha256:02e111d1dc6a50abb8eed6bf31c3e48ed8b0830d1ea2a1b78c61765c2513fdd8"}, +] + +[[package]] +name = "dulwich" +version = "0.23.2" +summary = "" +dependencies = [ + "urllib3", +] +files = [ + {file = "dulwich-0.23.2-py3-none-any.whl", hash = "sha256:0b0439d309cf808f7955f74776981d9ac9dc1ec715aa39798de9b22bb95ac163"}, + {file = "dulwich-0.23.2.tar.gz", hash = "sha256:a152ebb0e95bc0f23768be563f80ff1e719bf5c4f5c2696be4fa8ab625a39879"}, +] + +[[package]] +name = "fasteners" +version = "0.19" +summary = "" +files = [ + {file = "fasteners-0.19-py3-none-any.whl", hash = "sha256:758819cb5d94cdedf4e836988b74de396ceacb8e2794d21f82d131fd9ee77237"}, + {file = "fasteners-0.19.tar.gz", hash = "sha256:b4f37c3ac52d8a445af3a66bce57b33b5e90b97c696b7b984f530cf8f0ded09c"}, +] + +[[package]] +name = "idna" +version = "3.10" +summary = "" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +summary = "" +dependencies = [ + "markupsafe", +] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[[package]] +name = "libcst" +version = "1.8.2" +summary = "" +dependencies = [ + "pyyaml-ft", +] +files = [ + {file = "libcst-1.8.2.tar.gz", hash = "sha256:66e82cedba95a6176194a817be4232c720312f8be6d2c8f3847f3317d95a0c7f"}, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +summary = "" +dependencies = [ + "mdurl", +] +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +summary = "" +files = [ + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +summary = "" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "packaging" +version = "25.0" +summary = "" +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "patch-ng" +version = "1.18.1" +summary = "" +files = [ + {file = "patch-ng-1.18.1.tar.gz", hash = "sha256:52fd46ee46f6c8667692682c1fd7134edc65a2d2d084ebec1d295a6087fc0291"}, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +summary = "" +dependencies = [ + "annotated-types", + "pydantic-core", + "typing-extensions", + "typing-inspection", +] +files = [ + {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, + {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +summary = "" +dependencies = [ + "typing-extensions", +] +files = [ + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[[package]] +name = "pygments" +version = "2.19.2" +summary = "" +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +summary = "" +dependencies = [ + "six", +] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +summary = "" +files = [ + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "pyyaml-ft" +version = "8.0.0" +summary = "" +files = [ + {file = "pyyaml_ft-8.0.0.tar.gz", hash = "sha256:0c947dce03954c7b5d38869ed4878b2e6ff1d44b08a0d84dc83fdad205ae39ab"}, +] + +[[package]] +name = "requests" +version = "2.32.4" +summary = "" +dependencies = [ + "certifi", + "charset-normalizer", + "idna", + "urllib3", +] +files = [ + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, +] + +[[package]] +name = "rich" +version = "14.0.0" +summary = "" +dependencies = [ + "markdown-it-py", + "pygments", +] +files = [ + {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, + {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +summary = "" +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + +[[package]] +name = "six" +version = "1.17.0" +summary = "" +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "typer" +version = "0.16.0" +summary = "" +dependencies = [ + "click", + "rich", + "shellingham", + "typing-extensions", +] +files = [ + {file = "typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855"}, + {file = "typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b"}, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20250611" +summary = "" +dependencies = [ + "urllib3", +] +files = [ + {file = "types_requests-2.32.4.20250611-py3-none-any.whl", hash = "sha256:ad2fe5d3b0cb3c2c902c8815a70e7fb2302c4b8c1f77bdcd738192cdb3878072"}, + {file = "types_requests-2.32.4.20250611.tar.gz", hash = "sha256:741c8777ed6425830bf51e54d6abe245f79b4dcb9019f1622b773463946bf826"}, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +summary = "" +files = [ + {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, + {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +summary = "" +dependencies = [ + "typing-extensions", +] +files = [ + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, +] + +[[package]] +name = "urllib3" +version = "2.0.7" +summary = "" +files = [ + {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"}, + {file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"}, +] diff --git a/examples/conan_cmake/simple/pyproject.toml b/examples/conan_cmake/simple/pyproject.toml new file mode 100644 index 00000000..908aefd3 --- /dev/null +++ b/examples/conan_cmake/simple/pyproject.toml @@ -0,0 +1,23 @@ +[project] +description = "A simple project showing how to use conan with CPPython" +name = "cppython-conan-cmake-simple" +version = "1.0.0" + +license = { text = "MIT" } + +authors = [{ name = "Synodic Software", email = "contact@synodic.software" }] + +requires-python = ">=3.14" + +dependencies = ["cppython[conan, cmake, git]>=0.9.0"] + + +[tool.cppython] +install-path = "install" + +dependencies = ["fmt>=12.1.0"] + +[tool.cppython.providers.conan] + +[tool.pdm] +distribution = false diff --git a/examples/conan_cmake/simple/src/main.cpp b/examples/conan_cmake/simple/src/main.cpp new file mode 100644 index 00000000..4de35678 --- /dev/null +++ b/examples/conan_cmake/simple/src/main.cpp @@ -0,0 +1,7 @@ +#include "fmt/color.h" + +int main() +{ + fmt::print(fg(fmt::terminal_color::cyan), "Hello fmt {}!\n", FMT_VERSION); + return 0; +} \ No newline at end of file diff --git a/examples/vcpkg_cmake/simple/CMakeLists.txt b/examples/vcpkg_cmake/simple/CMakeLists.txt new file mode 100644 index 00000000..39fa2d17 --- /dev/null +++ b/examples/vcpkg_cmake/simple/CMakeLists.txt @@ -0,0 +1,10 @@ +cmake_minimum_required(VERSION 3.24) + +project(FormatOutput LANGUAGES CXX C) + +set(CMAKE_CXX_STANDARD 14) + +find_package(fmt REQUIRED) + +add_executable(main src/main.cpp) +target_link_libraries(main PRIVATE fmt::fmt) \ No newline at end of file diff --git a/examples/vcpkg_cmake/simple/pdm.lock b/examples/vcpkg_cmake/simple/pdm.lock new file mode 100644 index 00000000..81c25e96 --- /dev/null +++ b/examples/vcpkg_cmake/simple/pdm.lock @@ -0,0 +1,360 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default"] +strategy = ["inherit_metadata"] +lock_version = "4.5.0" +content_hash = "sha256:0008cf265767c5de8135ddaafc03fab234785fa2df8c953c047d779d6d85dedb" + +[[metadata.targets]] +requires_python = ">=3.14" + +[[package]] +name = "annotated-types" +version = "0.7.0" +requires_python = ">=3.8" +summary = "Reusable constraint types to use with typing.Annotated" +groups = ["default"] +dependencies = [ + "typing-extensions>=4.0.0; python_version < \"3.9\"", +] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +requires_python = ">=3.7" +summary = "Python package for providing Mozilla's CA Bundle." +groups = ["default"] +files = [ + {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, + {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +requires_python = ">=3.7" +summary = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +groups = ["default"] +files = [ + {file = "charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce"}, + {file = "charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c"}, + {file = "charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a"}, + {file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"}, +] + +[[package]] +name = "click" +version = "8.2.1" +requires_python = ">=3.10" +summary = "Composable command line interface toolkit" +groups = ["default"] +dependencies = [ + "colorama; platform_system == \"Windows\"", +] +files = [ + {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, + {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, +] + +[[package]] +name = "cmake" +version = "4.1.0" +requires_python = ">=3.8" +summary = "CMake is an open-source, cross-platform family of tools designed to build, test and package software" +groups = ["default"] +files = [ + {file = "cmake-4.1.0-py3-none-macosx_10_10_universal2.whl", hash = "sha256:69df62445b22d78c2002c22edeb0e85590ae788e477d222fb2ae82c871c33090"}, + {file = "cmake-4.1.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4e3a30a4f72a8a6d8d593dc289e791f1d84352c1f629543ac8e22c62dbadb20a"}, + {file = "cmake-4.1.0-py3-none-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0e2fea746d746f52aa52b8498777ff665a0627d9b136bec4ae0465c38b75e799"}, + {file = "cmake-4.1.0-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5a28a87601fa5e775017bf4f5836e8e75091d08f3e5aac411256754ba54fe5c4"}, + {file = "cmake-4.1.0-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2a8790473afbb895b8e684e479f26773e4fc5c86845e3438e8488d38de9db807"}, + {file = "cmake-4.1.0-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dab375932f5962e078da8cf76ca228c21bf4bea9ddeb1308e2b35797fa30f784"}, + {file = "cmake-4.1.0-py3-none-manylinux_2_31_armv7l.whl", hash = "sha256:f2eaa6f0a25e31fe09fb0b7f40fbf208eea5f1313093ff441ecfff7dc1b80adf"}, + {file = "cmake-4.1.0-py3-none-manylinux_2_35_riscv64.whl", hash = "sha256:3ee38de00cad0501c7dd2b94591522381e3ef9c8468094f037a17ed9e478ef13"}, + {file = "cmake-4.1.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2d9f14b7d58e447865c111b3b90945b150724876866f5801c80970151718f710"}, + {file = "cmake-4.1.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:574448a03acdf34c55a7c66485e7a8260709e8386e9145708e18e2abe5fc337b"}, + {file = "cmake-4.1.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8c2538fb557b9edd74d48c189fcde42a55ad7e2c39e04254f8c5d248ca1af4c"}, + {file = "cmake-4.1.0-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:7c7999c5a1d5a3a66adacc61056765557ed253dc7b8e9deab5cae546f4f9361c"}, + {file = "cmake-4.1.0-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:e77ac2554a7b8a94745add465413e3266b714766e9a5d22ac8e5b36a900a1136"}, + {file = "cmake-4.1.0-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:d54e68d5439193265fd7211671420601f6a672b8ca220f19e6c72238b41a84c2"}, + {file = "cmake-4.1.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c6bd346fe4d9c205310ef9a6e09ced7e610915fa982d7b649f9b12caa6fa0605"}, + {file = "cmake-4.1.0-py3-none-win32.whl", hash = "sha256:7219b7e85ed03a98af89371b9dee762e236ad94e8a09ce141070e6ac6415756f"}, + {file = "cmake-4.1.0-py3-none-win_amd64.whl", hash = "sha256:76e8e7d80a1a9bb5c7ec13ec8da961a8c5a997247f86a08b29f0c2946290c461"}, + {file = "cmake-4.1.0-py3-none-win_arm64.whl", hash = "sha256:8d39bbfee7c181e992875cd390fc6d51a317c9374656b332021a67bb40c0b07f"}, + {file = "cmake-4.1.0.tar.gz", hash = "sha256:bacdd21aebdf9a42e5631cfb365beb8221783fcd27c4e04f7db8b79c43fb12df"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +requires_python = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +summary = "Cross-platform colored terminal text." +groups = ["default"] +marker = "platform_system == \"Windows\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "cppython" +version = "0.9.7" +requires_python = ">=3.13" +summary = "A Python management solution for C++ dependencies" +groups = ["default"] +dependencies = [ + "packaging>=25.0", + "pydantic>=2.11.7", + "requests>=2.32.4", + "typer>=0.16.0", + "types-requests>=2.32.4.20250809", +] +files = [ + {file = "cppython-0.9.7-py3-none-any.whl", hash = "sha256:5efed0974ce39f8af7d9a15f71facdc59c5244d9ec7778a813accd48361cd016"}, + {file = "cppython-0.9.7.tar.gz", hash = "sha256:28f02a24793ad44888ef49fb9da6bd84d321592245afd90ba8c252509157488e"}, +] + +[[package]] +name = "cppython" +version = "0.9.7" +extras = ["cmake", "git", "vcpkg"] +requires_python = ">=3.13" +summary = "A Python management solution for C++ dependencies" +groups = ["default"] +dependencies = [ + "cmake>=4.1.0", + "cppython==0.9.7", + "dulwich>=0.24.1", +] +files = [ + {file = "cppython-0.9.7-py3-none-any.whl", hash = "sha256:5efed0974ce39f8af7d9a15f71facdc59c5244d9ec7778a813accd48361cd016"}, + {file = "cppython-0.9.7.tar.gz", hash = "sha256:28f02a24793ad44888ef49fb9da6bd84d321592245afd90ba8c252509157488e"}, +] + +[[package]] +name = "dulwich" +version = "0.24.1" +requires_python = ">=3.9" +summary = "Python Git Library" +groups = ["default"] +dependencies = [ + "typing-extensions>=4.0; python_version < \"3.11\"", + "urllib3>=1.25", +] +files = [ + {file = "dulwich-0.24.1-py3-none-any.whl", hash = "sha256:57cc0dc5a21059698ffa4ed9a7272f1040ec48535193df84b0ee6b16bf615676"}, + {file = "dulwich-0.24.1.tar.gz", hash = "sha256:e19fd864f10f02bb834bb86167d92dcca1c228451b04458761fc13dabd447758"}, +] + +[[package]] +name = "idna" +version = "3.10" +requires_python = ">=3.6" +summary = "Internationalized Domain Names in Applications (IDNA)" +groups = ["default"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +requires_python = ">=3.10" +summary = "Python port of markdown-it. Markdown parsing, done right!" +groups = ["default"] +dependencies = [ + "mdurl~=0.1", +] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +requires_python = ">=3.7" +summary = "Markdown URL utilities" +groups = ["default"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "packaging" +version = "25.0" +requires_python = ">=3.8" +summary = "Core utilities for Python packages" +groups = ["default"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +requires_python = ">=3.9" +summary = "Data validation using Python type hints" +groups = ["default"] +dependencies = [ + "annotated-types>=0.6.0", + "pydantic-core==2.33.2", + "typing-extensions>=4.12.2", + "typing-inspection>=0.4.0", +] +files = [ + {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, + {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +requires_python = ">=3.9" +summary = "Core functionality for Pydantic validation and serialization" +groups = ["default"] +dependencies = [ + "typing-extensions!=4.7.0,>=4.6.0", +] +files = [ + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, +] + +[[package]] +name = "pygments" +version = "2.19.2" +requires_python = ">=3.8" +summary = "Pygments is a syntax highlighting package written in Python." +groups = ["default"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[[package]] +name = "requests" +version = "2.32.4" +requires_python = ">=3.8" +summary = "Python HTTP for Humans." +groups = ["default"] +dependencies = [ + "certifi>=2017.4.17", + "charset-normalizer<4,>=2", + "idna<4,>=2.5", + "urllib3<3,>=1.21.1", +] +files = [ + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, +] + +[[package]] +name = "rich" +version = "14.1.0" +requires_python = ">=3.8.0" +summary = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +groups = ["default"] +dependencies = [ + "markdown-it-py>=2.2.0", + "pygments<3.0.0,>=2.13.0", +] +files = [ + {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, + {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +requires_python = ">=3.7" +summary = "Tool to Detect Surrounding Shell" +groups = ["default"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + +[[package]] +name = "typer" +version = "0.16.0" +requires_python = ">=3.7" +summary = "Typer, build great CLIs. Easy to code. Based on Python type hints." +groups = ["default"] +dependencies = [ + "click>=8.0.0", + "rich>=10.11.0", + "shellingham>=1.3.0", + "typing-extensions>=3.7.4.3", +] +files = [ + {file = "typer-0.16.0-py3-none-any.whl", hash = "sha256:1f79bed11d4d02d4310e3c1b7ba594183bcedb0ac73b27a9e5f28f6fb5b98855"}, + {file = "typer-0.16.0.tar.gz", hash = "sha256:af377ffaee1dbe37ae9440cb4e8f11686ea5ce4e9bae01b84ae7c63b87f1dd3b"}, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20250809" +requires_python = ">=3.9" +summary = "Typing stubs for requests" +groups = ["default"] +dependencies = [ + "urllib3>=2", +] +files = [ + {file = "types_requests-2.32.4.20250809-py3-none-any.whl", hash = "sha256:f73d1832fb519ece02c85b1f09d5f0dd3108938e7d47e7f94bbfa18a6782b163"}, + {file = "types_requests-2.32.4.20250809.tar.gz", hash = "sha256:d8060de1c8ee599311f56ff58010fb4902f462a1470802cf9f6ed27bc46c4df3"}, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +requires_python = ">=3.9" +summary = "Backported and Experimental Type Hints for Python 3.9+" +groups = ["default"] +files = [ + {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, + {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +requires_python = ">=3.9" +summary = "Runtime typing introspection tools" +groups = ["default"] +dependencies = [ + "typing-extensions>=4.12.0", +] +files = [ + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +requires_python = ">=3.9" +summary = "HTTP library with thread-safe connection pooling, file post, and more." +groups = ["default"] +files = [ + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, +] diff --git a/examples/vcpkg_cmake/simple/pyproject.toml b/examples/vcpkg_cmake/simple/pyproject.toml new file mode 100644 index 00000000..85f90668 --- /dev/null +++ b/examples/vcpkg_cmake/simple/pyproject.toml @@ -0,0 +1,26 @@ +[project] +description = "A simple project showing how to use vcpkg with CPPython" +name = "cppython-vcpkg-cmake-simple" +version = "1.0.0" + +license = { text = "MIT" } + +authors = [{ name = "Synodic Software", email = "contact@synodic.software" }] + +requires-python = ">=3.14" + +dependencies = ["cppython[vcpkg, cmake, git]>=0.9.0"] + + +[tool.cppython] +install-path = "install" + +dependencies = ["fmt>=12.1.0"] + +[tool.cppython.generators.cmake] +cmake_binary = "C:/Program Files/CMake/bin/cmake.exe" + +[tool.cppython.providers.vcpkg] + +[tool.pdm] +distribution = false diff --git a/examples/vcpkg_cmake/simple/src/main.cpp b/examples/vcpkg_cmake/simple/src/main.cpp new file mode 100644 index 00000000..4de35678 --- /dev/null +++ b/examples/vcpkg_cmake/simple/src/main.cpp @@ -0,0 +1,7 @@ +#include "fmt/color.h" + +int main() +{ + fmt::print(fg(fmt::terminal_color::cyan), "Hello fmt {}!\n", FMT_VERSION); + return 0; +} \ No newline at end of file diff --git a/pdm.lock b/pdm.lock new file mode 100644 index 00000000..c2e59cda --- /dev/null +++ b/pdm.lock @@ -0,0 +1,1210 @@ +# This file is @generated by PDM. +# It is not intended for manual editing. + +[metadata] +groups = ["default", "cmake", "conan", "docs", "git", "lint", "pdm", "pytest", "test"] +strategy = [] +lock_version = "4.5.0" +content_hash = "sha256:d7a6b39f750be5a4340b21439665064b066fd926c616853f914b3c0340ac3453" + +[[metadata.targets]] +requires_python = ">=3.14" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +requires_python = ">=3.8" +summary = "Document parameters, class attributes, return types, and variables inline, with Annotated." +files = [ + {file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"}, + {file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"}, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +summary = "" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.9.0" +summary = "" +dependencies = [ + "idna", + "sniffio", +] +files = [ + {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, + {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, +] + +[[package]] +name = "anysqlite" +version = "0.0.5" +requires_python = ">=3.8" +summary = "" +dependencies = [ + "anyio>3.4.0", +] +files = [ + {file = "anysqlite-0.0.5-py3-none-any.whl", hash = "sha256:cb345dc4f76f6b37f768d7a0b3e9cf5c700dfcb7a6356af8ab46a11f666edbe7"}, + {file = "anysqlite-0.0.5.tar.gz", hash = "sha256:9dfcf87baf6b93426ad1d9118088c41dbf24ef01b445eea4a5d486bac2755cce"}, +] + +[[package]] +name = "blinker" +version = "1.9.0" +summary = "" +files = [ + {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, + {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, +] + +[[package]] +name = "certifi" +version = "2025.7.9" +summary = "" +files = [ + {file = "certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39"}, + {file = "certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +summary = "" +files = [ + {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, + {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, + {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, +] + +[[package]] +name = "click" +version = "8.3.1" +requires_python = ">=3.10" +summary = "Composable command line interface toolkit" +dependencies = [ + "colorama; platform_system == \"Windows\"", +] +files = [ + {file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"}, + {file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"}, +] + +[[package]] +name = "cmake" +version = "4.2.1" +requires_python = ">=3.8" +summary = "CMake is an open-source, cross-platform family of tools designed to build, test and package software" +files = [ + {file = "cmake-4.2.1-py3-none-macosx_10_10_universal2.whl", hash = "sha256:ec44fa08b6ca25a63f7356a442469840841145d7b7b6f4d65318b6bd59a0f7f6"}, + {file = "cmake-4.2.1-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8bdf88f8d50b64c88ffc75fb671f3ab017d803f36589f21c3f1e9f3a1b236a7"}, + {file = "cmake-4.2.1-py3-none-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6ca394cdea61534f12e30f0188b19ace8ba844088105b77b9fd70e6df18ef241"}, + {file = "cmake-4.2.1-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c5742041f8e641d977928207e2697e9cc3242d0d01f7cb8671f63ad45dcc447b"}, + {file = "cmake-4.2.1-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ae0f51d2b8dd00a7ac1578c19364140358596e449d2ac1b978af3f0b35737d01"}, + {file = "cmake-4.2.1-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6333a2b16e1d55373419b9c1572a155b315bfb9d834fbdbba0f7d3428437c785"}, + {file = "cmake-4.2.1-py3-none-manylinux_2_31_armv7l.whl", hash = "sha256:4d7a62c462cc81a6f7a5e4db7b298b4e66d851010418c8cdc5a9de0a8701f60f"}, + {file = "cmake-4.2.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3455391ffce8a860bbbd22b83c2188f13806100a21f28b8ab2c6a785def25616"}, + {file = "cmake-4.2.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d0dfe33c993e3d58cfebe2ab1205668411aae1e6cb78430f3b9d070a97e1274"}, + {file = "cmake-4.2.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:52db8740e81d10c8d103899c87e0100e6aab969295ab99ce51eb11de4c36c9ce"}, + {file = "cmake-4.2.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:493abf42c003034c2bb1ad58a221542174a5c0fd2a76e9fdd91709ae6e53263c"}, + {file = "cmake-4.2.1-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:3d8d7632bb27cf1d0ac78098f2f7dfb7019927f35fb5a8c1508b17524af70000"}, + {file = "cmake-4.2.1-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:3e89d391096fdbdaab82e28b7e1fa964a873c0ba8d77c3542260c7d115aaac1f"}, + {file = "cmake-4.2.1-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:e758ae635c75aaf0258e2c46fe95a3821f01011d5dbe29b7f045976b88ce3ca8"}, + {file = "cmake-4.2.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fecc03edef6257b2bc8784f7880e84fe8a0b0fb54c952528c61ce14a4d693e16"}, + {file = "cmake-4.2.1-py3-none-win32.whl", hash = "sha256:72c860dae7c0315b05f59fd8e19253861c6e42f8d391a26aa6e2b4c9bd6014b8"}, + {file = "cmake-4.2.1-py3-none-win_amd64.whl", hash = "sha256:c186e7b826978f86bcbada91845e949e1f5ce5c670d6db49f7ecf5bac1b334e3"}, + {file = "cmake-4.2.1-py3-none-win_arm64.whl", hash = "sha256:82224245741cf389d7c9072002ae2a81b63accb42732803db9b449c9423d546d"}, + {file = "cmake-4.2.1.tar.gz", hash = "sha256:a07a790ca65946667c0fb286549e8e0b5a850e2f8170ae60d3418573011ca218"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +summary = "" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "conan" +version = "2.25.2" +requires_python = ">=3.7" +summary = "Conan C/C++ package manager" +dependencies = [ + "Jinja2<4.0.0,>=3.0", + "PyYAML<7.0,>=6.0", + "colorama<0.5.0,>=0.4.3", + "distro<2.0.0,>=1.4.0; platform_system == \"Linux\" or platform_system == \"FreeBSD\"", + "fasteners>=0.15", + "patch-ng<1.19,>=1.18.0", + "python-dateutil<3,>=2.8.0", + "requests<3.0.0,>=2.25", + "urllib3<3.0,>=1.26.6", +] +files = [ + {file = "conan-2.25.2.tar.gz", hash = "sha256:3a5214a095cee5c3d21ed45ea31139705703e49fa9c4bb45c4c73f5ee17a1031"}, +] + +[[package]] +name = "coverage" +version = "7.10.7" +requires_python = ">=3.9" +summary = "Code coverage measurement for Python" +files = [ + {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, + {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, + {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, + {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, + {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, + {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, + {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, + {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, + {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, + {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, + {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, + {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, + {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, + {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, + {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, +] + +[[package]] +name = "coverage" +version = "7.10.7" +extras = ["toml"] +requires_python = ">=3.9" +summary = "Code coverage measurement for Python" +dependencies = [ + "coverage==7.10.7", + "tomli; python_full_version <= \"3.11.0a6\"", +] +files = [ + {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, + {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, + {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, + {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, + {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, + {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, + {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, + {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, + {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, + {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, + {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, + {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, + {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, + {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, + {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, +] + +[[package]] +name = "deepmerge" +version = "2.0" +requires_python = ">=3.8" +summary = "A toolset for deeply merging Python dictionaries." +dependencies = [ + "typing-extensions; python_version <= \"3.9\"", +] +files = [ + {file = "deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00"}, + {file = "deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20"}, +] + +[[package]] +name = "dep-logic" +version = "0.5.1" +summary = "" +dependencies = [ + "packaging", +] +files = [ + {file = "dep_logic-0.5.1-py3-none-any.whl", hash = "sha256:6073e955945a0224440465f9382f4bf5b4be4c630c6f412bf9506639c13a3d22"}, + {file = "dep_logic-0.5.1.tar.gz", hash = "sha256:cfd10877277d3cbb6e66fd48f316ba6c284701af0e67d52eaaf10275753354a7"}, +] + +[[package]] +name = "distlib" +version = "0.3.9" +summary = "" +files = [ + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, +] + +[[package]] +name = "distro" +version = "1.8.0" +summary = "" +files = [ + {file = "distro-1.8.0-py3-none-any.whl", hash = "sha256:99522ca3e365cac527b44bde033f64c6945d90eb9f769703caaec52b09bbd3ff"}, + {file = "distro-1.8.0.tar.gz", hash = "sha256:02e111d1dc6a50abb8eed6bf31c3e48ed8b0830d1ea2a1b78c61765c2513fdd8"}, +] + +[[package]] +name = "dulwich" +version = "1.1.0" +requires_python = ">=3.10" +summary = "Python Git Library" +dependencies = [ + "typing-extensions>=4.6.0; python_version < \"3.12\"", + "urllib3>=2.2.2", +] +files = [ + {file = "dulwich-1.1.0-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:fc38cc6f60c5e475fa61dcd2b743113f35377602c1ba1c82264898d97a7d3c48"}, + {file = "dulwich-1.1.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:c9752d25f01e92587f8db52e50daf3e970deb49555340653ea44ba5e60f0f416"}, + {file = "dulwich-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:693c450a5d327a6a5276f5292d3dd0bc473066d2fd2a2d69a990d7738535deb6"}, + {file = "dulwich-1.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:dff1b67e0f76fcaae8f7345c05b1c4f00c11a6c42ace20864e80e7964af31827"}, + {file = "dulwich-1.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:1b1b9adaf82301fd7b360a5fa521cec1623cb9d77a0c5a09d04396637b39eb48"}, + {file = "dulwich-1.1.0-cp314-cp314-win32.whl", hash = "sha256:eb5440145bb2bbab71cdfa149fd297a8b7d4db889ab90c58d7a07009a73c1d28"}, + {file = "dulwich-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:333b0f93b289b14f98870317fb0583fdf73d5341f21fd09c694aa88bb06ad911"}, + {file = "dulwich-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a0f3421802225caedd11e95ce40f6a8d3c7a5df906489b6a5f49a20f88f62928"}, + {file = "dulwich-1.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:518307ab080746ee9c32fc13e76ad4f7df8f7665bb85922e974037dd9415541a"}, + {file = "dulwich-1.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0890fff677c617efbac0cd4584bec9753388e6cd6336e7131338ea034b47e899"}, + {file = "dulwich-1.1.0-cp314-cp314t-win32.whl", hash = "sha256:a05a1049b3928205672913f4c490cf7b08afaa3e7ee7e55e15476e696412672f"}, + {file = "dulwich-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ba6f3f0807868f788b7f1d53b9ac0be3e425136b16563994f5ef6ecf5b7c7863"}, + {file = "dulwich-1.1.0-py3-none-any.whl", hash = "sha256:bcd67e7f9bdffb4b660330c4597d251cd33e74f5df6898a2c1e6a1730a62af06"}, + {file = "dulwich-1.1.0.tar.gz", hash = "sha256:9aa855db9fee0a7065ae9ffb38e14e353876d82f17e33e1a1fb3830eb8d0cf43"}, +] + +[[package]] +name = "fasteners" +version = "0.19" +summary = "" +files = [ + {file = "fasteners-0.19-py3-none-any.whl", hash = "sha256:758819cb5d94cdedf4e836988b74de396ceacb8e2794d21f82d131fd9ee77237"}, + {file = "fasteners-0.19.tar.gz", hash = "sha256:b4f37c3ac52d8a445af3a66bce57b33b5e90b97c696b7b984f530cf8f0ded09c"}, +] + +[[package]] +name = "filelock" +version = "3.18.0" +summary = "" +files = [ + {file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"}, + {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"}, +] + +[[package]] +name = "findpython" +version = "0.7.0" +summary = "" +dependencies = [ + "packaging", + "platformdirs", +] +files = [ + {file = "findpython-0.7.0-py3-none-any.whl", hash = "sha256:f53cfcc29536f5b83c962cf922bba8ff6d6a3c2a05fda6a45aa58a47d005d8fc"}, + {file = "findpython-0.7.0.tar.gz", hash = "sha256:8b31647c76352779a3c1a0806699b68e6a7bdc0b5c2ddd9af2a07a0d40c673dc"}, +] + +[[package]] +name = "h11" +version = "0.16.0" +summary = "" +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "hishel" +version = "1.1.9" +requires_python = ">=3.10" +summary = "Elegant HTTP Caching for Python" +dependencies = [ + "msgpack>=1.1.2", + "typing-extensions>=4.14.1", +] +files = [ + {file = "hishel-1.1.9-py3-none-any.whl", hash = "sha256:6b6f294cb7593f170a9bf874849cc85330ff81f5e35d2ca189548498fed10806"}, + {file = "hishel-1.1.9.tar.gz", hash = "sha256:47248a50e4cff4fbaa141832782d8c07b2169914916f4bd792f37449176dfa23"}, +] + +[[package]] +name = "hishel" +version = "1.1.9" +extras = ["httpx"] +requires_python = ">=3.10" +summary = "Elegant HTTP Caching for Python" +dependencies = [ + "anyio>=4.9.0", + "anysqlite>=0.0.5", + "hishel==1.1.9", + "httpx>=0.28.1", +] +files = [ + {file = "hishel-1.1.9-py3-none-any.whl", hash = "sha256:6b6f294cb7593f170a9bf874849cc85330ff81f5e35d2ca189548498fed10806"}, + {file = "hishel-1.1.9.tar.gz", hash = "sha256:47248a50e4cff4fbaa141832782d8c07b2169914916f4bd792f37449176dfa23"}, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +summary = "" +dependencies = [ + "certifi", + "h11", +] +files = [ + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, +] + +[[package]] +name = "httpx" +version = "0.28.1" +summary = "" +dependencies = [ + "anyio", + "certifi", + "httpcore", + "idna", +] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[[package]] +name = "httpx" +version = "0.28.1" +extras = ["socks"] +summary = "" +dependencies = [ + "httpx==0.28.1", + "socksio", +] +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[[package]] +name = "id" +version = "1.5.0" +summary = "" +dependencies = [ + "requests", +] +files = [ + {file = "id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658"}, + {file = "id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d"}, +] + +[[package]] +name = "idna" +version = "3.10" +summary = "" +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +summary = "" +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "installer" +version = "0.7.0" +summary = "" +files = [ + {file = "installer-0.7.0-py3-none-any.whl", hash = "sha256:05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53"}, + {file = "installer-0.7.0.tar.gz", hash = "sha256:a26d3e3116289bb08216e0d0f7d925fcef0b0194eedfa0c944bcaaa106c4b631"}, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +summary = "" +dependencies = [ + "markupsafe", +] +files = [ + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, +] + +[[package]] +name = "markdown" +version = "3.10" +requires_python = ">=3.10" +summary = "Python implementation of John Gruber's Markdown." +files = [ + {file = "markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c"}, + {file = "markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e"}, +] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +summary = "" +dependencies = [ + "mdurl", +] +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +summary = "" +files = [ + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +summary = "" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "meson" +version = "1.10.1" +requires_python = ">=3.7" +summary = "A high performance build system" +files = [ + {file = "meson-1.10.1-py3-none-any.whl", hash = "sha256:fe43d1cc2e6de146fbea78f3a062194bcc0e779efc8a0f0d7c35544dfb86731f"}, + {file = "meson-1.10.1.tar.gz", hash = "sha256:c42296f12db316a4515b9375a5df330f2e751ccdd4f608430d41d7d6210e4317"}, +] + +[[package]] +name = "meson-python" +version = "0.19.0" +requires_python = ">=3.9" +summary = "Meson Python build backend (PEP 517)" +dependencies = [ + "meson>=0.64.0; python_version < \"3.12\"", + "meson>=1.2.3; python_version >= \"3.12\"", + "packaging>=23.2; sys_platform != \"ios\"", + "packaging>=24.2; sys_platform == \"ios\"", + "pyproject-metadata>=0.9.0", + "tomli>=1.0.0; python_version < \"3.11\"", +] +files = [ + {file = "meson_python-0.19.0-py3-none-any.whl", hash = "sha256:67b5906c37404396d23c195e12c8825506074460d4a2e7083266b845d14f0298"}, + {file = "meson_python-0.19.0.tar.gz", hash = "sha256:9959d198aa69b57fcfd354a34518c6f795b781a73ed0656f4d01660160cc2553"}, +] + +[[package]] +name = "msgpack" +version = "1.1.2" +requires_python = ">=3.9" +summary = "MessagePack serializer" +files = [ + {file = "msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00"}, + {file = "msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939"}, + {file = "msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e"}, + {file = "msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931"}, + {file = "msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014"}, + {file = "msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2"}, + {file = "msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717"}, + {file = "msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b"}, + {file = "msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af"}, + {file = "msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a"}, + {file = "msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b"}, + {file = "msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245"}, + {file = "msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90"}, + {file = "msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20"}, + {file = "msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27"}, + {file = "msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b"}, + {file = "msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff"}, + {file = "msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46"}, + {file = "msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e"}, +] + +[[package]] +name = "nanobind" +version = "2.11.0" +summary = "nanobind: tiny and efficient C++/Python bindings" +files = [ + {file = "nanobind-2.11.0-py3-none-any.whl", hash = "sha256:8097442c3e55d011a67f016ce1d9567ed9e3cdb3ad6749f13a76dbbc2721f0ee"}, + {file = "nanobind-2.11.0.tar.gz", hash = "sha256:6d98d063c61dbbd05a2d903e59be398bfcff9d59c54fbbc9d4488960485d40d0"}, +] + +[[package]] +name = "packaging" +version = "26.0" +requires_python = ">=3.8" +summary = "Core utilities for Python packages" +files = [ + {file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"}, + {file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"}, +] + +[[package]] +name = "patch-ng" +version = "1.18.1" +summary = "" +files = [ + {file = "patch-ng-1.18.1.tar.gz", hash = "sha256:52fd46ee46f6c8667692682c1fd7134edc65a2d2d084ebec1d295a6087fc0291"}, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +requires_python = ">=3.8" +summary = "Utility library for gitignore style pattern matching of file paths." +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + +[[package]] +name = "pbs-installer" +version = "2025.10.28" +requires_python = ">=3.8" +summary = "Installer for Python Build Standalone" +files = [ + {file = "pbs_installer-2025.10.28-py3-none-any.whl", hash = "sha256:329b0800df9ff8d50c79bfead69b0e05fa5c81d31e7e77377d1c422f94407eda"}, + {file = "pbs_installer-2025.10.28.tar.gz", hash = "sha256:399f1788b17c650e69c42729ba9e74d240909f36cfe187b5f9b60488314ba154"}, +] + +[[package]] +name = "pdm" +version = "2.26.6" +requires_python = ">=3.9" +summary = "A modern Python package and dependency manager supporting the latest PEP standards" +dependencies = [ + "blinker", + "certifi>=2024.8.30", + "dep-logic>=0.5", + "filelock>=3.13", + "findpython<1.0.0a0,>=0.7.0", + "hishel[httpx]>=1.0.0", + "httpcore>=1.0.6", + "httpx[socks]<1,>0.20", + "id>=1.5.0", + "importlib-metadata>=3.6; python_version < \"3.10\"", + "installer<0.8,>=0.7", + "packaging>22.0", + "pbs-installer>=2025.10.7", + "platformdirs", + "pyproject-hooks", + "python-dotenv>=0.15", + "resolvelib>=1.1", + "rich>=12.3.0", + "shellingham>=1.3.2", + "tomli>=1.1.0; python_version < \"3.11\"", + "tomlkit<1,>=0.11.1", + "truststore>=0.10.4; python_version >= \"3.10\"", + "unearth>=0.17.5", + "virtualenv>=20", +] +files = [ + {file = "pdm-2.26.6-py3-none-any.whl", hash = "sha256:39583edee738cc62b9ec2d5b4e434f44e501b55f609401a89732b5e8a451944f"}, + {file = "pdm-2.26.6.tar.gz", hash = "sha256:771f95b9a484f9eb34dcf8d851be6ff95333e4f3c46189f9004cfd5cc2e925f9"}, +] + +[[package]] +name = "platformdirs" +version = "4.3.8" +summary = "" +files = [ + {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, + {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +summary = "" +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +requires_python = ">=3.9" +summary = "Data validation using Python type hints" +dependencies = [ + "annotated-types>=0.6.0", + "pydantic-core==2.41.5", + "typing-extensions>=4.14.1", + "typing-inspection>=0.4.2", +] +files = [ + {file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"}, + {file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"}, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +requires_python = ">=3.9" +summary = "Core functionality for Pydantic validation and serialization" +dependencies = [ + "typing-extensions>=4.14.1", +] +files = [ + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a"}, + {file = "pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375"}, + {file = "pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07"}, + {file = "pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf"}, + {file = "pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c"}, + {file = "pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008"}, + {file = "pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e"}, +] + +[[package]] +name = "pygments" +version = "2.19.2" +summary = "" +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[[package]] +name = "pymdown-extensions" +version = "10.17.1" +requires_python = ">=3.9" +summary = "Extension pack for Python Markdown." +dependencies = [ + "markdown>=3.6", + "pyyaml", +] +files = [ + {file = "pymdown_extensions-10.17.1-py3-none-any.whl", hash = "sha256:1f160209c82eecbb5d8a0d8f89a4d9bd6bdcbde9a8537761844cfc57ad5cd8a6"}, + {file = "pymdown_extensions-10.17.1.tar.gz", hash = "sha256:60d05fe55e7fb5a1e4740fc575facad20dc6ee3a748e8d3d36ba44142e75ce03"}, +] + +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +summary = "" +files = [ + {file = "pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913"}, + {file = "pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8"}, +] + +[[package]] +name = "pyproject-metadata" +version = "0.11.0" +requires_python = ">=3.8" +summary = "PEP 621 metadata parsing" +dependencies = [ + "packaging>=23.2", +] +files = [ + {file = "pyproject_metadata-0.11.0-py3-none-any.whl", hash = "sha256:85bbecca8694e2c00f63b492c96921d6c228454057c88e7c352b2077fcaa4096"}, + {file = "pyproject_metadata-0.11.0.tar.gz", hash = "sha256:c72fa49418bb7c5a10f25e050c418009898d1c051721d19f98a6fb6da59a66cf"}, +] + +[[package]] +name = "pyrefly" +version = "0.53.0" +requires_python = ">=3.8" +summary = "A fast type checker and language server for Python with powerful IDE features" +files = [ + {file = "pyrefly-0.53.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:79d7fb35dff0988b3943c26f74cc752fad54357a0bc33f7db665f02d1c9a5bcc"}, + {file = "pyrefly-0.53.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e1d98b1e86f3c38db44860695b7986e731238e1b19c3cad7a3050476a8f6f84d"}, + {file = "pyrefly-0.53.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb9f2440f7e0c70aa18400f44aed994c326a1ab00f2b01cf7253a63fc62d7c6b"}, + {file = "pyrefly-0.53.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e4e826a5ff2aba2c41e02e6094580751c512db7916e60728cd8612dbcf178d7b"}, + {file = "pyrefly-0.53.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf4c69410c7a96b417a390a0e3d340f4370fdab02f9d3eaa222c4bd42e3ce24a"}, + {file = "pyrefly-0.53.0-py3-none-win32.whl", hash = "sha256:00687bb6be6e366b8c0137a89625da40ced3b9212a65e561857ff888fe88e6e8"}, + {file = "pyrefly-0.53.0-py3-none-win_amd64.whl", hash = "sha256:e0512e6f7af44ae01cfddba096ff7740e15cbd1d0497a3d34a7afcb504e2b300"}, + {file = "pyrefly-0.53.0-py3-none-win_arm64.whl", hash = "sha256:5066e2102769683749102421b8b8667cae26abe1827617f04e8df4317e0a94af"}, + {file = "pyrefly-0.53.0.tar.gz", hash = "sha256:aef117e8abb9aa4cf17fc64fbf450d825d3c65fc9de3c02ed20129ebdd57aa74"}, +] + +[[package]] +name = "pytest" +version = "9.0.2" +requires_python = ">=3.10" +summary = "pytest: simple powerful testing with Python" +dependencies = [ + "colorama>=0.4; sys_platform == \"win32\"", + "exceptiongroup>=1; python_version < \"3.11\"", + "iniconfig>=1.0.1", + "packaging>=22", + "pluggy<2,>=1.5", + "pygments>=2.7.2", + "tomli>=1; python_version < \"3.11\"", +] +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +requires_python = ">=3.9" +summary = "Pytest plugin for measuring coverage." +dependencies = [ + "coverage[toml]>=7.10.6", + "pluggy>=1.2", + "pytest>=7", +] +files = [ + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +requires_python = ">=3.9" +summary = "Thin-wrapper around the mock package for easier use with pytest" +dependencies = [ + "pytest>=6.2.5", +] +files = [ + {file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"}, + {file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"}, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +summary = "" +dependencies = [ + "six", +] +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +summary = "" +files = [ + {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, + {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +requires_python = ">=3.8" +summary = "YAML parser and emitter for Python" +files = [ + {file = "pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac"}, + {file = "pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788"}, + {file = "pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764"}, + {file = "pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac"}, + {file = "pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3"}, + {file = "pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702"}, + {file = "pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065"}, + {file = "pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9"}, + {file = "pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b"}, + {file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"}, +] + +[[package]] +name = "requests" +version = "2.32.5" +requires_python = ">=3.9" +summary = "Python HTTP for Humans." +dependencies = [ + "certifi>=2017.4.17", + "charset-normalizer<4,>=2", + "idna<4,>=2.5", + "urllib3<3,>=1.21.1", +] +files = [ + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[[package]] +name = "resolvelib" +version = "1.2.0" +summary = "" +files = [ + {file = "resolvelib-1.2.0-py3-none-any.whl", hash = "sha256:8e3e2000beaf53fdfd8772fda1a7b1df97e803ab7c8925621bbb87c4d187a94d"}, + {file = "resolvelib-1.2.0.tar.gz", hash = "sha256:c27fbb5098acd7dfc01fb2be3724bd0881168edc2bd3b4dc876ca3f46b8e4a3d"}, +] + +[[package]] +name = "rich" +version = "14.0.0" +summary = "" +dependencies = [ + "markdown-it-py", + "pygments", +] +files = [ + {file = "rich-14.0.0-py3-none-any.whl", hash = "sha256:1c9491e1951aac09caffd42f448ee3d04e58923ffe14993f6e83068dc395d7e0"}, + {file = "rich-14.0.0.tar.gz", hash = "sha256:82f1bc23a6a21ebca4ae0c45af9bdbc492ed20231dcb63f297d6d1021a9d5725"}, +] + +[[package]] +name = "ruff" +version = "0.15.1" +requires_python = ">=3.7" +summary = "An extremely fast Python linter and code formatter, written in Rust." +files = [ + {file = "ruff-0.15.1-py3-none-linux_armv6l.whl", hash = "sha256:b101ed7cf4615bda6ffe65bdb59f964e9f4a0d3f85cbf0e54f0ab76d7b90228a"}, + {file = "ruff-0.15.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:939c995e9277e63ea632cc8d3fae17aa758526f49a9a850d2e7e758bfef46602"}, + {file = "ruff-0.15.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1d83466455fdefe60b8d9c8df81d3c1bbb2115cede53549d3b522ce2bc703899"}, + {file = "ruff-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9457e3c3291024866222b96108ab2d8265b477e5b1534c7ddb1810904858d16"}, + {file = "ruff-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:92c92b003e9d4f7fbd33b1867bb15a1b785b1735069108dfc23821ba045b29bc"}, + {file = "ruff-0.15.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fe5c41ab43e3a06778844c586251eb5a510f67125427625f9eb2b9526535779"}, + {file = "ruff-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66a6dd6df4d80dc382c6484f8ce1bcceb55c32e9f27a8b94c32f6c7331bf14fb"}, + {file = "ruff-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a4a42cbb8af0bda9bcd7606b064d7c0bc311a88d141d02f78920be6acb5aa83"}, + {file = "ruff-0.15.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ab064052c31dddada35079901592dfba2e05f5b1e43af3954aafcbc1096a5b2"}, + {file = "ruff-0.15.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5631c940fe9fe91f817a4c2ea4e81f47bee3ca4aa646134a24374f3c19ad9454"}, + {file = "ruff-0.15.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68138a4ba184b4691ccdc39f7795c66b3c68160c586519e7e8444cf5a53e1b4c"}, + {file = "ruff-0.15.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:518f9af03bfc33c03bdb4cb63fabc935341bb7f54af500f92ac309ecfbba6330"}, + {file = "ruff-0.15.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:da79f4d6a826caaea95de0237a67e33b81e6ec2e25fc7e1993a4015dffca7c61"}, + {file = "ruff-0.15.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3dd86dccb83cd7d4dcfac303ffc277e6048600dfc22e38158afa208e8bf94a1f"}, + {file = "ruff-0.15.1-py3-none-win32.whl", hash = "sha256:660975d9cb49b5d5278b12b03bb9951d554543a90b74ed5d366b20e2c57c2098"}, + {file = "ruff-0.15.1-py3-none-win_amd64.whl", hash = "sha256:c820fef9dd5d4172a6570e5721704a96c6679b80cf7be41659ed439653f62336"}, + {file = "ruff-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5ff7d5f0f88567850f45081fac8f4ec212be8d0b963e385c3f7d0d2eb4899416"}, + {file = "ruff-0.15.1.tar.gz", hash = "sha256:c590fe13fb57c97141ae975c03a1aedb3d3156030cabd740d6ff0b0d601e203f"}, +] + +[[package]] +name = "scikit-build-core" +version = "0.11.6" +requires_python = ">=3.8" +summary = "Build backend for CMake based projects" +dependencies = [ + "exceptiongroup>=1.0; python_version < \"3.11\"", + "importlib-resources>=1.3; python_version < \"3.9\"", + "packaging>=23.2", + "pathspec>=0.10.1", + "tomli>=1.2.2; python_version < \"3.11\"", + "typing-extensions>=3.10.0; python_version < \"3.9\"", +] +files = [ + {file = "scikit_build_core-0.11.6-py3-none-any.whl", hash = "sha256:ce6d8fe64e6b4c759ea0fb95d2f8a68f60d2df31c2989838633b8ec930736360"}, + {file = "scikit_build_core-0.11.6.tar.gz", hash = "sha256:5982ccd839735be99cfd3b92a8847c6c196692f476c215da84b79d2ad12f9f1b"}, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +summary = "" +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + +[[package]] +name = "six" +version = "1.17.0" +summary = "" +files = [ + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +summary = "" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "socksio" +version = "1.0.0" +summary = "" +files = [ + {file = "socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3"}, + {file = "socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac"}, +] + +[[package]] +name = "tomlkit" +version = "0.13.3" +summary = "" +files = [ + {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, + {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, +] + +[[package]] +name = "truststore" +version = "0.10.4" +requires_python = ">=3.10" +summary = "Verify certificates using native system trust stores" +files = [ + {file = "truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981"}, + {file = "truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301"}, +] + +[[package]] +name = "typer" +version = "0.24.0" +requires_python = ">=3.10" +summary = "Typer, build great CLIs. Easy to code. Based on Python type hints." +dependencies = [ + "annotated-doc>=0.0.2", + "click>=8.2.1", + "rich>=12.3.0", + "shellingham>=1.3.0", +] +files = [ + {file = "typer-0.24.0-py3-none-any.whl", hash = "sha256:5fc435a9c8356f6160ed6e85a6301fdd6e3d8b2851da502050d1f92c5e9eddc8"}, + {file = "typer-0.24.0.tar.gz", hash = "sha256:f9373dc4eff901350694f519f783c29b6d7a110fc0dcc11b1d7e353b85ca6504"}, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20260107" +requires_python = ">=3.9" +summary = "Typing stubs for requests" +dependencies = [ + "urllib3>=2", +] +files = [ + {file = "types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d"}, + {file = "types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f"}, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +summary = "" +files = [ + {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, + {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +requires_python = ">=3.9" +summary = "Runtime typing introspection tools" +dependencies = [ + "typing-extensions>=4.12.0", +] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[[package]] +name = "unearth" +version = "0.17.5" +summary = "" +dependencies = [ + "httpx", + "packaging", +] +files = [ + {file = "unearth-0.17.5-py3-none-any.whl", hash = "sha256:9963e66b14f0484644c9b45b517e530befb2de6a8da4b06a9a38bed2d086dfe6"}, + {file = "unearth-0.17.5.tar.gz", hash = "sha256:a19e1c02e64b40518d088079c7416fc41b45a648b81a4128aac02597234ee6ba"}, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +requires_python = ">=3.9" +summary = "HTTP library with thread-safe connection pooling, file post, and more." +files = [ + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, +] + +[[package]] +name = "virtualenv" +version = "20.31.2" +summary = "" +dependencies = [ + "distlib", + "filelock", + "platformdirs", +] +files = [ + {file = "virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11"}, + {file = "virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af"}, +] + +[[package]] +name = "zensical" +version = "0.0.23" +requires_python = ">=3.10" +summary = "A modern static site generator built by the creators of Material for MkDocs" +dependencies = [ + "click>=8.1.8", + "deepmerge>=2.0", + "markdown>=3.7", + "pygments>=2.16", + "pymdown-extensions>=10.15", + "pyyaml>=6.0.2", + "tomli>=2.0; python_full_version < \"3.11\"", +] +files = [ + {file = "zensical-0.0.23-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:35d6d3eb803fe73a67187a1a25443408bd02a8dd50e151f4a4bafd40de3f0928"}, + {file = "zensical-0.0.23-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:5973267460a190f348f24d445ff0c01e8ed334fd075947687b305e68257f6b18"}, + {file = "zensical-0.0.23-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:953adf1f0b346a6c65fc6e05e6cc1c38a6440fec29c50c76fb29700cc1927006"}, + {file = "zensical-0.0.23-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49c1cbd6131dafa056be828e081759184f9b8dd24b99bf38d1e77c8c31b0c720"}, + {file = "zensical-0.0.23-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f5b7fe22c5d33b2b91899c5df7631ad4ce9cccfabac2560cc92ba73eafe2d297"}, + {file = "zensical-0.0.23-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a3679d6bf6374f503afb74d9f6061da5de83c25922f618042b63a30b16f0389"}, + {file = "zensical-0.0.23-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:54d981e21a19c3dcec6e7fa77c4421db47389dfdff20d29fea70df8e1be4062e"}, + {file = "zensical-0.0.23-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:afde7865cc3c79c99f6df4a911d638fb2c3b472a1b81367d47163f8e3c36f910"}, + {file = "zensical-0.0.23-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:c484674d7b0a3e6d39db83914db932249bccdef2efaf8a5669671c66c16f584d"}, + {file = "zensical-0.0.23-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:927d12fe2851f355fb3206809e04641d6651bdd2ff4afe9c205721aa3a32aa82"}, + {file = "zensical-0.0.23-cp310-abi3-win32.whl", hash = "sha256:ffb79db4244324e9cc063d16adff25a40b145153e5e76d75e0012ba3c05af25d"}, + {file = "zensical-0.0.23-cp310-abi3-win_amd64.whl", hash = "sha256:a8cfe240dca75231e8e525985366d010d09ee73aec0937930e88f7230694ce01"}, + {file = "zensical-0.0.23.tar.gz", hash = "sha256:5c4fc3aaf075df99d8cf41b9f2566e4d588180d9a89493014d3607dfe50ac4bc"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..b55e1db1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,138 @@ +[project] +description = "A Python management solution for C++ dependencies" +name = "cppython" + +license = { text = "MIT" } + +authors = [{ name = "Synodic Software", email = "contact@synodic.software" }] +readme = "README.md" + +dynamic = ["version"] + +requires-python = ">=3.14" + +dependencies = [ + "typer>=0.24.0", + "pydantic>=2.12.5", + "packaging>=26.0", + "requests>=2.32.5", + "types-requests>=2.32.4.20260107", + "scikit-build-core>=0.11.6", + "meson-python>=0.19.0", +] + +[project.optional-dependencies] +pytest = [ + "pytest>=9.0.2", + "pytest-mock>=3.15.1", +] +git = [ + "dulwich>=1.1.0", +] +pdm = [ + "pdm>=2.26.6", +] +cmake = [ + "cmake>=4.2.1", +] +meson = ["meson>=1.10.1"] +conan = [ + "conan>=2.25.2", +] + +[project.urls] +homepage = "https://github.com/Synodic-Software/CPPython" +repository = "https://github.com/Synodic-Software/CPPython" + +[project.entry-points."cppython.scm"] +git = "cppython.plugins.git.plugin:GitSCM" + +[project.entry-points."cppython.generator"] +cmake = "cppython.plugins.cmake.plugin:CMakeGenerator" +meson = "cppython.plugins.meson.plugin:MesonGenerator" + +[project.entry-points."cppython.provider"] +conan = "cppython.plugins.conan.plugin:ConanProvider" +vcpkg = "cppython.plugins.vcpkg.plugin:VcpkgProvider" + +[project.entry-points.pdm] +cppython = "cppython.plugins.pdm.plugin:CPPythonPlugin" + +[project.entry-points.pytest11] +cppython = "cppython.test.pytest.fixtures" + +[dependency-groups] +lint = [ + "ruff>=0.15.1", + "pyrefly>=0.53.0", +] +test = [ + "pytest>=9.0.2", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", + "nanobind>=2.11.0", +] +docs = [ + "zensical>=0.0.23", +] + +[project.scripts] +cppython = "cppython.console.entry:app" + +[tool.pytest.ini_options] +addopts = ["--color=yes"] +log_cli = true +testpaths = ["tests"] + +[tool.ruff] +line-length = 120 + +[tool.ruff.lint] +ignore = ["D206", "D300", "D415", "E111", "E114", "E117"] +select = [ + "D", # pydocstyle + "F", # Pyflakes + "I", # isort + "PL", # pylint + "UP", # pyupgrade + "E", # pycodestyle + "B", # flake8-bugbear + "SIM", # flake8-simplify + "PT", # flake8-pytest-style +] + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"cppython/console/entry.py" = ["PT"] # CLI commands, not pytest tests + +[tool.ruff.format] +docstring-code-format = true +indent-style = "space" +quote-style = "single" + +[tool.coverage.report] +skip_empty = true + +[tool.pyrefly] +project-excludes = ["examples"] + +[tool.pdm] +plugins = ["-e file:///${PROJECT_ROOT}"] + +[tool.pdm.version] +source = "scm" + +[tool.pdm.scripts] +analyze = { shell = "ruff check cppython tests" } +format = { shell = "ruff format" } +lint = { composite = ["analyze", "format", "type-check"] } +test = { shell = "pytest --cov=cppython --verbose tests" } +type-check = { shell = "pyrefly check" } +generate = { shell = "zensical build --clean" } +dev = { shell = "zensical serve" } + +[build-system] +build-backend = "pdm.backend" +requires = ["pdm.backend"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..73173d57 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,7 @@ +"""Unit tests for the CPPython project. + +This module contains various unit tests to ensure the correct functionality of +different components within the CPPython project. The tests cover a wide range +of features, including plugin interfaces, project configurations, and utility +functions. +""" diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 00000000..7f38ccdf --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1,10 @@ +"""Fixtures for tests. + +`pytest_plugins` is the preferred way to load fixtures, to prevent the overhead of a large root conftest file. +The plugins must be defined at the test module's global scope and not in non-root conftest files. + +ex. +``` +pytest_plugins = ['fixtures.fixture_name'] +``` +""" diff --git a/tests/fixtures/cli.py b/tests/fixtures/cli.py new file mode 100644 index 00000000..8f8688f6 --- /dev/null +++ b/tests/fixtures/cli.py @@ -0,0 +1,40 @@ +"""Fixtures for interfacing with the CLI.""" + +import os +import platform + +import pytest +from typer.testing import CliRunner + + +@pytest.fixture( + name='typer_runner', + scope='session', +) +def fixture_typer_runner() -> CliRunner: + """Returns a runner setup for the CPPython interface""" + runner = CliRunner() + + return runner + + +@pytest.fixture( + name='fresh_environment', + scope='session', +) +def fixture_fresh_environment(request: pytest.FixtureRequest) -> dict[str, str]: + """Create a fresh environment for subprocess calls.""" + # Start with a minimal environment + new_env = {} + + # Copy only variables you need + if platform.system() == 'Windows': + new_env['SystemRoot'] = os.environ['SystemRoot'] # noqa: SIM112 + + # Provide a PATH that doesn't contain venv references + new_env['PATH'] = os.environ['PATH'] + + # Set the Cppython root directory + new_env['CPPYTHON_ROOT'] = str(request.config.rootpath.resolve()) + + return new_env diff --git a/tests/fixtures/cmake.py b/tests/fixtures/cmake.py new file mode 100644 index 00000000..10c1767e --- /dev/null +++ b/tests/fixtures/cmake.py @@ -0,0 +1,40 @@ +"""Fixtures for the cmake plugin""" + +from pathlib import Path +from typing import cast + +import pytest + +from cppython.plugins.cmake.schema import CMakeConfiguration + + +def _cmake_data_list() -> list[CMakeConfiguration]: + """Creates a list of mocked configuration types + + Returns: + A list of variants to test + """ + # Default + default = CMakeConfiguration(configuration_name='default') + + # Non-root preset file + config = CMakeConfiguration(preset_file=Path('inner/CMakePresets.json'), configuration_name='default') + + return [default, config] + + +@pytest.fixture( + name='cmake_data', + scope='session', + params=_cmake_data_list(), +) +def fixture_cmake_data(request: pytest.FixtureRequest) -> CMakeConfiguration: + """A fixture to provide a list of configuration types + + Args: + request: Parameterization list + + Returns: + A configuration type instance + """ + return cast(CMakeConfiguration, request.param) diff --git a/tests/fixtures/conan.py b/tests/fixtures/conan.py new file mode 100644 index 00000000..933b7dcc --- /dev/null +++ b/tests/fixtures/conan.py @@ -0,0 +1,220 @@ +"""Shared fixtures for Conan plugin tests""" + +import os +from pathlib import Path +from typing import Any +from unittest.mock import Mock + +import pytest +from packaging.requirements import Requirement +from pytest_mock import MockerFixture + +from cppython.plugins.conan.plugin import ConanProvider +from cppython.plugins.conan.schema import ConanDependency + +# Shared parameterization for plugin data across all conan tests +CONAN_PLUGIN_DATA_PARAMS = [ + {'remotes': ['conancenter'], 'skip_upload': False}, # Default behavior + {'remotes': [], 'skip_upload': False}, # Empty remotes (upload to all) + {'remotes': ['conancenter'], 'skip_upload': True}, # Skip upload with specific remotes + {'remotes': [], 'skip_upload': True}, # Skip upload with empty remotes +] + + +@pytest.fixture(name='conan_plugin_data', scope='session', params=CONAN_PLUGIN_DATA_PARAMS) +def fixture_conan_plugin_data(request) -> dict[str, Any]: + """Shared parameterized plugin data for conan tests + + Returns: + The constructed plugin data with different combinations of remotes and skip_upload + """ + return request.param + + +@pytest.fixture(autouse=True) +def clean_conan_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Sets CONAN_HOME to a temporary directory for each test. + + This ensures all tests run with a clean Conan cache. Copies the user's + default profile if it exists to ensure tests have valid compiler settings. + + Args: + tmp_path: Pytest temporary directory fixture + monkeypatch: Pytest monkeypatch fixture for environment variable manipulation + """ + conan_home = tmp_path / 'conan_home' + conan_home.mkdir() + + # Copy user's default profile if it exists + user_conan_home = Path(os.getenv('CONAN_USER_HOME', Path.home() / '.conan2')) + user_profiles = user_conan_home / 'profiles' + if user_profiles.exists(): + test_profiles = conan_home / 'profiles' + test_profiles.mkdir(parents=True, exist_ok=True) + + for profile_file in ('default', 'default_build'): + if (src := user_profiles / profile_file).exists(): + src.copy(test_profiles / profile_file) + + # Set CONAN_HOME to the temporary directory + monkeypatch.setenv('CONAN_HOME', str(conan_home)) + + +@pytest.fixture(name='conan_mock_api') +def fixture_conan_mock_api(mocker: MockerFixture) -> Mock: + """Creates a mock ConanAPI instance for install/update operations + + Args: + mocker: Pytest mocker fixture + + Returns: + Mock ConanAPI instance + """ + mock_api = mocker.Mock() + + # Mock graph module + mock_deps_graph = mocker.Mock() + mock_deps_graph.nodes = [] + mock_api.graph.load_graph_consumer = mocker.Mock(return_value=mock_deps_graph) + mock_api.graph.analyze_binaries = mocker.Mock() + + # Mock install module + mock_api.install.install_binaries = mocker.Mock() + mock_api.install.install_consumer = mocker.Mock() + + # Mock remotes module + mock_remote = mocker.Mock() + mock_remote.name = 'conancenter' + mock_api.remotes.list = mocker.Mock(return_value=[mock_remote]) + + # Mock profiles module - simulate no default profile by default + mock_profile = mocker.Mock() + mock_api.profiles.get_default_host = mocker.Mock(return_value=None) + mock_api.profiles.get_default_build = mocker.Mock(return_value=None) + mock_api.profiles.get_profile = mocker.Mock(return_value=mock_profile) + mock_api.profiles.detect = mocker.Mock(return_value=mock_profile) + + return mock_api + + +@pytest.fixture(name='conan_mock_api_publish') +def fixture_conan_mock_api_publish(mocker: MockerFixture) -> Mock: + """Creates a mock ConanAPI instance for publish operations + + Args: + mocker: Pytest mocker fixture + + Returns: + Mock ConanAPI instance configured for publish operations + """ + mock_api = mocker.Mock() + + # Mock export module - export returns a tuple (ref, conanfile) + mock_ref = mocker.Mock() + mock_ref.name = 'test_package' + mock_conanfile = mocker.Mock() + mock_api.export.export = mocker.Mock(return_value=(mock_ref, mock_conanfile)) + + # Mock graph module + mock_api.graph.load_graph_consumer = mocker.Mock() + mock_api.graph.analyze_binaries = mocker.Mock() + + # Mock install module + mock_api.install.install_binaries = mocker.Mock() + + # Mock list module + mock_select_result = mocker.Mock() + mock_select_result.recipes = ['some_package/1.0@user/channel'] + mock_api.list.select = mocker.Mock(return_value=mock_select_result) + + # Mock remotes module + mock_remote = mocker.Mock() + mock_remote.name = 'conancenter' + mock_api.remotes.list = mocker.Mock(return_value=[mock_remote]) + + # Mock upload module + mock_api.upload.upload_full = mocker.Mock() + + # Mock profiles module + mock_profile = mocker.Mock() + mock_api.profiles.get_default_host = mocker.Mock(return_value='/path/to/default/host') + mock_api.profiles.get_default_build = mocker.Mock(return_value='/path/to/default/build') + mock_api.profiles.get_profile = mocker.Mock(return_value=mock_profile) + mock_api.profiles.detect = mocker.Mock(return_value=mock_profile) + + return mock_api + + +@pytest.fixture(name='conan_temp_conanfile') +def fixture_conan_temp_conanfile(plugin: ConanProvider) -> Path: + """Creates a temporary conanfile.py for testing + + Args: + plugin: The plugin instance + + Returns: + Path to the created conanfile.py + """ + project_root = plugin.core_data.project_data.project_root + conanfile_path = project_root / 'conanfile.py' + conanfile_path.write_text( + 'from conan import ConanFile\n\nclass TestConan(ConanFile):\n name = "test_package"\n version = "1.0"\n' + ) + return conanfile_path + + +@pytest.fixture(name='conan_mock_dependencies') +def fixture_conan_mock_dependencies() -> list[Requirement]: + """Creates mock dependencies for testing + + Returns: + List of mock requirements + """ + return [ + Requirement('boost>=1.70.0'), + Requirement('zlib>=1.2.11'), + ] + + +@pytest.fixture(name='conan_setup_mocks') +def fixture_conan_setup_mocks( + plugin: ConanProvider, + mocker: MockerFixture, +) -> dict[str, Mock]: + """Sets up all mocks for testing install/update operations + + Args: + plugin: The plugin instance + mocker: Pytest mocker fixture + + Returns: + Dictionary containing all mocks + """ + # Mock builder + mock_builder = mocker.Mock() + mock_builder.generate_conanfile = mocker.Mock() + # Set the builder attribute on the plugin + plugin.builder = mock_builder # type: ignore[attr-defined] + + # Mock subprocess.run to simulate successful command execution + mock_subprocess_run = mocker.patch('cppython.plugins.conan.plugin.subprocess.run') + mock_subprocess_run.return_value = mocker.Mock(returncode=0) + + # Mock resolve_conan_dependency + def mock_resolve(requirement: Requirement) -> ConanDependency: + return ConanDependency(name=requirement.name) + + mock_resolve_conan_dependency = mocker.patch( + 'cppython.plugins.conan.plugin.resolve_conan_dependency', side_effect=mock_resolve + ) + + # Mock getLogger to avoid logging setup issues + mock_logger = mocker.Mock() + mocker.patch('cppython.plugins.conan.plugin.getLogger', return_value=mock_logger) + + return { + 'builder': mock_builder, + 'subprocess_run': mock_subprocess_run, + 'resolve_conan_dependency': mock_resolve_conan_dependency, + 'logger': mock_logger, + } diff --git a/tests/fixtures/example.py b/tests/fixtures/example.py new file mode 100644 index 00000000..b1c8fc91 --- /dev/null +++ b/tests/fixtures/example.py @@ -0,0 +1,68 @@ +"""Fixtures for the cmake plugin""" + +import os +import shutil +from collections.abc import Generator +from pathlib import Path +from typing import cast + +import pytest +from typer.testing import CliRunner + +pytest_plugins = ['tests.fixtures.cli'] + + +def _examples() -> list[Path]: + """Returns the examples directory""" + matching_directories = [] + + for dirpath, _, filenames in os.walk('examples'): + for filename in filenames: + if filename == 'pyproject.toml': + absolute_path = Path(dirpath).absolute() + matching_directories.append(absolute_path) + break + + return matching_directories + + +@pytest.fixture( + name='example_directory', + scope='session', + params=_examples(), +) +def fixture_example_directory( + request: pytest.FixtureRequest, +) -> Path: + """Enumerates folders in the examples directory. + + Parameterizes all directories with a pyproject.toml file within the examples directory. + """ + directory = cast(Path, request.param) + return directory + + +@pytest.fixture( + name='example_runner', +) +def fixture_example_runner( + request: pytest.FixtureRequest, typer_runner: CliRunner, tmp_path: Path +) -> Generator[CliRunner]: + """Sets up an isolated filesystem for an example test.""" + # Get the root directory of the project + root_directory = Path(__file__).parent.parent.parent.absolute() + + # Remove the file extension and required 'test_' prefix from the test's file name + file_name = request.node.fspath.basename[:-3].replace('test_', '') + + # Get the test function name and remove the required 'test_' prefix + test_name = request.node.name.replace('test_', '') + + # Generate the example path from the pytest file and test name + example_path = root_directory / 'examples' / file_name / test_name + + with typer_runner.isolated_filesystem(temp_dir=tmp_path): + # Copy the example directory to the temporary directory + shutil.copytree(example_path, Path(), dirs_exist_ok=True) + + yield typer_runner diff --git a/tests/fixtures/meson.py b/tests/fixtures/meson.py new file mode 100644 index 00000000..388b4807 --- /dev/null +++ b/tests/fixtures/meson.py @@ -0,0 +1,40 @@ +"""Fixtures for the meson plugin""" + +from pathlib import Path +from typing import cast + +import pytest + +from cppython.plugins.meson.schema import MesonConfiguration + + +def _meson_data_list() -> list[MesonConfiguration]: + """Creates a list of mocked configuration types. + + Returns: + A list of variants to test + """ + # Default + default = MesonConfiguration() + + # Non-root build file + config = MesonConfiguration(build_file=Path('subdir/meson.build'), build_directory='custom-builddir') + + return [default, config] + + +@pytest.fixture( + name='meson_data', + scope='session', + params=_meson_data_list(), +) +def fixture_meson_data(request: pytest.FixtureRequest) -> MesonConfiguration: + """A fixture to provide a list of configuration types. + + Args: + request: Parameterization list + + Returns: + A configuration type instance + """ + return cast(MesonConfiguration, request.param) diff --git a/tests/fixtures/vcpkg.py b/tests/fixtures/vcpkg.py new file mode 100644 index 00000000..b59fc287 --- /dev/null +++ b/tests/fixtures/vcpkg.py @@ -0,0 +1 @@ +"""Shared fixtures for VCPkg plugin tests""" diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 00000000..73e5ba0a --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,7 @@ +"""Integration tests for the CPPython project. + +This module contains integration tests to ensure the correct functionality of +different components within the CPPython project. The tests cover a wide range +of features, including plugin interfaces, project configurations, and utility +functions. +""" diff --git a/tests/integration/examples/__init__.py b/tests/integration/examples/__init__.py new file mode 100644 index 00000000..707867c8 --- /dev/null +++ b/tests/integration/examples/__init__.py @@ -0,0 +1 @@ +"""Integration tests for CPPython examples.""" diff --git a/tests/integration/examples/test_conan_cmake.py b/tests/integration/examples/test_conan_cmake.py new file mode 100644 index 00000000..99ac4052 --- /dev/null +++ b/tests/integration/examples/test_conan_cmake.py @@ -0,0 +1,222 @@ +"""Integration tests for the conan and CMake project variation. + +This module contains integration tests for projects that use conan and CMake. +The tests ensure that the projects build, configure, and execute correctly. +""" + +import subprocess +import sys +import tomllib +import zipfile +from pathlib import Path +from tomllib import loads + +import pytest +from typer.testing import CliRunner + +from cppython.build import build_wheel +from cppython.console.schema import ConsoleInterface +from cppython.core.schema import ProjectConfiguration +from cppython.project import Project + +pytest_plugins = ['tests.fixtures.example', 'tests.fixtures.conan', 'tests.fixtures.cmake'] + +# C++20 modules require Ninja or Visual Studio generator, not Unix Makefiles +_skip_modules_test = pytest.mark.skipif( + sys.platform != 'win32', reason='C++20 modules require Ninja or Visual Studio generator, not Unix Makefiles.' +) + +# On Windows (multi-config generators), use 'default' preset +# On Linux/Mac (single-config generators), use 'default-release' because CMAKE_BUILD_TYPE is required +_cmake_preset = 'default' if sys.platform == 'win32' else 'default-release' + + +class TestConanCMake: + """Test project variation of conan and CMake""" + + @staticmethod + def _create_project(skip_upload: bool = True) -> Project: + """Create a project instance with common configuration.""" + project_root = Path.cwd() + config = ProjectConfiguration(project_root=project_root, version=None, verbosity=2, debug=True) + interface = ConsoleInterface() + + pyproject_path = project_root / 'pyproject.toml' + pyproject_data = loads(pyproject_path.read_text(encoding='utf-8')) + + if skip_upload: + TestConanCMake._ensure_conan_config(pyproject_data) + pyproject_data['tool']['cppython']['providers']['conan']['skip_upload'] = True + + return Project(config, interface, pyproject_data) + + @staticmethod + def _run_cmake_configure(cmake_binary: str) -> None: + """Run CMake configuration and assert success. + + Args: + cmake_binary: Path or command name for the CMake binary to use + """ + result = subprocess.run( + [cmake_binary, f'--preset={_cmake_preset}'], capture_output=True, text=True, check=False + ) + assert result.returncode == 0, f'CMake configuration failed: {result.stderr}' + + @staticmethod + def _run_cmake_build(cmake_binary: str) -> None: + """Run CMake build and assert success. + + Args: + cmake_binary: Path or command name for the CMake binary to use + """ + result = subprocess.run([cmake_binary, '--build', 'build'], capture_output=True, text=True, check=False) + assert result.returncode == 0, f'CMake build failed: {result.stderr}' + + @staticmethod + def _verify_build_artifacts() -> Path: + """Verify basic build artifacts exist and return build path.""" + build_path = Path('build').absolute() + assert (build_path / 'CMakeCache.txt').exists(), f'CMakeCache.txt not found in {build_path}' + return build_path + + @staticmethod + def _ensure_conan_config(pyproject_data: dict) -> None: + """Helper method to ensure Conan configuration exists in pyproject data""" + if 'tool' not in pyproject_data: + pyproject_data['tool'] = {} + if 'cppython' not in pyproject_data['tool']: + pyproject_data['tool']['cppython'] = {} + if 'providers' not in pyproject_data['tool']['cppython']: + pyproject_data['tool']['cppython']['providers'] = {} + if 'conan' not in pyproject_data['tool']['cppython']['providers']: + pyproject_data['tool']['cppython']['providers']['conan'] = {} + + @staticmethod + def _verify_conan_package_configs(package_name: str, expected_build_types: list[str]) -> None: + """Verify that specified build types exist in the Conan local cache. + + Args: + package_name: Name of the package to check (e.g., 'mathutils') + expected_build_types: List of build types that should exist (e.g., ['Release', 'Debug']) + """ + result = subprocess.run( + ['conan', 'list', f'{package_name}/*:*'], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, f'Failed to list Conan packages: {result.stderr}' + + output = result.stdout + for build_type in expected_build_types: + assert f'build_type: {build_type}' in output, ( + f'{build_type} configuration not found in Conan cache for {package_name}. Output: {output}' + ) + + @staticmethod + def test_simple(example_runner: CliRunner) -> None: + """Simple project""" + # Read cmake_binary from the current pyproject.toml (we're in the example directory) + pyproject_path = Path.cwd() / 'pyproject.toml' + with pyproject_path.open('rb') as file: + pyproject_data = tomllib.load(file) + + cmake_binary = ( + pyproject_data.get('tool', {}) + .get('cppython', {}) + .get('generators', {}) + .get('cmake', {}) + .get('cmake_binary', 'cmake') + ) + + # Create project and install dependencies + project = TestConanCMake._create_project(skip_upload=False) + project.install() + + # Configure and verify build + TestConanCMake._run_cmake_configure(cmake_binary) + TestConanCMake._verify_build_artifacts() + + # Test publishing with skip_upload enabled + publish_project = TestConanCMake._create_project(skip_upload=True) + publish_project.publish() + + @staticmethod + @_skip_modules_test + def test_library(example_runner: CliRunner) -> None: + """Test library creation and packaging workflow""" + # Read cmake_binary from the current pyproject.toml (we're in the example directory) + pyproject_path = Path.cwd() / 'pyproject.toml' + with pyproject_path.open('rb') as file: + pyproject_data = tomllib.load(file) + + cmake_binary = ( + pyproject_data.get('tool', {}) + .get('cppython', {}) + .get('generators', {}) + .get('cmake', {}) + .get('cmake_binary', 'cmake') + ) + + # Create project and install dependencies + project = TestConanCMake._create_project(skip_upload=False) + project.install() + + # Configure, build, and verify + TestConanCMake._run_cmake_configure(cmake_binary) + TestConanCMake._run_cmake_build(cmake_binary) + build_path = TestConanCMake._verify_build_artifacts() + + # Verify library files exist (platform-specific) + lib_files = list(build_path.glob('**/libmathutils.*')) + list(build_path.glob('**/mathutils.lib')) + assert len(lib_files) > 0, f'No library files found in {build_path}' + + # Package the library to local cache + publish_project = TestConanCMake._create_project(skip_upload=True) + publish_project.publish() + + # Verify both Debug and Release configurations were published and consumed successfully + # conan create already runs test_package for each build type, verifying consumption works + TestConanCMake._verify_conan_package_configs('mathutils', ['Release', 'Debug']) + + @staticmethod + def test_extension(example_runner: CliRunner) -> None: + """Test Python extension module built with cppython.build backend and scikit-build-core""" + # This test uses the cppython.build backend which wraps scikit-build-core + # The build backend automatically runs CPPython's provider workflow + + # Install C++ dependencies first (creates generators/ with conan_toolchain.cmake) + project = TestConanCMake._create_project() + project.install() + + # Create dist directory for the wheel + dist_path = Path('dist') + dist_path.mkdir(exist_ok=True) + + # Build the wheel using the cppython.build backend directly + wheel_name = build_wheel(str(dist_path)) + + # Verify wheel was created + wheel_path = dist_path / wheel_name + assert wheel_path.exists(), f'Wheel not created at {wheel_path}' + + # Extract and test the extension + install_path = Path('install_target') + install_path.mkdir(exist_ok=True) + + with zipfile.ZipFile(wheel_path, 'r') as whl: + whl.extractall(install_path) + + # Test the installed extension by adding install_target to path + test_code = ( + f'import sys; sys.path.insert(0, {str(install_path)!r}); ' + "import example_extension; print(example_extension.format_greeting('Test'))" + ) + test_result = subprocess.run( + [sys.executable, '-c', test_code], + capture_output=True, + text=True, + check=False, + ) + assert test_result.returncode == 0, f'Extension test failed: {test_result.stderr}' + assert 'Hello, Test!' in test_result.stdout, f'Unexpected output: {test_result.stdout}' diff --git a/tests/integration/examples/test_examples.py b/tests/integration/examples/test_examples.py new file mode 100644 index 00000000..e980fab9 --- /dev/null +++ b/tests/integration/examples/test_examples.py @@ -0,0 +1,18 @@ +"""Example folder tests. + +All examples can be run with the CPPython entry-point, and we use the examples as the test data for the CLI. +""" + +from pathlib import Path + +pytest_plugins = ['tests.fixtures.example'] + + +class TestExamples: + """Verification that the example directory is setup correctly""" + + @staticmethod + def test_example_directory(example_directory: Path) -> None: + """Verify that the fixture is returning the right data""" + assert example_directory.is_dir() + assert (example_directory / 'pyproject.toml').is_file() diff --git a/tests/integration/examples/test_vcpkg_cmake.py b/tests/integration/examples/test_vcpkg_cmake.py new file mode 100644 index 00000000..367be9fd --- /dev/null +++ b/tests/integration/examples/test_vcpkg_cmake.py @@ -0,0 +1,80 @@ +"""Integration tests for the vcpkg and CMake project variation. + +This module contains integration tests for projects that use vcpkg and CMake. +The tests ensure that the projects build, configure, and execute correctly. +""" + +import subprocess +import tomllib +from pathlib import Path +from tomllib import loads + +import pytest +from typer.testing import CliRunner + +from cppython.console.schema import ConsoleInterface +from cppython.core.schema import ProjectConfiguration +from cppython.project import Project + +pytest_plugins = ['tests.fixtures.example', 'tests.fixtures.vcpkg', 'tests.fixtures.cmake'] + + +@pytest.mark.skip(reason='Address file locks.') +class TestVcpkgCMake: + """Test project variation of vcpkg and CMake""" + + @staticmethod + def _create_project(skip_upload: bool = True) -> Project: + """Create a project instance with common configuration.""" + project_root = Path.cwd() + config = ProjectConfiguration(project_root=project_root, version=None, verbosity=2, debug=True) + interface = ConsoleInterface() + + pyproject_path = project_root / 'pyproject.toml' + pyproject_data = loads(pyproject_path.read_text(encoding='utf-8')) + + if skip_upload: + TestVcpkgCMake._ensure_vcpkg_config(pyproject_data) + pyproject_data['tool']['cppython']['providers']['vcpkg']['skip_upload'] = True + + return Project(config, interface, pyproject_data) + + @staticmethod + def _ensure_vcpkg_config(pyproject_data: dict) -> None: + """Helper method to ensure Vcpkg configuration exists in pyproject data""" + if 'tool' not in pyproject_data: + pyproject_data['tool'] = {} + if 'cppython' not in pyproject_data['tool']: + pyproject_data['tool']['cppython'] = {} + if 'providers' not in pyproject_data['tool']['cppython']: + pyproject_data['tool']['cppython']['providers'] = {} + if 'vcpkg' not in pyproject_data['tool']['cppython']['providers']: + pyproject_data['tool']['cppython']['providers']['vcpkg'] = {} + + @staticmethod + def test_simple(example_runner: CliRunner) -> None: + """Simple project""" + # Read cmake_binary from the current pyproject.toml (we're in the example directory) + pyproject_path = Path.cwd() / 'pyproject.toml' + with pyproject_path.open('rb') as file: + pyproject_data = tomllib.load(file) + + cmake_binary = ( + pyproject_data.get('tool', {}) + .get('cppython', {}) + .get('generators', {}) + .get('cmake', {}) + .get('cmake_binary', 'cmake') + ) + + # Create project and install dependencies + project = TestVcpkgCMake._create_project(skip_upload=False) + project.install() + + # Run the CMake configuration command + result = subprocess.run([cmake_binary, '--preset=default'], capture_output=True, text=True, check=False) + + assert result.returncode == 0, f'Cmake failed: {result.stderr}' + + # Verify that the build directory contains the expected files + assert (Path('build') / 'CMakeCache.txt').exists(), 'build/CMakeCache.txt not found' diff --git a/tests/integration/plugins/__init__.py b/tests/integration/plugins/__init__.py new file mode 100644 index 00000000..e88b7fbf --- /dev/null +++ b/tests/integration/plugins/__init__.py @@ -0,0 +1,7 @@ +"""Integration tests for the CPPython plugins. + +This module contains integration tests for various CPPython plugins, ensuring that +each plugin behaves as expected under different conditions. The tests cover +different aspects of the plugins' functionality, including data generation, +installation, update processes, and feature extraction. +""" diff --git a/tests/integration/plugins/cmake/__init__.py b/tests/integration/plugins/cmake/__init__.py new file mode 100644 index 00000000..73a604d4 --- /dev/null +++ b/tests/integration/plugins/cmake/__init__.py @@ -0,0 +1,7 @@ +"""Integration tests for the CMake generator plugin. + +This module contains integration tests for the CMake generator plugin, ensuring that +the plugin behaves as expected under various conditions. The tests cover +different aspects of the plugin's functionality, including preset writing, +data synchronization, and feature extraction. +""" diff --git a/tests/integration/plugins/cmake/test_generator.py b/tests/integration/plugins/cmake/test_generator.py new file mode 100644 index 00000000..92a308ec --- /dev/null +++ b/tests/integration/plugins/cmake/test_generator.py @@ -0,0 +1,38 @@ +"""Integration tests for the provider""" + +from typing import Any + +import pytest + +from cppython.plugins.cmake.plugin import CMakeGenerator +from cppython.plugins.cmake.schema import CMakeConfiguration +from cppython.test.pytest.contracts import GeneratorIntegrationTestContract + +pytest_plugins = ['tests.fixtures.cmake'] + + +class TestCPPythonGenerator(GeneratorIntegrationTestContract[CMakeGenerator]): + """The tests for the CMake generator""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data(cmake_data: CMakeConfiguration) -> dict[str, Any]: + """A required testing hook that allows data generation + + Args: + cmake_data: The input data + + Returns: + The constructed plugin data + """ + return cmake_data.model_dump() + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[CMakeGenerator]: + """A required testing hook that allows type generation + + Returns: + The type of the Generator + """ + return CMakeGenerator diff --git a/tests/integration/plugins/conan/__init__.py b/tests/integration/plugins/conan/__init__.py new file mode 100644 index 00000000..12f45626 --- /dev/null +++ b/tests/integration/plugins/conan/__init__.py @@ -0,0 +1 @@ +"""Conan plugin integration tests""" diff --git a/tests/integration/plugins/conan/test_provider.py b/tests/integration/plugins/conan/test_provider.py new file mode 100644 index 00000000..761fff49 --- /dev/null +++ b/tests/integration/plugins/conan/test_provider.py @@ -0,0 +1,34 @@ +"""Integration tests for the provider""" + +from typing import Any + +import pytest + +from cppython.plugins.conan.plugin import ConanProvider +from cppython.test.pytest.contracts import ProviderIntegrationTestContract + +pytest_plugins = ['tests.fixtures.conan'] + + +class TestConanProvider(ProviderIntegrationTestContract[ConanProvider]): + """The tests for the conan provider""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data() -> dict[str, Any]: + """A required testing hook that allows data generation + + Returns: + The constructed plugin data + """ + return {} + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[ConanProvider]: + """A required testing hook that allows type generation + + Returns: + The type of the Provider + """ + return ConanProvider diff --git a/tests/integration/plugins/git/__init__.py b/tests/integration/plugins/git/__init__.py new file mode 100644 index 00000000..5f01350f --- /dev/null +++ b/tests/integration/plugins/git/__init__.py @@ -0,0 +1 @@ +"""Git plugin integration tests""" diff --git a/tests/integration/plugins/git/test_interface.py b/tests/integration/plugins/git/test_interface.py new file mode 100644 index 00000000..c67c2239 --- /dev/null +++ b/tests/integration/plugins/git/test_interface.py @@ -0,0 +1,20 @@ +"""Integration tests for the cppython SCM plugin""" + +import pytest + +from cppython.plugins.git.plugin import GitSCM +from cppython.test.pytest.contracts import SCMIntegrationTestContract + + +class TestGitInterface(SCMIntegrationTestContract[GitSCM]): + """Integration tests for the Git SCM plugin""" + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[GitSCM]: + """A required testing hook that allows type generation + + Returns: + The SCM type + """ + return GitSCM diff --git a/tests/integration/plugins/pdm/__init__.py b/tests/integration/plugins/pdm/__init__.py new file mode 100644 index 00000000..a38eeca2 --- /dev/null +++ b/tests/integration/plugins/pdm/__init__.py @@ -0,0 +1,7 @@ +"""Integration tests for the PDM interface plugin. + +This module contains integration tests for the PDM interface plugin, ensuring that +the plugin behaves as expected under various conditions. The tests cover +different aspects of the plugin's functionality, including project configuration +and integration with the PDM tool. +""" diff --git a/tests/integration/plugins/pdm/test_interface.py b/tests/integration/plugins/pdm/test_interface.py new file mode 100644 index 00000000..7a78e3b0 --- /dev/null +++ b/tests/integration/plugins/pdm/test_interface.py @@ -0,0 +1,38 @@ +"""Integration tests for the interface""" + +import pytest +from pdm.core import Core +from pytest_mock import MockerFixture + +from cppython.plugins.pdm.plugin import CPPythonPlugin + + +class TestCPPythonInterface: + """The tests for the PDM interface""" + + @staticmethod + @pytest.fixture(name='interface') + def fixture_interface(plugin_type: type[CPPythonPlugin]) -> CPPythonPlugin: + """A hook allowing implementations to override the fixture + + Args: + plugin_type: An input interface type + + Returns: + A newly constructed interface + """ + return plugin_type(Core()) + + @staticmethod + def test_entrypoint(mocker: MockerFixture) -> None: + """Verify that this project's plugin hook is setup correctly + + Args: + mocker: Mocker fixture for plugin patch + """ + patch = mocker.patch('cppython.plugins.pdm.plugin.CPPythonPlugin') + + core = Core() + core.load_plugins() + + assert patch.called diff --git a/tests/integration/plugins/vcpkg/__init__.py b/tests/integration/plugins/vcpkg/__init__.py new file mode 100644 index 00000000..1a0c18e4 --- /dev/null +++ b/tests/integration/plugins/vcpkg/__init__.py @@ -0,0 +1,7 @@ +"""Integration tests for the vcpkg provider plugin. + +This module contains integration tests for the vcpkg provider plugin, ensuring that +the plugin behaves as expected under various conditions. The tests cover +different aspects of the plugin's functionality, including data generation, +installation, and update processes. +""" diff --git a/tests/integration/plugins/vcpkg/test_provider.py b/tests/integration/plugins/vcpkg/test_provider.py new file mode 100644 index 00000000..4c120216 --- /dev/null +++ b/tests/integration/plugins/vcpkg/test_provider.py @@ -0,0 +1,33 @@ +"""Integration tests for the provider""" + +from typing import Any + +import pytest + +from cppython.plugins.vcpkg.plugin import VcpkgProvider +from cppython.test.pytest.contracts import ProviderIntegrationTestContract + + +@pytest.mark.skip(reason='Requires system dependencies (zip, unzip, tar) not available in all environments.') +class TestCPPythonProvider(ProviderIntegrationTestContract[VcpkgProvider]): + """The tests for the vcpkg provider""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data() -> dict[str, Any]: + """A required testing hook that allows data generation + + Returns: + The constructed plugin data + """ + return {} + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[VcpkgProvider]: + """A required testing hook that allows type generation + + Returns: + The type of the Provider + """ + return VcpkgProvider diff --git a/tests/integration/test/__init__.py b/tests/integration/test/__init__.py new file mode 100644 index 00000000..4e267f64 --- /dev/null +++ b/tests/integration/test/__init__.py @@ -0,0 +1,7 @@ +"""Integration tests for the public test harness used by CPPython plugins. + +This module contains integration tests for the public test harness that plugins +can use to ensure their functionality. The tests cover various aspects of the +plugin integration, including entry points, group names, and plugin-specific +features. +""" diff --git a/tests/integration/test/test_generator.py b/tests/integration/test/test_generator.py new file mode 100644 index 00000000..7e75f97a --- /dev/null +++ b/tests/integration/test/test_generator.py @@ -0,0 +1,32 @@ +"""Tests the integration test plugin""" + +from typing import Any + +import pytest + +from cppython.test.mock.generator import MockGenerator +from cppython.test.pytest.contracts import GeneratorIntegrationTestContract + + +class TestCPPythonGenerator(GeneratorIntegrationTestContract[MockGenerator]): + """The tests for the Mock generator""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data() -> dict[str, Any]: + """Returns mock data + + Returns: + An overridden data instance + """ + return {} + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[MockGenerator]: + """A required testing hook that allows type generation + + Returns: + An overridden generator type + """ + return MockGenerator diff --git a/tests/integration/test/test_provider.py b/tests/integration/test/test_provider.py new file mode 100644 index 00000000..0aaae822 --- /dev/null +++ b/tests/integration/test/test_provider.py @@ -0,0 +1,36 @@ +"""Test integrations related to the internal provider implementation. + +Test integrations related to the internal provider implementation and the +'Provider' interface itself. +""" + +from typing import Any + +import pytest + +from cppython.test.mock.provider import MockProvider +from cppython.test.pytest.contracts import ProviderIntegrationTestContract + + +class TestMockProvider(ProviderIntegrationTestContract[MockProvider]): + """The tests for our Mock provider""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data() -> dict[str, Any]: + """Returns mock data + + Returns: + An overridden data instance + """ + return {} + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[MockProvider]: + """A required testing hook that allows type generation + + Returns: + The overridden provider type + """ + return MockProvider diff --git a/tests/integration/test/test_scm.py b/tests/integration/test/test_scm.py new file mode 100644 index 00000000..f5db6094 --- /dev/null +++ b/tests/integration/test/test_scm.py @@ -0,0 +1,32 @@ +"""Tests the integration test plugin""" + +from typing import Any + +import pytest + +from cppython.test.mock.scm import MockSCM +from cppython.test.pytest.contracts import SCMIntegrationTestContract + + +class TestCPPythonSCM(SCMIntegrationTestContract[MockSCM]): + """The tests for the Mock version control""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data() -> dict[str, Any]: + """Returns mock data + + Returns: + An overridden data instance + """ + return {} + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[MockSCM]: + """A required testing hook that allows type generation + + Returns: + An overridden version control type + """ + return MockSCM diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 00000000..73173d57 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,7 @@ +"""Unit tests for the CPPython project. + +This module contains various unit tests to ensure the correct functionality of +different components within the CPPython project. The tests cover a wide range +of features, including plugin interfaces, project configurations, and utility +functions. +""" diff --git a/tests/unit/core/__init__.py b/tests/unit/core/__init__.py new file mode 100644 index 00000000..03146814 --- /dev/null +++ b/tests/unit/core/__init__.py @@ -0,0 +1,7 @@ +"""Unit tests for the core functionality of the CPPython project. + +This module contains unit tests for the core components of the CPPython project, +ensuring that the core functionality behaves as expected under various conditions. +The tests cover different aspects of the core functionality, including schema +validation, resolution processes, and plugin schema handling. +""" diff --git a/tests/unit/core/test_plugin_schema.py b/tests/unit/core/test_plugin_schema.py new file mode 100644 index 00000000..616d5d9d --- /dev/null +++ b/tests/unit/core/test_plugin_schema.py @@ -0,0 +1,113 @@ +"""Test plugin schemas""" + +import pytest + +from cppython.core.plugin_schema.generator import SyncConsumer +from cppython.core.plugin_schema.provider import SyncProducer +from cppython.core.schema import SyncData +from cppython.utility.utility import TypeName + + +class TestSchema: + """Test validation""" + + class GeneratorSyncDataSuccess(SyncData): + """Dummy generator data""" + + success: bool + + class GeneratorSyncDataFail(SyncData): + """Dummy generator data""" + + failure: bool + + class Consumer(SyncConsumer): + """Dummy consumer""" + + @staticmethod + def sync_types() -> list[type[SyncData]]: + """Fulfils protocol + + Returns: + Fulfils protocol + """ + return [TestSchema.GeneratorSyncDataSuccess, TestSchema.GeneratorSyncDataFail] + + @staticmethod + def sync(sync_data: SyncData) -> None: + """Fulfils protocol + + Args: + sync_data: Fulfils protocol + """ + if isinstance(sync_data, TestSchema.GeneratorSyncDataSuccess): + assert sync_data.success + else: + pytest.fail('Invalid sync data') + + class Producer(SyncProducer): + """Dummy producer""" + + @staticmethod + def supported_sync_type(sync_type: type[SyncData]) -> bool: + """Fulfils protocol + + Args: + sync_type: Fulfils protocol + + Returns: + Fulfils protocol + """ + return sync_type == TestSchema.GeneratorSyncDataSuccess + + @staticmethod + def sync_data(consumer: SyncConsumer) -> SyncData | None: + """Fulfils protocol + + Args: + consumer: Fulfils protocol + + Returns: + Fulfils protocol + """ + for sync_type in consumer.sync_types(): + if sync_type == TestSchema.GeneratorSyncDataSuccess: + return TestSchema.GeneratorSyncDataSuccess(provider_name=TypeName('Dummy'), success=True) + + return None + + def test_sync_broadcast(self) -> None: + """Verifies broadcast support""" + consumer = self.Consumer() + producer = self.Producer() + + types = consumer.sync_types() + + assert producer.supported_sync_type(types[0]) + assert not producer.supported_sync_type(types[1]) + + def test_sync_production(self) -> None: + """Verifies the variant behavior of SyncData""" + producer = self.Producer() + consumer = self.Consumer() + assert producer.sync_data(consumer) + + def test_sync_consumption(self) -> None: + """Verifies the variant behavior of SyncData""" + consumer = self.Consumer() + + data = self.GeneratorSyncDataSuccess(provider_name=TypeName('Dummy'), success=True) + consumer.sync(data) + + def test_sync_flow(self) -> None: + """Verifies the variant behavior of SyncData""" + consumer = self.Consumer() + producer = self.Producer() + + types = consumer.sync_types() + + for test in types: + if producer.supported_sync_type(test): + data = producer.sync_data(consumer) + if data: + consumer.sync(data) diff --git a/tests/unit/core/test_resolution.py b/tests/unit/core/test_resolution.py new file mode 100644 index 00000000..43b6f393 --- /dev/null +++ b/tests/unit/core/test_resolution.py @@ -0,0 +1,155 @@ +"""Test data resolution""" + +from typing import Annotated + +import pytest +from pydantic import Field + +from cppython.core.exception import ConfigException +from cppython.core.plugin_schema.generator import Generator +from cppython.core.plugin_schema.provider import Provider +from cppython.core.plugin_schema.scm import SCM +from cppython.core.resolution import ( + PluginCPPythonData, + resolve_cppython, + resolve_cppython_plugin, + resolve_generator, + resolve_model, + resolve_pep621, + resolve_project_configuration, + resolve_provider, + resolve_scm, +) +from cppython.core.schema import ( + CPPythonGlobalConfiguration, + CPPythonLocalConfiguration, + CPPythonModel, + PEP621Configuration, + ProjectConfiguration, +) +from cppython.utility.utility import TypeName + + +class TestResolve: + """Test resolution of data""" + + @staticmethod + def test_pep621_resolve(project_configuration: ProjectConfiguration) -> None: + """Test the PEP621 schema resolve function""" + data = PEP621Configuration(name='pep621-resolve-test', dynamic=['version']) + resolved = resolve_pep621(data, project_configuration, None) + + class_variables = vars(resolved) + + assert class_variables + assert None not in class_variables.values() + + @staticmethod + def test_project_resolve(project_configuration: ProjectConfiguration) -> None: + """Tests project configuration resolution""" + assert resolve_project_configuration(project_configuration) + + @staticmethod + def test_cppython_resolve(project_configuration: ProjectConfiguration) -> None: + """Tests cppython configuration resolution""" + cppython_local_configuration = CPPythonLocalConfiguration() + cppython_global_configuration = CPPythonGlobalConfiguration() + + project_data = resolve_project_configuration(project_configuration) + + plugin_build_data = PluginCPPythonData( + generator_name=TypeName('generator'), provider_name=TypeName('provider'), scm_name=TypeName('scm') + ) + + cppython_data = resolve_cppython( + cppython_local_configuration, cppython_global_configuration, project_data, plugin_build_data + ) + + assert cppython_data + + @staticmethod + def test_model_resolve() -> None: + """Test model resolution""" + + class MockModel(CPPythonModel): + """Mock model for testing""" + + field: Annotated[str, Field()] + + bad_data = {'field': 4} + + with pytest.raises(ConfigException): + resolve_model(MockModel, bad_data) + + good_data = {'field': 'good'} + + resolve_model(MockModel, good_data) + + @staticmethod + def test_generator_resolve(project_configuration: ProjectConfiguration) -> None: + """Test generator resolution""" + cppython_local_configuration = CPPythonLocalConfiguration() + cppython_global_configuration = CPPythonGlobalConfiguration() + + project_data = resolve_project_configuration(project_configuration) + + plugin_build_data = PluginCPPythonData( + generator_name=TypeName('generator'), provider_name=TypeName('provider'), scm_name=TypeName('scm') + ) + + cppython_data = resolve_cppython( + cppython_local_configuration, cppython_global_configuration, project_data, plugin_build_data + ) + + class MockGenerator(Generator): + """Mock generator for testing""" + + cppython_plugin_data = resolve_cppython_plugin(cppython_data, MockGenerator) + + assert resolve_generator(project_data, cppython_plugin_data) + + @staticmethod + def test_provider_resolve(project_configuration: ProjectConfiguration) -> None: + """Test provider resolution""" + cppython_local_configuration = CPPythonLocalConfiguration() + cppython_global_configuration = CPPythonGlobalConfiguration() + + project_data = resolve_project_configuration(project_configuration) + + plugin_build_data = PluginCPPythonData( + generator_name=TypeName('generator'), provider_name=TypeName('provider'), scm_name=TypeName('scm') + ) + + cppython_data = resolve_cppython( + cppython_local_configuration, cppython_global_configuration, project_data, plugin_build_data + ) + + class MockProvider(Provider): + """Mock provider for testing""" + + cppython_plugin_data = resolve_cppython_plugin(cppython_data, MockProvider) + + assert resolve_provider(project_data, cppython_plugin_data) + + @staticmethod + def test_scm_resolve(project_configuration: ProjectConfiguration) -> None: + """Test scm resolution""" + cppython_local_configuration = CPPythonLocalConfiguration() + cppython_global_configuration = CPPythonGlobalConfiguration() + + project_data = resolve_project_configuration(project_configuration) + + plugin_build_data = PluginCPPythonData( + generator_name=TypeName('generator'), provider_name=TypeName('provider'), scm_name=TypeName('scm') + ) + + cppython_data = resolve_cppython( + cppython_local_configuration, cppython_global_configuration, project_data, plugin_build_data + ) + + class MockSCM(SCM): + """Mock SCM for testing""" + + cppython_plugin_data = resolve_cppython_plugin(cppython_data, MockSCM) + + assert resolve_scm(project_data, cppython_plugin_data) diff --git a/tests/unit/core/test_schema.py b/tests/unit/core/test_schema.py new file mode 100644 index 00000000..0b2b515f --- /dev/null +++ b/tests/unit/core/test_schema.py @@ -0,0 +1,61 @@ +"""Test custom schema validation that cannot be verified by the Pydantic validation""" + +from tomllib import loads +from typing import Annotated + +import pytest +from pydantic import Field + +from cppython.core.schema import ( + CPPythonGlobalConfiguration, + CPPythonLocalConfiguration, + CPPythonModel, + PEP621Configuration, +) + + +class TestSchema: + """Test validation""" + + class Model(CPPythonModel): + """Testing Model""" + + aliased_variable: Annotated[bool, Field(alias='aliased-variable', description='Alias test')] = False + + def test_model_construction(self) -> None: + """Verifies that the base model type has the expected construction behaviors""" + model = self.Model(**{'aliased_variable': True}) + assert model.aliased_variable is True + + model = self.Model(**{'aliased-variable': True}) + assert model.aliased_variable is True + + def test_model_construction_from_data(self) -> None: + """Verifies that the base model type has the expected construction behaviors""" + toml_str = """ + aliased_variable = false\n + aliased-variable = true + """ + + data = loads(toml_str) + result = self.Model.model_validate(data) + assert result.aliased_variable is True + + @staticmethod + def test_cppython_local() -> None: + """Ensures that the CPPython local config data can be defaulted""" + CPPythonLocalConfiguration() + + @staticmethod + def test_cppython_global() -> None: + """Ensures that the CPPython global config data can be defaulted""" + CPPythonGlobalConfiguration() + + @staticmethod + def test_pep621_version() -> None: + """Tests the dynamic version validation""" + with pytest.raises(ValueError, match="'version' is not a dynamic field. It must be defined"): + PEP621Configuration(name='empty-test') + + with pytest.raises(ValueError, match="'version' is a dynamic field. It must not be defined"): + PEP621Configuration(name='both-test', version='1.0.0', dynamic=['version']) diff --git a/tests/unit/plugins/__init__.py b/tests/unit/plugins/__init__.py new file mode 100644 index 00000000..2a857ac1 --- /dev/null +++ b/tests/unit/plugins/__init__.py @@ -0,0 +1,7 @@ +"""Unit tests for the CPPython plugins. + +This module contains unit tests for various CPPython plugins, ensuring that +each plugin behaves as expected under different conditions. The tests cover +different aspects of the plugins' functionality, including data generation, +installation, update processes, and feature extraction. +""" diff --git a/tests/unit/plugins/cmake/__init__.py b/tests/unit/plugins/cmake/__init__.py new file mode 100644 index 00000000..94d6fa13 --- /dev/null +++ b/tests/unit/plugins/cmake/__init__.py @@ -0,0 +1,7 @@ +"""Unit tests for the CMake generator plugin. + +This module contains unit tests for the CMake generator plugin, ensuring that +the plugin behaves as expected under various conditions. The tests cover +different aspects of the plugin's functionality, including preset writing, +data synchronization, and feature extraction. +""" diff --git a/tests/unit/plugins/cmake/test_generator.py b/tests/unit/plugins/cmake/test_generator.py new file mode 100644 index 00000000..c6e42215 --- /dev/null +++ b/tests/unit/plugins/cmake/test_generator.py @@ -0,0 +1,40 @@ +"""Unit test the provider plugin""" + +from typing import Any + +import pytest + +from cppython.plugins.cmake.plugin import CMakeGenerator +from cppython.plugins.cmake.schema import ( + CMakeConfiguration, +) +from cppython.test.pytest.contracts import GeneratorUnitTestContract + +pytest_plugins = ['tests.fixtures.cmake'] + + +class TestCPPythonGenerator(GeneratorUnitTestContract[CMakeGenerator]): + """The tests for the CMake generator""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data(cmake_data: CMakeConfiguration) -> dict[str, Any]: + """A required testing hook that allows data generation + + Args: + cmake_data: The input data + + Returns: + The constructed plugin data + """ + return cmake_data.model_dump() + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[CMakeGenerator]: + """A required testing hook that allows type generation + + Returns: + The type of the Generator + """ + return CMakeGenerator diff --git a/tests/unit/plugins/cmake/test_presets.py b/tests/unit/plugins/cmake/test_presets.py new file mode 100644 index 00000000..2d21350f --- /dev/null +++ b/tests/unit/plugins/cmake/test_presets.py @@ -0,0 +1,150 @@ +"""Tests for CMakePresets""" + +import json + +from cppython.core.schema import ProjectData +from cppython.plugins.cmake.builder import Builder +from cppython.plugins.cmake.schema import CMakeData, CMakePresets, CMakeSyncData +from cppython.utility.utility import TypeName + + +class TestBuilder: + """Tests for the CMakePresets class""" + + @staticmethod + def test_generate_root_preset_new(project_data: ProjectData) -> None: + """Test generate_root_preset when the preset file does not exist""" + builder = Builder() + preset_file = project_data.project_root / 'CMakePresets.json' + cppython_preset_file = project_data.project_root / 'cppython.json' + cmake_data = CMakeData(preset_file=preset_file, configuration_name='test-configuration', cmake_binary=None) + + build_directory = project_data.project_root / 'build' + + # The function should create a new preset with the correct name and inheritance + result = builder.generate_root_preset(preset_file, cppython_preset_file, cmake_data, build_directory) + assert result.configurePresets is not None + assert any(p.name == 'test-configuration' for p in result.configurePresets) + + preset = next(p for p in result.configurePresets if p.name == 'test-configuration') + assert preset.inherits == 'cppython' + + @staticmethod + def test_generate_root_preset_existing(project_data: ProjectData) -> None: + """Test generate_root_preset when the preset file already exists""" + builder = Builder() + preset_file = project_data.project_root / 'CMakePresets.json' + cppython_preset_file = project_data.project_root / 'cppython.json' + cmake_data = CMakeData(preset_file=preset_file, configuration_name='test-configuration', cmake_binary=None) + + # Create an initial preset file with a different preset + initial_presets = CMakePresets(configurePresets=[]) + with open(preset_file, 'w', encoding='utf-8') as f: + f.write(initial_presets.model_dump_json(exclude_none=True, by_alias=False, indent=4)) + + build_directory = project_data.project_root / 'build' + + # Should add the new preset and include + result = builder.generate_root_preset(preset_file, cppython_preset_file, cmake_data, build_directory) + assert result.configurePresets is not None + assert any(p.name == 'test-configuration' for p in result.configurePresets) + + +class TestWrites: + """Tests for writing the CMakePresets class""" + + @staticmethod + def test_root_write(project_data: ProjectData) -> None: + """Verifies that the root preset writing works as intended + + Args: + project_data: The project data with a temporary workspace + """ + builder = Builder() + + cppython_preset_directory = project_data.project_root / 'cppython' + cppython_preset_directory.mkdir(parents=True, exist_ok=True) + + provider_directory = cppython_preset_directory / 'providers' + provider_directory.mkdir(parents=True, exist_ok=True) + + includes_file = provider_directory / 'includes.cmake' + with includes_file.open('w', encoding='utf-8') as file: + file.write('example contents') + + root_file = project_data.project_root / 'CMakePresets.json' + presets = CMakePresets() + + serialized = presets.model_dump_json(exclude_none=True, by_alias=False, indent=4) + with open(root_file, 'w', encoding='utf8') as file: + file.write(serialized) + + # Create a mock provider preset file + provider_preset_file = provider_directory / 'CMakePresets.json' + provider_preset_data = {'version': 3, 'configurePresets': [{'name': 'test-provider-base', 'hidden': True}]} + with provider_preset_file.open('w') as f: + json.dump(provider_preset_data, f) + + data = CMakeSyncData(provider_name=TypeName('test-provider')) + + cppython_preset_file = builder.write_cppython_preset( + cppython_preset_directory, provider_preset_file, data, project_data.project_root + ) + + build_directory = project_data.project_root / 'build' + builder.write_root_presets( + root_file, + cppython_preset_file, + CMakeData(preset_file=root_file, configuration_name='default', cmake_binary=None), + build_directory, + ) + + @staticmethod + def test_relative_root_write(project_data: ProjectData) -> None: + """Verifies that the root preset writing works as intended + + Args: + project_data: The project data with a temporary workspace + """ + builder = Builder() + + cppython_preset_directory = project_data.project_root / 'tool' / 'cppython' + cppython_preset_directory.mkdir(parents=True, exist_ok=True) + + provider_directory = cppython_preset_directory / 'providers' + provider_directory.mkdir(parents=True, exist_ok=True) + + includes_file = provider_directory / 'includes.cmake' + with includes_file.open('w', encoding='utf-8') as file: + file.write('example contents') + + relative_indirection = project_data.project_root / 'nested' + relative_indirection.mkdir(parents=True, exist_ok=True) + + root_file = relative_indirection / 'CMakePresets.json' + presets = CMakePresets() + serialized = presets.model_dump_json(exclude_none=True, by_alias=False, indent=4) + with open(root_file, 'w', encoding='utf8') as file: + file.write(serialized) + + # Create a mock provider preset file + provider_preset_file = provider_directory / 'CMakePresets.json' + provider_preset_data = {'version': 3, 'configurePresets': [{'name': 'test-provider-base', 'hidden': True}]} + with provider_preset_file.open('w') as f: + json.dump(provider_preset_data, f) + + data = CMakeSyncData(provider_name=TypeName('test-provider')) + + # For this test, the root file is in a relative indirection subdirectory + project_root = root_file.parent + cppython_preset_file = builder.write_cppython_preset( + cppython_preset_directory, provider_preset_file, data, project_root + ) + + build_directory = project_data.project_root / 'build' + builder.write_root_presets( + root_file, + cppython_preset_file, + CMakeData(preset_file=root_file, configuration_name='default', cmake_binary=None), + build_directory, + ) diff --git a/tests/unit/plugins/cmake/test_schema.py b/tests/unit/plugins/cmake/test_schema.py new file mode 100644 index 00000000..493d12ac --- /dev/null +++ b/tests/unit/plugins/cmake/test_schema.py @@ -0,0 +1,43 @@ +"""Tests for the CMake schema""" + +from cppython.plugins.cmake.schema import CacheVariable, VariableType + + +class TestCacheVariable: + """Tests for the CacheVariable class""" + + @staticmethod + def test_bool() -> None: + """Tests the CacheVariable class with a boolean value""" + var = CacheVariable(type=VariableType.BOOL, value=True) + assert var.type == VariableType.BOOL + assert var.value is True + + @staticmethod + def test_string() -> None: + """Tests the CacheVariable class with a string value""" + var = CacheVariable(type=VariableType.STRING, value='SomeValue') + assert var.type == VariableType.STRING + assert var.value == 'SomeValue' + + @staticmethod + def test_null_type() -> None: + """Tests the CacheVariable class with a null type""" + var = CacheVariable(type=None, value='Unset') + assert var.type is None + assert var.value == 'Unset' + + @staticmethod + def test_bool_value_as_string() -> None: + """Tests the CacheVariable class with a boolean value as a string""" + # CMake allows bool as "TRUE"/"FALSE" as well + var = CacheVariable(type=VariableType.BOOL, value='TRUE') + assert var.value == 'TRUE' + + @staticmethod + def test_type_optional() -> None: + """Tests the CacheVariable class with an optional type""" + # type is optional + var = CacheVariable(value='SomeValue') + assert var.type is None + assert var.value == 'SomeValue' diff --git a/tests/unit/plugins/conan/__init__.py b/tests/unit/plugins/conan/__init__.py new file mode 100644 index 00000000..3a03d8fc --- /dev/null +++ b/tests/unit/plugins/conan/__init__.py @@ -0,0 +1 @@ +"""Conan plugin unit tests""" diff --git a/tests/unit/plugins/conan/test_builder.py b/tests/unit/plugins/conan/test_builder.py new file mode 100644 index 00000000..a9b83327 --- /dev/null +++ b/tests/unit/plugins/conan/test_builder.py @@ -0,0 +1,189 @@ +"""Unit tests for Conan builder functionality.""" + +from pathlib import Path +from textwrap import dedent + +import pytest + +from cppython.plugins.conan.builder import Builder +from cppython.plugins.conan.schema import ConanDependency, ConanfileGenerationData, ConanVersion + + +class TestBuilder: + """Test the Conan Builder class.""" + + @pytest.fixture + def builder(self) -> Builder: + """Create a Builder instance for testing.""" + return Builder() + + def test_mixed_dependencies(self, builder: Builder, tmp_path: Path) -> None: + """Test base conanfile with both regular and test dependencies.""" + base_file = tmp_path / 'conanfile_base.py' + + dependencies = [ + ConanDependency(name='boost', version=ConanVersion.from_string('1.80.0')), + ] + dependency_groups = { + 'test': [ + ConanDependency(name='gtest', version=ConanVersion.from_string('1.14.0')), + ] + } + + builder._create_base_conanfile(base_file, dependencies, dependency_groups) + + assert base_file.exists() + content = base_file.read_text(encoding='utf-8') + assert 'self.requires("boost/1.80.0")' in content + assert 'self.test_requires("gtest/1.14.0")' in content + + def test_creates_both_files(self, builder: Builder, tmp_path: Path) -> None: + """Test generate_conanfile creates both base and user files.""" + dependencies = [ + ConanDependency(name='boost', version=ConanVersion.from_string('1.80.0')), + ] + dependency_groups = {} + + data = ConanfileGenerationData( + dependencies=dependencies, + dependency_groups=dependency_groups, + name='test-project', + version='1.0.0', + ) + builder.generate_conanfile( + directory=tmp_path, + data=data, + ) + + base_file = tmp_path / 'conanfile_base.py' + conan_file = tmp_path / 'conanfile.py' + + assert base_file.exists() + assert conan_file.exists() + + def test_regenerates_base_file(self, builder: Builder, tmp_path: Path) -> None: + """Test base file is always regenerated with new dependencies.""" + initial_dependencies = [ + ConanDependency(name='boost', version=ConanVersion.from_string('1.80.0')), + ] + + initial_data = ConanfileGenerationData( + dependencies=initial_dependencies, + dependency_groups={}, + name='test-project', + version='1.0.0', + ) + builder.generate_conanfile( + directory=tmp_path, + data=initial_data, + ) + + base_file = tmp_path / 'conanfile_base.py' + initial_content = base_file.read_text(encoding='utf-8') + assert 'boost/1.80.0' in initial_content + + updated_dependencies = [ + ConanDependency(name='zlib', version=ConanVersion.from_string('1.2.13')), + ] + + updated_data = ConanfileGenerationData( + dependencies=updated_dependencies, + dependency_groups={}, + name='test-project', + version='1.0.0', + ) + builder.generate_conanfile( + directory=tmp_path, + data=updated_data, + ) + + updated_content = base_file.read_text(encoding='utf-8') + assert 'zlib/1.2.13' in updated_content + assert 'boost/1.80.0' not in updated_content + + def test_preserves_user_file(self, builder: Builder, tmp_path: Path) -> None: + """Test user conanfile is never modified once created.""" + conan_file = tmp_path / 'conanfile.py' + custom_content = dedent(""" + from conanfile_base import CPPythonBase + + class CustomPackage(CPPythonBase): + name = "custom" + version = "1.0.0" + + def requirements(self): + super().requirements() + self.requires("custom-lib/1.0.0") + """) + conan_file.write_text(custom_content) + + dependencies = [ + ConanDependency(name='boost', version=ConanVersion.from_string('1.80.0')), + ] + + data = ConanfileGenerationData( + dependencies=dependencies, + dependency_groups={}, + name='new-name', + version='2.0.0', + ) + builder.generate_conanfile( + directory=tmp_path, + data=data, + ) + + final_content = conan_file.read_text() + assert final_content == custom_content + assert 'CustomPackage' in final_content + assert 'custom-lib/1.0.0' in final_content + + def test_inheritance_chain(self, builder: Builder, tmp_path: Path) -> None: + """Test complete inheritance chain from base to user file.""" + dependencies = [ + ConanDependency(name='boost', version=ConanVersion.from_string('1.80.0')), + ConanDependency(name='zlib', version=ConanVersion.from_string('1.2.13')), + ] + dependency_groups = { + 'test': [ + ConanDependency(name='gtest', version=ConanVersion.from_string('1.14.0')), + ] + } + + data = ConanfileGenerationData( + dependencies=dependencies, + dependency_groups=dependency_groups, + name='test-project', + version='1.0.0', + ) + builder.generate_conanfile( + directory=tmp_path, + data=data, + ) + + base_content = (tmp_path / 'conanfile_base.py').read_text(encoding='utf-8') + user_content = (tmp_path / 'conanfile.py').read_text(encoding='utf-8') + + assert 'self.requires("boost/1.80.0")' in base_content + assert 'self.requires("zlib/1.2.13")' in base_content + assert 'self.test_requires("gtest/1.14.0")' in base_content + + assert 'from conanfile_base import CPPythonBase' in user_content + assert 'class TestProjectPackage(CPPythonBase):' in user_content + assert 'super().requirements()' in user_content + assert 'super().build_requirements()' in user_content + + +class TestConanfileContent: + """Tests for conanfile.py template content generation.""" + + @pytest.fixture + def builder(self) -> Builder: + """Create a Builder instance for testing.""" + return Builder() + + def test_conanfile_content_is_valid_python(self, builder: Builder, tmp_path: Path) -> None: + """_conanfile_content returns valid Python without version markers.""" + content = Builder._conanfile_content('my-project', '0.1.0') + assert 'cppython-template-version' not in content + assert 'from conanfile_base import CPPythonBase' in content + assert 'class MyProjectPackage(CPPythonBase):' in content diff --git a/tests/unit/plugins/conan/test_install.py b/tests/unit/plugins/conan/test_install.py new file mode 100644 index 00000000..74fd89f6 --- /dev/null +++ b/tests/unit/plugins/conan/test_install.py @@ -0,0 +1,38 @@ +"""Unit tests for the conan plugin install functionality""" + +from typing import Any + +import pytest + +from cppython.plugins.conan.plugin import ConanProvider +from cppython.test.pytest.mixins import ProviderPluginTestMixin + +# Use shared fixtures +pytest_plugins = ['tests.fixtures.conan'] + +# Constants for test verification +EXPECTED_DEPENDENCY_COUNT = 2 + + +class TestConanInstall(ProviderPluginTestMixin[ConanProvider]): + """Tests for the Conan provider install functionality""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data(conan_plugin_data: dict[str, Any]) -> dict[str, Any]: + """A required testing hook that allows data generation + + Returns: + The constructed plugin data + """ + return conan_plugin_data + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[ConanProvider]: + """A required testing hook that allows type generation + + Returns: + The type of the Provider + """ + return ConanProvider diff --git a/tests/unit/plugins/conan/test_provider.py b/tests/unit/plugins/conan/test_provider.py new file mode 100644 index 00000000..94e75eee --- /dev/null +++ b/tests/unit/plugins/conan/test_provider.py @@ -0,0 +1,32 @@ +"""Unit test the provider plugin""" + +from typing import Any + +import pytest + +from cppython.plugins.conan.plugin import ConanProvider +from cppython.test.pytest.contracts import ProviderUnitTestContract + + +class TestConanProvider(ProviderUnitTestContract[ConanProvider]): + """The tests for the Conan Provider""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data(conan_plugin_data: dict[str, Any]) -> dict[str, Any]: + """A required testing hook that allows data generation + + Returns: + The constructed plugin data + """ + return conan_plugin_data + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[ConanProvider]: + """A required testing hook that allows type generation + + Returns: + The type of the Provider + """ + return ConanProvider diff --git a/tests/unit/plugins/conan/test_publish.py b/tests/unit/plugins/conan/test_publish.py new file mode 100644 index 00000000..c1bd7609 --- /dev/null +++ b/tests/unit/plugins/conan/test_publish.py @@ -0,0 +1,37 @@ +"""Unit tests for the conan plugin publish functionality""" + +from typing import Any + +import pytest + +from cppython.plugins.conan.plugin import ConanProvider +from cppython.test.pytest.mixins import ProviderPluginTestMixin + +# Use shared fixtures +pytest_plugins = ['tests.fixtures.conan'] + + +class TestConanPublish(ProviderPluginTestMixin[ConanProvider]): + """Tests for the Conan provider publish functionality""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data() -> dict[str, Any]: + """A required testing hook that allows data generation + + Returns: + The constructed plugin data + """ + return { + 'remotes': ['conancenter'], + } + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[ConanProvider]: + """A required testing hook that allows type generation + + Returns: + The type of the Provider + """ + return ConanProvider diff --git a/tests/unit/plugins/conan/test_resolution.py b/tests/unit/plugins/conan/test_resolution.py new file mode 100644 index 00000000..7f5889e5 --- /dev/null +++ b/tests/unit/plugins/conan/test_resolution.py @@ -0,0 +1,204 @@ +"""Unit tests for Conan resolution functionality.""" + +import pytest +from packaging.requirements import Requirement + +from cppython.core.exception import ConfigException +from cppython.plugins.conan.resolution import ( + resolve_conan_dependency, +) +from cppython.plugins.conan.schema import ( + ConanDependency, + ConanRevision, + ConanUserChannel, + ConanVersion, + ConanVersionRange, +) + +# Constants for test validation +EXPECTED_PROFILE_CALL_COUNT = 2 + + +class TestResolveDependency: + """Test dependency resolution.""" + + def test_with_version(self) -> None: + """Test resolving a dependency with a >= version specifier.""" + requirement = Requirement('boost>=1.80.0') + + result = resolve_conan_dependency(requirement) + + assert result.name == 'boost' + assert result.version_range is not None + assert result.version_range.expression == '>=1.80.0' + assert result.version is None + + def test_with_exact_version(self) -> None: + """Test resolving a dependency with an exact version specifier.""" + requirement = Requirement('abseil==20240116.2') + + result = resolve_conan_dependency(requirement) + + assert result.name == 'abseil' + assert result.version is not None + assert str(result.version) == '20240116.2' + assert result.version_range is None + + def test_without_version(self) -> None: + """Test resolving a dependency without a version specifier.""" + requirement = Requirement('boost') + + result = resolve_conan_dependency(requirement) + + assert result.name == 'boost' + assert result.version is None + assert result.version_range is None + + def test_compatible_release(self) -> None: + """Test resolving a dependency with ~= (compatible release) operator.""" + requirement = Requirement('package~=1.2.3') + + result = resolve_conan_dependency(requirement) + + assert result.name == 'package' + assert result.version_range is not None + assert result.version_range.expression == '~1.2' + assert result.version is None + + def test_multiple_specifiers(self) -> None: + """Test resolving a dependency with multiple specifiers.""" + requirement = Requirement('boost>=1.80.0,<2.0.0') + + result = resolve_conan_dependency(requirement) + + assert result.name == 'boost' + assert result.version_range is not None + assert result.version_range.expression == '>=1.80.0 <2.0.0' + assert result.version is None + + def test_unsupported_operator(self) -> None: + """Test that unsupported operators raise an error.""" + requirement = Requirement('boost===1.80.0') + + with pytest.raises(ConfigException, match="Unsupported single specifier '==='"): + resolve_conan_dependency(requirement) + + def test_contradictory_exact_versions(self) -> None: + """Test that multiple specifiers work correctly for valid ranges.""" + # Test our logic with a valid range instead of invalid syntax + requirement = Requirement('package>=1.0,<=2.0') # Valid range + result = resolve_conan_dependency(requirement) + + assert result.name == 'package' + assert result.version_range is not None + assert result.version_range.expression == '>=1.0 <=2.0' + + def test_requires_exact_version(self) -> None: + """Test that ConanDependency generates correct requires for exact versions.""" + dependency = ConanDependency(name='abseil', version=ConanVersion.from_string('20240116.2')) + + assert dependency.requires() == 'abseil/20240116.2' + + def test_requires_version_range(self) -> None: + """Test that ConanDependency generates correct requires for version ranges.""" + dependency = ConanDependency(name='boost', version_range=ConanVersionRange(expression='>=1.80.0 <2.0')) + + assert dependency.requires() == 'boost/[>=1.80.0 <2.0]' + + def test_requires_legacy_minimum_version(self) -> None: + """Test that ConanDependency generates correct requires for legacy minimum versions.""" + dependency = ConanDependency(name='boost', version_range=ConanVersionRange(expression='>=1.80.0')) + + assert dependency.requires() == 'boost/[>=1.80.0]' + + def test_requires_legacy_exact_version(self) -> None: + """Test that ConanDependency generates correct requires for legacy exact versions.""" + dependency = ConanDependency(name='abseil', version=ConanVersion.from_string('20240116.2')) + + assert dependency.requires() == 'abseil/20240116.2' + + def test_requires_no_version(self) -> None: + """Test that ConanDependency generates correct requires for dependencies without version.""" + dependency = ConanDependency(name='somelib') + + assert dependency.requires() == 'somelib' + + def test_with_user_channel(self) -> None: + """Test that ConanDependency handles user/channel correctly.""" + dependency = ConanDependency( + name='example', + version=ConanVersion.from_string('1.0.0'), + user_channel=ConanUserChannel(user='myuser', channel='stable'), + ) + + assert dependency.requires() == 'example/1.0.0@myuser/stable' + + def test_with_revision(self) -> None: + """Test that ConanDependency handles revisions correctly.""" + dependency = ConanDependency( + name='example', version=ConanVersion.from_string('1.0.0'), revision=ConanRevision(revision='abc123') + ) + + assert dependency.requires() == 'example/1.0.0#abc123' + + def test_full_reference(self) -> None: + """Test that ConanDependency handles full references correctly.""" + dependency = ConanDependency( + name='example', + version=ConanVersion.from_string('1.0.0'), + user_channel=ConanUserChannel(user='myuser', channel='stable'), + revision=ConanRevision(revision='abc123'), + ) + + assert dependency.requires() == 'example/1.0.0@myuser/stable#abc123' + + def test_from_reference_simple(self) -> None: + """Test parsing a simple package name.""" + dependency = ConanDependency.from_conan_reference('example') + + assert dependency.name == 'example' + assert dependency.version is None + assert dependency.user_channel is None + assert dependency.revision is None + + def test_from_reference_with_version(self) -> None: + """Test parsing a package with version.""" + dependency = ConanDependency.from_conan_reference('example/1.0.0') + + assert dependency.name == 'example' + assert dependency.version is not None + assert str(dependency.version) == '1.0.0' + assert dependency.user_channel is None + assert dependency.revision is None + + def test_from_reference_with_version_range(self) -> None: + """Test parsing a package with version range.""" + dependency = ConanDependency.from_conan_reference('example/[>=1.0 <2.0]') + + assert dependency.name == 'example' + assert dependency.version is None + assert dependency.version_range is not None + assert dependency.version_range.expression == '>=1.0 <2.0' + assert dependency.user_channel is None + assert dependency.revision is None + + def test_from_reference_full(self) -> None: + """Test parsing a full Conan reference.""" + dependency = ConanDependency.from_conan_reference('example/1.0.0@myuser/stable#abc123') + + assert dependency.name == 'example' + assert dependency.version is not None + assert str(dependency.version) == '1.0.0' + assert dependency.user_channel is not None + assert dependency.user_channel.user == 'myuser' + assert dependency.user_channel.channel == 'stable' + assert dependency.revision is not None + assert dependency.revision.revision == 'abc123' + + +class TestResolveProfiles: + """Test profile resolution functionality.""" + + +class TestResolveConanData: + """Test Conan data resolution.""" diff --git a/tests/unit/plugins/conan/test_update.py b/tests/unit/plugins/conan/test_update.py new file mode 100644 index 00000000..b39e780f --- /dev/null +++ b/tests/unit/plugins/conan/test_update.py @@ -0,0 +1,39 @@ +"""Unit tests for the conan plugin update functionality + +This module tests the update-specific behavior and differences from install. +The core installation functionality is tested in test_install.py since both +install() and update() now share the same underlying implementation. +""" + +from typing import Any + +import pytest + +from cppython.plugins.conan.plugin import ConanProvider +from cppython.test.pytest.mixins import ProviderPluginTestMixin + +pytest_plugins = ['tests.fixtures.conan'] + + +class TestConanUpdate(ProviderPluginTestMixin[ConanProvider]): + """Tests for the Conan provider update-specific functionality""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data(conan_plugin_data: dict[str, Any]) -> dict[str, Any]: + """A required testing hook that allows data generation + + Returns: + The constructed plugin data + """ + return conan_plugin_data + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[ConanProvider]: + """A required testing hook that allows type generation + + Returns: + The type of the Provider + """ + return ConanProvider diff --git a/tests/unit/plugins/git/__init__.py b/tests/unit/plugins/git/__init__.py new file mode 100644 index 00000000..ba3d6609 --- /dev/null +++ b/tests/unit/plugins/git/__init__.py @@ -0,0 +1,7 @@ +"""Unit tests for the Git SCM plugin. + +This module contains unit tests for the Git SCM plugin, ensuring that +the plugin behaves as expected under various conditions. The tests cover +different aspects of the plugin's functionality, including feature extraction, +version control operations, and project description handling. +""" diff --git a/tests/unit/plugins/git/test_version_control.py b/tests/unit/plugins/git/test_version_control.py new file mode 100644 index 00000000..e0c85cfd --- /dev/null +++ b/tests/unit/plugins/git/test_version_control.py @@ -0,0 +1,20 @@ +"""Unit tests for the cppython SCM plugin""" + +import pytest + +from cppython.plugins.git.plugin import GitSCM +from cppython.test.pytest.contracts import SCMUnitTestContract + + +class TestGitInterface(SCMUnitTestContract[GitSCM]): + """Unit tests for the Git SCM plugin""" + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[GitSCM]: + """A required testing hook that allows type generation + + Returns: + The SCM type + """ + return GitSCM diff --git a/tests/unit/plugins/meson/__init__.py b/tests/unit/plugins/meson/__init__.py new file mode 100644 index 00000000..928f71b3 --- /dev/null +++ b/tests/unit/plugins/meson/__init__.py @@ -0,0 +1,7 @@ +"""Unit tests for the Meson generator plugin. + +This module contains unit tests for the Meson generator plugin, ensuring that +the plugin behaves as expected under various conditions. The tests cover +different aspects of the plugin's functionality, including native/cross file +writing, data synchronization, and feature extraction. +""" diff --git a/tests/unit/plugins/meson/test_generator.py b/tests/unit/plugins/meson/test_generator.py new file mode 100644 index 00000000..cca974b7 --- /dev/null +++ b/tests/unit/plugins/meson/test_generator.py @@ -0,0 +1,40 @@ +"""Unit test the Meson generator plugin""" + +from typing import Any + +import pytest + +from cppython.plugins.meson.plugin import MesonGenerator +from cppython.plugins.meson.schema import ( + MesonConfiguration, +) +from cppython.test.pytest.contracts import GeneratorUnitTestContract + +pytest_plugins = ['tests.fixtures.meson'] + + +class TestMesonGenerator(GeneratorUnitTestContract[MesonGenerator]): + """The tests for the Meson generator""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data(meson_data: MesonConfiguration) -> dict[str, Any]: + """A required testing hook that allows data generation. + + Args: + meson_data: The input data + + Returns: + The constructed plugin data + """ + return meson_data.model_dump() + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[MesonGenerator]: + """A required testing hook that allows type generation. + + Returns: + The type of the Generator + """ + return MesonGenerator diff --git a/tests/unit/plugins/meson/test_schema.py b/tests/unit/plugins/meson/test_schema.py new file mode 100644 index 00000000..494ade26 --- /dev/null +++ b/tests/unit/plugins/meson/test_schema.py @@ -0,0 +1,82 @@ +"""Tests for the Meson schema""" + +from pathlib import Path + +from cppython.plugins.meson.schema import MesonConfiguration, MesonData, MesonSyncData +from cppython.utility.utility import TypeName + + +class TestMesonSyncData: + """Tests for the MesonSyncData class""" + + @staticmethod + def test_default() -> None: + """Tests MesonSyncData with default values.""" + data = MesonSyncData(provider_name=TypeName('test')) + assert data.native_file is None + assert data.cross_file is None + + @staticmethod + def test_native_file() -> None: + """Tests MesonSyncData with a native file.""" + data = MesonSyncData(provider_name=TypeName('conan'), native_file=Path('/path/to/native.ini')) + assert data.native_file == Path('/path/to/native.ini') + assert data.cross_file is None + + @staticmethod + def test_cross_file() -> None: + """Tests MesonSyncData with a cross file.""" + data = MesonSyncData(provider_name=TypeName('conan'), cross_file=Path('/path/to/cross.ini')) + assert data.native_file is None + assert data.cross_file == Path('/path/to/cross.ini') + + @staticmethod + def test_both_files() -> None: + """Tests MesonSyncData with both native and cross files.""" + data = MesonSyncData( + provider_name=TypeName('conan'), + native_file=Path('/path/to/native.ini'), + cross_file=Path('/path/to/cross.ini'), + ) + assert data.native_file == Path('/path/to/native.ini') + assert data.cross_file == Path('/path/to/cross.ini') + + +class TestMesonConfiguration: + """Tests for the MesonConfiguration class""" + + @staticmethod + def test_defaults() -> None: + """Tests MesonConfiguration with default values.""" + config = MesonConfiguration() + assert config.build_file == Path('meson.build') + assert config.build_directory == 'builddir' + assert config.meson_binary is None + + @staticmethod + def test_custom_values() -> None: + """Tests MesonConfiguration with custom values.""" + config = MesonConfiguration( + build_file=Path('subdir/meson.build'), + build_directory='custom-build', + meson_binary=Path('/usr/bin/meson'), + ) + assert config.build_file == Path('subdir/meson.build') + assert config.build_directory == 'custom-build' + assert config.meson_binary == Path('/usr/bin/meson') + + +class TestMesonData: + """Tests for the MesonData class""" + + @staticmethod + def test_construction() -> None: + """Tests MesonData construction.""" + data = MesonData( + build_file=Path('/project/meson.build'), + build_directory='builddir', + meson_binary=None, + ) + assert data.build_file == Path('/project/meson.build') + assert data.build_directory == 'builddir' + assert data.meson_binary is None diff --git a/tests/unit/plugins/pdm/__init__.py b/tests/unit/plugins/pdm/__init__.py new file mode 100644 index 00000000..f081a058 --- /dev/null +++ b/tests/unit/plugins/pdm/__init__.py @@ -0,0 +1,7 @@ +"""Unit tests for the PDM interface plugin. + +This module contains unit tests for the PDM interface plugin, ensuring that +the plugin behaves as expected under various conditions. The tests cover +different aspects of the plugin's functionality, including project configuration +and integration with the PDM tool. +""" diff --git a/tests/unit/plugins/pdm/test_interface.py b/tests/unit/plugins/pdm/test_interface.py new file mode 100644 index 00000000..fd1dccba --- /dev/null +++ b/tests/unit/plugins/pdm/test_interface.py @@ -0,0 +1,33 @@ +"""Unit tests for the interface""" + +import pytest +from pdm.core import Core +from pdm.project.core import Project + +from cppython.plugins.pdm.plugin import CPPythonPlugin + + +class TestCPPythonInterface: + """The tests for the PDM interface""" + + @staticmethod + @pytest.fixture(name='interface') + def fixture_interface(plugin_type: type[CPPythonPlugin]) -> CPPythonPlugin: + """A hook allowing implementations to override the fixture + + Args: + plugin_type: An input interface type + + Returns: + A newly constructed interface + """ + return plugin_type(Core()) + + @staticmethod + def test_pdm_project() -> None: + """Verify that this PDM won't return empty data""" + core = Core() + core.load_plugins() + pdm_project = Project(core, root_path=None) + + assert pdm_project diff --git a/tests/unit/plugins/vcpkg/__init__.py b/tests/unit/plugins/vcpkg/__init__.py new file mode 100644 index 00000000..8b02cf60 --- /dev/null +++ b/tests/unit/plugins/vcpkg/__init__.py @@ -0,0 +1,7 @@ +"""Unit tests for the vcpkg provider plugin. + +This module contains unit tests for the vcpkg provider plugin, ensuring that +the plugin behaves as expected under various conditions. The tests cover +different aspects of the plugin's functionality, including data generation, +installation, and update processes. +""" diff --git a/tests/unit/plugins/vcpkg/test_provider.py b/tests/unit/plugins/vcpkg/test_provider.py new file mode 100644 index 00000000..a31d56e7 --- /dev/null +++ b/tests/unit/plugins/vcpkg/test_provider.py @@ -0,0 +1,32 @@ +"""Unit test the provider plugin""" + +from typing import Any + +import pytest + +from cppython.plugins.vcpkg.plugin import VcpkgProvider +from cppython.test.pytest.contracts import ProviderUnitTestContract + + +class TestCPPythonProvider(ProviderUnitTestContract[VcpkgProvider]): + """The tests for the vcpkg Provider""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data() -> dict[str, Any]: + """A required testing hook that allows data generation + + Returns: + The constructed plugin data + """ + return {} + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[VcpkgProvider]: + """A required testing hook that allows type generation + + Returns: + The type of the Provider + """ + return VcpkgProvider diff --git a/tests/unit/plugins/vcpkg/test_resolution.py b/tests/unit/plugins/vcpkg/test_resolution.py new file mode 100644 index 00000000..927eb7f6 --- /dev/null +++ b/tests/unit/plugins/vcpkg/test_resolution.py @@ -0,0 +1,23 @@ +"""Unit tests for the Vcpkg resolution plugin.""" + +from packaging.requirements import Requirement + +from cppython.plugins.vcpkg.resolution import resolve_vcpkg_dependency + + +class TestVcpkgResolution: + """Test the resolution of Vcpkg dependencies""" + + @staticmethod + def test_dependency_resolution() -> None: + """Test resolving a VcpkgDependency from a packaging requirement.""" + requirement = Requirement('example-package>=1.2.3') + + dependency = resolve_vcpkg_dependency(requirement) + + assert dependency.name == 'example-package' + assert dependency.version_ge == '1.2.3' + assert dependency.default_features is True + assert dependency.features == [] + assert dependency.platform is None + assert dependency.host is False diff --git a/tests/unit/test/__init__.py b/tests/unit/test/__init__.py new file mode 100644 index 00000000..28f02b8b --- /dev/null +++ b/tests/unit/test/__init__.py @@ -0,0 +1,6 @@ +"""Unit tests for the public test harness used by CPPython plugins. + +This module contains tests for various utility functions, including subprocess +calls, logging, and name canonicalization. The tests ensure that the utility +functions behave as expected under different conditions. +""" diff --git a/tests/unit/test/test_generator.py b/tests/unit/test/test_generator.py new file mode 100644 index 00000000..ca2caa44 --- /dev/null +++ b/tests/unit/test/test_generator.py @@ -0,0 +1,32 @@ +"""Tests the integration test plugin""" + +from typing import Any + +import pytest + +from cppython.test.mock.generator import MockGenerator +from cppython.test.pytest.contracts import GeneratorUnitTestContract + + +class TestCPPythonGenerator(GeneratorUnitTestContract[MockGenerator]): + """The tests for the Mock generator""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data() -> dict[str, Any]: + """Returns mock data + + Returns: + An overridden data instance + """ + return {} + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[MockGenerator]: + """A required testing hook that allows type generation + + Returns: + An overridden generator type + """ + return MockGenerator diff --git a/tests/unit/test/test_provider.py b/tests/unit/test/test_provider.py new file mode 100644 index 00000000..2c56ede6 --- /dev/null +++ b/tests/unit/test/test_provider.py @@ -0,0 +1,51 @@ +"""Test functions related to the internal provider implementation. + +Test functions related to the internal provider implementation and the +'Provider' interface itself. +""" + +from typing import Any + +import pytest +from pytest_mock import MockerFixture + +from cppython.test.mock.generator import MockGenerator +from cppython.test.mock.provider import MockProvider +from cppython.test.pytest.contracts import ProviderUnitTestContract + + +class TestMockProvider(ProviderUnitTestContract[MockProvider]): + """The tests for our Mock provider""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_provider_data() -> dict[str, Any]: + """Returns mock data + + Returns: + An overridden data instance + """ + return {} + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[MockProvider]: + """A required testing hook that allows type generation + + Returns: + An overridden provider type + """ + return MockProvider + + @staticmethod + def test_sync_types(plugin: MockProvider, mocker: MockerFixture) -> None: + """Verify that the mock provider can handle the mock generator's sync data + + Args: + plugin: The plugin instance + mocker: The pytest-mock fixture + """ + mock_generator = mocker.Mock(spec=MockGenerator) + mock_generator.sync_types.return_value = MockGenerator.sync_types() + + assert plugin.sync_data(mock_generator) diff --git a/tests/unit/test/test_scm.py b/tests/unit/test/test_scm.py new file mode 100644 index 00000000..00135d67 --- /dev/null +++ b/tests/unit/test/test_scm.py @@ -0,0 +1,32 @@ +"""Tests the unit test plugin""" + +from typing import Any + +import pytest + +from cppython.test.mock.scm import MockSCM +from cppython.test.pytest.contracts import SCMUnitTestContract + + +class TestCPPythonSCM(SCMUnitTestContract[MockSCM]): + """The tests for the Mock version control""" + + @staticmethod + @pytest.fixture(name='plugin_data', scope='session') + def fixture_plugin_data() -> dict[str, Any]: + """Returns mock data + + Returns: + An overridden data instance + """ + return {} + + @staticmethod + @pytest.fixture(name='plugin_type', scope='session') + def fixture_plugin_type() -> type[MockSCM]: + """A required testing hook that allows type generation + + Returns: + An overridden version control type + """ + return MockSCM diff --git a/tests/unit/test_builder.py b/tests/unit/test_builder.py new file mode 100644 index 00000000..9a9fbb6e --- /dev/null +++ b/tests/unit/test_builder.py @@ -0,0 +1,71 @@ +"""Tests the Builder and Resolver types""" + +import logging +from importlib import metadata + +from pytest_mock import MockerFixture + +from cppython.builder import Builder, Resolver +from cppython.core.schema import ( + CPPythonLocalConfiguration, + PEP621Configuration, + ProjectConfiguration, + ProjectData, +) +from cppython.test.mock.generator import MockGenerator +from cppython.test.mock.provider import MockProvider +from cppython.test.mock.scm import MockSCM + + +class TestBuilder: + """Various tests for the Builder type""" + + @staticmethod + def test_build( + project_configuration: ProjectConfiguration, + pep621_configuration: PEP621Configuration, + cppython_local_configuration: CPPythonLocalConfiguration, + mocker: MockerFixture, + ) -> None: + """Verifies that the builder can build a project with all test variants + + Args: + project_configuration: Variant fixture for the project configuration + pep621_configuration: Variant fixture for PEP 621 configuration + cppython_local_configuration: Variant fixture for cppython configuration + mocker: Pytest mocker fixture + """ + logger = logging.getLogger() + builder = Builder(project_configuration, logger) + + # Insert ourself into the builder and load the mock plugins by returning them directly in the expected order + # they will be built + mocker.patch( + 'cppython.builder.entry_points', + return_value=[metadata.EntryPoint(name='mock', value='mock', group='mock')], + ) + mocker.patch.object(metadata.EntryPoint, 'load', side_effect=[MockGenerator, MockProvider, MockSCM]) + + assert builder.build(pep621_configuration, cppython_local_configuration) + + +class TestResolver: + """Various tests for the Resolver type""" + + @staticmethod + def test_generate_plugins( + project_configuration: ProjectConfiguration, + cppython_local_configuration: CPPythonLocalConfiguration, + project_data: ProjectData, + ) -> None: + """Verifies that the resolver can generate plugins + + Args: + project_configuration: Variant fixture for the project configuration + cppython_local_configuration: Variant fixture for cppython configuration + project_data: Variant fixture for the project data + """ + logger = logging.getLogger() + resolver = Resolver(project_configuration, logger) + + assert resolver.generate_plugins(cppython_local_configuration, project_data) diff --git a/tests/unit/test_configuration.py b/tests/unit/test_configuration.py new file mode 100644 index 00000000..c5c18238 --- /dev/null +++ b/tests/unit/test_configuration.py @@ -0,0 +1,507 @@ +"""Tests for the configuration loading and merging system""" + +from pathlib import Path + +import pytest + +from cppython.configuration import ConfigurationLoader + + +class TestConfigurationLoader: + """Tests for ConfigurationLoader class""" + + def test_load_pyproject_only(self, tmp_path: Path) -> None: + """Test loading configuration from pyproject.toml only""" + pyproject_path = tmp_path / 'pyproject.toml' + pyproject_path.write_text( + """ +[project] +name = "test-project" +version = "1.0.0" + +[tool.cppython] +install-path = ".cppython" +dependencies = ["fmt>=10.0.0"] +""", + encoding='utf-8', + ) + + loader = ConfigurationLoader(tmp_path) + config = loader.load_cppython_table() + + assert config is not None + assert config['install-path'] == '.cppython' + assert config['dependencies'] == ['fmt>=10.0.0'] + + def test_load_cppython_toml(self, tmp_path: Path) -> None: + """Test loading configuration from cppython.toml""" + pyproject_path = tmp_path / 'pyproject.toml' + pyproject_path.write_text( + """ +[project] +name = "test-project" +version = "1.0.0" +""", + encoding='utf-8', + ) + + cppython_path = tmp_path / 'cppython.toml' + cppython_path.write_text( + """ +[cppython] +install-path = ".cppython" +dependencies = ["fmt>=10.0.0"] +""", + encoding='utf-8', + ) + + loader = ConfigurationLoader(tmp_path) + config = loader.load_cppython_table() + + assert config is not None + assert config['install-path'] == '.cppython' + assert config['dependencies'] == ['fmt>=10.0.0'] + + def test_load_with_global_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test loading with global configuration""" + # Create a fake home directory with global config + fake_home = tmp_path / 'home' + fake_home.mkdir() + global_config_dir = fake_home / '.cppython' + global_config_dir.mkdir() + global_config_path = global_config_dir / 'config.toml' + global_config_path.write_text( + """ +[cppython] +install-path = "/global/install" +tool-path = "global-tools" + +[cppython.providers.conan] +remotes = ["global-remote"] +""", + encoding='utf-8', + ) + + # Mock Path.home() to return fake home + monkeypatch.setattr(Path, 'home', lambda: fake_home) + + # Create project with minimal config + project_root = tmp_path / 'project' + project_root.mkdir() + pyproject_path = project_root / 'pyproject.toml' + pyproject_path.write_text( + """ +[project] +name = "test-project" +version = "1.0.0" + +[tool.cppython] +dependencies = ["fmt>=10.0.0"] +""", + encoding='utf-8', + ) + + loader = ConfigurationLoader(project_root) + config = loader.load_cppython_table() + + assert config is not None + # Project config overrides global + assert config['dependencies'] == ['fmt>=10.0.0'] + # Global config provides defaults + assert config['install-path'] == '/global/install' + assert config['tool-path'] == 'global-tools' + assert config['providers']['conan']['remotes'] == ['global-remote'] + + def test_local_overrides_highest_priority(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that .cppython.toml has highest priority and overrides all other config sources""" + # Create fake home with global config + fake_home = tmp_path / 'home' + fake_home.mkdir() + global_config_dir = fake_home / '.cppython' + global_config_dir.mkdir() + global_config_path = global_config_dir / 'config.toml' + global_config_path.write_text( + """ +[cppython] +install-path = "/global/install" +build-path = "global-build" + +[cppython.providers.conan] +remotes = ["global-remote"] +profile_dir = "global-profiles" +""", + encoding='utf-8', + ) + + monkeypatch.setattr(Path, 'home', lambda: fake_home) + + # Create project + project_root = tmp_path / 'project' + project_root.mkdir() + pyproject_path = project_root / 'pyproject.toml' + pyproject_path.write_text( + """ +[project] +name = "test-project" +version = "1.0.0" + +[tool.cppython] +dependencies = ["fmt>=10.0.0"] +build-path = "project-build" +""", + encoding='utf-8', + ) + + # Create local overrides (highest priority) + local_override_path = project_root / '.cppython.toml' + local_override_path.write_text( + """ +install-path = "/local/install" +build-path = "local-build" + +[providers.conan] +profile_dir = "/local/profiles" +""", + encoding='utf-8', + ) + + loader = ConfigurationLoader(project_root) + config = loader.load_cppython_table() + + assert config is not None + # Local overrides have highest priority - overrides project config + assert config['build-path'] == 'local-build' + # Project config is preserved for non-overridden fields + assert config['dependencies'] == ['fmt>=10.0.0'] + + # Local override has highest priority + assert config['install-path'] == '/local/install' + + # Provider settings: local override takes precedence + assert config['providers']['conan']['profile_dir'] == '/local/profiles' + # Global remote preserved since not overridden + assert config['providers']['conan']['remotes'] == ['global-remote'] + + def test_conflicting_configs_error(self, tmp_path: Path) -> None: + """Test that using both pyproject.toml and cppython.toml raises error""" + pyproject_path = tmp_path / 'pyproject.toml' + pyproject_path.write_text( + """ +[project] +name = "test-project" +version = "1.0.0" + +[tool.cppython] +install-path = ".cppython" +""", + encoding='utf-8', + ) + + cppython_path = tmp_path / 'cppython.toml' + cppython_path.write_text( + """ +[cppython] +install-path = "/other/path" +""", + encoding='utf-8', + ) + + loader = ConfigurationLoader(tmp_path) + + with pytest.raises(ValueError, match='both pyproject.toml and cppython.toml'): + loader.load_cppython_table() + + def test_deep_merge(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test deep merging of nested dictionaries across all config layers""" + # Global config + fake_home = tmp_path / 'home' + fake_home.mkdir() + global_config_dir = fake_home / '.cppython' + global_config_dir.mkdir() + global_config_path = global_config_dir / 'config.toml' + global_config_path.write_text( + """ +[cppython.providers.conan] +remotes = ["global-remote"] +profile_dir = "global-profiles" +skip_upload = false + +[cppython.providers.vcpkg] +some_setting = "global-value" +""", + encoding='utf-8', + ) + + monkeypatch.setattr(Path, 'home', lambda: fake_home) + + # Project config + project_root = tmp_path / 'project' + project_root.mkdir() + pyproject_path = project_root / 'pyproject.toml' + pyproject_path.write_text( + """ +[project] +name = "test-project" +version = "1.0.0" + +[tool.cppython.providers.conan] +remotes = ["project-remote"] + +[tool.cppython.providers.vcpkg] +another_setting = "project-value" +""", + encoding='utf-8', + ) + + # Local overrides + local_override_path = project_root / '.cppython.toml' + local_override_path.write_text( + """ +[providers.conan] +profile_dir = "/custom/profiles" + +[providers.vcpkg] +some_setting = "override-value" +""", + encoding='utf-8', + ) + + loader = ConfigurationLoader(project_root) + config = loader.load_cppython_table() + + assert config is not None + # Project config overrides everything for conan remotes + assert config['providers']['conan']['remotes'] == ['project-remote'] + # Local override affects global, then project merges + assert config['providers']['conan']['profile_dir'] == '/custom/profiles' + assert config['providers']['conan']['skip_upload'] is False + + # vcpkg: deep merge across all layers + assert config['providers']['vcpkg']['some_setting'] == 'override-value' + assert config['providers']['vcpkg']['another_setting'] == 'project-value' + + def test_list_override_in_project_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that project config list values override global completely""" + # Global config + fake_home = tmp_path / 'home' + fake_home.mkdir() + global_config_dir = fake_home / '.cppython' + global_config_dir.mkdir() + global_config_path = global_config_dir / 'config.toml' + global_config_path.write_text( + """ +[cppython] +dependencies = ["fmt>=10.0.0", "spdlog>=1.12.0"] +""", + encoding='utf-8', + ) + + monkeypatch.setattr(Path, 'home', lambda: fake_home) + + # Project config + project_root = tmp_path / 'project' + project_root.mkdir() + pyproject_path = project_root / 'pyproject.toml' + pyproject_path.write_text( + """ +[project] +name = "test-project" +version = "1.0.0" + +[tool.cppython] +dependencies = ["catch2>=3.0.0"] +""", + encoding='utf-8', + ) + + loader = ConfigurationLoader(project_root) + config = loader.load_cppython_table() + + assert config is not None + # Project list replaces global entirely + assert config['dependencies'] == ['catch2>=3.0.0'] + + def test_config_source_info(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test config_source_info returns correct existence flags""" + # Create fake home with global config + fake_home = tmp_path / 'home' + fake_home.mkdir() + global_config_dir = fake_home / '.cppython' + global_config_dir.mkdir() + global_config_path = global_config_dir / 'config.toml' + global_config_path.write_text('[cppython]\ninstall-path = "/path"', encoding='utf-8') + + monkeypatch.setattr(Path, 'home', lambda: fake_home) + + # Create project + project_root = tmp_path / 'project' + project_root.mkdir() + pyproject_path = project_root / 'pyproject.toml' + pyproject_path.write_text('[project]\nname = "test"', encoding='utf-8') + + local_override_path = project_root / '.cppython.toml' + local_override_path.write_text('install-path = "/local"', encoding='utf-8') + + loader = ConfigurationLoader(project_root) + info = loader.config_source_info() + + assert info['global_config'] is True + assert info['pyproject'] is True + assert info['cppython'] is False + assert info['local_overrides'] is True + + def test_no_cppython_config(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test when no CPPython configuration exists anywhere""" + # No global config + fake_home = tmp_path / 'home' + fake_home.mkdir() + monkeypatch.setattr(Path, 'home', lambda: fake_home) + + # Project with no cppython config + project_root = tmp_path / 'project' + project_root.mkdir() + pyproject_path = project_root / 'pyproject.toml' + pyproject_path.write_text( + """ +[project] +name = "test-project" +version = "1.0.0" +""", + encoding='utf-8', + ) + + loader = ConfigurationLoader(project_root) + config = loader.load_cppython_table() + + assert config is None + + def test_only_local_overrides(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test local overrides when no global or project config exists""" + # No global config + fake_home = tmp_path / 'home' + fake_home.mkdir() + monkeypatch.setattr(Path, 'home', lambda: fake_home) + + # Project with only local overrides + project_root = tmp_path / 'project' + project_root.mkdir() + pyproject_path = project_root / 'pyproject.toml' + pyproject_path.write_text( + """ +[project] +name = "test-project" +version = "1.0.0" +""", + encoding='utf-8', + ) + + local_override_path = project_root / '.cppython.toml' + local_override_path.write_text( + """ +install-path = "/custom/path" + +[providers.conan] +remotes = ["my-remote"] +""", + encoding='utf-8', + ) + + loader = ConfigurationLoader(project_root) + config = loader.load_cppython_table() + + assert config is not None + assert config['install-path'] == '/custom/path' + assert config['providers']['conan']['remotes'] == ['my-remote'] + + def test_global_config_missing_cppython_table(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that global config.toml without [cppython] table raises error""" + fake_home = tmp_path / 'home' + fake_home.mkdir() + global_config_dir = fake_home / '.cppython' + global_config_dir.mkdir() + global_config_path = global_config_dir / 'config.toml' + global_config_path.write_text( + """ +[other] +some_value = "value" +""", + encoding='utf-8', + ) + + monkeypatch.setattr(Path, 'home', lambda: fake_home) + + project_root = tmp_path / 'project' + project_root.mkdir() + pyproject_path = project_root / 'pyproject.toml' + pyproject_path.write_text('[project]\nname = "test"', encoding='utf-8') + + loader = ConfigurationLoader(project_root) + + with pytest.raises(ValueError, match='must contain a \\[cppython\\] table'): + loader.load_global_config() + + def test_cppython_toml_missing_cppython_table(self, tmp_path: Path) -> None: + """Test that cppython.toml without [cppython] table raises error""" + pyproject_path = tmp_path / 'pyproject.toml' + pyproject_path.write_text('[project]\nname = "test"', encoding='utf-8') + + cppython_path = tmp_path / 'cppython.toml' + cppython_path.write_text( + """ +[other] +some_value = "value" +""", + encoding='utf-8', + ) + + loader = ConfigurationLoader(tmp_path) + + with pytest.raises(ValueError, match='must contain a \\[cppython\\] table'): + loader.load_cppython_config() + + def test_get_project_data(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Test get_project_data returns merged data in pyproject format""" + # Global config + fake_home = tmp_path / 'home' + fake_home.mkdir() + global_config_dir = fake_home / '.cppython' + global_config_dir.mkdir() + global_config_path = global_config_dir / 'config.toml' + global_config_path.write_text( + """ +[cppython] +install-path = "/global/install" +""", + encoding='utf-8', + ) + + monkeypatch.setattr(Path, 'home', lambda: fake_home) + + # Project config + project_root = tmp_path / 'project' + project_root.mkdir() + pyproject_path = project_root / 'pyproject.toml' + pyproject_path.write_text( + """ +[project] +name = "test-project" +version = "1.0.0" + +[tool.cppython] +dependencies = ["fmt>=10.0.0"] +""", + encoding='utf-8', + ) + + # Local overrides + local_override_path = project_root / '.cppython.toml' + local_override_path.write_text('install-path = "/custom/path"', encoding='utf-8') + + loader = ConfigurationLoader(project_root) + project_data = loader.get_project_data() + + assert project_data['project']['name'] == 'test-project' + # Project config has priority + assert project_data['tool']['cppython']['dependencies'] == ['fmt>=10.0.0'] + # Local override affects global, then merged with project + assert project_data['tool']['cppython']['install-path'] == '/custom/path' diff --git a/tests/unit/test_console.py b/tests/unit/test_console.py new file mode 100644 index 00000000..a2076c4c --- /dev/null +++ b/tests/unit/test_console.py @@ -0,0 +1,88 @@ +"""Tests the typer interface type""" + +import pytest +import typer +from typer.testing import CliRunner + +from cppython.console.entry import _parse_groups_argument, app + +runner = CliRunner() + + +class TestConsole: + """Various that all the examples are accessible to cppython. The project should be mocked so nothing executes""" + + @staticmethod + def test_entrypoint() -> None: + """Verifies that the entry functions with CPPython hooks""" + with runner.isolated_filesystem(): + runner.invoke(app, []) + + +class TestParseGroupsArgument: + """Tests for the _parse_groups_argument helper function""" + + @staticmethod + def test_none_input() -> None: + """Test that None input returns None""" + assert _parse_groups_argument(None) is None + + @staticmethod + def test_empty_string() -> None: + """Test that empty string returns None""" + assert _parse_groups_argument('') is None + assert _parse_groups_argument(' ') is None + + @staticmethod + def test_single_group() -> None: + """Test parsing a single group""" + result = _parse_groups_argument('[test]') + assert result == ['test'] + + @staticmethod + def test_multiple_groups() -> None: + """Test parsing multiple groups""" + result = _parse_groups_argument('[dev,test]') + assert result == ['dev', 'test'] + + @staticmethod + def test_groups_with_spaces() -> None: + """Test parsing groups with whitespace""" + result = _parse_groups_argument('[dev, test, docs]') + assert result == ['dev', 'test', 'docs'] + + @staticmethod + def test_missing_brackets() -> None: + """Test that missing brackets raises an error""" + with pytest.raises(typer.BadParameter, match='Invalid groups format'): + _parse_groups_argument('test') + + @staticmethod + def test_missing_opening_bracket() -> None: + """Test that missing opening bracket raises an error""" + with pytest.raises(typer.BadParameter, match='Invalid groups format'): + _parse_groups_argument('test]') + + @staticmethod + def test_missing_closing_bracket() -> None: + """Test that missing closing bracket raises an error""" + with pytest.raises(typer.BadParameter, match='Invalid groups format'): + _parse_groups_argument('[test') + + @staticmethod + def test_empty_brackets() -> None: + """Test that empty brackets raises an error""" + with pytest.raises(typer.BadParameter, match='Empty groups specification'): + _parse_groups_argument('[]') + + @staticmethod + def test_empty_group_name() -> None: + """Test that empty group names raise an error""" + with pytest.raises(typer.BadParameter, match='Group names cannot be empty'): + _parse_groups_argument('[test,,dev]') + + @staticmethod + def test_whitespace_only_group() -> None: + """Test that whitespace-only group names raise an error""" + with pytest.raises(typer.BadParameter, match='Group names cannot be empty'): + _parse_groups_argument('[test, ,dev]') diff --git a/tests/unit/test_data.py b/tests/unit/test_data.py new file mode 100644 index 00000000..8b5e8d2f --- /dev/null +++ b/tests/unit/test_data.py @@ -0,0 +1,79 @@ +"""Tests the Data type""" + +import logging + +import pytest + +from cppython.builder import Builder +from cppython.core.resolution import PluginBuildData +from cppython.core.schema import ( + CPPythonLocalConfiguration, + GeneratorData, + PEP621Configuration, + ProjectConfiguration, + ProviderData, +) +from cppython.data import Data +from cppython.test.mock.generator import MockGenerator +from cppython.test.mock.provider import MockProvider +from cppython.test.mock.scm import MockSCM +from cppython.utility.utility import TypeName + + +class TestData: + """Various tests for the Data type""" + + @staticmethod + @pytest.fixture( + name='data', + ) + def fixture_data( + project_configuration: ProjectConfiguration, + pep621_configuration: PEP621Configuration, + cppython_local_configuration: CPPythonLocalConfiguration, + ) -> Data: + """Creates a mock plugins fixture. + + We want all the plugins to use the same data variants at the same time, so we + have to resolve data inside the fixture instead of using other data fixtures + + Args: + project_configuration: Variant fixture for the project configuration + pep621_configuration: Variant fixture for PEP 621 configuration + cppython_local_configuration: Variant fixture for cppython configuration + + Returns: + The mock plugins fixture + + """ + logger = logging.getLogger() + builder = Builder(project_configuration, logger) + + plugin_build_data = PluginBuildData(generator_type=MockGenerator, provider_type=MockProvider, scm_type=MockSCM) + + return builder.build(pep621_configuration, cppython_local_configuration, plugin_build_data) + + @staticmethod + def test_sync(data: Data) -> None: + """Verifies that the sync method executes without error + + Args: + data: Fixture for the mocked data class + """ + data.sync() + + @staticmethod + def test_named_plugin_configuration() -> None: + """Test that named plugin configuration is properly validated""" + # Test valid named configuration + config = CPPythonLocalConfiguration( + providers={TypeName('conan'): ProviderData({'some_setting': 'value'})}, + generators={TypeName('cmake'): GeneratorData({'another_setting': True})}, + ) + assert config.providers == {TypeName('conan'): {'some_setting': 'value'}} + assert config.generators == {TypeName('cmake'): {'another_setting': True}} + + # Test empty configuration is valid + config_empty = CPPythonLocalConfiguration() + assert config_empty.providers == {} + assert config_empty.generators == {} diff --git a/tests/unit/test_project.py b/tests/unit/test_project.py new file mode 100644 index 00000000..7c514fdf --- /dev/null +++ b/tests/unit/test_project.py @@ -0,0 +1,258 @@ +"""Tests the Project type""" + +import logging +import tomllib +from importlib import metadata +from pathlib import Path +from typing import Any + +import pytest +from pytest_mock import MockerFixture + +from cppython.core.schema import ( + CPPythonLocalConfiguration, + PEP621Configuration, + ProjectConfiguration, + PyProject, + ToolData, +) +from cppython.project import Project +from cppython.test.mock.generator import MockGenerator +from cppython.test.mock.interface import MockInterface +from cppython.test.mock.provider import MockProvider +from cppython.test.mock.scm import MockSCM +from cppython.utility.exception import InstallationVerificationError + +pep621 = PEP621Configuration(name='test-project', version='0.1.0') + + +class TestProject: + """Various tests for the project object""" + + @staticmethod + def test_self_construction(request: pytest.FixtureRequest) -> None: + """The project type should be constructable with this projects configuration + + Args: + request: The pytest request fixture + """ + # Use the CPPython directory as the test data + file = request.config.rootpath / 'pyproject.toml' + project_configuration = ProjectConfiguration(project_root=file.parent, version=None) + interface = MockInterface() + + pyproject_data = tomllib.loads(file.read_text(encoding='utf-8')) + project = Project(project_configuration, interface, pyproject_data) + + # Doesn't have the cppython table + assert not project.enabled + + @staticmethod + def test_missing_tool_table_raw_dict(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + """Constructing Project with a raw dict lacking tool.cppython should produce zero log output. + + This simulates input from a host tool like PDM that passes raw pyproject data + rather than a model_dump() result. + + Args: + tmp_path: Temporary directory for dummy data + caplog: Pytest fixture for capturing logs + """ + project_configuration = ProjectConfiguration(project_root=tmp_path, version=None) + interface = MockInterface() + + # Raw dict as PDM would provide — no tool table at all + raw_data: dict[str, Any] = {'project': {'name': 'some-other-project', 'version': '1.0.0'}} + + with caplog.at_level(logging.DEBUG): + project = Project(project_configuration, interface, raw_data) + + # Absolutely no log output for projects without CPPython configuration + assert len(caplog.records) == 0 + + assert not project.enabled + + @staticmethod + def test_missing_tool_table(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + """The project type should be constructable without the tool table + + Args: + tmp_path: Temporary directory for dummy data + caplog: Pytest fixture for capturing logs + """ + file_path = tmp_path / 'pyproject.toml' + + with open(file_path, 'a', encoding='utf8'): + pass + + project_configuration = ProjectConfiguration(project_root=file_path.parent, version=None) + interface = MockInterface() + + pyproject = PyProject(project=pep621) + + with caplog.at_level(logging.WARNING): + project = Project(project_configuration, interface, pyproject.model_dump(by_alias=True)) + + # We don't want to have the log of the calling tool polluted with any default logging + assert len(caplog.records) == 0 + + assert not project.enabled + + @staticmethod + def test_missing_cppython_table(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + """The project type should be constructable without the cppython table + + Args: + tmp_path: Temporary directory for dummy data + caplog: Pytest fixture for capturing logs + """ + file_path = tmp_path / 'pyproject.toml' + + with open(file_path, 'a', encoding='utf8'): + pass + + project_configuration = ProjectConfiguration(project_root=file_path.parent, version=None) + interface = MockInterface() + + tool_data = ToolData() + pyproject = PyProject(project=pep621, tool=tool_data) + + with caplog.at_level(logging.WARNING): + project = Project(project_configuration, interface, pyproject.model_dump(by_alias=True)) + + # We don't want to have the log of the calling tool polluted with any default logging + assert len(caplog.records) == 0 + + assert not project.enabled + + @staticmethod + def test_default_cppython_table(tmp_path: Path, mocker: MockerFixture, caplog: pytest.LogCaptureFixture) -> None: + """The project type should be constructable with the default cppython table + + Args: + tmp_path: Temporary directory for dummy data + mocker: Pytest mocker fixture + caplog: Pytest fixture for capturing logs + """ + # Insert ourself into the builder and load the mock plugins by returning them directly in the expected order + # they will be built + mocker.patch( + 'cppython.builder.entry_points', + return_value=[metadata.EntryPoint(name='mock', value='mock', group='mock')], + ) + mocker.patch.object(metadata.EntryPoint, 'load', side_effect=[MockGenerator, MockProvider, MockSCM]) + + file_path = tmp_path / 'pyproject.toml' + + with open(file_path, 'a', encoding='utf8'): + pass + + project_configuration = ProjectConfiguration(project_root=file_path.parent, version=None) + interface = MockInterface() + + cppython_config = CPPythonLocalConfiguration() + tool_data = ToolData(cppython=cppython_config) + pyproject = PyProject(project=pep621, tool=tool_data) + + with caplog.at_level(logging.WARNING): + project = Project(project_configuration, interface, pyproject.model_dump(by_alias=True)) + + # We don't want to have the log of the calling tool polluted with any default logging + assert len(caplog.records) == 0 + + assert project.enabled + + +class TestPrepareBuild: + """Tests for Project.prepare_build()""" + + @staticmethod + def _create_enabled_project(tmp_path: Path, mocker: MockerFixture) -> Project: + """Helper to create an enabled project with mock plugins. + + Args: + tmp_path: Temporary directory + mocker: Pytest mocker fixture + + Returns: + An enabled Project instance + """ + mocker.patch( + 'cppython.builder.entry_points', + return_value=[metadata.EntryPoint(name='mock', value='mock', group='mock')], + ) + mocker.patch.object(metadata.EntryPoint, 'load', side_effect=[MockGenerator, MockProvider, MockSCM]) + + file_path = tmp_path / 'pyproject.toml' + with open(file_path, 'a', encoding='utf8'): + pass + + project_configuration = ProjectConfiguration(project_root=file_path.parent, version=None) + interface = MockInterface() + + cppython_config = CPPythonLocalConfiguration() + tool_data = ToolData(cppython=cppython_config) + pyproject = PyProject(project=pep621, tool=tool_data) + + return Project(project_configuration, interface, pyproject.model_dump(by_alias=True)) + + def test_prepare_build_calls_sync_and_verify(self, tmp_path: Path, mocker: MockerFixture) -> None: + """prepare_build() should call sync and verify_installed, not install. + + Args: + tmp_path: Temporary directory + mocker: Pytest mocker fixture + """ + project = self._create_enabled_project(tmp_path, mocker) + assert project.enabled + + # Spy on the key methods + sync_spy = mocker.patch.object(project._data, 'sync') # noqa: SLF001 + verify_spy = mocker.patch.object(project._data.plugins.provider, 'verify_installed') # noqa: SLF001 + install_spy = mocker.patch.object(project._data.plugins.provider, 'install') # noqa: SLF001 + + project.prepare_build() + + sync_spy.assert_called_once() + verify_spy.assert_called_once() + install_spy.assert_not_called() + + def test_prepare_build_returns_none_when_disabled(self, tmp_path: Path) -> None: + """prepare_build() should return None for a disabled project. + + Args: + tmp_path: Temporary directory + """ + file_path = tmp_path / 'pyproject.toml' + with open(file_path, 'a', encoding='utf8'): + pass + + project_configuration = ProjectConfiguration(project_root=file_path.parent, version=None) + interface = MockInterface() + + pyproject = PyProject(project=pep621) + project = Project(project_configuration, interface, pyproject.model_dump(by_alias=True)) + + assert not project.enabled + assert project.prepare_build() is None + + def test_prepare_build_raises_on_missing_artifacts(self, tmp_path: Path, mocker: MockerFixture) -> None: + """prepare_build() should propagate InstallationVerificationError. + + Args: + tmp_path: Temporary directory + mocker: Pytest mocker fixture + """ + project = self._create_enabled_project(tmp_path, mocker) + assert project.enabled + + # Make verify_installed raise + mocker.patch.object(project._data, 'sync') # noqa: SLF001 + mocker.patch.object( + project._data.plugins.provider, # noqa: SLF001 + 'verify_installed', + side_effect=InstallationVerificationError('mock', ['test artifact']), + ) + + with pytest.raises(InstallationVerificationError, match='mock'): + project.prepare_build() diff --git a/tests/unit/utility/__init__.py b/tests/unit/utility/__init__.py new file mode 100644 index 00000000..001920a5 --- /dev/null +++ b/tests/unit/utility/__init__.py @@ -0,0 +1,6 @@ +"""Unit tests for the utility functions in the CPPython project. + +This module contains tests for various utility functions, including subprocess +calls, logging, and name canonicalization. The tests ensure that the utility +functions behave as expected under different conditions. +""" diff --git a/tests/unit/utility/test_plugin.py b/tests/unit/utility/test_plugin.py new file mode 100644 index 00000000..5b2b2587 --- /dev/null +++ b/tests/unit/utility/test_plugin.py @@ -0,0 +1,17 @@ +"""This module tests the plugin functionality""" + +from cppython.utility.plugin import Plugin + + +class MockPlugin(Plugin): + """A mock plugin""" + + +class TestPlugin: + """Tests the plugin functionality""" + + @staticmethod + def test_plugin() -> None: + """Test that the plugin functionality works""" + assert MockPlugin.name() == 'mock' + assert MockPlugin.group() == 'plugin' diff --git a/tests/unit/utility/test_utility.py b/tests/unit/utility/test_utility.py new file mode 100644 index 00000000..f4d39a17 --- /dev/null +++ b/tests/unit/utility/test_utility.py @@ -0,0 +1,67 @@ +"""Tests the scope of utilities""" + +import logging +from logging import StreamHandler +from pathlib import Path +from typing import NamedTuple + +from cppython.utility.utility import canonicalize_name + +cppython_logger = logging.getLogger('cppython') +cppython_logger.addHandler(StreamHandler()) + + +class TestUtility: + """Tests the utility functionality""" + + class ModelTest(NamedTuple): + """Model definition to help test IO utilities""" + + test_path: Path + test_int: int + + @staticmethod + def test_none() -> None: + """Verifies that no exception is thrown with an empty string""" + test = canonicalize_name('') + + assert not test.group + assert not test.name + + @staticmethod + def test_only_group() -> None: + """Verifies that no exception is thrown when only a group is specified""" + test = canonicalize_name('Group') + + assert test.group == 'group' + assert not test.name + + @staticmethod + def test_name_group() -> None: + """Test that canonicalization works""" + test = canonicalize_name('NameGroup') + + assert test.group == 'group' + assert test.name == 'name' + + @staticmethod + def test_group_only_caps() -> None: + """Test that canonicalization works""" + test = canonicalize_name('NameGROUP') + + assert test.group == 'group' + assert test.name == 'name' + + @staticmethod + def test_name_only_caps() -> None: + """Test that canonicalization works""" + test = canonicalize_name('NAMEGroup') + assert test.group == 'group' + assert test.name == 'name' + + @staticmethod + def test_name_multi_caps() -> None: + """Test that caps works""" + test = canonicalize_name('NAmeGroup') + assert test.group == 'group' + assert test.name == 'name' diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 00000000..77467af9 --- /dev/null +++ b/zensical.toml @@ -0,0 +1,5 @@ +[project] +site_name = "CPPython" +site_url = "https://synodic.github.io/CPPython" +docs_dir = "docs" +site_dir = "dist"