diff --git a/.gitignore b/.gitignore index 250e0b4..b75a945 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,6 @@ __marimo__/ .streamlit/secrets.toml .serena/ +plans/**/* +!plans/templates/* +repomix-output.xml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ab696b8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,22 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Quick Reference + +`trobz_local` (CLI: `tlc`) - Odoo dev environment automation tool. + +```bash +uv sync && uv run pre-commit install # Setup +make check # Lint + type check +make test # Run tests +uv run pytest tests/test_file.py -v # Single test +make build # Build wheel +``` + +## Documentation + +**Read `docs/` for details** - architecture, code standards, config examples: +- `docs/project-overview-pdr.md` - Features, requirements, config schema +- `docs/codebase-summary.md` - Module analysis +- `docs/code-standards.md` - Conventions, security practices diff --git a/README.md b/README.md index bd898dc..6def056 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,116 @@ -# trobz_local +# trobz_local (tlc) +[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/) +[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-lightgrey.svg)](https://www.gnu.org/licenses/agpl-3.0) -Local is a CLI tool to streamline Odoo development environments. -It automates repository management, directory structures, and virtual environments using `uv` and `odoo-venv`. +A developer tool for automating setup and management of local Odoo development environments. Streamlines directory structure creation, source code repository management, and installation of required development tools. -![Demo](./assets/demo.gif) +## What is trobz_local? +`trobz_local` (CLI: `tlc`) automates repetitive tasks when setting up an Odoo development environment. Instead of manually creating directories, cloning repositories, and installing dependencies, developers declare their desired environment in a TOML configuration file and `tlc` handles the rest. + +## Key Features + +- **Environment Initialization** (`init`): Creates standardized directory structure at `~/code/` +- **Repository Management** (`pull-repos`): Clones/updates Odoo and OCA repos in parallel +- **Tool Installation** (`install-tools`): Installs from four sources: scripts, system packages, NPM, and UV tools +- **Virtual Environments** (`create-venvs`): Creates Odoo venvs for each configured version +- **Interactive Mode**: Newcomer mode with confirmations and guidance +- **Security**: HTTPS enforcement for all downloads ## Installation -With [`uv`](https://docs.astral.sh/uv/): +Install globally using [uv](https://docs.astral.sh/uv/): ```bash uv tool install git+ssh://git@github.com:trobz/local.py.git ``` -### Getting Started - -The first step is to initialize the directory structure for your local environment: +## Quick Start ```bash +# 1. Initialize directory structure tlc init + +# 2. Create config file +cat > ~/code/config.toml << 'EOF' +versions = ["16.0", "17.0"] + +[tools] +uv = ["odoo-venv", "pre-commit"] +npm = ["prettier"] +system_packages = ["postgresql"] + +[repos] +odoo = ["odoo", "enterprise"] +oca = ["server-tools"] +EOF + +# 3. Setup environment +tlc pull-repos # Clone repositories +tlc create-venvs # Create virtual environments +tlc install-tools # Install tools +``` + +## Commands + +| Command | Purpose | +|---------|---------| +| `tlc init` | Create directory structure in `~/code/` | +| `tlc pull-repos` | Clone or update Odoo/OCA repositories | +| `tlc create-venvs` | Create Python virtual environments | +| `tlc install-tools` | Install scripts, packages, and tools | + +Use `--newcomer=false` to skip confirmation prompts. Use `--help` on any command for options. + +## Configuration + +Place `~/code/config.toml` with your environment definition: + +```toml +versions = ["16.0", "17.0", "18.0"] + +[tools] +uv = ["odoo-venv", "odoo-addons-path", "pre-commit"] +npm = ["prettier", "eslint"] +system_packages = ["git", "postgresql", "pnpm"] + +[[tools.script]] +url = "https://astral.sh/uv/install.sh" +name = "uv installer" + +[repos] +odoo = ["odoo", "enterprise"] +oca = ["server-tools", "server-ux", "web"] ``` -* `tlc pull-repos`: Clone or update Odoo and OCA repositories. - * **Filtering**: You can filter specific repositories using the `-f` or `--filter` flag. - ```bash - tlc pull-repos -f odoo -f web - ``` -* `tlc create-venvs`: Create virtual environments for your Odoo versions. -* `tlc install-tools`: Install other CLI tools defined in your config. -* `tlc install-packages`: Install required system packages. +See [Configuration Schema](./docs/project-overview-pdr.md#configuration-schema) for all options and validation rules. + +## System Requirements -### Configuration +- Python 3.10+ +- `uv` package manager +- Linux (Arch, Ubuntu) or macOS +- System tools: `git`, `wget` or `curl`, `sh` -You can configure the tool using environment variables: +## Documentation + +For detailed information: + +- [**Project Overview & PDR**](./docs/project-overview-pdr.md): Features, requirements, configuration schema +- [**System Architecture**](./docs/system-architecture.md): Design patterns, component interactions +- [**Codebase Summary**](./docs/codebase-summary.md): Module-by-module technical details +- [**Code Standards**](./docs/code-standards.md): Development guidelines and conventions + +## Development + +```bash +git clone git@github.com:trobz/local.py.git && cd local.py +uv sync && uv run pre-commit install +make check # Linters and type checks +make test # Run tests +``` -* `NEWCOMER_MODE`: Set to `false` or `0` to disable interactive confirmations and help messages for a faster workflow. +## License -Run `tlc --help` for more information on all commands. +AGPL-3.0 - See [LICENSE](./LICENSE) for details. diff --git a/docs/code-standards.md b/docs/code-standards.md new file mode 100644 index 0000000..e2b8ec6 --- /dev/null +++ b/docs/code-standards.md @@ -0,0 +1,213 @@ +# Code Standards & Structure + +Development guidelines, architectural patterns, and best practices for `trobz_local`. + +## Project Architecture + +Four-layer modular design with clear separation of concerns: + +| Layer | Module(s) | Responsibility | +|---|---|---| +| **CLI Layer** | `main.py` | Command routing, user interaction, newcomer mode | +| **Implementation** | `installers.py` | Four installation strategies (script, system, npm, uv) | +| **Utility Layer** | `utils.py` | Config validation, platform detection, helpers | +| **Infrastructure** | `concurrency.py`, `exceptions.py` | Parallel execution, custom exceptions | + +--- + +## Code Style Guidelines + +### Python Conventions +- **Standard**: PEP 8 compliance +- **Formatter**: ruff (fast, deterministic) +- **Line Length**: Max 120 characters (ruff.toml config) +- **Import Order**: stdlib → third-party → local (ruff-managed) +- **Active Rules**: YTT, S (security), B, A, C4, T10, SIM, I, C90, E, W, F, PGH, UP, RUF, TRY + +### Type Safety (Mandatory) +- All function signatures must have type hints +- Complex variables must be annotated +- Static analysis via mypy (ty wrapper) +- Pydantic for external data validation (config.toml) + +### Naming Rules + +| Element | Pattern | Example | +|---|---|---| +| Modules | snake_case | `installers.py` | +| Classes | PascalCase | `ConfigModel` | +| Functions | snake_case | `pull_repos` | +| Constants | SCREAMING_SNAKE_CASE | `ARCH_PACKAGES` | +| Private | _snake_case | `_run_installers` | + +--- + +## Architectural Patterns + +### 1. Declarative Configuration +System state defined in TOML. Tool reconciles local environment with definition. Pydantic validates before any side effects. + +### 2. Strategy Pattern +`installers.py`: Multiple strategies per tool source (script, system packages, npm, uv) with OS-aware selection. + +### 3. Command Pattern +`main.py`: Each CLI command is discrete but shares context (newcomer mode, dry-run). Easy to add new commands. + +### 4. Observer Pattern +`GitProgress` and `run_tasks()`: Real-time progress callbacks to Rich UI. Decoupled from execution logic. + +### 5. Fail-Fast Validation +Config validated at startup. Early detection prevents side effects on invalid input. + +--- + +## Security Requirements + +### Subprocess Safety +- **Never use shell=True** - Always pass arguments as list +- **Full paths only** - Use shutil.which() instead of relying on PATH +- **No user input in commands** - Build commands from validated config only + +### Download Security +- **HTTPS enforcement** - Pydantic validator rejects non-HTTPS URLs +- **Trusted sources only** - Script URLs must be whitelisted or reviewed + +### Input Validation +- **Regex patterns** - All config fields validated with specific patterns: + - Versions: `^\d+\.\d+$` + - UV tools: `^[a-zA-Z0-9][a-zA-Z0-9._\-\[\]@=<>!,]*$` + - Repo names: `^[a-zA-Z0-9._-]+$` +- **Pydantic models** - No ad-hoc parsing of config +- **Ruff S* rules** - Security linting enabled in make check + +### Credential Handling +- No credentials stored in code +- No sensitive data logged +- Environment variables used for user-specific paths only + +--- + +## Error Handling Patterns + +### Custom Exceptions +Use specialized exceptions from `exceptions.py` with context: +```python +raise DownloadError(url=script_url, reason=str(e)) +raise ExecutableNotFoundError(executable="wget") +``` + +### Fail Paths +1. **Config errors**: Print message + example, exit 1 +2. **Task failures**: Catch exception, record in TaskResult, continue +3. **Validation errors**: Pydantic prints field-level details, exit 1 + +### Cleanup +- Use context managers for temporary files +- Temporary directories auto-deleted on function exit +- No partial state on failure + +--- + +## Testing Requirements + +### Framework +- **Tool**: pytest +- **Mocking**: unittest.mock for filesystem, network, subprocesses +- **Coverage**: All new features must include tests +- **No real I/O**: Tests must not actually download, install, or git-clone + +### Test Organization +- Test file naming: `test_{module}.py` +- Test function naming: `test_{function}_case_description` +- Fixtures for common setup +- Parametrized tests for multiple scenarios + +### Running Tests +```bash +make test # Full suite +pytest -xvs file.py # Single file with verbose output +pytest --cov # Coverage report +``` + +--- + +## Development Workflow + +### Setup +```bash +uv sync # Install dependencies +uv run pre-commit install # Setup git hooks +make check # Verify setup (lint + type check) +``` + +### Code Quality Checklist +- [ ] Code follows naming conventions and style guidelines +- [ ] Type hints on all function signatures +- [ ] Tests written and passing (make test) +- [ ] Linting passes (make check) +- [ ] No security issues (ruff S rules) +- [ ] Error handling implemented +- [ ] Documentation updated + +### Before Committing +```bash +make check # Linters + type checkers +make test # Full test suite +``` + +### Commit Message Format +Follow [Conventional Commits](https://www.conventionalcommits.org/): +- `feat: description` - New feature +- `fix: description` - Bug fix +- `docs: description` - Documentation +- `refactor: description` - Code restructuring +- `test: description` - Test additions +- `chore: description` - Maintenance + +--- + +## File Structure + +**Max file size**: 500 LOC (split if larger) +- `main.py`: CLI commands and orchestration +- `installers.py`: Installation strategies +- `utils.py`: Config, platform detection, helpers +- `concurrency.py`: Task runner with progress +- `exceptions.py`: Custom exception classes + +**Imports in each module**: +- No circular imports +- Public functions documented +- Private functions start with underscore + +--- + +## Documentation Standards + +### Docstrings (Google Style) +```python +def function(param: Type) -> ReturnType: + """Brief one-line description. + + Longer description if needed. + + Args: + param: Description + + Returns: + Description of return value + + Raises: + CustomError: When this happens + """ +``` + +### Code Comments +- Explain "why", not "what" +- Comment complex logic only +- Keep comments synchronized with code + +### Inline Documentation +- Update docs/ files when changing features +- Keep code examples in docs/ current +- Reference specific line numbers when helpful diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md new file mode 100644 index 0000000..376e7c0 --- /dev/null +++ b/docs/codebase-summary.md @@ -0,0 +1,192 @@ +# Codebase Summary + +Technical overview of the `trobz_local` codebase structure, implementation patterns, and module responsibilities. + +## Project Statistics + +| Metric | Value | +|---|---| +| **Language** | Python 3.10+ | +| **Total LOC** | ~1,096 lines (core logic + tests) | +| **Core Modules** | 6 files (main, installers, utils, concurrency, exceptions, \_\_init\_\_) | +| **Test Coverage** | pytest with comprehensive unit tests | +| **Primary Frameworks** | Typer (CLI), Pydantic (validation), Rich (UI), GitPython (git) | +| **Concurrency Model** | ThreadPoolExecutor, max 4 workers, I/O-bound tasks | +| **License** | AGPL-3.0 | + +## Module Breakdown + +### `main.py` (452 LOC) +**Purpose**: CLI entry point and command orchestration + +**Responsibilities**: +- Define Typer application with 4 main commands +- Manage "newcomer mode" state (interactive confirmations) +- Orchestrate calls to installers, git operations, and venv creation +- Handle user interaction and progress reporting + +**Key Commands**: +- `init`: Create directory structure +- `pull-repos`: Clone/update repositories +- `create-venvs`: Create Python virtual environments +- `install-tools`: Install from four sources + +**Key Functions**: +- `main()` - Typer callback and default behavior +- `_get_tasks()` - Build task list from config (with optional filtering) +- `_pull_repo()` - Git clone/update worker +- `_create_venvs()` - venv creation worker via odoo-venv +- `_run_installers()` - Orchestrate four-stage installer pipeline +- `_build_install_message()` - Format preview message for tools + +--- + +### `installers.py` (282 LOC) +**Purpose**: Multi-source tool installation strategies + +**Strategies**: +1. **Scripts**: Download via wget/curl, execute with /bin/sh +2. **System Packages**: OS-aware (apt-get, pacman, brew) with platform defaults +3. **NPM Packages**: Global via pnpm install -g +4. **UV Tools**: Global via uv tool install + +**Key Functions**: +- `install_scripts()` - Download and execute shell scripts with progress +- `install_system_packages()` - OS detection and package manager invocation +- `install_npm_packages()` - Parallel npm package installation +- `install_uv_tools()` - Parallel UV tool installation + +**Pattern**: Each installer function: +- Takes list of items and optional dry_run flag +- Returns TaskResult(s) or boolean +- Uses run_tasks() for parallelization (except system packages) +- Progress callback signature: (progress, task_id, **kwargs) + +--- + +### `utils.py` (264 LOC) +**Purpose**: Configuration validation, platform detection, utilities + +**Pydantic Models**: +- `ConfigModel` - Root config with versions, tools, repos +- `ToolsConfig` - Tool specifications (uv, npm, script, system_packages) +- `ScriptItem` - Script definition with url and optional name +- `RepoConfig` - Repository definitions (odoo, oca) + +**Validation**: +- Versions: Pattern `^\d+\.\d+$` +- UV tools: Pattern `^[a-zA-Z0-9][a-zA-Z0-9._\-\[\]@=<>!,]*$` +- NPM packages: Scoped and unscoped validation +- Scripts: HTTPS-only enforcement +- Repo names: Pattern `^[a-zA-Z0-9._-]+$` + +**Key Functions**: +- `get_config()` - Load and validate config.toml with error handling +- `get_uv_path()` - Locate uv executable +- `get_os_info()` - Return {system, distro} for platform detection +- `confirm_step()` - Interactive confirmation in newcomer mode +- `GitProgress` class - Custom progress callback for GitPython + +**Platform Defaults**: Arch, Ubuntu, macOS with curated default packages + +--- + +### `concurrency.py` (60 LOC) +**Purpose**: Generic parallel task execution with progress tracking + +**TaskResult Dataclass**: +```python +@dataclass +class TaskResult: + name: str # Task identifier + success: bool # Outcome + message: str | None = None # Status message + error: Exception | None = None # Exception if failed +``` + +**Key Function**: +- `run_tasks(tasks, max_workers=4)` - Execute tasks in parallel + - Task format: {name, func, args (optional)} + - Progress reporting with Rich 3-column layout + - Per-task error isolation + - Graceful KeyboardInterrupt handling + +--- + +### `exceptions.py` (38 LOC) +**Purpose**: Custom exception hierarchy for granular error handling + +**Exception Classes**: +- `InstallerError` - Base exception +- `DownloadError(url, reason)` - Script download failures +- `ScriptExecutionError(script_name, reason)` - Script execution failures +- `PackageInstallError(package, reason)` - Installation failures +- `ExecutableNotFoundError(executable)` - Missing system executables + +--- + +## Architecture Patterns + +| Pattern | Implementation | Purpose | +|---|---|---| +| **Declarative Config** | TOML + Pydantic | Single source of truth for desired state | +| **Strategy Pattern** | Four installer strategies | Pluggable installation methods | +| **Command Pattern** | Typer commands | Discrete, composable CLI commands | +| **Builder Pattern** | Task construction in main.py | Dynamic task list generation | +| **Observer Pattern** | GitProgress + run_tasks | Real-time progress feedback | +| **Fail-Fast** | Config validation at startup | Early detection of configuration errors | + +--- + +## Data Flow + +``` +User Command + ↓ +CLI (main.py) - Parse and route + ↓ +Config Loading (utils.py) - Validate ~/code/config.toml + ↓ +Task Generation - Build list of operations + ↓ +Execution (concurrency.py) - ThreadPoolExecutor (max 4 workers) + ↓ +Progress Reporting (Rich) - Real-time UI updates + ↓ +Results Aggregation - TaskResult collection + ↓ +Summary Display - Success/failure reporting with exit code +``` + +--- + +## External Dependencies + +| Dependency | Version | Purpose | +|---|---|---| +| `typer` | Latest | CLI framework with type hints | +| `pydantic` | v2+ | Configuration validation with strict typing | +| `rich` | Latest | Terminal UI (progress bars, colors, trees) | +| `gitpython` | Latest | Programmatic git operations | +| `tomli` | Latest | TOML parsing (Python <3.11) | + +--- + +## Key Implementation Details + +### Concurrency Model +- ThreadPoolExecutor with configurable max_workers (default: 4) +- I/O-bound: downloads, git operations, package installation +- Per-task error isolation: one failure doesn't block others + +### Security Hardening +- **No shell=True**: All subprocess calls use argument lists +- **HTTPS enforcement**: Pydantic validator rejects non-HTTPS URLs +- **Path resolution**: shutil.which() for full executable paths +- **Input validation**: All config fields regex-validated + +### Error Handling +- Configuration errors: Exit immediately with examples +- Task failures: Isolated and aggregated, exit 1 if any fail +- Cleanup: Temporary directories auto-deleted on function exit +- User interruption: Graceful shutdown with KeyboardInterrupt handler diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md new file mode 100644 index 0000000..2abeda4 --- /dev/null +++ b/docs/project-overview-pdr.md @@ -0,0 +1,323 @@ +# Project Overview & Product Requirements + +## Overview + +`trobz_local` (CLI: `tlc`) is a developer automation tool for setting up and managing local Odoo development environments. It reduces setup time from hours to minutes by automating directory creation, repository management, and tool installation. + +The tool uses declarative configuration: developers specify desired environment state in `~/code/config.toml`, and `tlc` performs necessary operations to reach that state. + +## Vision & Purpose + +**Problem Solved**: Odoo developers spend significant time on environment setup tasks (directory creation, git operations, virtual environments, dependencies). This is repetitive, error-prone, and inconsistent across team members. + +**Solution**: Single declarative configuration file with automated, parallelized execution ensures consistent, reproducible environments. + +**Success Metric**: Setup time reduced from hours to under 15 minutes. All developers on a team have identical directory structures and tool versions. + +## Target Users + +- **Primary**: Odoo developers at Trobz +- **Secondary**: Teams developing multiple Odoo versions (16.0, 17.0, 18.0+) +- **Onboarding**: New developers joining Odoo projects + +## Core Features + +### 1. Environment Initialization (`init`) +Creates standardized directory structure at `~/code/`: +``` +~/code/ +├── venvs/ # Python virtual environments +├── oca/ # OCA repositories (Odoo Community Association) +├── odoo/ # Odoo repositories +│ ├── odoo/ # Source code by version +│ └── enterprise/ # Enterprise code by version +└── trobz/ # Internal Trobz repositories + ├── projects/ + └── packages/ +``` + +### 2. Repository Management (`pull-repos`) +Clones or updates Odoo and OCA repositories: +- **Sources**: Official Odoo repos and OCA GitHub repos +- **Speed**: Shallow clones (depth=1) for bandwidth efficiency +- **Parallelization**: Multiple repos cloned/updated simultaneously +- **Operations**: Clone new repos, fetch and hard-reset existing ones + +### 3. Tool Installation (`install-tools`) +Four-stage installation pipeline: +1. **Shell Scripts**: Download and execute scripts (e.g., uv installer) +2. **System Packages**: OS-aware installation via apt/pacman/brew +3. **NPM Packages**: Global packages via pnpm +4. **UV Tools**: Python tools via uv tool install + +### 4. Virtual Environment Management (`create-venvs`) +Creates Odoo-specific environments: +- Uses `odoo-venv` tool via UV +- Parallelized creation for multiple versions +- Preset: demo, Python: 3.12 + +### 5. Interactive Mode +**Newcomer Mode** (default enabled, can disable with `--newcomer=false`): +- Confirmation prompts before operations +- Detailed messages explaining actions +- Helps new developers understand workflow +- Can be disabled via flag or `NEWCOMER_MODE` environment variable + +### 6. Security +- HTTPS-only enforcement for script downloads +- No shell injection vulnerabilities (no shell=True) +- Subprocess safety with explicit executable paths +- Configuration validation via Pydantic + +## Functional Requirements + +| Requirement | Description | +|---|---| +| **FR-1: Directory Structure** | Create `~/code/` with `venvs/`, `oca/`, `odoo/`, `trobz/` subdirectories and version-specific folders | +| **FR-2: Repository Operations** | Clone repos with `depth=1`, update via fetch+reset, support parallelization, allow name filtering | +| **FR-3: Virtual Environments** | Create venvs for each Odoo version using `odoo-venv`, support parallel creation | +| **FR-4: Tool Installation** | Four-stage pipeline: scripts → system packages → npm → uv tools; OS-aware package managers | +| **FR-5: Configuration** | TOML config at `~/code/config.toml`, strict Pydantic validation, clear error messages with examples | +| **FR-6: User Interaction** | Interactive "newcomer mode", dry-run preview, rich console UI (progress bars, trees, colors) | + +## Non-Functional Requirements + +| Requirement | Details | +|---|---| +| **NFR-1: Performance** | Default 4-worker thread pool for parallel tasks; shallow clones for bandwidth efficiency | +| **NFR-2: Security** | HTTPS-only downloads; no shell injection (no shell=True); explicit executable paths | +| **NFR-3: Compatibility** | Python 3.10+; Linux (Arch, Ubuntu) and macOS; WSL experimental/untested | +| **NFR-4: Reliability** | Task isolation: failures don't block others; exit 1 if any task fails; graceful KeyboardInterrupt | + +## Success Metrics + +- **Setup Time**: Reduce full environment setup from hours to under 15 minutes +- **Consistency**: All team developers have identical directory structures and tool versions +- **Usability**: Newcomers complete setup following interactive prompts with minimal external guidance +- **Reliability**: Tool handles errors gracefully with clear messages and recoverable states + +## Configuration Schema + +### Basic Example + +```toml +versions = ["16.0", "17.0", "18.0"] + +[tools] +uv = ["odoo-venv", "odoo-addons-path", "pre-commit"] +npm = ["prettier", "eslint"] +system_packages = ["git", "postgresql", "pnpm"] + +[[tools.script]] +url = "https://astral.sh/uv/install.sh" +name = "uv installer" + +[repos] +odoo = ["odoo", "enterprise"] +oca = ["server-tools", "server-ux", "web"] +``` + +### Field Reference & Validation + +#### `versions` (Required) +Odoo versions to set up. Format: Major.Minor (e.g., "16.0", "17.0") +- **Type**: List of strings +- **Pattern**: `^\d+\.\d+$` +- **Example**: `["16.0", "17.0", "18.0"]` + +#### `tools.uv` (Optional) +UV tools to install globally via `uv tool install` +- **Type**: List of strings +- **Pattern**: `^[a-zA-Z0-9][a-zA-Z0-9._\-\[\]@=<>!,]*$` +- **Supports**: Pip-style specifiers (e.g., `package[extra]@1.0`, `package>=1.0`) +- **Examples**: `["odoo-venv", "pre-commit", "black[d]>=24.0"]` + +#### `tools.npm` (Optional) +NPM packages to install globally via pnpm +- **Type**: List of strings +- **Pattern**: `^(@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$` +- **Supports**: Scoped (@org/package) and unscoped packages +- **Examples**: `["prettier", "@babel/core", "eslint"]` + +#### `tools.script` (Optional) +Shell scripts to download and execute +- **Type**: List of objects +- **Fields**: + - `url` (required): HTTPS URL only (enforced) + - `name` (optional): Display name for logging +- **Example**: + ```toml + [[tools.script]] + url = "https://astral.sh/uv/install.sh" + name = "uv installer" + ``` + +#### `tools.system_packages` (Optional) +System packages to install via OS-specific manager +- **Type**: List of strings +- **Merged with**: Platform-specific defaults (see table below) +- **Examples**: `["postgresql", "git", "libxml2"]` + +#### `repos.odoo` (Optional) +Official Odoo repositories to clone +- **Type**: List of strings +- **Pattern**: `^[a-zA-Z0-9._-]+$` +- **Valid values**: `"odoo"` (source code), `"enterprise"` (proprietary) +- **URL bases**: `git@github.com:odoo/{repo_name}.git` + +#### `repos.oca` (Optional) +Odoo Community Association repositories to clone +- **Type**: List of strings +- **Pattern**: `^[a-zA-Z0-9._-]+$` +- **URL base**: `git@github.com:OCA/{repo_name}.git` +- **Examples**: `"server-tools"`, `"web"`, `"server-ux"` + +### Platform-Specific Defaults + +System packages are automatically merged with defaults for your OS: + +| OS | Default Packages | +|---|---| +| **Arch** | gcc, postgresql, libxml2, libxslt, libjpeg, libsass, base-devel, git | +| **Ubuntu** | git, gcc, libsasl2-dev, libldap2-dev, libssl-dev, libffi-dev, libxml2-dev, libxslt1-dev, libjpeg-dev, libpq-dev, libsass-dev, postgresql, postgresql-client, postgresql-contrib | +| **macOS** | git, postgresql, node | + +### Validation Examples + +**Valid configurations:** +```toml +versions = ["16.0", "17.0"] # ✓ Correct format +tools.uv = ["package[extra]@1.0", "tool"] # ✓ Supports extras syntax +tools.npm = ["@babel/core", "prettier"] # ✓ Scoped and unscoped +repos.odoo = ["odoo", "enterprise"] # ✓ Both sources +repos.oca = ["server-tools", "web"] # ✓ Valid names +``` + +**Invalid configurations:** +```toml +versions = ["16", "17.0"] # ✗ First version invalid format +tools.uv = [" invalid"] # ✗ Leading space +tools.npm = ["__invalid"] # ✗ Double underscore +tools.script = [{url = "http://..."}] # ✗ HTTP not allowed (HTTPS only) +repos.odoo = ["invalid/name"] # ✗ Slash not allowed +``` + +## CLI Command Reference + +### `tlc init` +Initialize the directory structure at `~/code/`. + +**Usage**: `tlc init` + +**Options**: +- `--newcomer / --no-newcomer`: Enable interactive mode (default: True) + +**Output**: Rich tree showing created directory structure + +**Exit Codes**: 0 on success, 1 on failure + +--- + +### `tlc pull-repos` +Clone missing repositories and update existing ones. + +**Usage**: `tlc pull-repos [OPTIONS]` + +**Options**: +- `-f, --filter`: Filter repositories by name (repeatable, e.g., `-f server-tools -f web`) +- `--dry-run`: Preview operations without executing +- `--newcomer / --no-newcomer`: Enable interactive mode (default: True) + +**Behavior**: +- Reads `~/code/config.toml` and loads repo definitions +- For each version, clones missing repos (shallow, depth=1) or updates existing ones +- Updates via: git fetch, checkout branch, hard reset to origin/branch +- Executes up to 4 repos in parallel +- Uses GitProgress for progress reporting + +**Exit Codes**: 0 on success, 1 if any repo operation fails + +--- + +### `tlc create-venvs` +Create Python virtual environments for each Odoo version. + +**Usage**: `tlc create-venvs [OPTIONS]` + +**Options**: +- `--newcomer / --no-newcomer`: Enable interactive mode (default: True) + +**Behavior**: +- Reads versions from `~/code/config.toml` +- Invokes `uv tool run odoo-venv --preset demo --python 3.12` for each version +- Creates venvs at `~/code/venvs/{version}/` +- Executes up to 4 venvs in parallel +- Shows progress bar during creation + +**Prerequisites**: `odoo-venv` must be installed (via `tlc install-tools`) + +**Exit Codes**: 0 on success, 1 if any venv creation fails + +--- + +### `tlc install-tools` +Install tools from four sources in order: scripts, system packages, npm, uv. + +**Usage**: `tlc install-tools [OPTIONS]` + +**Options**: +- `--dry-run`: Preview without executing +- `--newcomer / --no-newcomer`: Enable interactive mode (default: True) + +**Execution Order**: +1. **Scripts**: Download and execute via `wget` or `curl`, then `sh` +2. **System Packages**: OS-aware installation (apt/pacman/brew) +3. **NPM Packages**: Global installation via `pnpm install -g` +4. **UV Tools**: Global installation via `uv tool install` + +**Behavior**: +- Reads `[tools]` section from `~/code/config.toml` +- Shows descriptive message of all tools to be installed +- Confirms in newcomer mode +- Scripts and NPM/UV tools run in parallel (max 4 workers) +- System packages run sequentially (sudo required) +- Aggregates results and reports failures + +**Exit Codes**: 0 on success, 1 if any tool installation fails + +--- + +### Global Options + +**`--newcomer / --no-newcomer`** (all commands) +- **Default**: True +- **Environment Variable**: `NEWCOMER_MODE=true/false` +- **Behavior**: + - Enabled: Shows confirmation prompts with detailed action descriptions + - Disabled: Executes silently without prompts +- **Use case**: Disable with `--no-newcomer` or `NEWCOMER_MODE=false` after becoming familiar + +**Examples**: +```bash +tlc pull-repos --no-newcomer # Silent mode +NEWCOMER_MODE=false tlc init # Via environment variable +tlc install-tools --dry-run # Preview operations +``` + +--- + +### Environment Variables + +| Variable | Default | Affects | +|---|---|---| +| `NEWCOMER_MODE` | true | Interactive mode for all commands | +| `HOME` | (user home) | Location of `~/code/config.toml` | + +--- + +### Exit Codes + +| Code | Meaning | +|---|---| +| `0` | Success | +| `1` | Configuration error, validation failure, or operation failure | diff --git a/docs/system-architecture.md b/docs/system-architecture.md new file mode 100644 index 0000000..b876e07 --- /dev/null +++ b/docs/system-architecture.md @@ -0,0 +1,537 @@ +# System Architecture + +High-level design and component interactions in `trobz_local`. + +## Architecture Overview + +``` +┌─────────────────────────────────────────────────────────────┐ +│ User (CLI Interface) │ +│ tlc [options] │ +└────────────────────┬────────────────────────────────────────┘ + │ + ┌────────────┴────────────┐ + │ │ + ┌────▼──────┐ ┌───────▼────┐ + │ Newcomer │ │ Dry-Run │ + │ Mode │ │ Preview │ + └────┬──────┘ └────┬───────┘ + │ │ + └────────────┬────────┘ + │ + ┌────────────▼────────────────────┐ + │ main.py (CLI Orchestration) │ + │ - Command routing │ + │ - Task generation │ + │ - Progress management │ + └────┬──────────────┬─────────────┘ + │ │ + ┌────────▼──┐ ┌──────▼────────┐ + │ Config │ │ Installer │ + │ (utils) │ │ (installers) │ + │ │ │ │ + │ - Validate│ │ Strategy 1: │ + │ - Parse │ │ Scripts │ + │ - Load OS │ │ │ + └────┬──────┘ │ Strategy 2: │ + │ │ System Pkgs │ + │ │ │ + │ │ Strategy 3: │ + │ │ NPM Packages │ + │ │ │ + │ │ Strategy 4: │ + │ │ UV Tools │ + │ └──────┬────────┘ + │ │ + │ ┌─────────────┴──────────────┐ + │ │ │ + ┌────▼────▼────────────────────┐ ┌──▼──────────┐ + │ concurrency.py │ │ exceptions │ + │ - ThreadPoolExecutor │ │ │ + │ - TaskResult aggregation │ │ Custom │ + │ - Progress tracking │ │ exceptions │ + └────────┬─────────────────────┘ └─────────────┘ + │ + ┌────────▼──────────────┐ + │ Rich Progress Bars │ + │ (User Feedback) │ + └───────────────────────┘ +``` + +--- + +## Command Flow Diagrams + +### `tlc init` Flow +``` +init command + │ + └─ Create directory tree at ~/code/ + ├─ venvs/ + ├─ oca/{version}/ + ├─ odoo/odoo/{version}/ + ├─ odoo/enterprise/{version}/ + └─ trobz/{projects, packages}/ + │ + └─ Display Rich tree + └─ Exit 0 +``` + +--- + +### `tlc pull-repos` Flow +``` +pull-repos command + │ + ├─ Load config.toml + ├─ Validate config + │ + ├─ Build task list: + │ for each version: + │ for each repo in config: + │ if --filter && repo not in filter: skip + │ create task: {name, func: _pull_repo, args: {repo, version, path}} + │ + ├─ Show confirmation (newcomer mode) + │ └─ If --dry-run: show preview, exit 0 + │ + ├─ Execute tasks in parallel (max 4 workers): + │ for each task: + │ if repo exists: + │ git fetch origin {branch} + │ git checkout {branch} + │ git reset --hard origin/{branch} + │ else: + │ git clone --depth=1 --branch={branch} {url} {path} + │ with GitProgress callback + │ + ├─ Aggregate TaskResults + └─ Report failures, exit 0 or 1 +``` + +--- + +### `tlc install-tools` Flow +``` +install-tools command + │ + ├─ Load config.toml + ├─ Validate config + │ + ├─ Build installation preview: + │ - Scripts: list URLs + │ - System packages: list packages + │ - NPM packages: list packages + │ - UV tools: list tools + │ + ├─ Show confirmation (newcomer mode) + │ └─ If --dry-run: show preview, exit 0 + │ + ├─ Execute four installers in sequence: + │ 1. install_scripts() + │ ├─ Create temp directory + │ ├─ For each script: + │ │ ├─ Download via wget or curl + │ │ └─ Execute with /bin/sh + │ └─ Clean up temp directory + │ + │ 2. install_system_packages() + │ ├─ Detect OS (Arch, Ubuntu, macOS) + │ ├─ Merge user packages with platform defaults + │ ├─ Run package manager with sudo + │ └─ Return success/failure boolean + │ + │ 3. install_npm_packages() + │ ├─ Check if pnpm exists + │ └─ Parallel: run_tasks() with pnpm install -g + │ + │ 4. install_uv_tools() + │ └─ Parallel: run_tasks() with uv tool install + │ + ├─ Aggregate all results + └─ Report summary, exit 0 or 1 +``` + +--- + +### `tlc create-venvs` Flow +``` +create-venvs command + │ + ├─ Load config.toml + ├─ Validate config + │ + ├─ Build task list: + │ for each version: + │ create task: {name: "venv-{version}", func: _create_venvs, ...} + │ + ├─ Show confirmation (newcomer mode) + │ + ├─ Execute tasks in parallel (max 4 workers): + │ for each task: + │ uv tool run odoo-venv --preset demo --python 3.12 {version} + │ creates: ~/code/venvs/{version}/ + │ + ├─ Aggregate TaskResults + └─ Report failures, exit 0 or 1 +``` + +--- + +## Component Interaction Details + +### Configuration Pipeline + +``` +~/code/config.toml + │ + ▼ + [Read TOML] + (tomli.loads) + │ + ▼ + [Validate Structure] + (Pydantic ConfigModel) + │ + ├─ ConfigModel + │ ├─ versions: list[str] + │ ├─ tools: ToolsConfig + │ └─ repos: RepoConfig + │ + ├─ ToolsConfig + │ ├─ uv: list[str] + │ ├─ npm: list[str] + │ ├─ script: list[ScriptItem] + │ └─ system_packages: list[str] + │ + └─ RepoConfig + ├─ odoo: list[str] + └─ oca: list[str] + │ + ▼ + [Return Validated Dict] + (get_config() in utils.py) + │ + ▼ + [Main Commands Use Dict] + (Generate tasks, installers) +``` + +--- + +### Task Execution Pipeline + +``` +Task Dict: {name, func, args} + │ + ▼ +[ThreadPoolExecutor] +(concurrency.run_tasks, max_workers=4) + │ + ├─ Create Future for each task + ├─ Provide Progress callback + └─ Add to Rich progress bar + │ + ▼ +[Task Execution with Progress] +func(progress: Progress, task_id: TaskID, **args) + │ + ├─ Success: TaskResult(name, success=True) + │ + └─ Exception: TaskResult(name, success=False, error=...) + │ + ▼ +[Results Aggregation] +(list[TaskResult]) + │ + └─ Display summary with Rich +``` + +--- + +### Installer Strategy Pattern + +``` +install_tools request + │ + ├─ Scripts + │ ├─ Create temp directory + │ ├─ For each URL: + │ │ ├─ _get_download_command(url) + │ │ │ ├─ Try wget (preferred) + │ │ │ └─ Fall back to curl + │ │ ├─ Execute download command + │ │ └─ _execute_script(path) + │ │ └─ Run with /bin/sh (safe) + │ └─ Auto-cleanup temp directory + │ + ├─ System Packages + │ ├─ _get_package_manager_config(os, distro) + │ │ ├─ Arch → pacman -S --noconfirm --needed + │ │ ├─ Ubuntu → apt-get install -y + │ │ └─ macOS → brew install + │ ├─ Merge user packages with defaults + │ └─ _run_package_install(cmd, packages) + │ └─ Execute with sudo + │ + ├─ NPM Packages + │ ├─ Check: which pnpm + │ ├─ For each package: + │ │ └─ pnpm install -g {package} + │ └─ Parallel via run_tasks() + │ + └─ UV Tools + ├─ For each tool: + │ └─ uv tool install -- {tool} + └─ Parallel via run_tasks() +``` + +--- + +## Error Handling Architecture + +``` +[Error Detection Point] + │ + ├─ Configuration Error + │ └─ Pydantic ValidationError + │ ├─ Print field-level errors + │ ├─ Show example config + │ └─ Exit 1 + │ + ├─ Prerequisite Missing + │ └─ ExecutableNotFoundError(executable) + │ ├─ Record in TaskResult + │ ├─ Continue other tasks + │ └─ Fail at end if any error + │ + ├─ Task Exception + │ └─ Custom exception + │ ├─ Catch and record + │ ├─ Continue in parallel + │ └─ Show ✗ Error in progress + │ + └─ User Interrupt + └─ KeyboardInterrupt + ├─ Cancel running futures + ├─ Cleanup resources + └─ Exit gracefully +``` + +--- + +## Concurrency Model + +### Worker Pool +- **Type**: ThreadPoolExecutor +- **Max Workers**: 4 (configurable) +- **Rationale**: I/O-bound tasks (downloads, git, package installation) +- **Not suitable for**: CPU-intensive tasks + +### Task Isolation +``` +Worker 1: Git clone repo-1 +Worker 2: Git clone repo-2 +Worker 3: Git fetch repo-3 +Worker 4: Git checkout repo-4 + +If Worker 1 fails: + - TaskResult(name="repo-1", success=False, error=...) + - Workers 2, 3, 4 continue + - Final exit code: 1 (any failure) +``` + +### Progress Aggregation +``` +Rich Progress Bar (one per task) +├─ Task: "repo-1" +│ ├─ Status: [████████░░░░░░░░░░░░] 45% +│ └─ Message: "Fetching objects..." +│ +├─ Task: "repo-2" +│ ├─ Status: [██████████████████░░░] 95% +│ └─ Message: "Writing objects..." +│ +└─ Task: "repo-3" + ├─ Status: [██████████████████████] 100% ✓ + └─ Message: "Complete" +``` + +--- + +## Platform Detection & Configuration + +``` +[get_os_info()] + │ + ├─ Darwin (macOS) + │ └─ system: "Darwin" + │ distro: "macos" + │ + └─ Linux + ├─ Try freedesktop_os_release() + │ ├─ Arch Linux → distro: "arch" + │ ├─ Ubuntu → distro: "ubuntu" + │ └─ Other → distro: "linux" + │ + └─ Fallback → system: "unknown" + +[get_package_manager_config(system, distro)] + │ + ├─ macOS → brew + ├─ Arch → pacman + ├─ Ubuntu → apt-get + └─ Fallback error +``` + +--- + +## Data Structures + +### TaskResult +```python +@dataclass +class TaskResult: + name: str # Task identifier ("repo-1", "tool-x", etc.) + success: bool # True if completed, False if exception + message: str | None = None # Status message ("Completed", error summary) + error: Exception | None = None # Exception object if failed +``` + +### Task Dict +```python +{ + "name": "string", # Identifier for display + "func": callable, # Function to execute + "args": { # Keyword arguments (optional) + "repo": "repo_name", + "version": "16.0", + "path": "/home/user/code/oca/repo_name" + } +} +``` + +### Pydantic Models (utils.py) +```python +ConfigModel( + versions: list[str], + tools: ToolsConfig | None, + repos: RepoConfig | None +) + +ToolsConfig( + uv: list[str] | None, + npm: list[str] | None, + script: list[ScriptItem] | None, + system_packages: list[str] | None +) + +ScriptItem( + url: str, # HTTPS enforced + name: str | None # Optional display name +) + +RepoConfig( + odoo: list[str] | None, + oca: list[str] | None +) +``` + +--- + +## Security Boundaries + +### Trust Model +``` +User (trusted) + │ + ▼ +~/code/config.toml (semi-trusted, validated) + │ + ├─ Pydantic validation + ├─ Regex pattern validation + └─ HTTPS enforcement for URLs + │ + ▼ +External Resources (untrusted) + │ + ├─ Git repositories + ├─ Shell scripts + ├─ NPM packages + └─ UV tools +``` + +### Execution Safety +``` +Script Execution + │ + ├─ Subprocess safety: no shell=True + ├─ Full paths: shutil.which() instead of PATH + ├─ Argument lists: never string concatenation + └─ Temp directory: auto-cleanup +``` + +--- + +## Extension Points + +### Adding New Commands +1. Define function in `main.py` with `@app.command()` decorator +2. Follow signature: `async func(ctx: typer.Context, args: Type)` +3. Use `confirm_step()` for newcomer mode +4. Call installers or utilities as needed + +### Adding New Installers +1. Create function: `install_xxx(items: list[str], dry_run: bool) -> list[TaskResult]` +2. Use `run_tasks()` for parallelization +3. Add to `_run_installers()` in main.py + +### Adding Configuration Fields +1. Update Pydantic models in `utils.py` +2. Add validators if needed (regex patterns, custom logic) +3. Document in `docs/project-overview-pdr.md` + +--- + +## Performance Characteristics + +### Typical Execution Times +- **init**: < 1 second (local filesystem only) +- **pull-repos** (1 repo): 10-30 seconds (network dependent) +- **pull-repos** (5 repos parallel): 15-40 seconds (vs 50-150 sequential) +- **install-tools** (all types): 5-15 minutes (highly variable, depends on tools) +- **create-venvs** (4 versions parallel): 10-20 minutes (vs 40-80 sequential) + +### Parallelization Impact +- 4 concurrent git clones ≈ 3x faster than sequential +- 4 concurrent venv creations ≈ 3-3.5x faster than sequential +- System package install remains sequential (sudo, package manager serialization) + +--- + +## Deployment Model + +### Installation +```bash +uv tool install git+ssh://git@github.com:trobz/local.py.git +``` +- Installs to user's Python tool directory +- Creates `tlc` entry point +- Self-contained, no global state + +### Configuration +```bash +# User creates once: +~/code/config.toml +``` +- Personal environment definition +- Not committed to any repository +- Can vary per developer + +### Execution +```bash +tlc # Works from anywhere +``` +- Uses `$HOME/code/` as root +- No elevated privileges needed except for package installation (sudo) +- All changes isolated to user's home directory diff --git a/pyproject.toml b/pyproject.toml index e20fe36..c5b791a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dev = [ "ruff>=0.11.5", "pytest>=7.2.0", "python-semantic-release>=10.5.3", + "pytest-cov>=7.0.0", ] [tool.pytest.ini_options] diff --git a/tests/test_install_packages.py b/tests/test_install_packages.py deleted file mode 100644 index 6240fb8..0000000 --- a/tests/test_install_packages.py +++ /dev/null @@ -1,88 +0,0 @@ -import subprocess -from unittest.mock import patch - -import pytest -from typer.testing import CliRunner - -from trobz_local.main import app -from trobz_local.utils import ARCH_PACKAGES, MACOS_PACKAGES, UBUNTU_PACKAGES - -runner = CliRunner() - - -@pytest.fixture(autouse=True) -def mock_typer_confirm(): - with patch("typer.confirm", return_value=True) as mock_tc: - yield mock_tc - - -def test_install_packages_unsupported_os(): - with ( - patch("trobz_local.main.get_os_info", return_value={"system": "Windows", "distro": "unknown"}), - ): - result = runner.invoke(app, ["install-packages"]) - assert result.exit_code == 1 - assert "Unsupported operating system" in result.stdout - - -def test_install_packages_macos_brew_missing(): - with ( - patch("trobz_local.main.get_os_info", return_value={"system": "Darwin", "distro": "macos"}), - patch("shutil.which", return_value=None), - ): - result = runner.invoke(app, ["install-packages"]) - assert result.exit_code == 1 - assert "Homebrew is not installed" in result.stdout - - -def test_install_packages_macos_success(): - with ( - patch("trobz_local.main.get_os_info", return_value={"system": "Darwin", "distro": "macos"}), - patch("shutil.which", return_value="/usr/local/bin/brew"), - patch("trobz_local.main.subprocess.run") as mock_run, - ): - result = runner.invoke(app, ["install-packages"]) - assert result.exit_code == 0 - mock_run.assert_called_with( - ["brew", "install"] + MACOS_PACKAGES, - check=True, - text=True, - ) - - -def test_install_packages_arch_success(): - with ( - patch("trobz_local.main.get_os_info", return_value={"system": "Linux", "distro": "arch"}), - patch("trobz_local.main.subprocess.run") as mock_run, - ): - result = runner.invoke(app, ["install-packages"]) - assert result.exit_code == 0 - mock_run.assert_called_with( - ["sudo", "pacman", "-S", "--noconfirm", "--needed"] + ARCH_PACKAGES, - check=True, - text=True, - ) - - -def test_install_packages_ubuntu_success(): - with ( - patch("trobz_local.main.get_os_info", return_value={"system": "Linux", "distro": "ubuntu"}), - patch("trobz_local.main.subprocess.run") as mock_run, - ): - result = runner.invoke(app, ["install-packages"]) - assert result.exit_code == 0 - mock_run.assert_called_with( - ["sudo", "apt-get", "install", "-y"] + UBUNTU_PACKAGES, - check=True, - text=True, - ) - - -def test_install_packages_subprocess_error(): - with patch("trobz_local.main.get_os_info", return_value={"system": "Linux", "distro": "arch"}): - error = subprocess.CalledProcessError(1, ["cmd"], stderr="Installation failed") - with patch("trobz_local.main.subprocess.run", side_effect=error): - result = runner.invoke(app, ["install-packages"]) - assert result.exit_code == 0 - assert "Error installing packages" in result.stdout - assert "Installation failed" in result.stdout diff --git a/tests/test_install_tools.py b/tests/test_install_tools.py index 7bbe74c..d9f39e1 100644 --- a/tests/test_install_tools.py +++ b/tests/test_install_tools.py @@ -1,5 +1,10 @@ -from unittest.mock import call, patch +"""Tests for install_tools command with new multi-package support.""" +import subprocess +from pathlib import Path +from unittest.mock import patch + +import pytest from typer.testing import CliRunner from trobz_local.main import app @@ -7,53 +12,242 @@ runner = CliRunner() -@patch("trobz_local.utils.shutil.which") +@pytest.fixture(autouse=True) +def mock_typer_confirm(): + with patch("typer.confirm", return_value=True) as mock_tc: + yield mock_tc + + +# ============================================================================= +# UV Tools Tests +# ============================================================================= + + +@patch("trobz_local.installers.shutil.which") @patch("trobz_local.main.get_config") -@patch("trobz_local.main.subprocess.run") -def test_install_tools(mock_subprocess, mock_get_config, mock_which): +@patch("trobz_local.installers.subprocess.run") +def test_install_uv_tools(mock_subprocess, mock_get_config, mock_which): mock_which.return_value = "/usr/bin/uv" - mock_get_config.return_value = {"tools": ["invoke", "git-aggregator"]} + mock_get_config.return_value = { + "tools": { + "uv": ["invoke", "git-aggregator"], + "npm": [], + "script": [], + "system_packages": [], + } + } result = runner.invoke(app, ["--no-newcomer", "install-tools"]) assert result.exit_code == 0 + # Each uv tool is installed in parallel, so we expect 2 calls assert mock_subprocess.call_count == 2 - expected_calls = [ - call(["/usr/bin/uv", "tool", "install", "--", "invoke"], check=True, capture_output=True, text=True), - call(["/usr/bin/uv", "tool", "install", "--", "git-aggregator"], check=True, capture_output=True, text=True), - ] - mock_subprocess.assert_has_calls(expected_calls) - -@patch("trobz_local.utils.shutil.which") +@patch("trobz_local.installers.shutil.which") @patch("trobz_local.main.get_config") -@patch("trobz_local.main.subprocess.run") +@patch("trobz_local.installers.subprocess.run") def test_install_tools_empty_config(mock_subprocess, mock_get_config, mock_which): mock_which.return_value = "/usr/bin/uv" - mock_get_config.return_value = {} # No tools key + mock_get_config.return_value = {"tools": {}} # Empty tools config result = runner.invoke(app, ["--no-newcomer", "install-tools"]) assert result.exit_code == 0 mock_subprocess.assert_not_called() + assert "No tools found in config" in result.stdout + + +@patch("trobz_local.installers.shutil.which") +@patch("trobz_local.main.get_config") +@patch("trobz_local.installers.subprocess.run") +def test_install_tools_uv_missing(mock_subprocess, mock_get_config, mock_which): + mock_which.return_value = None # uv not found + mock_get_config.return_value = { + "tools": { + "uv": ["invoke"], + "npm": [], + "script": [], + "system_packages": [], + } + } - mock_get_config.return_value = {"tools": []} # Empty tools list result = runner.invoke(app, ["--no-newcomer", "install-tools"]) + assert result.exit_code == 1 + assert "uv is not installed" in result.stdout + + +# ============================================================================= +# Dry Run Tests +# ============================================================================= + + +@patch("trobz_local.installers.shutil.which") +@patch("trobz_local.main.get_config") +def test_install_tools_dry_run(mock_get_config, mock_which): + mock_which.return_value = "/usr/bin/uv" + mock_get_config.return_value = { + "tools": { + "uv": ["ruff", "pre-commit"], + "npm": ["prettier"], + "script": [{"url": "https://example.com/install.sh"}], + "system_packages": ["git"], + } + } + + result = runner.invoke(app, ["--no-newcomer", "install-tools", "--dry-run"]) + assert result.exit_code == 0 - mock_subprocess.assert_not_called() + # Verify dry-run output contains all categories + assert "Scripts - would be downloaded" in result.stdout + assert "System packages - would be installed" in result.stdout + assert "NPM packages - would be installed" in result.stdout + assert "UV tools - would be installed" in result.stdout + +# ============================================================================= +# NPM Packages Tests +# ============================================================================= -@patch("trobz_local.utils.shutil.which") + +@patch("trobz_local.installers.shutil.which") @patch("trobz_local.main.get_config") -@patch("trobz_local.main.subprocess.run") -def test_install_tools_uv_missing(mock_subprocess, mock_get_config, mock_which): - mock_which.return_value = None # uv not found - mock_get_config.return_value = {"tools": ["invoke"]} +@patch("trobz_local.installers.subprocess.run") +def test_install_npm_packages_pnpm_missing(mock_subprocess, mock_get_config, mock_which): + # pnpm not found + mock_which.side_effect = lambda cmd: None if cmd == "pnpm" else "/usr/bin/uv" + mock_get_config.return_value = { + "tools": { + "uv": [], + "npm": ["prettier"], + "script": [], + "system_packages": [], + } + } result = runner.invoke(app, ["--no-newcomer", "install-tools"]) assert result.exit_code == 1 - assert "uv is not installed" in result.stdout - mock_subprocess.assert_not_called() + assert "pnpm is not installed" in result.stdout + + +@patch("trobz_local.installers.shutil.which") +@patch("trobz_local.main.get_config") +@patch("trobz_local.installers.subprocess.run") +def test_install_npm_packages_success(mock_subprocess, mock_get_config, mock_which): + mock_which.return_value = "/usr/bin/pnpm" + mock_get_config.return_value = { + "tools": { + "uv": [], + "npm": ["prettier", "eslint"], + "script": [], + "system_packages": [], + } + } + + result = runner.invoke(app, ["--no-newcomer", "install-tools"]) + + assert result.exit_code == 0 + # Each npm package is installed in parallel + assert mock_subprocess.call_count == 2 + + +# ============================================================================= +# System Packages Tests +# ============================================================================= + + +@patch("trobz_local.installers.get_os_info") +@patch("trobz_local.installers.shutil.which") +@patch("trobz_local.main.get_config") +@patch("trobz_local.installers.subprocess.run") +def test_install_system_packages_arch(mock_subprocess, mock_get_config, mock_which, mock_os_info): + mock_os_info.return_value = {"system": "Linux", "distro": "arch"} + mock_which.return_value = "/usr/bin/pacman" + mock_get_config.return_value = { + "tools": { + "uv": [], + "npm": [], + "script": [], + "system_packages": ["pnpm"], + } + } + + result = runner.invoke(app, ["--no-newcomer", "install-tools"]) + + assert result.exit_code == 0 + # System packages are installed as a single batch + mock_subprocess.assert_called_once() + call_args = mock_subprocess.call_args[0][0] + assert "pacman" in call_args + assert "pnpm" in call_args + + +@patch("trobz_local.installers.get_os_info") +@patch("trobz_local.installers.shutil.which") +@patch("trobz_local.main.get_config") +def test_install_system_packages_unsupported_os(mock_get_config, mock_which, mock_os_info): + mock_os_info.return_value = {"system": "Windows", "distro": "unknown"} + mock_which.return_value = None + mock_get_config.return_value = { + "tools": { + "uv": [], + "npm": [], + "script": [], + "system_packages": ["git"], + } + } + + result = runner.invoke(app, ["--no-newcomer", "install-tools"]) + + # Should show error for unsupported OS + assert "Unsupported operating system" in result.stdout + + +# ============================================================================= +# Script Tests +# ============================================================================= + + +@patch("trobz_local.installers.shutil.which") +@patch("trobz_local.main.get_config") +@patch("trobz_local.installers.subprocess.run") +def test_install_script_success(mock_subprocess, mock_get_config, mock_which): + """Script downloads and executes successfully.""" + + def which_side_effect(cmd): + if cmd == "curl": + return "/usr/bin/curl" + if cmd == "sh": + return "/bin/sh" + return None + + mock_which.side_effect = which_side_effect + + content = b"#!/bin/sh\necho 'test'" + + mock_get_config.return_value = { + "tools": { + "uv": [], + "npm": [], + "script": [{"url": "https://example.com/test.sh"}], + "system_packages": [], + } + } + + def subprocess_side_effect(cmd, **kwargs): + if "-o" in cmd: + idx = cmd.index("-o") + output_path = Path(cmd[idx + 1]) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_bytes(content) + return subprocess.CompletedProcess(cmd, 0, "", "") + return subprocess.CompletedProcess(cmd, 0, "", "") + + mock_subprocess.side_effect = subprocess_side_effect + + result = runner.invoke(app, ["--no-newcomer", "install-tools"]) + + assert result.exit_code == 0 + assert "✓ https://example.com/test.sh executed." in result.stdout diff --git a/trobz_local/exceptions.py b/trobz_local/exceptions.py new file mode 100644 index 0000000..d9f096e --- /dev/null +++ b/trobz_local/exceptions.py @@ -0,0 +1,38 @@ +class InstallerError(Exception): + """Base exception for installer errors.""" + + +class DownloadError(InstallerError): + def __init__(self, url: str, reason: str = ""): + self.url = url + self.reason = reason + message = f"Failed to download {url}" + if reason: + message = f"{message}: {reason}" + super().__init__(message) + + +class ScriptExecutionError(InstallerError): + def __init__(self, script_name: str, reason: str = ""): + self.script_name = script_name + self.reason = reason + message = f"Script execution failed: {script_name}" + if reason: + message = f"{message}: {reason}" + super().__init__(message) + + +class PackageInstallError(InstallerError): + def __init__(self, package: str, reason: str = ""): + self.package = package + self.reason = reason + message = f"Failed to install {package}" + if reason: + message = f"{message}: {reason}" + super().__init__(message) + + +class ExecutableNotFoundError(InstallerError): + def __init__(self, executable: str): + self.executable = executable + super().__init__(f"Required executable not found: {executable}") diff --git a/trobz_local/installers.py b/trobz_local/installers.py new file mode 100644 index 0000000..72ec22d --- /dev/null +++ b/trobz_local/installers.py @@ -0,0 +1,282 @@ +import logging +import shutil +import subprocess +import tempfile +from pathlib import Path + +import typer +from rich.progress import Progress, TaskID + +from .concurrency import TaskResult, run_tasks +from .exceptions import ( + DownloadError, + ExecutableNotFoundError, + PackageInstallError, + ScriptExecutionError, +) +from .utils import ( + ARCH_PACKAGES, + MACOS_PACKAGES, + UBUNTU_PACKAGES, + get_os_info, + get_uv_path, +) + +logger = logging.getLogger(__name__) + + +def _get_executable_path(name: str) -> str: + path = shutil.which(name) + if path is None: + raise ExecutableNotFoundError(name) + return path + + +def _get_download_command(url: str, output_path: str) -> list[str]: + wget_path = shutil.which("wget") + if wget_path: + return [wget_path, "-q", "-O", output_path, url] + + curl_path = shutil.which("curl") + if curl_path: + return [curl_path, "-fsSL", "-o", output_path, url] + + raise ExecutableNotFoundError("wget/curl") + + +def _execute_script(script_path: Path, script_name: str) -> None: + """Execute a shell script using full path to sh.""" + sh_path = _get_executable_path("sh") + + subprocess.run( # noqa: S603 + [sh_path, str(script_path)], + check=True, + capture_output=True, + text=True, + ) + + +def _install_script(progress: Progress, task_id: TaskID, script: dict, temp_dir: str): + url = script["url"] + script_name = script.get("name") or url + + # === Step 1: Download === + progress.update(task_id, description=f"Downloading {script_name}...", total=100, completed=0) + + url_filename = url.split("/")[-1].split("?")[0] + download_path = Path(temp_dir) / url_filename + + download_cmd = _get_download_command(url, str(download_path)) + + try: + subprocess.run(download_cmd, check=True, capture_output=True, text=True) # noqa: S603 + except subprocess.CalledProcessError as e: + progress.update(task_id, description=f"[red]✗ Download failed: {script_name}") + raise DownloadError(url, e.stderr) from e + + # === Step 2: Execute === + progress.update(task_id, description=f"Executing {script_name}...", completed=50) + + try: + _execute_script(download_path, script_name) + except subprocess.CalledProcessError as e: + progress.update(task_id, description=f"[red]✗ Script execution failed: {script_name}") + raise ScriptExecutionError(script_name, e.stderr) from e + + progress.update(task_id, description=f"✓ {script_name} executed.", completed=100) + + +def install_scripts(scripts: list[dict], dry_run: bool = False) -> list: + """Download and execute scripts. + + Args: + scripts: List of script dicts with keys: + - url: HTTPS URL to download + - name: Optional display name + dry_run: If True, only show what would be installed + """ + if not scripts: + return [] + + if dry_run: + typer.echo("\n[Scripts - would be downloaded and executed]") + for script in scripts: + script_name = script["name"] if script.get("name") else script["url"] + typer.echo(f" - {script_name}") + return [] + + typer.secho("\n--- Installing Scripts ---", fg=typer.colors.BLUE, bold=True) + + with tempfile.TemporaryDirectory() as temp_dir: + tasks = [] + for script in scripts: + script_name = script.get("name") or script["url"] + tasks.append({ + "name": script_name, + "func": _install_script, + "args": {"script": script, "temp_dir": temp_dir}, + }) + + return run_tasks(tasks) + + +def _get_package_manager_config(system: str, distro: str) -> tuple[list[str], list[str]] | None: + """Get package manager command and default packages for the OS. + + Returns: + Tuple of (command_prefix, default_packages) or None if unsupported. + """ + if system == "Darwin": + if not shutil.which("brew"): + return None + return ["brew", "install"], MACOS_PACKAGES + + if system == "Linux": + if distro == "arch": + return ["sudo", "pacman", "-S", "--noconfirm", "--needed"], ARCH_PACKAGES + if distro == "ubuntu": + return ["sudo", "apt-get", "install", "-y"], UBUNTU_PACKAGES + + return None + + +def _run_package_install(cmd: list[str], packages: list[str]) -> bool: + full_cmd = cmd + packages + try: + subprocess.run(full_cmd, check=True, text=True) # noqa: S603 + except subprocess.CalledProcessError as e: + typer.secho(f"Error installing packages: {e}", fg=typer.colors.RED) + return False + return True + + +def install_system_packages(packages: list[str], dry_run: bool = False) -> bool: + if not packages: + return True + + os_info = get_os_info() + system = os_info["system"] + distro = os_info["distro"] + + config = _get_package_manager_config(system, distro) + + if config is None: + if system == "Darwin": + typer.secho("Error: Homebrew is not installed. Please install it first.", fg=typer.colors.RED) + elif system == "Linux": + typer.secho(f"Error: Unsupported Linux distribution: {distro}", fg=typer.colors.RED) + else: + typer.secho(f"Error: Unsupported operating system: {system}", fg=typer.colors.RED) + return False + + cmd, default_packages = config + + # Merge user packages with default packages + all_packages = list(dict.fromkeys(default_packages + packages)) + + if dry_run: + typer.echo(f"\n[System packages - would be installed via {cmd[0]}]") + for pkg in all_packages: + source = "[default]" if pkg in default_packages else "[config]" + typer.echo(f" - {pkg} {source}") + return True + + typer.secho("\n--- Installing System Packages ---", fg=typer.colors.BLUE, bold=True) + typer.echo(f"Package manager: {cmd[0]}") + typer.echo(f"Packages: {', '.join(all_packages)}") + + if _run_package_install(cmd, all_packages): + typer.secho("✓ System packages installed successfully.", fg=typer.colors.GREEN) + return True + return False + + +def _install_npm_package(progress: Progress, task_id: TaskID, package: str, pnpm_path: str): + progress.update(task_id, description=f"Installing {package}...", total=100, completed=0) + + try: + subprocess.run( # noqa: S603 + [pnpm_path, "install", "-g", package], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + progress.update(task_id, description=f"[red]✗ Failed to install {package}") + raise PackageInstallError(package, e.stderr) from e + + progress.update(task_id, description=f"✓ {package} installed.", completed=100) + + +def install_npm_packages(packages: list[str], dry_run: bool = False) -> list: + if not packages: + return [] + + pnpm_path = shutil.which("pnpm") + if not pnpm_path: + typer.secho( + "Error: pnpm is not installed.\n" + "Please add 'pnpm' to system_packages in your config.toml and rerun install-tools.", + fg=typer.colors.RED, + ) + return [TaskResult(name="pnpm-check", success=False, message="pnpm is not installed")] + + if dry_run: + typer.echo("\n[NPM packages - would be installed globally via pnpm]") + for pkg in packages: + typer.echo(f" - {pkg}") + return [] + + typer.secho("\n--- Installing NPM Packages ---", fg=typer.colors.BLUE, bold=True) + + tasks = [] + for package in packages: + tasks.append({ + "name": package, + "func": _install_npm_package, + "args": {"package": package, "pnpm_path": pnpm_path}, + }) + + return run_tasks(tasks) + + +def _install_uv_tool(progress: Progress, task_id: TaskID, tool: str, uv_path: str): + progress.update(task_id, description=f"Installing {tool}...", total=100, completed=0) + + try: + subprocess.run( # noqa: S603 + [uv_path, "tool", "install", "--", tool], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError as e: + progress.update(task_id, description=f"[red]✗ Failed to install {tool}") + raise PackageInstallError(tool, e.stderr) from e + + progress.update(task_id, description=f"✓ {tool} installed.", completed=100) + + +def install_uv_tools(tools: list[str], dry_run: bool = False) -> list: + if not tools: + return [] + + if dry_run: + typer.echo("\n[UV tools - would be installed via uv tool install]") + for tool in tools: + typer.echo(f" - {tool}") + return [] + + typer.secho("\n--- Installing UV Tools ---", fg=typer.colors.BLUE, bold=True) + + uv_path = get_uv_path() + + tasks = [] + for tool in tools: + tasks.append({ + "name": tool, + "func": _install_uv_tool, + "args": {"tool": tool, "uv_path": uv_path}, + }) + + return run_tasks(tasks) diff --git a/trobz_local/main.py b/trobz_local/main.py index ca32c7c..73bede7 100644 --- a/trobz_local/main.py +++ b/trobz_local/main.py @@ -1,5 +1,4 @@ import os -import shutil import subprocess from pathlib import Path from typing import Annotated @@ -10,15 +9,17 @@ from rich.progress import Progress, TaskID from rich.tree import Tree -from .concurrency import run_tasks +from .concurrency import TaskResult, run_tasks +from .installers import ( + install_npm_packages, + install_scripts, + install_system_packages, + install_uv_tools, +) from .utils import ( - ARCH_PACKAGES, - MACOS_PACKAGES, - UBUNTU_PACKAGES, GitProgress, confirm_step, get_config, - get_os_info, get_uv_path, ) @@ -242,41 +243,115 @@ def _pull_repo(progress: Progress, task_id: TaskID, repo_info: dict): raise # to be caught by run_tasks +def _build_install_message(tools_config: dict) -> str: + msg = "This command will install tools in the following order:\n" + + if tools_config.get("script"): + msg += "\n[1] Scripts (download & execute):\n" + for script in tools_config["script"]: + name = script.get("name") if isinstance(script, dict) else getattr(script, "name", None) + url = script["url"] if isinstance(script, dict) else script.url + sha256 = script.get("sha256") if isinstance(script, dict) else getattr(script, "sha256", None) + display_name = name or url + hash_status = "✓ verified" if sha256 else "⚠ no hash" + msg += f" - {display_name} ({hash_status})\n" + + if tools_config.get("system_packages"): + msg += "\n[2] System packages:\n" + for pkg in tools_config["system_packages"]: + msg += f" - {pkg}\n" + + if tools_config.get("npm"): + msg += "\n[3] NPM packages (via pnpm -g):\n" + for pkg in tools_config["npm"]: + msg += f" - {pkg}\n" + + if tools_config.get("uv"): + msg += "\n[4] UV tools:\n" + for tool in tools_config["uv"]: + msg += f" - {tool}\n" + + return msg + + +def _run_installers(tools_config: dict, dry_run: bool) -> tuple[list, bool]: + all_results = [] + any_failed = False + + if tools_config.get("script"): + scripts = [ + { + "url": s["url"] if isinstance(s, dict) else s.url, + "sha256": s.get("sha256") if isinstance(s, dict) else getattr(s, "sha256", None), + "name": s.get("name") if isinstance(s, dict) else getattr(s, "name", None), + } + for s in tools_config["script"] + ] + results = install_scripts(scripts, dry_run) + all_results.extend(results) + if any(not r.success for r in results): + any_failed = True + + if tools_config.get("system_packages"): + success = install_system_packages(tools_config["system_packages"], dry_run) + if not success: + any_failed = True + + all_results.append( + TaskResult(name="system-packages", success=False, message="System package installation failed") + ) + + if tools_config.get("npm"): + results = install_npm_packages(tools_config["npm"], dry_run) + all_results.extend(results) + if any(not r.success for r in results): + any_failed = True + + if tools_config.get("uv"): + results = install_uv_tools(tools_config["uv"], dry_run) + all_results.extend(results) + if any(not r.success for r in results): + any_failed = True + + return all_results, any_failed + + @app.command() -def install_tools(ctx: typer.Context): +def install_tools( + ctx: typer.Context, + dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be installed without executing."), +): """ Install tools using uv tool based on config. """ config = get_config() - tools = config.get("tools", []) + tools_config = config.get("tools", {}) - if not tools: - typer.echo("No tools found in config.") - return + has_any = any([ + tools_config.get("script"), + tools_config.get("system_packages"), + tools_config.get("npm"), + tools_config.get("uv"), + ]) - msg = "This command will install the following command-line tools using 'uv':\n\n" - for tool in tools: - msg += f"- {tool}\n" + if not has_any: + typer.echo("No tools found in config. Add tools to [tools] section in ~/code/config.toml") + return - confirm_step( - ctx, - msg, - "install-tools", - ) + msg = _build_install_message(tools_config) + confirm_step(ctx, msg, "install-tools") - uv_path = get_uv_path() + all_results, any_failed = _run_installers(tools_config, dry_run) - for tool in tools: - try: - subprocess.run( # noqa: S603 - tool input is safely checked above - [uv_path, "tool", "install", "--", tool], - check=True, - capture_output=True, - text=True, - ) - typer.secho(f"✓ {tool} installed successfully.", fg=typer.colors.GREEN) - except subprocess.CalledProcessError as e: - typer.secho(f"✗ Error installing {tool}: {e.stderr.strip()}", fg=typer.colors.RED) + if not dry_run: + if any_failed: + failed = [r for r in all_results if not r.success] + typer.secho("\n--- Some installations failed ---", fg=typer.colors.RED) + for r in failed: + typer.secho(f"✗ {r.name}: {r.message}", fg=typer.colors.RED) + raise typer.Exit(code=1) + else: + typer.secho("\n✓ All tools installed successfully.", fg=typer.colors.GREEN) @app.command() @@ -375,63 +450,3 @@ def _create_venvs( except Exception as e: progress.update(task_id, description=f"[red]✗ Error venv {version}: {e}") raise - - -@app.command() -def install_packages(ctx: typer.Context): - """ - Install system-wide packages required. - """ - os_info = get_os_info() - system = os_info["system"] - distro = os_info["distro"] - - cmd = [] - packages = [] - - if system == "Darwin": - if not shutil.which("brew"): - typer.secho("Error: Homebrew is not installed. Please install it first.", fg=typer.colors.RED) - raise typer.Exit(code=1) - cmd = ["brew", "install"] - packages = MACOS_PACKAGES - - elif system == "Linux": - if distro == "arch": - cmd = ["sudo", "pacman", "-S", "--noconfirm", "--needed"] - packages = ARCH_PACKAGES - elif distro == "ubuntu": - cmd = ["sudo", "apt-get", "install", "-y"] - packages = UBUNTU_PACKAGES - else: - typer.secho(f"Error: Unsupported operating system: {system} ({distro})", fg=typer.colors.RED) - raise typer.Exit(code=1) - else: - typer.secho(f"Error: Unsupported operating system: {system}", fg=typer.colors.RED) - raise typer.Exit(code=1) - - msg = "This command will install the following system-wide packages:\n\n" - for package in packages: - msg += f"- {package}\n" - msg += "\nIt might ask for your sudo password." - - confirm_step( - ctx, - msg, - "install-packages", - ) - - full_cmd = cmd + packages - - try: - subprocess.run( # noqa: S603 - full_cmd, - check=True, - text=True, - ) - typer.secho("✓ System packages installed successfully.", fg=typer.colors.GREEN) - - except subprocess.CalledProcessError as e: - typer.secho(f"Error installing packages: {e}", fg=typer.colors.RED) - if e.stderr: - typer.echo(e.stderr) diff --git a/trobz_local/utils.py b/trobz_local/utils.py index db14d9a..bae8baf 100644 --- a/trobz_local/utils.py +++ b/trobz_local/utils.py @@ -67,6 +67,16 @@ def __init__(self, tool: str): super().__init__(f"Invalid tool name: {tool}") +class InvalidScriptUrlError(ValueError): + def __init__(self, url: str): + super().__init__(f"Script URL must use HTTPS for security: {url}") + + +class InvalidNpmPackageError(ValueError): + def __init__(self, pkg: str): + super().__init__(f"Invalid npm package name: {pkg}") + + class RepoConfig(BaseModel): odoo: list[str] = [] oca: list[str] = [] @@ -80,9 +90,49 @@ def validate_repo_names(cls, v: list[str]): return v +class ScriptItem(BaseModel): + """Configuration for a script to download and execute.""" + + url: str + install_script: str = "install.sh" + name: str | None = None + + @field_validator("url") + @classmethod + def validate_url(cls, v: str): + if not v.startswith("https://"): + raise InvalidScriptUrlError(v) + return v + + +class ToolsConfig(BaseModel): + """Configuration for tools to install.""" + + uv: list[str] = Field(default_factory=list) + npm: list[str] = Field(default_factory=list) + script: list[ScriptItem] = Field(default_factory=list) + system_packages: list[str] = Field(default_factory=list) + + @field_validator("uv") + @classmethod + def validate_uv_tools(cls, v: list[str]): + for tool in v: + if not TOOL_NAME_REGEX.match(tool): + raise InvalidToolNameError(tool) + return v + + @field_validator("npm") + @classmethod + def validate_npm_packages(cls, v: list[str]): + for pkg in v: + if not re.match(r"^(@[a-z0-9-~][a-z0-9-._~]*/)?[a-z0-9-~][a-z0-9-._~]*$", pkg): + raise InvalidNpmPackageError(pkg) + return v + + class ConfigModel(BaseModel): versions: list[str] = Field(default_factory=list) - tools: list[str] = Field(default_factory=list) + tools: ToolsConfig = Field(default_factory=ToolsConfig) repos: RepoConfig = Field(default_factory=RepoConfig) @field_validator("versions") @@ -93,14 +143,6 @@ def validate_versions(cls, v: list[str]): raise InvalidVersionError(version) return v - @field_validator("tools") - @classmethod - def validate_tools(cls, v: list[str]): - for tool in v: - if not TOOL_NAME_REGEX.match(tool): - raise InvalidToolNameError(tool) - return v - def get_uv_path(): uv_path = shutil.which("uv") @@ -111,21 +153,40 @@ def get_uv_path(): def show_config_instructions(): - content = """ -versions = ["16.0", "17.0", "18.0"] + content = """versions = ["14.0", "15.0", "16.0", "17.0", "18.0"] -tools = [ +[tools] +uv = [ "odoo-venv", "odoo-addons-path", "pre-commit", ] +npm = [ + "prettier", +] + +[[tools.script]] +name = "uv" +url = "https://astral.sh/uv/install.sh" + +[[tools.script]] +name = "nvm" +url = "https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh" + +system_packages = [ + "pnpm", +] + [repos] -odoo = ["odoo", "enterprise"] +odoo = [ + "odoo", + "enterprise", +] + oca = [ "server-tools", "server-ux", - "web", ] """ typer.secho("Config file not found.", fg=typer.colors.YELLOW) diff --git a/uv.lock b/uv.lock index c3c8604..0dfb3a4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [[package]] @@ -151,6 +151,110 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/9a/3742e58fd04b233df95c012ee9f3dfe04708a5e1d32613bd2d47d4e1be0d/coverage-7.13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1fa280b3ad78eea5be86f94f461c04943d942697e0dac889fa18fff8f5f9147", size = 218633, upload-time = "2025-12-28T15:40:10.165Z" }, + { url = "https://files.pythonhosted.org/packages/7e/45/7e6bdc94d89cd7c8017ce735cf50478ddfe765d4fbf0c24d71d30ea33d7a/coverage-7.13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c3d8c679607220979434f494b139dfb00131ebf70bb406553d69c1ff01a5c33d", size = 219147, upload-time = "2025-12-28T15:40:12.069Z" }, + { url = "https://files.pythonhosted.org/packages/f7/38/0d6a258625fd7f10773fe94097dc16937a5f0e3e0cdf3adef67d3ac6baef/coverage-7.13.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:339dc63b3eba969067b00f41f15ad161bf2946613156fb131266d8debc8e44d0", size = 245894, upload-time = "2025-12-28T15:40:13.556Z" }, + { url = "https://files.pythonhosted.org/packages/27/58/409d15ea487986994cbd4d06376e9860e9b157cfbfd402b1236770ab8dd2/coverage-7.13.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db622b999ffe49cb891f2fff3b340cdc2f9797d01a0a202a0973ba2562501d90", size = 247721, upload-time = "2025-12-28T15:40:15.37Z" }, + { url = "https://files.pythonhosted.org/packages/da/bf/6e8056a83fd7a96c93341f1ffe10df636dd89f26d5e7b9ca511ce3bcf0df/coverage-7.13.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1443ba9acbb593fa7c1c29e011d7c9761545fe35e7652e85ce7f51a16f7e08d", size = 249585, upload-time = "2025-12-28T15:40:17.226Z" }, + { url = "https://files.pythonhosted.org/packages/f4/15/e1daff723f9f5959acb63cbe35b11203a9df77ee4b95b45fffd38b318390/coverage-7.13.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c832ec92c4499ac463186af72f9ed4d8daec15499b16f0a879b0d1c8e5cf4a3b", size = 246597, upload-time = "2025-12-28T15:40:19.028Z" }, + { url = "https://files.pythonhosted.org/packages/74/a6/1efd31c5433743a6ddbc9d37ac30c196bb07c7eab3d74fbb99b924c93174/coverage-7.13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:562ec27dfa3f311e0db1ba243ec6e5f6ab96b1edfcfc6cf86f28038bc4961ce6", size = 247626, upload-time = "2025-12-28T15:40:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9f/1609267dd3e749f57fdd66ca6752567d1c13b58a20a809dc409b263d0b5f/coverage-7.13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:4de84e71173d4dada2897e5a0e1b7877e5eefbfe0d6a44edee6ce31d9b8ec09e", size = 245629, upload-time = "2025-12-28T15:40:22.397Z" }, + { url = "https://files.pythonhosted.org/packages/e2/f6/6815a220d5ec2466383d7cc36131b9fa6ecbe95c50ec52a631ba733f306a/coverage-7.13.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:a5a68357f686f8c4d527a2dc04f52e669c2fc1cbde38f6f7eb6a0e58cbd17cae", size = 245901, upload-time = "2025-12-28T15:40:23.836Z" }, + { url = "https://files.pythonhosted.org/packages/ac/58/40576554cd12e0872faf6d2c0eb3bc85f71d78427946ddd19ad65201e2c0/coverage-7.13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:77cc258aeb29a3417062758975521eae60af6f79e930d6993555eeac6a8eac29", size = 246505, upload-time = "2025-12-28T15:40:25.421Z" }, + { url = "https://files.pythonhosted.org/packages/3b/77/9233a90253fba576b0eee81707b5781d0e21d97478e5377b226c5b096c0f/coverage-7.13.1-cp310-cp310-win32.whl", hash = "sha256:bb4f8c3c9a9f34423dba193f241f617b08ffc63e27f67159f60ae6baf2dcfe0f", size = 221257, upload-time = "2025-12-28T15:40:27.217Z" }, + { url = "https://files.pythonhosted.org/packages/e0/43/e842ff30c1a0a623ec80db89befb84a3a7aad7bfe44a6ea77d5a3e61fedd/coverage-7.13.1-cp310-cp310-win_amd64.whl", hash = "sha256:c8e2706ceb622bc63bac98ebb10ef5da80ed70fbd8a7999a5076de3afaef0fb1", size = 222191, upload-time = "2025-12-28T15:40:28.916Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9b/77baf488516e9ced25fc215a6f75d803493fc3f6a1a1227ac35697910c2a/coverage-7.13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a55d509a1dc5a5b708b5dad3b5334e07a16ad4c2185e27b40e4dba796ab7f88", size = 218755, upload-time = "2025-12-28T15:40:30.812Z" }, + { url = "https://files.pythonhosted.org/packages/d7/cd/7ab01154e6eb79ee2fab76bf4d89e94c6648116557307ee4ebbb85e5c1bf/coverage-7.13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d010d080c4888371033baab27e47c9df7d6fb28d0b7b7adf85a4a49be9298b3", size = 219257, upload-time = "2025-12-28T15:40:32.333Z" }, + { url = "https://files.pythonhosted.org/packages/01/d5/b11ef7863ffbbdb509da0023fad1e9eda1c0eaea61a6d2ea5b17d4ac706e/coverage-7.13.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d938b4a840fb1523b9dfbbb454f652967f18e197569c32266d4d13f37244c3d9", size = 249657, upload-time = "2025-12-28T15:40:34.1Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7c/347280982982383621d29b8c544cf497ae07ac41e44b1ca4903024131f55/coverage-7.13.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf100a3288f9bb7f919b87eb84f87101e197535b9bd0e2c2b5b3179633324fee", size = 251581, upload-time = "2025-12-28T15:40:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/82/f6/ebcfed11036ade4c0d75fa4453a6282bdd225bc073862766eec184a4c643/coverage-7.13.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef6688db9bf91ba111ae734ba6ef1a063304a881749726e0d3575f5c10a9facf", size = 253691, upload-time = "2025-12-28T15:40:37.626Z" }, + { url = "https://files.pythonhosted.org/packages/02/92/af8f5582787f5d1a8b130b2dcba785fa5e9a7a8e121a0bb2220a6fdbdb8a/coverage-7.13.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0b609fc9cdbd1f02e51f67f51e5aee60a841ef58a68d00d5ee2c0faf357481a3", size = 249799, upload-time = "2025-12-28T15:40:39.47Z" }, + { url = "https://files.pythonhosted.org/packages/24/aa/0e39a2a3b16eebf7f193863323edbff38b6daba711abaaf807d4290cf61a/coverage-7.13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c43257717611ff5e9a1d79dce8e47566235ebda63328718d9b65dd640bc832ef", size = 251389, upload-time = "2025-12-28T15:40:40.954Z" }, + { url = "https://files.pythonhosted.org/packages/73/46/7f0c13111154dc5b978900c0ccee2e2ca239b910890e674a77f1363d483e/coverage-7.13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e09fbecc007f7b6afdfb3b07ce5bd9f8494b6856dd4f577d26c66c391b829851", size = 249450, upload-time = "2025-12-28T15:40:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/e80da6769e8b669ec3695598c58eef7ad98b0e26e66333996aee6316db23/coverage-7.13.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a03a4f3a19a189919c7055098790285cc5c5b0b3976f8d227aea39dbf9f8bfdb", size = 249170, upload-time = "2025-12-28T15:40:44.279Z" }, + { url = "https://files.pythonhosted.org/packages/af/18/9e29baabdec1a8644157f572541079b4658199cfd372a578f84228e860de/coverage-7.13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3820778ea1387c2b6a818caec01c63adc5b3750211af6447e8dcfb9b6f08dbba", size = 250081, upload-time = "2025-12-28T15:40:45.748Z" }, + { url = "https://files.pythonhosted.org/packages/00/f8/c3021625a71c3b2f516464d322e41636aea381018319050a8114105872ee/coverage-7.13.1-cp311-cp311-win32.whl", hash = "sha256:ff10896fa55167371960c5908150b434b71c876dfab97b69478f22c8b445ea19", size = 221281, upload-time = "2025-12-28T15:40:47.232Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/c216625f453df6e0559ed666d246fcbaaa93f3aa99eaa5080cea1229aa3d/coverage-7.13.1-cp311-cp311-win_amd64.whl", hash = "sha256:a998cc0aeeea4c6d5622a3754da5a493055d2d95186bad877b0a34ea6e6dbe0a", size = 222215, upload-time = "2025-12-28T15:40:49.19Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/be342e76f6e531cae6406dc46af0d350586f24d9b67fdfa6daee02df71af/coverage-7.13.1-cp311-cp311-win_arm64.whl", hash = "sha256:fea07c1a39a22614acb762e3fbbb4011f65eedafcb2948feeef641ac78b4ee5c", size = 220886, upload-time = "2025-12-28T15:40:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -592,6 +696,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + [[package]] name = "python-gitlab" version = "6.5.0" @@ -855,6 +973,7 @@ build = [ dev = [ { name = "pre-commit" }, { name = "pytest" }, + { name = "pytest-cov" }, { name = "python-semantic-release" }, { name = "ruff" }, { name = "ty" }, @@ -874,6 +993,7 @@ provides-extras = ["build"] dev = [ { name = "pre-commit", specifier = ">=2.20.0" }, { name = "pytest", specifier = ">=7.2.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "python-semantic-release", specifier = ">=10.5.3" }, { name = "ruff", specifier = ">=0.11.5" }, { name = "ty", specifier = ">=0.0.1a16" },