diff --git a/.github/workflows/publish-prereleases.yaml b/.github/workflows/publish-prereleases.yaml new file mode 100644 index 0000000..e16b880 --- /dev/null +++ b/.github/workflows/publish-prereleases.yaml @@ -0,0 +1,35 @@ +name: Publish Prerelease + +on: + release: + types: [prereleased] + +jobs: + publish: + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + if: github.event.release.prerelease == true + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v3 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Build package + run: uvx --with uv-dynamic-versioning hatchling build -d ./dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + attestations: true diff --git a/.github/workflows/publish-releases.yaml b/.github/workflows/publish-releases.yaml index 955fa6b..c6f3a3d 100644 --- a/.github/workflows/publish-releases.yaml +++ b/.github/workflows/publish-releases.yaml @@ -33,6 +33,7 @@ jobs: environment: release permissions: id-token: write + if: github.event.release.prerelease == false steps: - uses: actions/checkout@v4 diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..e3cbdad --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,33 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal", + "justMyCode": false, + }, + { + "name": "Python Debugger: invtk CLI", + "type": "debugpy", + "request": "launch", + "module": "invoke_toolkit.main", + "console": "integratedTerminal", + "justMyCode": false + }, + { + "name": "Python Debugger: Test program (tests/program/main)", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/tests/program/main.py", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}/tests/program/", + "justMyCode": false + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b556da2 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,16 @@ +{ + "peacock.color": "#9d4640", + "ruff.format.preview": true, + "ruff.lint.preview": true, + "ruff.showNotifications": "off", + "python.testing.pytestArgs": [ + "." + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "files.exclude": { + ".venv": true, + ".ruff_cache": true, + ".pytest_cache": true + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..c00d163 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,12 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "pre-commit", + "type": "shell", + "command": "pre-commit run --all-files" + } + ] +} diff --git a/config_improvements.md b/config_improvements.md new file mode 100644 index 0000000..61b3002 --- /dev/null +++ b/config_improvements.md @@ -0,0 +1,18 @@ +# Config Improvements + +## Goal + +Make the `Config` base class compatible with `attrs` to allow for more structured and less human-readable configuration files, especially for nested configurations. + +## Findings + +* The current configuration class is `ToolkitConfig`, which inherits from `invoke.config.Config`. +* The `ToolkitContext` class uses `ToolkitConfig` to manage configuration. +* To add `attrs` compatibility, a new class `AttrsConfig` will be created. + +## Plan + +1. **Create `AttrsConfig`:** This new class will inherit from `ToolkitConfig` and use the `attrs` library to define a structured configuration. It will override `__getattr__` to recursively convert nested dictionaries into `AttrsConfig` instances, enabling dot-notation access. +2. **Integrate with `ToolkitContext`:** The `ToolkitContext` will be modified to use `AttrsConfig` instead of `ToolkitConfig`. +3. **Dependencies:** `attrs` and `cattrs` have been added to `pyproject.toml` and installed. +4. **Testing:** Add tests to verify the new `attrs`-based configuration works as expected. diff --git a/extensions/invoke-toolkit-mcp/pyproject.toml b/extensions/invoke-toolkit-mcp/pyproject.toml new file mode 100644 index 0000000..aff3c68 --- /dev/null +++ b/extensions/invoke-toolkit-mcp/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "invoke-toolkit-mcp" +version = "0.1.0" +description = "MCP (Model Context Protocol) server for invoke-toolkit collections" +readme = "README.md" +requires-python = ">=3.10" +license = "MIT" +keywords = ["mcp", "model-context-protocol", "invoke", "fastmcp", "llm"] +authors = [ + { name = "Nahuel Defossé", email = "D3f0@users.noreply.github.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: Implementation :: CPython", + "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Scientific/Engineering :: Artificial Intelligence", +] + +dependencies = [ + "invoke-toolkit>=2.0.0", # Core dependency - will be satisfied by workspace + "fastmcp>=2.14.5,<3.0.0", # Pin to v2 for stability +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3.5", + "pytest-cov>=5.0.0", + "pytest-html>=4.1.1", +] + +[project.scripts] +invoke-mcp = "invoke_toolkit_mcp.cli:main" + +[project.urls] +Documentation = "https://github.com/D3f0/invoke-toolkit" +Issues = "https://github.com/D3f0/invoke-toolkit/issues" +Source = "https://github.com/D3f0/invoke-toolkit" + +[tool.hatch.build.targets.wheel] +packages = ["src/invoke_toolkit_mcp"] diff --git a/local_tasks.py b/local_tasks.py new file mode 100644 index 0000000..3ca1c65 --- /dev/null +++ b/local_tasks.py @@ -0,0 +1,5 @@ +from invoke import task, Context + + +@task() +def im_local(ctx: Context) -> None: ... diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..972f458 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,74 @@ +site_name: Invoke Toolkit Documentation +site_description: A set of extended APIs for the venerable PyInvoke aimed at scripts composition, plugins and richer output +site_url: https://github.com/D3f0/invoke-toolkit +repo_url: https://github.com/D3f0/invoke-toolkit +repo_name: D3f0/invoke-toolkit + +theme: + name: readthedocs + features: + - navigation.tabs + - navigation.sections + - navigation.expand + - navigation.top + - search.highlight + - search.share + - content.code.copy + palette: + - scheme: default + primary: blue + accent: blue + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: blue + accent: blue + toggle: + icon: material/brightness-4 + name: Switch to light mode + +plugins: + - search + - gen-files: + scripts: + - docs/gen_ref_pages.py + - literate-nav: + nav_file: SUMMARY.md + - section-index + - mkdocstrings: + handlers: + python: + options: + docstring_style: google + show_source: true + show_root_heading: true + show_root_toc_entry: false + merge_init_into_class: true + +nav: + - Home: index.md + - User Guide: + - Installation: user-guide/installation.md + - Quick Start: user-guide/quickstart.md + - Configuration: user-guide/configuration.md + - Tasks: + - Overview: tasks/index.md + - Available Tasks: tasks/available.md + - API Reference: reference/ + - Development: + - Contributing: development/contributing.md + - Testing: development/testing.md + +# markdown_extensions: +# - admonition +# - pymdownx.details +# - pymdownx.superfences +# - pymdownx.highlight: +# anchor_linenums: true +# - pymdownx.inlinehilite +# - pymdownx.snippets +# - pymdownx.tabbed: +# alternate_style: true +# - toc: +# permalink: true diff --git a/src/invoke_toolkit/completion.py b/src/invoke_toolkit/completion.py index 3e1a149..1f9538a 100644 --- a/src/invoke_toolkit/completion.py +++ b/src/invoke_toolkit/completion.py @@ -176,7 +176,12 @@ def get_choices_for_argument( if arg_name in callbacks: try: # Try to call the callback with context and incomplete - ctx = ToolkitContext(config=ToolkitConfig()) + # Automatically load project config for completion callbacks + config = ToolkitConfig() + if hasattr(collection, "root") and collection.root: + config.set_project_location(collection.root) + config.load_project() + ctx = ToolkitContext(config=config) # Get timeout from config (default: 10 seconds) timeout = ctx.get_config_value( diff --git a/src/invoke_toolkit/config/attrs_config.py b/src/invoke_toolkit/config/attrs_config.py new file mode 100644 index 0000000..90d3423 --- /dev/null +++ b/src/invoke_toolkit/config/attrs_config.py @@ -0,0 +1,7 @@ +from attrs import define +from .config import ToolkitConfig + + +@define +class AttrsConfig(ToolkitConfig): + pass diff --git a/tests/examples/completion_config/README.md b/tests/examples/completion_config/README.md new file mode 100644 index 0000000..0b913e9 --- /dev/null +++ b/tests/examples/completion_config/README.md @@ -0,0 +1,89 @@ +# Completion Config Example + +This example demonstrates how completion callbacks can read values from configuration files using `ctx.get_config_value()`. + +## Overview + +Instead of hardcoding completion options, this example shows how to: +- Read completion values from `invoke.yaml` configuration +- Use `ctx.get_config_value()` method (no import needed) +- Provide fallback defaults when config is missing +- Share config values between tasks and completion callbacks + +## Files + +- `invoke.yaml` - Configuration file with completion values +- `tasks.py` - Tasks with completion callbacks that read from config + +## Usage + +```bash +# Navigate to this directory +cd tests/examples/completion_config + +# View current config values +intk show-config + +# Try tab completion (reads from invoke.yaml) +intk deploy --environment +intk deploy --region +intk connect-db --instance +intk toggle-feature --flag +``` + +## How It Works + +### 1. Define completion callback that reads config + +```python +def complete_environments(ctx: Context, incomplete: str) -> list[str]: + # Read from config - no import needed! + environments = ctx.get_config_value( + "deployment.environments", + default=["development", "staging", "production"], + ) + + if incomplete: + environments = [e for e in environments if e.startswith(incomplete)] + + return sorted(environments) +``` + +### 2. Use the callback with Annotated type hint + +```python +@task +def deploy( + ctx: Context, + environment: Annotated[str, complete_environments], +) -> None: + ... +``` + +### 3. Configure values in invoke.yaml + +```yaml +deployment: + environments: + - development + - staging + - production + - canary +``` + +## Config Sources + +The `ctx.get_config_value()` method reads from the full config hierarchy: + +1. **Project config** - `./invoke.yaml` in current directory +2. **User config** - `~/.invoke.yaml` in home directory +3. **System config** - `/etc/invoke.yaml` +4. **Environment variables** - `INVOKE_*` prefix +5. **Defaults** - Built-in toolkit defaults + +## Benefits + +- **Dynamic completions** - Options come from config, not code +- **User customizable** - Users can add/remove options via config +- **Shared values** - Same config used by tasks and completions +- **Fallback defaults** - Works even without config file diff --git a/tests/examples/completion_config/tasks.py b/tests/examples/completion_config/tasks.py new file mode 100644 index 0000000..c17f5cb --- /dev/null +++ b/tests/examples/completion_config/tasks.py @@ -0,0 +1,215 @@ +""" +Example demonstrating completion callbacks that read from config files. + +This example shows how completion callbacks can use ctx.get_config_value() +to read values from invoke.yaml (or other config sources) and provide +dynamic completions based on project configuration. + +The invoke.yaml file in this directory contains: +- deployment.environments: List of deployment environments +- deployment.regions: List of available regions +- database.instances: List of database instances +- database.profiles: List of connection profiles +- features.available_flags: List of feature flags + +Try these commands (from this directory): + cd tests/examples/completion_config + intk deploy --environment # Shows environments from config + intk deploy --region # Shows regions from config + intk connect-db --instance # Shows database instances from config + intk toggle-feature --flag # Shows feature flags from config + +Key benefits: +- Completions are driven by configuration, not hardcoded +- Users can customize available options via config files +- Same config values can be used by both tasks and completions +- Supports user (~/.invoke.yaml), system (/etc/invoke.yaml), and project config +""" + +from typing import Annotated + +from invoke_toolkit import Context, task + + +def complete_environments(ctx: Context, incomplete: str) -> list[str]: + """ + Completion callback that reads deployment environments from config. + + The context (`ctx`) passed to this callback is now pre-loaded with + the project's configuration, so we can directly query values. + """ + environments = ctx.get_config_value( + "deployment.environments", + default=["development", "staging", "production"], + ) + + # Filter by incomplete prefix + if incomplete: + environments = [e for e in environments if e.startswith(incomplete)] + + return sorted(environments) + + +def complete_regions(ctx: Context, incomplete: str) -> list[str]: + """Completion callback that reads available regions from config.""" + regions = ctx.get_config_value( + "deployment.regions", + default=["us-east-1", "us-west-2", "eu-west-1"], + ) + + if incomplete: + regions = [r for r in regions if r.startswith(incomplete)] + + return sorted(regions) + + +def complete_db_instances(ctx: Context, incomplete: str) -> list[str]: + """Completion callback for database instances.""" + instances = ctx.get_config_value( + "database.instances", + default=["primary-db", "replica-db"], + ) + + if incomplete: + instances = [i for i in instances if i.startswith(incomplete)] + + return sorted(instances) + + +def complete_db_profiles(ctx: Context, incomplete: str) -> list[str]: + """Completion callback for database connection profiles.""" + profiles = ctx.get_config_value( + "database.profiles", + default=["readonly", "readwrite"], + ) + + if incomplete: + profiles = [p for p in profiles if p.startswith(incomplete)] + + return sorted(profiles) + + +def complete_feature_flags(ctx: Context, incomplete: str) -> list[str]: + """Completion callback for feature flags.""" + flags = ctx.get_config_value( + "features.available_flags", + default=["debug_mode", "beta_features"], + ) + + if incomplete: + flags = [f for f in flags if f.startswith(incomplete)] + + return sorted(flags) + + +@task +def deploy( + ctx: Context, + environment: Annotated[str, complete_environments], + region: Annotated[str, complete_regions] = "", +) -> None: + """ + Deploy the application to a specified environment. + + The --environment and --region arguments have tab completion that reads + available options from invoke.yaml configuration. + + Examples: + intk deploy --environment staging + intk deploy --environment production --region eu-west-1 + """ + # Read the default region from config if not specified + if not region: + region = ctx.get_config_value("deployment.default_region", default="us-east-1") + + ctx.print(f"[green]Deploying to {environment} in {region}[/green]") + + # In a real task, you would do the actual deployment here + ctx.print(f" Environment: {environment}") + ctx.print(f" Region: {region}") + + +@task +def connect_db( + ctx: Context, + instance: Annotated[str, complete_db_instances], + profile: Annotated[str, complete_db_profiles] = "readonly", +) -> None: + """ + Connect to a database instance. + + Both --instance and --profile have completions from config. + + Examples: + intk connect-db --instance primary-db + intk connect-db --instance analytics-db --profile admin + """ + ctx.print("[cyan]Connecting to database[/cyan]") + ctx.print(f" Instance: {instance}") + ctx.print(f" Profile: {profile}") + + +@task +def toggle_feature( + ctx: Context, + flag: Annotated[str, complete_feature_flags], + enable: bool = True, +) -> None: + """ + Toggle a feature flag. + + The --flag argument completes with available feature flags from config. + + Examples: + intk toggle-feature --flag dark_mode + intk toggle-feature --flag beta_features --no-enable + """ + action = "Enabling" if enable else "Disabling" + ctx.print(f"[yellow]{action} feature flag: {flag}[/yellow]") + + +@task +def show_config(ctx: Context) -> None: + """ + Display the current configuration values used for completions. + + This task shows what values the completion callbacks will use, + which helps verify your configuration is loaded correctly. + """ + ctx.print("[bold]Current completion configuration:[/bold]\n") + + # Show completion timeout + timeout = ctx.get_config_value("completion.callback_timeout", default=10.0) + ctx.print(f"[cyan]Completion timeout:[/cyan] {timeout}s\n") + + # Show deployment config + ctx.print("[cyan]Deployment environments:[/cyan]") + environments = ctx.get_config_value("deployment.environments", default=[]) + for env in environments: + ctx.print(f" - {env}") + + ctx.print( + f"\n[cyan]Default region:[/cyan] {ctx.get_config_value('deployment.default_region', default='not set')}" + ) + + ctx.print("\n[cyan]Available regions:[/cyan]") + regions = ctx.get_config_value("deployment.regions", default=[]) + for region in regions: + ctx.print(f" - {region}") + + # Show database config + ctx.print("\n[cyan]Database instances:[/cyan]") + instances = ctx.get_g_config_value("database.instances", default=[]) + for instance in instances: + ctx.print(f" - {instance}") + + ctx.print("\n[cyan]Database profiles:[/cyan]") + profiles = ctx.get_config_value("database.profiles", default=[]) + for profile in profiles: + ctx.print(f" - {profile}") + + # Show feature flags + ctx.print("\n[cyan]Feature flags:[/cyan]") + flags = ctx.get_config_value("features.available_flags", default=[]) + for flag in flags: + ctx.print(f" - {flag}")