From 5e47138198dcddd86a546e4b84d260468706de02 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Thu, 26 Feb 2026 14:54:09 -0800 Subject: [PATCH 1/3] List/Info Groups --- cppython/console/entry.py | 115 +++++++++++++++++------ cppython/core/plugin_schema/generator.py | 9 ++ cppython/plugins/cmake/plugin.py | 23 +++++ cppython/plugins/meson/plugin.py | 20 ++++ cppython/project.py | 13 +++ cppython/schema.py | 9 ++ cppython/test/mock/generator.py | 4 + 7 files changed, 163 insertions(+), 30 deletions(-) diff --git a/cppython/console/entry.py b/cppython/console/entry.py index 6839e2e..929d6de 100644 --- a/cppython/console/entry.py +++ b/cppython/console/entry.py @@ -1,5 +1,6 @@ """A Typer CLI for CPPython interfacing""" +from importlib.metadata import entry_points from pathlib import Path from typing import Annotated @@ -14,6 +15,12 @@ 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_enabled_project(context: typer.Context) -> Project: """Helper to load and validate an enabled Project from CLI context.""" @@ -123,43 +130,62 @@ def main( context.obj = ConsoleConfiguration(project_configuration=project_configuration, interface=interface) -@app.command() -def info( +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: - """Prints project information including plugin configuration, managed files, and templates.""" + """Show provider plugin information.""" project = get_enabled_project(context) project_info = project.info() - if not project_info: + entry = project_info.get('provider') + if entry is None: return - for role in ('provider', 'generator'): - entry = project_info.get(role) - if entry is None: - continue - - name: str = entry['name'] - report: PluginReport = entry['report'] + _print_plugin_report('Provider', entry['name'], entry['report']) - print(f'\n[bold]{role.title()}:[/bold] {name}') - if report.configuration: - print(' [bold]Configuration:[/bold]') - for key, value in report.configuration.items(): - print(f' {key}: {value}') +@info_app.command() +def info_generator( + context: typer.Context, +) -> None: + """Show generator plugin information.""" + project = get_enabled_project(context) + project_info = project.info() - if report.managed_files: - print(' [bold]Managed files:[/bold]') - for path in report.managed_files: - print(f' {path}') + entry = project_info.get('generator') + if entry is None: + return - 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)) + _print_plugin_report('Generator', entry['name'], entry['report']) @app.command() @@ -218,11 +244,40 @@ def update( project.update(groups=group_list) -@app.command(name='list') -def list_command( - _: typer.Context, +@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: - """Prints project information""" + """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() diff --git a/cppython/core/plugin_schema/generator.py b/cppython/core/plugin_schema/generator.py index 9e170bd..ab4b58d 100644 --- a/cppython/core/plugin_schema/generator.py +++ b/cppython/core/plugin_schema/generator.py @@ -109,3 +109,12 @@ def run(self, target: str, configuration: str | None = None) -> None: 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/plugins/cmake/plugin.py b/cppython/plugins/cmake/plugin.py index f082225..3798fb2 100644 --- a/cppython/plugins/cmake/plugin.py +++ b/cppython/plugins/cmake/plugin.py @@ -182,6 +182,29 @@ def run(self, target: str, configuration: str | None = None) -> None: executable = executables[0] subprocess.run([str(executable)], check=True, cwd=self.data.preset_file.parent) + 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. diff --git a/cppython/plugins/meson/plugin.py b/cppython/plugins/meson/plugin.py index e66abe9..1b666e1 100644 --- a/cppython/plugins/meson/plugin.py +++ b/cppython/plugins/meson/plugin.py @@ -194,3 +194,23 @@ def run(self, target: str, configuration: str | None = None) -> None: executable = executables[0] subprocess.run([str(executable)], check=True, cwd=self.data.build_file.parent) + + 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/project.py b/cppython/project.py index 6a5f56a..9c239c1 100644 --- a/cppython/project.py +++ b/cppython/project.py @@ -268,3 +268,16 @@ def run(self, target: str, configuration: str | None = None) -> None: self.logger.info('Running target: %s', target) self._data.sync() 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/schema.py b/cppython/schema.py index 522d9e3..59d1bc8 100644 --- a/cppython/schema.py +++ b/cppython/schema.py @@ -71,3 +71,12 @@ def run(self, target: str, configuration: str | None = None) -> None: 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/mock/generator.py b/cppython/test/mock/generator.py index 536b3b6..48e0ede 100644 --- a/cppython/test/mock/generator.py +++ b/cppython/test/mock/generator.py @@ -72,3 +72,7 @@ def bench(self, configuration: str | None = None) -> None: 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 [] From 21ecfa40ffc50f836be001f63fa3d214ee6356c1 Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Thu, 26 Feb 2026 15:00:10 -0800 Subject: [PATCH 2/3] Update entry.py --- cppython/console/entry.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cppython/console/entry.py b/cppython/console/entry.py index 929d6de..4a1833d 100644 --- a/cppython/console/entry.py +++ b/cppython/console/entry.py @@ -15,7 +15,10 @@ 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.') +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.') From 11a432b71a4c81472e5203f062eb05e25543e18e Mon Sep 17 00:00:00 2001 From: Asher Norland Date: Wed, 4 Mar 2026 10:42:59 -0800 Subject: [PATCH 3/3] Console Overhaul --- cppython/build/prepare.py | 47 ++++--- cppython/builder.py | 9 +- cppython/console/entry.py | 67 ++++++--- cppython/plugins/cmake/plugin.py | 13 +- cppython/plugins/conan/plugin.py | 47 ++++++- cppython/plugins/meson/plugin.py | 15 +- cppython/plugins/vcpkg/plugin.py | 94 +++---------- cppython/project.py | 82 ++++++++--- cppython/utility/output.py | 235 +++++++++++++++++++++++++++++++ cppython/utility/subprocess.py | 77 ++++++++++ 10 files changed, 535 insertions(+), 151 deletions(-) create mode 100644 cppython/utility/output.py create mode 100644 cppython/utility/subprocess.py diff --git a/cppython/build/prepare.py b/cppython/build/prepare.py index b864ae9..2461ad9 100644 --- a/cppython/build/prepare.py +++ b/cppython/build/prepare.py @@ -11,10 +11,13 @@ 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 @@ -89,31 +92,35 @@ def prepare(self) -> BuildPreparationResult: verbosity=1, ) - # Create the CPPython project - interface = BuildInterface() - project = Project(project_config, interface, pyproject_data) + # 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() + 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') + # 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 + 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') + 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) + return BuildPreparationResult(sync_data=sync_data) def prepare_build(source_dir: Path) -> BuildPreparationResult: diff --git a/cppython/builder.py b/cppython/builder.py index 43a9417..b25f19e 100644 --- a/cppython/builder.py +++ b/cppython/builder.py @@ -395,7 +395,7 @@ def __init__(self, project_configuration: ProjectConfiguration, logger: Logger) # Informal standard to check for color force_color = os.getenv('FORCE_COLOR', '1') != '0' - console = Console( + self._console = Console( force_terminal=force_color, color_system='auto', width=120, @@ -404,7 +404,7 @@ def __init__(self, project_configuration: ProjectConfiguration, logger: Logger) ) rich_handler = RichHandler( - console=console, + console=self._console, rich_tracebacks=True, show_time=False, show_path=False, @@ -420,6 +420,11 @@ def __init__(self, project_configuration: ProjectConfiguration, logger: Logger) 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, diff --git a/cppython/console/entry.py b/cppython/console/entry.py index 4a1833d..509d54e 100644 --- a/cppython/console/entry.py +++ b/cppython/console/entry.py @@ -1,17 +1,21 @@ """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) @@ -25,11 +29,21 @@ app.add_typer(list_app, name='list') -def get_enabled_project(context: typer.Context) -> Project: - """Helper to load and validate an enabled Project from CLI context.""" +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) @@ -47,6 +61,23 @@ def get_enabled_project(context: typer.Context) -> Project: 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. @@ -211,12 +242,10 @@ def install( Raises: ValueError: If the configuration object is missing """ - project = get_enabled_project(context) - - # Parse groups from pip-style syntax group_list = _parse_groups_argument(groups) - project.install(groups=group_list) + with _session_project(context) as project: + project.install(groups=group_list) @app.command() @@ -239,12 +268,10 @@ def update( Raises: ValueError: If the configuration object is missing """ - project = get_enabled_project(context) - - # Parse groups from pip-style syntax group_list = _parse_groups_argument(groups) - project.update(groups=group_list) + with _session_project(context) as project: + project.update(groups=group_list) @list_app.command() @@ -295,8 +322,8 @@ def publish( Raises: ValueError: If the configuration object is missing """ - project = get_enabled_project(context) - project.publish() + with _session_project(context) as project: + project.publish() @app.command() @@ -315,8 +342,8 @@ def build( context: The CLI configuration object configuration: Optional named configuration """ - project = get_enabled_project(context) - project.build(configuration=configuration) + with _session_project(context) as project: + project.build(configuration=configuration) @app.command() @@ -335,8 +362,8 @@ def test( context: The CLI configuration object configuration: Optional named configuration """ - project = get_enabled_project(context) - project.test(configuration=configuration) + with _session_project(context) as project: + project.test(configuration=configuration) @app.command() @@ -355,8 +382,8 @@ def bench( context: The CLI configuration object configuration: Optional named configuration """ - project = get_enabled_project(context) - project.bench(configuration=configuration) + with _session_project(context) as project: + project.bench(configuration=configuration) @app.command() @@ -380,5 +407,5 @@ def run( target: The name of the build target to run configuration: Optional named configuration """ - project = get_enabled_project(context) - project.run(target, configuration=configuration) + with _session_project(context) as project: + project.run(target, configuration=configuration) diff --git a/cppython/plugins/cmake/plugin.py b/cppython/plugins/cmake/plugin.py index 3798fb2..bcc5789 100644 --- a/cppython/plugins/cmake/plugin.py +++ b/cppython/plugins/cmake/plugin.py @@ -1,6 +1,6 @@ """The CMake generator implementation""" -import subprocess +from logging import getLogger from pathlib import Path from typing import Any @@ -13,6 +13,9 @@ 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): @@ -136,7 +139,7 @@ def build(self, configuration: str | None = None) -> None: """ preset = self._resolve_configuration(configuration) cmd = [self._cmake_command(), '--build', '--preset', preset] - subprocess.run(cmd, check=True, cwd=self.data.preset_file.parent) + 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. @@ -146,7 +149,7 @@ def test(self, configuration: str | None = None) -> None: """ preset = self._resolve_configuration(configuration) cmd = [self._ctest_command(), '--preset', preset] - subprocess.run(cmd, check=True, cwd=self.data.preset_file.parent) + 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. @@ -156,7 +159,7 @@ def bench(self, configuration: str | None = None) -> None: """ preset = self._resolve_configuration(configuration) cmd = [self._ctest_command(), '--preset', preset] - subprocess.run(cmd, check=True, cwd=self.data.preset_file.parent) + 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. @@ -180,7 +183,7 @@ def run(self, target: str, configuration: str | None = None) -> None: raise FileNotFoundError(f"Could not find executable '{target}' in build directory: {build_path}") executable = executables[0] - subprocess.run([str(executable)], check=True, cwd=self.data.preset_file.parent) + 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. diff --git a/cppython/plugins/conan/plugin.py b/cppython/plugins/conan/plugin.py index 24a6919..ae5772c 100644 --- a/cppython/plugins/conan/plugin.py +++ b/cppython/plugins/conan/plugin.py @@ -5,6 +5,8 @@ installation, and synchronization with other tools. """ +import contextlib +import io import os from logging import Logger, getLogger from pathlib import Path @@ -48,6 +50,43 @@ def __init__( 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: @@ -203,7 +242,7 @@ def _run_conan_install(self, conanfile_path: Path, update: bool, build_type: str original_cwd = os.getcwd() try: os.chdir(str(self.core_data.project_data.project_root)) - self._conan_api.command.run(command_args) + self._capture_conan_call(command_args) finally: os.chdir(original_cwd) except Exception as e: @@ -432,7 +471,7 @@ def _run_conan_create(self, conanfile_path: Path, build_type: str, logger: Logge original_cwd = os.getcwd() try: os.chdir(str(self.core_data.project_data.project_root)) - self._conan_api.command.run(command_args) + self._capture_conan_call(command_args) finally: os.chdir(original_cwd) @@ -451,7 +490,7 @@ def _upload_package(self, logger) -> None: logger.info('Executing conan upload command: conan %s', ' '.join(command_args)) try: - self._conan_api.command.run(command_args) + 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) @@ -462,7 +501,7 @@ def _upload_package(self, logger) -> None: logger.info('Executing conan upload command: conan %s', ' '.join(command_args)) try: - self._conan_api.command.run(command_args) + 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) diff --git a/cppython/plugins/meson/plugin.py b/cppython/plugins/meson/plugin.py index 1b666e1..4710f9e 100644 --- a/cppython/plugins/meson/plugin.py +++ b/cppython/plugins/meson/plugin.py @@ -1,6 +1,6 @@ """The Meson generator implementation""" -import subprocess +from logging import getLogger from pathlib import Path from typing import Any @@ -13,6 +13,9 @@ 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): @@ -125,7 +128,7 @@ def _ensure_setup(self) -> None: cmd.extend([str(build_dir), str(source_dir)]) - subprocess.run(cmd, check=True, cwd=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. @@ -149,7 +152,7 @@ def build(self, configuration: str | None = None) -> None: self._ensure_setup() build_dir = self._effective_build_dir(configuration) cmd = [self._meson_command(), 'compile', '-C', str(build_dir)] - subprocess.run(cmd, check=True, cwd=self.data.build_file.parent) + run_subprocess(cmd, cwd=self.data.build_file.parent, logger=logger) def test(self, configuration: str | None = None) -> None: """Runs tests using meson test. @@ -159,7 +162,7 @@ def test(self, configuration: str | None = None) -> None: """ build_dir = self._effective_build_dir(configuration) cmd = [self._meson_command(), 'test', '-C', str(build_dir)] - subprocess.run(cmd, check=True, cwd=self.data.build_file.parent) + 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. @@ -169,7 +172,7 @@ def bench(self, configuration: str | None = None) -> None: """ build_dir = self._effective_build_dir(configuration) cmd = [self._meson_command(), 'test', '--benchmark', '-C', str(build_dir)] - subprocess.run(cmd, check=True, cwd=self.data.build_file.parent) + 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. @@ -193,7 +196,7 @@ def run(self, target: str, configuration: str | None = None) -> None: raise FileNotFoundError(f"Could not find executable '{target}' in build directory: {build_dir}") executable = executables[0] - subprocess.run([str(executable)], check=True, cwd=self.data.build_file.parent) + 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. diff --git a/cppython/plugins/vcpkg/plugin.py b/cppython/plugins/vcpkg/plugin.py index 05f08ad..f79eb2e 100644 --- a/cppython/plugins/vcpkg/plugin.py +++ b/cppython/plugins/vcpkg/plugin.py @@ -25,8 +25,11 @@ ProviderInstallationError, ProviderToolingError, ) +from cppython.utility.subprocess import run_subprocess from cppython.utility.utility import TypeName +logger = getLogger('cppython.vcpkg') + class VcpkgProvider(Provider): """vcpkg Provider""" @@ -39,39 +42,6 @@ def __init__( self.core_data: CorePluginData = core_data self.data: VcpkgData = resolve_vcpkg_data(configuration_data, core_data) - @staticmethod - def _handle_subprocess_error( - logger_instance, operation: str, error: subprocess.CalledProcessError, exception_class: type - ) -> None: - """Handles subprocess errors with comprehensive error message formatting. - - Args: - logger_instance: The logger instance to use for error logging - operation: Description of the operation that failed (e.g., 'install', 'clone') - error: The CalledProcessError exception - exception_class: The exception class to raise - - Raises: - The specified exception_class with the formatted error message - """ - # Capture both stdout and stderr for better error reporting - stdout_msg = error.stdout.strip() if error.stdout else '' - stderr_msg = error.stderr.strip() if error.stderr else '' - - # Combine both outputs for comprehensive error message - error_parts = [] - if stderr_msg: - error_parts.append(f'stderr: {stderr_msg}') - if stdout_msg: - error_parts.append(f'stdout: {stdout_msg}') - - if not error_parts: - error_parts.append(f'Command failed with exit code {error.returncode}') - - error_msg = ' | '.join(error_parts) - logger_instance.error('Unable to %s: %s', operation, error_msg, exc_info=True) - raise exception_class('vcpkg', operation, error_msg, error) from error - @staticmethod def features(directory: Path) -> SupportedFeatures: """Queries vcpkg support @@ -112,29 +82,23 @@ def _update_provider(cls, path: Path) -> None: Args: path: The path where the script is located """ - logger = getLogger('cppython.vcpkg') - try: if system_name == 'nt': - subprocess.run( + run_subprocess( [str(WindowsPath('bootstrap-vcpkg.bat')), '-disableMetrics'], cwd=path, + logger=logger, shell=True, - check=True, - capture_output=True, - text=True, ) elif system_name == 'posix': - subprocess.run( + run_subprocess( ['./' + str(PosixPath('bootstrap-vcpkg.sh')), '-disableMetrics'], cwd=path, + logger=logger, shell=True, - check=True, - capture_output=True, - text=True, ) except subprocess.CalledProcessError as e: - cls._handle_subprocess_error(logger, 'bootstrap the vcpkg repository', e, ProviderToolingError) + 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. @@ -226,44 +190,36 @@ async def download_tooling(cls, directory: Path) -> None: Args: directory: The directory to download any extra tooling to """ - logger = getLogger('cppython.vcpkg') - 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 - subprocess.run( + run_subprocess( ['git', 'fetch', 'origin'], cwd=directory, - check=True, - capture_output=True, - text=True, + logger=logger, ) - subprocess.run( + run_subprocess( ['git', 'pull'], cwd=directory, - check=True, - capture_output=True, - text=True, + logger=logger, ) except subprocess.CalledProcessError as e: - cls._handle_subprocess_error(logger, 'update the vcpkg repository', e, ProviderToolingError) + 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 - subprocess.run( + run_subprocess( ['git', 'clone', 'https://github.com/microsoft/vcpkg', '.'], cwd=directory, - check=True, - capture_output=True, - text=True, + logger=logger, ) except subprocess.CalledProcessError as e: - cls._handle_subprocess_error(logger, 'clone the vcpkg repository', e, ProviderToolingError) + raise ProviderToolingError('vcpkg', 'clone the vcpkg repository', str(e), e) from e cls._update_provider(directory) @@ -309,17 +265,14 @@ def install(self, groups: list[str] | None = None) -> None: install_directory = self.data.install_directory build_path = self.core_data.cppython_data.build_path - logger = getLogger('cppython.vcpkg') try: - subprocess.run( + run_subprocess( [str(executable), 'install', f'--x-install-root={str(install_directory)}'], cwd=str(build_path), - check=True, - capture_output=True, - text=True, + logger=logger, ) except subprocess.CalledProcessError as e: - self._handle_subprocess_error(logger, 'install project dependencies', e, ProviderInstallationError) + 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. @@ -340,17 +293,14 @@ def update(self, groups: list[str] | None = None) -> None: install_directory = self.data.install_directory build_path = self.core_data.cppython_data.build_path - logger = getLogger('cppython.vcpkg') try: - subprocess.run( + run_subprocess( [str(executable), 'install', f'--x-install-root={str(install_directory)}'], cwd=str(build_path), - check=True, - capture_output=True, - text=True, + logger=logger, ) except subprocess.CalledProcessError as e: - self._handle_subprocess_error(logger, 'update project dependencies', e, ProviderInstallationError) + raise ProviderInstallationError('vcpkg', f'update project dependencies: {e}', e) from e def publish(self) -> None: """Called when the project needs to be published. diff --git a/cppython/project.py b/cppython/project.py index 9c239c1..0411474 100644 --- a/cppython/project.py +++ b/cppython/project.py @@ -9,13 +9,19 @@ 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] + self, + project_configuration: ProjectConfiguration, + interface: Interface, + pyproject_data: dict[str, Any], + *, + session: SessionProtocol | None = None, ) -> None: """Initializes the project @@ -23,9 +29,11 @@ def __init__( 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 @@ -63,6 +71,15 @@ def enabled(self) -> bool: """ 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. @@ -100,7 +117,9 @@ def install(self, groups: list[str] | None = None) -> None: return self.logger.info('Installing tools') - asyncio.run(self._data.download_provider_tools()) + + with self._session.spinner('Downloading provider tools...'): + asyncio.run(self._data.download_provider_tools()) self.logger.info('Installing project') @@ -114,10 +133,11 @@ def install(self, groups: list[str] | None = None) -> None: self._data.apply_dependency_groups(groups) # Sync before install to allow provider to access generator's resolved configuration - self._data.sync() + with self._session.spinner('Syncing project data...'): + self._data.sync() - # Let provider handle its own exceptions for better error context - self._data.plugins.provider.install(groups=groups) + 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 @@ -133,7 +153,9 @@ def update(self, groups: list[str] | None = None) -> None: return self.logger.info('Updating tools') - asyncio.run(self._data.download_provider_tools()) + + with self._session.spinner('Downloading provider tools...'): + asyncio.run(self._data.download_provider_tools()) self.logger.info('Updating project') @@ -146,11 +168,11 @@ def update(self, groups: list[str] | None = None) -> None: # Validate and log active groups self._data.apply_dependency_groups(groups) - # Sync before update to allow provider to access generator's resolved configuration - self._data.sync() + with self._session.spinner('Syncing project data...'): + self._data.sync() - # Let provider handle its own exceptions for better error context - self._data.plugins.provider.update(groups=groups) + with self._session.spinner('Updating dependencies...'): + self._data.plugins.provider.update(groups=groups) def publish(self) -> None: """Publishes the project @@ -164,11 +186,11 @@ def publish(self) -> None: self.logger.info('Publishing project') - # Ensure sync is performed before publishing to generate necessary files - self._data.sync() + with self._session.spinner('Syncing project data...'): + self._data.sync() - # Let provider handle its own exceptions for better error context - self._data.plugins.provider.publish() + 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. @@ -214,8 +236,12 @@ def build(self, configuration: str | None = None) -> None: return self.logger.info('Building project') - self._data.sync() - self._data.plugins.generator.build(configuration=configuration) + + 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 @@ -231,8 +257,12 @@ def test(self, configuration: str | None = None) -> None: return self.logger.info('Running tests') - self._data.sync() - self._data.plugins.generator.test(configuration=configuration) + + 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 @@ -248,8 +278,12 @@ def bench(self, configuration: str | None = None) -> None: return self.logger.info('Running benchmarks') - self._data.sync() - self._data.plugins.generator.bench(configuration=configuration) + + 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 @@ -266,8 +300,12 @@ def run(self, target: str, configuration: str | None = None) -> None: return self.logger.info('Running target: %s', target) - self._data.sync() - self._data.plugins.generator.run(target, configuration=configuration) + + 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. diff --git a/cppython/utility/output.py b/cppython/utility/output.py new file mode 100644 index 0000000..44a6b3a --- /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/subprocess.py b/cppython/utility/subprocess.py new file mode 100644 index 0000000..0dace02 --- /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