From bd51495d9f5a2779d075168d280c40a3ad6c4b28 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 24 Oct 2025 16:01:29 +0100 Subject: [PATCH 1/7] Add project config files/GHAs/etc. --- .github/ISSUE_TEMPLATE/bug_report.yaml | 85 ++++ .github/ISSUE_TEMPLATE/config.yaml | 5 + .github/ISSUE_TEMPLATE/feature_request.yaml | 54 +++ .github/workflows/lint.yml | 28 ++ .github/workflows/release.yml | 39 ++ .github/workflows/test.yml | 39 ++ .gitignore | 65 +++ .pre-commit-config.yaml | 24 + .python-version | 1 + ruff.toml | 75 +++ scripts/generate_protos.sh | 62 +++ scripts/setup_uv.sh | 54 +++ uv.lock | 483 ++++++++++++++++++++ 13 files changed, 1014 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yaml create mode 100644 .github/ISSUE_TEMPLATE/config.yaml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yaml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .python-version create mode 100644 ruff.toml create mode 100755 scripts/generate_protos.sh create mode 100755 scripts/setup_uv.sh create mode 100644 uv.lock diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..de05753 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,85 @@ +name: Bug Report +description: Report a bug or an unexpected behavior +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report! + Please read the [Contributing Guidelines](CONTRIBUTING.md) before submitting. + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of what the bug is. + placeholder: Describe the bug here. + validations: + required: true + - type: textarea + id: reproduction-steps + attributes: + label: Steps to Reproduce + description: | + Provide clear steps to reproduce the behavior. + Please include code snippets, configurations, or detailed commands if relevant. + placeholder: | + 1. Go to '...' + 2. Run cmd '....' + 3. View file '....' + 4. See error + validations: + required: true + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: A clear and concise description of what you expected to happen. + placeholder: What did you expect to happen? + validations: + required: true + - type: textarea + id: actual-behavior + attributes: + label: Actual Behavior + description: A clear and concise description of what actually happened. + placeholder: What actually happened? + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Provide details about your environment. + value: | + - **OS:** [e.g. macOS, Windows, Linux] (and version) + - **mcpd Version:** [e.g. v0.0.2] (mcpd --version) + - **mcpd Python plugin SDK Version:** [e.g. 0.0.1] + - **Other relevant libraries/versions:** + render: markdown + validations: + required: false + - type: textarea + id: logs + attributes: + label: Relevant Log Output + description: | + If applicable, paste any relevant log and terminal output, error messages, or screenshots. + Please wrap code blocks in triple backticks (```). + render: shell + validations: + required: false + - type: checkboxes + id: self-checklist + attributes: + label: Self-Checklist + options: + - label: I have read the [Contributing Guidelines](CONTRIBUTING.md). + required: true + - label: I have searched the existing issues and found no duplicate. + required: true + - label: I have provided a clear and concise description of the bug. + required: true + - label: I have provided clear steps to reproduce the bug. + required: true + - label: I have provided details about my environment. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yaml b/.github/ISSUE_TEMPLATE/config.yaml new file mode 100644 index 0000000..4d35f0b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yaml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/mozilla-ai/mcpd-plugins-sdk-python/security/policy + about: Please report security vulnerabilities responsibly. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..c60205c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,54 @@ +name: Feature Request +description: Suggest an idea or enhancement for the project. +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting an idea! + Please read the [Contributing Guidelines](CONTRIBUTING.md) before submitting. + - type: textarea + id: problem-description + attributes: + label: Is your feature request related to a problem? Please describe. + description: A clear and concise description of the problem you're trying to solve. e.g. "I'm always frustrated when [...]" + placeholder: Describe the problem. + validations: + required: true + - type: textarea + id: solution-description + attributes: + label: Describe the solution you'd like + description: A clear and concise description of what you want to happen. + placeholder: Describe the solution. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: A clear and concise description of any alternative solutions or features you've considered. + placeholder: Describe alternatives. + validations: + required: false + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Add any other context or screenshots about the feature request here. + placeholder: Any additional information. + validations: + required: false + - type: checkboxes + id: self-checklist + attributes: + label: Self-Checklist + options: + - label: I have read the [Contributing Guidelines](CONTRIBUTING.md). + required: true + - label: I have searched the existing issues and found no duplicate. + required: true + - label: I have provided a clear and concise description of the problem. + required: true + - label: I have provided a clear and concise description of the proposed solution. + required: true diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..ee990a2 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,28 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + run-linter: + timeout-minutes: 30 + runs-on: ubuntu-latest + + steps: + - name: Check out the repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Install the latest version of uv and set the python version to 3.11 + uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3 + with: + python-version: 3.11 + activate-environment: true + + - name: Install dependencies + run: uv sync --group lint + + - name: Run pre-commit hooks + run: uv run pre-commit run --all-files diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fd74a77 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release + +on: + release: + types: [published] + workflow_dispatch: + +jobs: + release: + environment: pypi + permissions: + contents: read + id-token: write + runs-on: ubuntu-latest + steps: + - name: Check out the repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.11' + + - name: Upgrade pip + run: | + pip install --upgrade pip + pip --version + + - name: Install + run: python -m pip install build setuptools + + - name: Build package + run: python -m build + + - name: Upload package + if: github.event_name == 'release' + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..e848e21 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: Test + +on: + push: + branches: [main] + paths: + - 'src/**' + - 'tests/**' + pull_request: + paths: + - 'src/**' + - 'tests/**' + workflow_dispatch: + +jobs: + test: + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: + os: [ ubuntu-latest, macos-latest ] + python-version: [ '3.11', '3.12', '3.13' ] + + runs-on: ${{ matrix.os }} + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Setup UV + uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6.4.3 + with: + python-version: ${{ matrix.python-version }} + activate-environment: true + + - run: uv sync --group tests + + - run: pytest -v tests/unit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..483b058 --- /dev/null +++ b/.gitignore @@ -0,0 +1,65 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +venv/ +ENV/ +env/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json + +# Ruff +.ruff_cache/ + +# Distribution / packaging +*.egg-info/ + +# Proto source files (downloaded during build, not committed) +tmp/ + +# Version file (generated by setuptools_scm) +src/mcpd_plugins/_version.py + +# OS +.DS_Store +Thumbs.db + +# Claude Code (never commit this) +.claude/CLAUDE.local.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c1bbe57 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-merge-conflict + - id: trailing-whitespace + - id: end-of-file-fixer + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.4 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.8.0 + hooks: + - id: uv-lock diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..6b46191 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,75 @@ +# Ruff configuration for mcpd-plugins-sdk-python + +target-version = "py311" +src = ["src"] +line-length = 120 +include = ["*.py", "*.pyi"] +# Enable auto-fixing +fix = true + +# Exclude common directories +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".mypy_cache", + ".nox", + ".pants.d", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", +] + +[lint] +# Select rules to enforce +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "I", # isort + "N", # pep8-naming + "ISC", # flake8-implicit-str-concat + "PTH", # flake8-use-pathlib + "D", # pydocstyle +] + +# Rules to ignore +ignore = [ + # No ignored rules - we want strict enforcement for a public SDK +] + +# Never autofix these - require manual review +unfixable = [ + "B", # flake8-bugbear (potential bugs) + "F841", # unused variables +] + +[lint.per-file-ignores] +"__init__.py" = ["F401", "D104"] # Allow unused imports and missing docstrings in __init__.py +"tests/**" = ["D", "N"] # Skip docstrings and naming conventions in tests +"examples/**" = ["N802"] # Allow PascalCase method names (protobuf convention) +"src/mcpd_plugins/base_plugin.py" = ["N802"] # Allow PascalCase methods (protobuf convention) +"**/plugin_pb2*.py" = ["ALL"] # Ignore all linting in generated proto files +"**/_version.py" = ["ALL"] # Ignore all linting in generated version files + +[lint.pydocstyle] +convention = "google" + +[format] +quote-style = "double" +indent-style = "space" +line-ending = "auto" diff --git a/scripts/generate_protos.sh b/scripts/generate_protos.sh new file mode 100755 index 0000000..d722504 --- /dev/null +++ b/scripts/generate_protos.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +# Script to download proto files from mcpd-proto and generate Python code. +# Generated files are committed to the repository for ease of use. + +# Configuration +PROTO_VERSION="${PROTO_VERSION:-v0.0.3}" +PROTO_BASE_URL="https://raw.githubusercontent.com/mozilla-ai/mcpd-proto/${PROTO_VERSION}" +PROTO_FILE="plugins/v1/plugin.proto" +TMP_DIR="tmp" +PROTO_TMP_DIR="${TMP_DIR}/plugins" +OUTPUT_DIR="src/mcpd_plugins/v1/plugins" + +echo "πŸ”„ Generating protocol buffer files from mcpd-proto ${PROTO_VERSION}..." + +# Create temp directory +mkdir -p "$PROTO_TMP_DIR" + +# Download proto file +echo "πŸ“₯ Downloading ${PROTO_FILE}..." +PROTO_URL="${PROTO_BASE_URL}/${PROTO_FILE}" +if ! curl -fsSL "$PROTO_URL" -o "${PROTO_TMP_DIR}/plugin.proto"; then + echo "❌ Failed to download proto file from: ${PROTO_URL}" + echo " Please check the PROTO_VERSION (${PROTO_VERSION}) is valid." + exit 1 +fi + +# Ensure output directory exists +mkdir -p "$OUTPUT_DIR" + +# Generate Python code +echo "πŸ”¨ Generating Python code..." +uv run python -m grpc_tools.protoc \ + --proto_path="$TMP_DIR" \ + --python_out="$OUTPUT_DIR" \ + --grpc_python_out="$OUTPUT_DIR" \ + "${PROTO_TMP_DIR}/plugin.proto" + +# Fix imports in generated files (grpc_tools generates incorrect relative imports) +echo "πŸ”§ Fixing imports in generated files..." +if [[ "$OSTYPE" == "darwin"* ]]; then + # macOS + sed -i '' 's/import plugin_pb2/from . import plugin_pb2/' "$OUTPUT_DIR/plugin_pb2_grpc.py" +else + # Linux + sed -i 's/import plugin_pb2/from . import plugin_pb2/' "$OUTPUT_DIR/plugin_pb2_grpc.py" +fi + +# Create __init__.py files if they don't exist +touch "$OUTPUT_DIR/__init__.py" +touch "src/mcpd_plugins/v1/__init__.py" + +echo "βœ… Protocol buffer generation complete!" +echo " Proto version: ${PROTO_VERSION}" +echo " Generated files in: ${OUTPUT_DIR}" +echo " - plugin_pb2.py" +echo " - plugin_pb2_grpc.py" +echo "" +echo "To use a different proto version, set PROTO_VERSION environment variable:" +echo " PROTO_VERSION=v0.0.4 ./scripts/generate_protos.sh" diff --git a/scripts/setup_uv.sh b/scripts/setup_uv.sh new file mode 100755 index 0000000..67030da --- /dev/null +++ b/scripts/setup_uv.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +# ########## +# Install UV +# ########## + +# Expected installation path for uv +# See: https://docs.astral.sh/uv/configuration/installer/#changing-the-install-path +LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}" + +# Ensure LOCAL_BIN exists and is on PATH +mkdir -p "$LOCAL_BIN" +if [[ ":$PATH:" != *":$LOCAL_BIN:"* ]]; then + export PATH="$LOCAL_BIN:$PATH" +fi + +VENV_DIR=".venv" + +# Install or update uv +if ! command -v uv &>/dev/null; then + echo "uv not found – installing to $LOCAL_BIN" + curl -fsSL https://astral.sh/uv/install.sh | UV_INSTALL_DIR="$LOCAL_BIN" sh +else + current=$(uv --version | awk '{print $2}') + echo "Found uv v$current" + if command -v jq &>/dev/null; then + latest=$(curl -fsS https://api.github.com/repos/astral-sh/uv/releases/latest \ + | jq -r .tag_name) + if [[ "$current" != "$latest" ]]; then + echo "Updating uv: $current β†’ $latest" + uv self update + fi + fi +fi + +# Bootstrap root .venv +echo "Bootstrapping root .venv in folder $VENV_DIR" +uv venv "$VENV_DIR" +uv sync --group all --active + +echo "Done! Root environment is ready in: $VENV_DIR" + +# Install pre-commit hooks +echo "Installing pre-commit hooks" +uv run pre-commit install + +# After detecting PATH lacked LOCAL_BIN… +if [[ ":$PATH:" != *":$LOCAL_BIN:"* ]]; then + echo "Note: added $LOCAL_BIN to PATH for this session." + echo "To make it permanent, add to your shell profile:" + echo " export PATH=\"$LOCAL_BIN:\$PATH\"" +fi diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..9afc576 --- /dev/null +++ b/uv.lock @@ -0,0 +1,483 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +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 = "debugpy" +version = "1.8.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/ad/71e708ff4ca377c4230530d6a7aa7992592648c122a2cd2b321cf8b35a76/debugpy-1.8.17.tar.gz", hash = "sha256:fd723b47a8c08892b1a16b2c6239a8b96637c62a59b94bb5dab4bac592a58a8e", size = 1644129, upload-time = "2025-09-17T16:33:20.633Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/53/3af72b5c159278c4a0cf4cffa518675a0e73bdb7d1cac0239b815502d2ce/debugpy-1.8.17-cp311-cp311-macosx_15_0_universal2.whl", hash = "sha256:d3fce3f0e3de262a3b67e69916d001f3e767661c6e1ee42553009d445d1cd840", size = 2207154, upload-time = "2025-09-17T16:33:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6d/204f407df45600e2245b4a39860ed4ba32552330a0b3f5f160ae4cc30072/debugpy-1.8.17-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:c6bdf134457ae0cac6fb68205776be635d31174eeac9541e1d0c062165c6461f", size = 3170322, upload-time = "2025-09-17T16:33:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/f2/13/1b8f87d39cf83c6b713de2620c31205299e6065622e7dd37aff4808dd410/debugpy-1.8.17-cp311-cp311-win32.whl", hash = "sha256:e79a195f9e059edfe5d8bf6f3749b2599452d3e9380484cd261f6b7cd2c7c4da", size = 5155078, upload-time = "2025-09-17T16:33:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c5/c012c60a2922cc91caa9675d0ddfbb14ba59e1e36228355f41cab6483469/debugpy-1.8.17-cp311-cp311-win_amd64.whl", hash = "sha256:b532282ad4eca958b1b2d7dbcb2b7218e02cb934165859b918e3b6ba7772d3f4", size = 5179011, upload-time = "2025-09-17T16:33:35.711Z" }, + { url = "https://files.pythonhosted.org/packages/08/2b/9d8e65beb2751876c82e1aceb32f328c43ec872711fa80257c7674f45650/debugpy-1.8.17-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:f14467edef672195c6f6b8e27ce5005313cb5d03c9239059bc7182b60c176e2d", size = 2549522, upload-time = "2025-09-17T16:33:38.466Z" }, + { url = "https://files.pythonhosted.org/packages/b4/78/eb0d77f02971c05fca0eb7465b18058ba84bd957062f5eec82f941ac792a/debugpy-1.8.17-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:24693179ef9dfa20dca8605905a42b392be56d410c333af82f1c5dff807a64cc", size = 4309417, upload-time = "2025-09-17T16:33:41.299Z" }, + { url = "https://files.pythonhosted.org/packages/37/42/c40f1d8cc1fed1e75ea54298a382395b8b937d923fcf41ab0797a554f555/debugpy-1.8.17-cp312-cp312-win32.whl", hash = "sha256:6a4e9dacf2cbb60d2514ff7b04b4534b0139facbf2abdffe0639ddb6088e59cf", size = 5277130, upload-time = "2025-09-17T16:33:43.554Z" }, + { url = "https://files.pythonhosted.org/packages/72/22/84263b205baad32b81b36eac076de0cdbe09fe2d0637f5b32243dc7c925b/debugpy-1.8.17-cp312-cp312-win_amd64.whl", hash = "sha256:e8f8f61c518952fb15f74a302e068b48d9c4691768ade433e4adeea961993464", size = 5319053, upload-time = "2025-09-17T16:33:53.033Z" }, + { url = "https://files.pythonhosted.org/packages/50/76/597e5cb97d026274ba297af8d89138dfd9e695767ba0e0895edb20963f40/debugpy-1.8.17-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:857c1dd5d70042502aef1c6d1c2801211f3ea7e56f75e9c335f434afb403e464", size = 2538386, upload-time = "2025-09-17T16:33:54.594Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/ce5c34fcdfec493701f9d1532dba95b21b2f6394147234dce21160bd923f/debugpy-1.8.17-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:3bea3b0b12f3946e098cce9b43c3c46e317b567f79570c3f43f0b96d00788088", size = 4292100, upload-time = "2025-09-17T16:33:56.353Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/7873cf2146577ef71d2a20bf553f12df865922a6f87b9e8ee1df04f01785/debugpy-1.8.17-cp313-cp313-win32.whl", hash = "sha256:e34ee844c2f17b18556b5bbe59e1e2ff4e86a00282d2a46edab73fd7f18f4a83", size = 5277002, upload-time = "2025-09-17T16:33:58.231Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/18c79a1cee5ff539a94ec4aa290c1c069a5580fd5cfd2fb2e282f8e905da/debugpy-1.8.17-cp313-cp313-win_amd64.whl", hash = "sha256:6c5cd6f009ad4fca8e33e5238210dc1e5f42db07d4b6ab21ac7ffa904a196420", size = 5319047, upload-time = "2025-09-17T16:34:00.586Z" }, + { url = "https://files.pythonhosted.org/packages/de/45/115d55b2a9da6de812696064ceb505c31e952c5d89c4ed1d9bb983deec34/debugpy-1.8.17-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:045290c010bcd2d82bc97aa2daf6837443cd52f6328592698809b4549babcee1", size = 2536899, upload-time = "2025-09-17T16:34:02.657Z" }, + { url = "https://files.pythonhosted.org/packages/5a/73/2aa00c7f1f06e997ef57dc9b23d61a92120bec1437a012afb6d176585197/debugpy-1.8.17-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:b69b6bd9dba6a03632534cdf67c760625760a215ae289f7489a452af1031fe1f", size = 4268254, upload-time = "2025-09-17T16:34:04.486Z" }, + { url = "https://files.pythonhosted.org/packages/86/b5/ed3e65c63c68a6634e3ba04bd10255c8e46ec16ebed7d1c79e4816d8a760/debugpy-1.8.17-cp314-cp314-win32.whl", hash = "sha256:5c59b74aa5630f3a5194467100c3b3d1c77898f9ab27e3f7dc5d40fc2f122670", size = 5277203, upload-time = "2025-09-17T16:34:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/b0/26/394276b71c7538445f29e792f589ab7379ae70fd26ff5577dfde71158e96/debugpy-1.8.17-cp314-cp314-win_amd64.whl", hash = "sha256:893cba7bb0f55161de4365584b025f7064e1f88913551bcd23be3260b231429c", size = 5318493, upload-time = "2025-09-17T16:34:08.483Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d0/89247ec250369fc76db477720a26b2fce7ba079ff1380e4ab4529d2fe233/debugpy-1.8.17-py2.py3-none-any.whl", hash = "sha256:60c7dca6571efe660ccb7a9508d73ca14b8796c4ed484c2002abba714226cfef", size = 5283210, upload-time = "2025-09-17T16:34:25.835Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" }, +] + +[[package]] +name = "grpcio" +version = "1.76.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/e0/318c1ce3ae5a17894d5791e87aea147587c9e702f24122cc7a5c8bbaeeb1/grpcio-1.76.0.tar.gz", hash = "sha256:7be78388d6da1a25c0d5ec506523db58b18be22d9c37d8d3a32c08be4987bd73", size = 12785182, upload-time = "2025-10-21T16:23:12.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/00/8163a1beeb6971f66b4bbe6ac9457b97948beba8dd2fc8e1281dce7f79ec/grpcio-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2e1743fbd7f5fa713a1b0a8ac8ebabf0ec980b5d8809ec358d488e273b9cf02a", size = 5843567, upload-time = "2025-10-21T16:20:52.829Z" }, + { url = "https://files.pythonhosted.org/packages/10/c1/934202f5cf335e6d852530ce14ddb0fef21be612ba9ecbbcbd4d748ca32d/grpcio-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:a8c2cf1209497cf659a667d7dea88985e834c24b7c3b605e6254cbb5076d985c", size = 11848017, upload-time = "2025-10-21T16:20:56.705Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/8dec16b1863d74af6eb3543928600ec2195af49ca58b16334972f6775663/grpcio-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:08caea849a9d3c71a542827d6df9d5a69067b0a1efbea8a855633ff5d9571465", size = 6412027, upload-time = "2025-10-21T16:20:59.3Z" }, + { url = "https://files.pythonhosted.org/packages/d7/64/7b9e6e7ab910bea9d46f2c090380bab274a0b91fb0a2fe9b0cd399fffa12/grpcio-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f0e34c2079d47ae9f6188211db9e777c619a21d4faba6977774e8fa43b085e48", size = 7075913, upload-time = "2025-10-21T16:21:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/093c46e9546073cefa789bd76d44c5cb2abc824ca62af0c18be590ff13ba/grpcio-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8843114c0cfce61b40ad48df65abcfc00d4dba82eae8718fab5352390848c5da", size = 6615417, upload-time = "2025-10-21T16:21:03.844Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b6/5709a3a68500a9c03da6fb71740dcdd5ef245e39266461a03f31a57036d8/grpcio-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8eddfb4d203a237da6f3cc8a540dad0517d274b5a1e9e636fd8d2c79b5c1d397", size = 7199683, upload-time = "2025-10-21T16:21:06.195Z" }, + { url = "https://files.pythonhosted.org/packages/91/d3/4b1f2bf16ed52ce0b508161df3a2d186e4935379a159a834cb4a7d687429/grpcio-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:32483fe2aab2c3794101c2a159070584e5db11d0aa091b2c0ea9c4fc43d0d749", size = 8163109, upload-time = "2025-10-21T16:21:08.498Z" }, + { url = "https://files.pythonhosted.org/packages/5c/61/d9043f95f5f4cf085ac5dd6137b469d41befb04bd80280952ffa2a4c3f12/grpcio-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dcfe41187da8992c5f40aa8c5ec086fa3672834d2be57a32384c08d5a05b4c00", size = 7626676, upload-time = "2025-10-21T16:21:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/36/95/fd9a5152ca02d8881e4dd419cdd790e11805979f499a2e5b96488b85cf27/grpcio-1.76.0-cp311-cp311-win32.whl", hash = "sha256:2107b0c024d1b35f4083f11245c0e23846ae64d02f40b2b226684840260ed054", size = 3997688, upload-time = "2025-10-21T16:21:12.746Z" }, + { url = "https://files.pythonhosted.org/packages/60/9c/5c359c8d4c9176cfa3c61ecd4efe5affe1f38d9bae81e81ac7186b4c9cc8/grpcio-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:522175aba7af9113c48ec10cc471b9b9bd4f6ceb36aeb4544a8e2c80ed9d252d", size = 4709315, upload-time = "2025-10-21T16:21:15.26Z" }, + { url = "https://files.pythonhosted.org/packages/bf/05/8e29121994b8d959ffa0afd28996d452f291b48cfc0875619de0bde2c50c/grpcio-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:81fd9652b37b36f16138611c7e884eb82e0cec137c40d3ef7c3f9b3ed00f6ed8", size = 5799718, upload-time = "2025-10-21T16:21:17.939Z" }, + { url = "https://files.pythonhosted.org/packages/d9/75/11d0e66b3cdf998c996489581bdad8900db79ebd83513e45c19548f1cba4/grpcio-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:04bbe1bfe3a68bbfd4e52402ab7d4eb59d72d02647ae2042204326cf4bbad280", size = 11825627, upload-time = "2025-10-21T16:21:20.466Z" }, + { url = "https://files.pythonhosted.org/packages/28/50/2f0aa0498bc188048f5d9504dcc5c2c24f2eb1a9337cd0fa09a61a2e75f0/grpcio-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d388087771c837cdb6515539f43b9d4bf0b0f23593a24054ac16f7a960be16f4", size = 6359167, upload-time = "2025-10-21T16:21:23.122Z" }, + { url = "https://files.pythonhosted.org/packages/66/e5/bbf0bb97d29ede1d59d6588af40018cfc345b17ce979b7b45424628dc8bb/grpcio-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:9f8f757bebaaea112c00dba718fc0d3260052ce714e25804a03f93f5d1c6cc11", size = 7044267, upload-time = "2025-10-21T16:21:25.995Z" }, + { url = "https://files.pythonhosted.org/packages/f5/86/f6ec2164f743d9609691115ae8ece098c76b894ebe4f7c94a655c6b03e98/grpcio-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:980a846182ce88c4f2f7e2c22c56aefd515daeb36149d1c897f83cf57999e0b6", size = 6573963, upload-time = "2025-10-21T16:21:28.631Z" }, + { url = "https://files.pythonhosted.org/packages/60/bc/8d9d0d8505feccfdf38a766d262c71e73639c165b311c9457208b56d92ae/grpcio-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f92f88e6c033db65a5ae3d97905c8fea9c725b63e28d5a75cb73b49bda5024d8", size = 7164484, upload-time = "2025-10-21T16:21:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/67/e6/5d6c2fc10b95edf6df9b8f19cf10a34263b7fd48493936fffd5085521292/grpcio-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4baf3cbe2f0be3289eb68ac8ae771156971848bb8aaff60bad42005539431980", size = 8127777, upload-time = "2025-10-21T16:21:33.577Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c8/dce8ff21c86abe025efe304d9e31fdb0deaaa3b502b6a78141080f206da0/grpcio-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:615ba64c208aaceb5ec83bfdce7728b80bfeb8be97562944836a7a0a9647d882", size = 7594014, upload-time = "2025-10-21T16:21:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/e0/42/ad28191ebf983a5d0ecef90bab66baa5a6b18f2bfdef9d0a63b1973d9f75/grpcio-1.76.0-cp312-cp312-win32.whl", hash = "sha256:45d59a649a82df5718fd9527ce775fd66d1af35e6d31abdcdc906a49c6822958", size = 3984750, upload-time = "2025-10-21T16:21:44.006Z" }, + { url = "https://files.pythonhosted.org/packages/9e/00/7bd478cbb851c04a48baccaa49b75abaa8e4122f7d86da797500cccdd771/grpcio-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:c088e7a90b6017307f423efbb9d1ba97a22aa2170876223f9709e9d1de0b5347", size = 4704003, upload-time = "2025-10-21T16:21:46.244Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ed/71467ab770effc9e8cef5f2e7388beb2be26ed642d567697bb103a790c72/grpcio-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:26ef06c73eb53267c2b319f43e6634c7556ea37672029241a056629af27c10e2", size = 5807716, upload-time = "2025-10-21T16:21:48.475Z" }, + { url = "https://files.pythonhosted.org/packages/2c/85/c6ed56f9817fab03fa8a111ca91469941fb514e3e3ce6d793cb8f1e1347b/grpcio-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:45e0111e73f43f735d70786557dc38141185072d7ff8dc1829d6a77ac1471468", size = 11821522, upload-time = "2025-10-21T16:21:51.142Z" }, + { url = "https://files.pythonhosted.org/packages/ac/31/2b8a235ab40c39cbc141ef647f8a6eb7b0028f023015a4842933bc0d6831/grpcio-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83d57312a58dcfe2a3a0f9d1389b299438909a02db60e2f2ea2ae2d8034909d3", size = 6362558, upload-time = "2025-10-21T16:21:54.213Z" }, + { url = "https://files.pythonhosted.org/packages/bd/64/9784eab483358e08847498ee56faf8ff6ea8e0a4592568d9f68edc97e9e9/grpcio-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3e2a27c89eb9ac3d81ec8835e12414d73536c6e620355d65102503064a4ed6eb", size = 7049990, upload-time = "2025-10-21T16:21:56.476Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/8c12319a6369434e7a184b987e8e9f3b49a114c489b8315f029e24de4837/grpcio-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61f69297cba3950a524f61c7c8ee12e55c486cb5f7db47ff9dcee33da6f0d3ae", size = 6575387, upload-time = "2025-10-21T16:21:59.051Z" }, + { url = "https://files.pythonhosted.org/packages/15/0f/f12c32b03f731f4a6242f771f63039df182c8b8e2cf8075b245b409259d4/grpcio-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a15c17af8839b6801d554263c546c69c4d7718ad4321e3166175b37eaacca77", size = 7166668, upload-time = "2025-10-21T16:22:02.049Z" }, + { url = "https://files.pythonhosted.org/packages/ff/2d/3ec9ce0c2b1d92dd59d1c3264aaec9f0f7c817d6e8ac683b97198a36ed5a/grpcio-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:25a18e9810fbc7e7f03ec2516addc116a957f8cbb8cbc95ccc80faa072743d03", size = 8124928, upload-time = "2025-10-21T16:22:04.984Z" }, + { url = "https://files.pythonhosted.org/packages/1a/74/fd3317be5672f4856bcdd1a9e7b5e17554692d3db9a3b273879dc02d657d/grpcio-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:931091142fd8cc14edccc0845a79248bc155425eee9a98b2db2ea4f00a235a42", size = 7589983, upload-time = "2025-10-21T16:22:07.881Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/ca038cf420f405971f19821c8c15bcbc875505f6ffadafe9ffd77871dc4c/grpcio-1.76.0-cp313-cp313-win32.whl", hash = "sha256:5e8571632780e08526f118f74170ad8d50fb0a48c23a746bef2a6ebade3abd6f", size = 3984727, upload-time = "2025-10-21T16:22:10.032Z" }, + { url = "https://files.pythonhosted.org/packages/41/80/84087dc56437ced7cdd4b13d7875e7439a52a261e3ab4e06488ba6173b0a/grpcio-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:f9f7bd5faab55f47231ad8dba7787866b69f5e93bc306e3915606779bbfb4ba8", size = 4702799, upload-time = "2025-10-21T16:22:12.709Z" }, + { url = "https://files.pythonhosted.org/packages/b4/46/39adac80de49d678e6e073b70204091e76631e03e94928b9ea4ecf0f6e0e/grpcio-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:ff8a59ea85a1f2191a0ffcc61298c571bc566332f82e5f5be1b83c9d8e668a62", size = 5808417, upload-time = "2025-10-21T16:22:15.02Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/a4531f7fb8b4e2a60b94e39d5d924469b7a6988176b3422487be61fe2998/grpcio-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:06c3d6b076e7b593905d04fdba6a0525711b3466f43b3400266f04ff735de0cd", size = 11828219, upload-time = "2025-10-21T16:22:17.954Z" }, + { url = "https://files.pythonhosted.org/packages/4b/1c/de55d868ed7a8bd6acc6b1d6ddc4aa36d07a9f31d33c912c804adb1b971b/grpcio-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd5ef5932f6475c436c4a55e4336ebbe47bd3272be04964a03d316bbf4afbcbc", size = 6367826, upload-time = "2025-10-21T16:22:20.721Z" }, + { url = "https://files.pythonhosted.org/packages/59/64/99e44c02b5adb0ad13ab3adc89cb33cb54bfa90c74770f2607eea629b86f/grpcio-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b331680e46239e090f5b3cead313cc772f6caa7d0fc8de349337563125361a4a", size = 7049550, upload-time = "2025-10-21T16:22:23.637Z" }, + { url = "https://files.pythonhosted.org/packages/43/28/40a5be3f9a86949b83e7d6a2ad6011d993cbe9b6bd27bea881f61c7788b6/grpcio-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2229ae655ec4e8999599469559e97630185fdd53ae1e8997d147b7c9b2b72cba", size = 6575564, upload-time = "2025-10-21T16:22:26.016Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a9/1be18e6055b64467440208a8559afac243c66a8b904213af6f392dc2212f/grpcio-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:490fa6d203992c47c7b9e4a9d39003a0c2bcc1c9aa3c058730884bbbb0ee9f09", size = 7176236, upload-time = "2025-10-21T16:22:28.362Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/dba05d3fcc151ce6e81327541d2cc8394f442f6b350fead67401661bf041/grpcio-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:479496325ce554792dba6548fae3df31a72cef7bad71ca2e12b0e58f9b336bfc", size = 8125795, upload-time = "2025-10-21T16:22:31.075Z" }, + { url = "https://files.pythonhosted.org/packages/4a/45/122df922d05655f63930cf42c9e3f72ba20aadb26c100ee105cad4ce4257/grpcio-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c9b93f79f48b03ada57ea24725d83a30284a012ec27eab2cf7e50a550cbbbcc", size = 7592214, upload-time = "2025-10-21T16:22:33.831Z" }, + { url = "https://files.pythonhosted.org/packages/4a/6e/0b899b7f6b66e5af39e377055fb4a6675c9ee28431df5708139df2e93233/grpcio-1.76.0-cp314-cp314-win32.whl", hash = "sha256:747fa73efa9b8b1488a95d0ba1039c8e2dca0f741612d80415b1e1c560febf4e", size = 4062961, upload-time = "2025-10-21T16:22:36.468Z" }, + { url = "https://files.pythonhosted.org/packages/19/41/0b430b01a2eb38ee887f88c1f07644a1df8e289353b78e82b37ef988fb64/grpcio-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:922fa70ba549fce362d2e2871ab542082d66e2aaf0c19480ea453905b01f384e", size = 4834462, upload-time = "2025-10-21T16:22:39.772Z" }, +] + +[[package]] +name = "grpcio-tools" +version = "1.76.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/77/17d60d636ccd86a0db0eccc24d02967bbc3eea86b9db7324b04507ebaa40/grpcio_tools-1.76.0.tar.gz", hash = "sha256:ce80169b5e6adf3e8302f3ebb6cb0c3a9f08089133abca4b76ad67f751f5ad88", size = 5390807, upload-time = "2025-10-21T16:26:55.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/d1/efbeed1a864c846228c0a3b322e7a2d6545f025e35246aebf96496a36004/grpcio_tools-1.76.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:c6480f6af6833850a85cca1c6b435ef4ffd2ac8e88ef683b4065233827950243", size = 2545931, upload-time = "2025-10-21T16:24:50.201Z" }, + { url = "https://files.pythonhosted.org/packages/af/8e/f257c0f565d9d44658301238b01a9353bc6f3b272bb4191faacae042579d/grpcio_tools-1.76.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c7c23fe1dc09818e16a48853477806ad77dd628b33996f78c05a293065f8210c", size = 5844794, upload-time = "2025-10-21T16:24:53.312Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c0/6c1e89c67356cb20e19ed670c5099b13e40fd678cac584c778f931666a86/grpcio_tools-1.76.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fcdce7f7770ff052cd4e60161764b0b3498c909bde69138f8bd2e7b24a3ecd8f", size = 2591772, upload-time = "2025-10-21T16:24:55.729Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/5f33aa7bc3ddaad0cfd2f4e950ac4f1a310e8d0c7b1358622a581e8b7a2f/grpcio_tools-1.76.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b598fdcebffa931c7da5c9e90b5805fff7e9bc6cf238319358a1b85704c57d33", size = 2905140, upload-time = "2025-10-21T16:24:57.952Z" }, + { url = "https://files.pythonhosted.org/packages/f4/3e/23e3a52a77368f47188ed83c34eb53866d3ce0f73835b2f6764844ae89eb/grpcio_tools-1.76.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6a9818ff884796b12dcf8db32126e40ec1098cacf5697f27af9cfccfca1c1fae", size = 2656475, upload-time = "2025-10-21T16:25:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/51/85/a74ae87ec7dbd3d2243881f5c548215aed1148660df7945be3a125ba9a21/grpcio_tools-1.76.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:105e53435b2eed3961da543db44a2a34479d98d18ea248219856f30a0ca4646b", size = 3106158, upload-time = "2025-10-21T16:25:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/d5/a6ed1e5823bc5d55a1eb93e0c14ccee0b75951f914832ab51fb64d522a0f/grpcio_tools-1.76.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:454a1232c7f99410d92fa9923c7851fd4cdaf657ee194eac73ea1fe21b406d6e", size = 3654980, upload-time = "2025-10-21T16:25:05.717Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/c05d5501ba156a242079ef71d073116d2509c195b5e5e74c545f0a3a3a69/grpcio_tools-1.76.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ca9ccf667afc0268d45ab202af4556c72e57ea36ebddc93535e1a25cbd4f8aba", size = 3322658, upload-time = "2025-10-21T16:25:07.885Z" }, + { url = "https://files.pythonhosted.org/packages/02/b6/ee0317b91da19a7537d93c4161cbc2a45a165c8893209b0bbd470d830ffa/grpcio_tools-1.76.0-cp311-cp311-win32.whl", hash = "sha256:a83c87513b708228b4cad7619311daba65b40937745103cadca3db94a6472d9c", size = 993837, upload-time = "2025-10-21T16:25:10.133Z" }, + { url = "https://files.pythonhosted.org/packages/81/63/9623cadf0406b264737f16d4ed273bb2d65001d87fbd803b565c45d665d1/grpcio_tools-1.76.0-cp311-cp311-win_amd64.whl", hash = "sha256:2ce5e87ec71f2e4041dce4351f2a8e3b713e3bca6b54c69c3fbc6c7ad1f4c386", size = 1158634, upload-time = "2025-10-21T16:25:12.705Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ca/a931c1439cabfe305c9afd07e233150cd0565aa062c20d1ee412ed188852/grpcio_tools-1.76.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:4ad555b8647de1ebaffb25170249f89057721ffb74f7da96834a07b4855bb46a", size = 2546852, upload-time = "2025-10-21T16:25:15.024Z" }, + { url = "https://files.pythonhosted.org/packages/4c/07/935cfbb7dccd602723482a86d43fbd992f91e9867bca0056a1e9f348473e/grpcio_tools-1.76.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:243af7c8fc7ff22a40a42eb8e0f6f66963c1920b75aae2a2ec503a9c3c8b31c1", size = 5841777, upload-time = "2025-10-21T16:25:17.425Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/8fcb5acebdccb647e0fa3f002576480459f6cf81e79692d7b3c4d6e29605/grpcio_tools-1.76.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8207b890f423142cc0025d041fb058f7286318df6a049565c27869d73534228b", size = 2594004, upload-time = "2025-10-21T16:25:19.809Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ea/64838e8113b7bfd4842b15c815a7354cb63242fdce9d6648d894b5d50897/grpcio_tools-1.76.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3dafa34c2626a6691d103877e8a145f54c34cf6530975f695b396ed2fc5c98f8", size = 2905563, upload-time = "2025-10-21T16:25:21.889Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d6/53798827d821098219e58518b6db52161ce4985620850aa74ce3795da8a7/grpcio_tools-1.76.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:30f1d2dda6ece285b3d9084e94f66fa721ebdba14ae76b2bc4c581c8a166535c", size = 2656936, upload-time = "2025-10-21T16:25:24.369Z" }, + { url = "https://files.pythonhosted.org/packages/89/a3/d9c1cefc46a790eec520fe4e70e87279abb01a58b1a3b74cf93f62b824a2/grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a889af059dc6dbb82d7b417aa581601316e364fe12eb54c1b8d95311ea50916d", size = 3109811, upload-time = "2025-10-21T16:25:26.711Z" }, + { url = "https://files.pythonhosted.org/packages/50/75/5997752644b73b5d59377d333a51c8a916606df077f5a487853e37dca289/grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c3f2c3c44c56eb5d479ab178f0174595d0a974c37dade442f05bb73dfec02f31", size = 3658786, upload-time = "2025-10-21T16:25:28.819Z" }, + { url = "https://files.pythonhosted.org/packages/84/47/dcf8380df4bd7931ffba32fc6adc2de635b6569ca27fdec7121733797062/grpcio_tools-1.76.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:479ce02dff684046f909a487d452a83a96b4231f7c70a3b218a075d54e951f56", size = 3325144, upload-time = "2025-10-21T16:25:30.863Z" }, + { url = "https://files.pythonhosted.org/packages/04/88/ea3e5fdb874d8c2d04488e4b9d05056537fba70915593f0c283ac77df188/grpcio_tools-1.76.0-cp312-cp312-win32.whl", hash = "sha256:9ba4bb539936642a44418b38ee6c3e8823c037699e2cb282bd8a44d76a4be833", size = 993523, upload-time = "2025-10-21T16:25:32.594Z" }, + { url = "https://files.pythonhosted.org/packages/de/b1/ce7d59d147675ec191a55816be46bc47a343b5ff07279eef5817c09cc53e/grpcio_tools-1.76.0-cp312-cp312-win_amd64.whl", hash = "sha256:0cd489016766b05f9ed8a6b6596004b62c57d323f49593eac84add032a6d43f7", size = 1158493, upload-time = "2025-10-21T16:25:34.5Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b16fe73f129df49811d886dc99d3813a33cf4d1c6e101252b81c895e929f/grpcio_tools-1.76.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ff48969f81858397ef33a36b326f2dbe2053a48b254593785707845db73c8f44", size = 2546312, upload-time = "2025-10-21T16:25:37.138Z" }, + { url = "https://files.pythonhosted.org/packages/25/17/2594c5feb76bb0b25bfbf91ec1075b276e1b2325e4bc7ea649a7b5dbf353/grpcio_tools-1.76.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa2f030fd0ef17926026ee8e2b700e388d3439155d145c568fa6b32693277613", size = 5839627, upload-time = "2025-10-21T16:25:40.082Z" }, + { url = "https://files.pythonhosted.org/packages/c7/c6/097b1aa26fbf72fb3cdb30138a2788529e4f10d8759de730a83f5c06726e/grpcio_tools-1.76.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bacbf3c54f88c38de8e28f8d9b97c90b76b105fb9ddef05d2c50df01b32b92af", size = 2592817, upload-time = "2025-10-21T16:25:42.301Z" }, + { url = "https://files.pythonhosted.org/packages/03/78/d1d985b48592a674509a85438c1a3d4c36304ddfc99d1b05d27233b51062/grpcio_tools-1.76.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0d4e4afe9a0e3c24fad2f1af45f98cf8700b2bfc4d790795756ba035d2ea7bdc", size = 2905186, upload-time = "2025-10-21T16:25:44.395Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0e/770afbb47f0b5f594b93a7b46a95b892abda5eebe60efb511e96cee52170/grpcio_tools-1.76.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fbbd4e1fc5af98001ceef5e780e8c10921d94941c3809238081e73818ef707f1", size = 2656188, upload-time = "2025-10-21T16:25:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2b/017c2fcf4c5d3cf00cf7d5ce21eb88521de0d89bdcf26538ad2862ec6d07/grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b05efe5a59883ab8292d596657273a60e0c3e4f5a9723c32feb9fc3a06f2f3ef", size = 3109141, upload-time = "2025-10-21T16:25:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5f/2495f88e3d50c6f2c2da2752bad4fa3a30c52ece6c9d8b0c636cd8b1430b/grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:be483b90e62b7892eb71fa1fc49750bee5b2ee35b5ec99dd2b32bed4bedb5d71", size = 3657892, upload-time = "2025-10-21T16:25:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/5e/1d/c4f39d31b19d9baf35d900bf3f969ce1c842f63a8560c8003ed2e5474760/grpcio_tools-1.76.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:630cd7fd3e8a63e20703a7ad816979073c2253e591b5422583c27cae2570de73", size = 3324778, upload-time = "2025-10-21T16:25:54.629Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b6/35ee3a6e4af85a93da28428f81f4b29bcb36f6986b486ad71910fcc02e25/grpcio_tools-1.76.0-cp313-cp313-win32.whl", hash = "sha256:eb2567280f9f6da5444043f0e84d8408c7a10df9ba3201026b30e40ef3814736", size = 993084, upload-time = "2025-10-21T16:25:56.52Z" }, + { url = "https://files.pythonhosted.org/packages/f3/7a/5bd72344d86ee860e5920c9a7553cfe3bc7b1fce79f18c00ac2497f5799f/grpcio_tools-1.76.0-cp313-cp313-win_amd64.whl", hash = "sha256:0071b1c0bd0f5f9d292dca4efab32c92725d418e57f9c60acdc33c0172af8b53", size = 1158151, upload-time = "2025-10-21T16:25:58.468Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c0/aa20eebe8f3553b7851643e9c88d237c3a6ca30ade646897e25dbb27be99/grpcio_tools-1.76.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:c53c5719ef2a435997755abde3826ba4087174bd432aa721d8fac781fcea79e4", size = 2546297, upload-time = "2025-10-21T16:26:01.258Z" }, + { url = "https://files.pythonhosted.org/packages/d9/98/6af702804934443c1d0d4d27d21b990d92d22ddd1b6bec6b056558cbbffa/grpcio_tools-1.76.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:e3db1300d7282264639eeee7243f5de7e6a7c0283f8bf05d66c0315b7b0f0b36", size = 5839804, upload-time = "2025-10-21T16:26:05.495Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8d/7725fa7b134ef8405ffe0a37c96eeb626e5af15d70e1bdac4f8f1abf842e/grpcio_tools-1.76.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0b018a4b7455a7e8c16d0fdb3655a6ba6c9536da6de6c5d4f11b6bb73378165b", size = 2593922, upload-time = "2025-10-21T16:26:07.563Z" }, + { url = "https://files.pythonhosted.org/packages/de/ff/5b6b5012c79fa72f9107dc13f7226d9ce7e059ea639fd8c779e0dd284386/grpcio_tools-1.76.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ec6e4de3866e47cfde56607b1fae83ecc5aa546e06dec53de11f88063f4b5275", size = 2905327, upload-time = "2025-10-21T16:26:09.668Z" }, + { url = "https://files.pythonhosted.org/packages/24/01/2691d369ea462cd6b6c92544122885ca01f7fa5ac75dee023e975e675858/grpcio_tools-1.76.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b8da4d828883913f1852bdd67383713ae5c11842f6c70f93f31893eab530aead", size = 2656214, upload-time = "2025-10-21T16:26:11.773Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e7/3f8856e6ec3dd492336a91572993344966f237b0e3819fbe96437b19d313/grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5c120c2cf4443121800e7f9bcfe2e94519fa25f3bb0b9882359dd3b252c78a7b", size = 3109889, upload-time = "2025-10-21T16:26:15.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e4/ce5248072e47db276dc7e069e93978dcde490c959788ce7cce8081d0bfdc/grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:8b7df5591d699cd9076065f1f15049e9c3597e0771bea51c8c97790caf5e4197", size = 3657939, upload-time = "2025-10-21T16:26:17.34Z" }, + { url = "https://files.pythonhosted.org/packages/f6/df/81ff88af93c52135e425cd5ec9fe8b186169c7d5f9e0409bdf2bbedc3919/grpcio_tools-1.76.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a25048c5f984d33e3f5b6ad7618e98736542461213ade1bd6f2fcfe8ce804e3d", size = 3324752, upload-time = "2025-10-21T16:26:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/35/3d/f6b83044afbf6522254a3b509515a00fed16a819c87731a478dbdd1d35c1/grpcio_tools-1.76.0-cp314-cp314-win32.whl", hash = "sha256:4b77ce6b6c17869858cfe14681ad09ed3a8a80e960e96035de1fd87f78158740", size = 1015578, upload-time = "2025-10-21T16:26:22.517Z" }, + { url = "https://files.pythonhosted.org/packages/95/4d/31236cddb7ffb09ba4a49f4f56d2608fec3bbb21c7a0a975d93bca7cd22e/grpcio_tools-1.76.0-cp314-cp314-win_amd64.whl", hash = "sha256:2ccd2c8d041351cc29d0fc4a84529b11ee35494a700b535c1f820b642f2a72fc", size = 1190242, upload-time = "2025-10-21T16:26:25.296Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mcpd-plugins" +source = { editable = "." } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, +] + +[package.dev-dependencies] +all = [ + { name = "debugpy" }, + { name = "grpcio-tools" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "setuptools" }, + { name = "setuptools-scm" }, +] +dev = [ + { name = "debugpy" }, + { name = "grpcio-tools" }, +] +lint = [ + { name = "pre-commit" }, + { name = "ruff" }, +] +tests = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "setuptools" }, + { name = "setuptools-scm" }, +] + +[package.metadata] +requires-dist = [ + { name = "grpcio", specifier = ">=1.68.0" }, + { name = "protobuf", specifier = ">=5.29.0" }, +] + +[package.metadata.requires-dev] +all = [ + { name = "debugpy", specifier = ">=1.8.14" }, + { name = "grpcio-tools", specifier = ">=1.68.0" }, + { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-asyncio", specifier = ">=0.25.2" }, + { name = "ruff", specifier = ">=0.11.13" }, + { name = "setuptools", specifier = ">=80.9.0" }, + { name = "setuptools-scm", specifier = ">=8.3.1" }, +] +dev = [ + { name = "debugpy", specifier = ">=1.8.14" }, + { name = "grpcio-tools", specifier = ">=1.68.0" }, +] +lint = [ + { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "ruff", specifier = ">=0.11.13" }, +] +tests = [ + { name = "pytest", specifier = ">=8.3.5" }, + { name = "pytest-asyncio", specifier = ">=0.25.2" }, + { name = "setuptools", specifier = ">=80.9.0" }, + { name = "setuptools-scm", specifier = ">=8.3.1" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/ff/64a6c8f420818bb873713988ca5492cba3a7946be57e027ac63495157d97/protobuf-6.33.0.tar.gz", hash = "sha256:140303d5c8d2037730c548f8c7b93b20bb1dc301be280c378b82b8894589c954", size = 443463, upload-time = "2025-10-15T20:39:52.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/ee/52b3fa8feb6db4a833dfea4943e175ce645144532e8a90f72571ad85df4e/protobuf-6.33.0-cp310-abi3-win32.whl", hash = "sha256:d6101ded078042a8f17959eccd9236fb7a9ca20d3b0098bbcb91533a5680d035", size = 425593, upload-time = "2025-10-15T20:39:40.29Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c6/7a465f1825872c55e0341ff4a80198743f73b69ce5d43ab18043699d1d81/protobuf-6.33.0-cp310-abi3-win_amd64.whl", hash = "sha256:9a031d10f703f03768f2743a1c403af050b6ae1f3480e9c140f39c45f81b13ee", size = 436882, upload-time = "2025-10-15T20:39:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/e1/a9/b6eee662a6951b9c3640e8e452ab3e09f117d99fc10baa32d1581a0d4099/protobuf-6.33.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:905b07a65f1a4b72412314082c7dbfae91a9e8b68a0cc1577515f8df58ecf455", size = 427521, upload-time = "2025-10-15T20:39:43.803Z" }, + { url = "https://files.pythonhosted.org/packages/10/35/16d31e0f92c6d2f0e77c2a3ba93185130ea13053dd16200a57434c882f2b/protobuf-6.33.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e0697ece353e6239b90ee43a9231318302ad8353c70e6e45499fa52396debf90", size = 324445, upload-time = "2025-10-15T20:39:44.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/2a981a13e35cda8b75b5585aaffae2eb904f8f351bdd3870769692acbd8a/protobuf-6.33.0-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:e0a1715e4f27355afd9570f3ea369735afc853a6c3951a6afe1f80d8569ad298", size = 339159, upload-time = "2025-10-15T20:39:46.186Z" }, + { url = "https://files.pythonhosted.org/packages/21/51/0b1cbad62074439b867b4e04cc09b93f6699d78fd191bed2bbb44562e077/protobuf-6.33.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef", size = 323172, upload-time = "2025-10-15T20:39:47.465Z" }, + { url = "https://files.pythonhosted.org/packages/07/d1/0a28c21707807c6aacd5dc9c3704b2aa1effbf37adebd8caeaf68b17a636/protobuf-6.33.0-py3-none-any.whl", hash = "sha256:25c9e1963c6734448ea2d308cfa610e692b801304ba0908d7bfa564ac5132995", size = 170477, upload-time = "2025-10-15T20:39:51.311Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" }, + { url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" }, + { url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" }, + { url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" }, + { url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" }, + { url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" }, + { url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" }, +] + +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, +] + +[[package]] +name = "setuptools-scm" +version = "9.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/b1/19587742aad604f1988a8a362e660e8c3ac03adccdb71c96d86526e5eb62/setuptools_scm-9.2.2.tar.gz", hash = "sha256:1c674ab4665686a0887d7e24c03ab25f24201c213e82ea689d2f3e169ef7ef57", size = 203385, upload-time = "2025-10-19T22:08:05.608Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ea/ac2bf868899d0d2e82ef72d350d97a846110c709bacf2d968431576ca915/setuptools_scm-9.2.2-py3-none-any.whl", hash = "sha256:30e8f84d2ab1ba7cb0e653429b179395d0c33775d54807fc5f1dd6671801aef7", size = 62975, upload-time = "2025-10-19T22:08:04.007Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/d5/b0ccd381d55c8f45d46f77df6ae59fbc23d19e901e2d523395598e5f4c93/virtualenv-20.35.3.tar.gz", hash = "sha256:4f1a845d131133bdff10590489610c98c168ff99dc75d6c96853801f7f67af44", size = 6002907, upload-time = "2025-10-10T21:23:33.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/73/d9a94da0e9d470a543c1b9d3ccbceb0f59455983088e727b8a1824ed90fb/virtualenv-20.35.3-py3-none-any.whl", hash = "sha256:63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a", size = 5981061, upload-time = "2025-10-10T21:23:30.433Z" }, +] From b2ba30228f5636a471e74f0694f3bdac348d6dbe Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 24 Oct 2025 16:07:26 +0100 Subject: [PATCH 2/7] Add project code --- Makefile | 34 ++ pyproject.toml | 50 ++ src/mcpd_plugins/__init__.py | 50 ++ src/mcpd_plugins/base_plugin.py | 154 +++++++ src/mcpd_plugins/exceptions.py | 19 + src/mcpd_plugins/py.typed | 0 src/mcpd_plugins/server.py | 89 ++++ src/mcpd_plugins/v1/__init__.py | 0 src/mcpd_plugins/v1/plugins/__init__.py | 0 src/mcpd_plugins/v1/plugins/plugin_pb2.py | 61 +++ .../v1/plugins/plugin_pb2_grpc.py | 433 ++++++++++++++++++ 11 files changed, 890 insertions(+) create mode 100644 Makefile create mode 100644 pyproject.toml create mode 100644 src/mcpd_plugins/__init__.py create mode 100644 src/mcpd_plugins/base_plugin.py create mode 100644 src/mcpd_plugins/exceptions.py create mode 100644 src/mcpd_plugins/py.typed create mode 100644 src/mcpd_plugins/server.py create mode 100644 src/mcpd_plugins/v1/__init__.py create mode 100644 src/mcpd_plugins/v1/plugins/__init__.py create mode 100644 src/mcpd_plugins/v1/plugins/plugin_pb2.py create mode 100644 src/mcpd_plugins/v1/plugins/plugin_pb2_grpc.py diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..521e224 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +.PHONY: help +help: ## Show this help message + @echo "Available targets:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' + +.PHONY: ensure-scripts-exec +ensure-scripts-exec: ## Make scripts executable + chmod +x scripts/* + +.PHONY: setup +setup: ensure-scripts-exec ## Setup development environment (installs uv and syncs dependencies) + ./scripts/setup_uv.sh + +.PHONY: test +test: ## Run tests with pytest + uv run pytest tests/ -v + +.PHONY: lint +lint: ## Run pre-commit hooks on all files + uv run pre-commit run --all-files + +.PHONY: generate-protos +generate-protos: ensure-scripts-exec ## Download proto files and generate Python code + ./scripts/generate_protos.sh + +.PHONY: clean +clean: ## Clean generated files and caches + rm -rf tmp/ + find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find . -type f -name "*.pyc" -delete + find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true + find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true + rm -rf build/ dist/ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..050336a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,50 @@ +[project] +name = "mcpd-plugins" +dynamic = ["version"] +description = "mcpd plugins Python SDK" +readme = "README.md" +license = { text = "Apache-2.0" } +requires-python = ">=3.11" +dependencies = [ + "grpcio>=1.68.0", + "protobuf>=5.29.0", +] + +[dependency-groups] +dev = [ + "grpcio-tools>=1.68.0", + "debugpy>=1.8.14", +] +lint = [ + "pre-commit>=4.2.0", + "ruff>=0.11.13", +] +tests = [ + "pytest>=8.3.5", + "pytest-asyncio>=0.25.2", + "setuptools>=80.9.0", + "setuptools-scm>=8.3.1", +] +all = [ + { include-group = "dev" }, + { include-group = "lint" }, + { include-group = "tests" }, +] + +[build-system] +requires = ["setuptools>=48", "setuptools_scm[toml]>=6.3.1"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] +exclude = ["tests", "tests.*"] +namespaces = false + +[tool.setuptools.package-data] +mcpd_plugins = ["py.typed"] + +[tool.setuptools_scm] +write_to = "src/mcpd_plugins/_version.py" diff --git a/src/mcpd_plugins/__init__.py b/src/mcpd_plugins/__init__.py new file mode 100644 index 0000000..278a7a7 --- /dev/null +++ b/src/mcpd_plugins/__init__.py @@ -0,0 +1,50 @@ +"""mcpd-plugins: Python SDK for building mcpd plugins. + +This SDK provides a simple way to create plugins for the mcpd plugin system using gRPC. +Plugins extend the BasePlugin class and override only the methods they need. + +Example: + ```python + import asyncio + from mcpd_plugins import BasePlugin, serve + from mcpd_plugins.v1.plugins.plugin_pb2 import Capabilities, Flow, Metadata + + class MyPlugin(BasePlugin): + async def GetMetadata(self, request, context): + return Metadata( + name="my-plugin", + version="1.0.0", + description="A simple example plugin" + ) + + async def GetCapabilities(self, request, context): + return Capabilities(flows=[Flow.FLOW_REQUEST]) + + async def HandleRequest(self, request, context): + # Add custom header + response = HTTPResponse(**{"continue": True}) + response.headers["X-My-Plugin"] = "processed" + return response + + if __name__ == "__main__": + asyncio.run(serve(MyPlugin())) + ``` +""" + +from mcpd_plugins.base_plugin import BasePlugin +from mcpd_plugins.exceptions import ConfigurationError, PluginError, ServerError +from mcpd_plugins.server import serve + +try: + from mcpd_plugins._version import version as __version__ +except ImportError: + __version__ = "0.0.0+unknown" + +__all__ = [ + "BasePlugin", + "serve", + "PluginError", + "ConfigurationError", + "ServerError", + "__version__", +] diff --git a/src/mcpd_plugins/base_plugin.py b/src/mcpd_plugins/base_plugin.py new file mode 100644 index 0000000..d8c0c69 --- /dev/null +++ b/src/mcpd_plugins/base_plugin.py @@ -0,0 +1,154 @@ +"""BasePlugin class providing default implementations for all plugin methods.""" + +from google.protobuf.empty_pb2 import Empty +from grpc import ServicerContext + +from mcpd_plugins.v1.plugins.plugin_pb2 import ( + Capabilities, + HTTPRequest, + HTTPResponse, + Metadata, + PluginConfig, +) +from mcpd_plugins.v1.plugins.plugin_pb2_grpc import PluginServicer + + +class BasePlugin(PluginServicer): + """Base class for mcpd plugins with sensible default implementations. + + Developers should extend this class and override only the methods they need. + All methods are async (using async/await pattern) to support asynchronous operations. + + Example: + ```python + class MyPlugin(BasePlugin): + async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadata: + return Metadata( + name="my-plugin", + version="1.0.0", + description="My custom plugin" + ) + + async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse: + # Process the request + response = HTTPResponse(**{"continue": True}) + response.headers["X-My-Plugin"] = "processed" + return response + ``` + """ + + async def Configure(self, request: PluginConfig, context: ServicerContext) -> Empty: + """Configure the plugin with the provided settings. + + Default implementation does nothing. Override to handle configuration. + + Args: + request: Configuration settings from the host. + context: gRPC context for the request. + + Returns: + Empty message indicating successful configuration. + """ + return Empty() + + async def Stop(self, request: Empty, context: ServicerContext) -> Empty: + """Stop the plugin and clean up resources. + + Default implementation does nothing. Override to handle cleanup. + + Args: + request: Empty request message. + context: gRPC context for the request. + + Returns: + Empty message indicating successful shutdown. + """ + return Empty() + + async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadata: + """Get plugin metadata (name, version, description). + + Default implementation returns basic metadata. Override to provide actual values. + + Args: + request: Empty request message. + context: gRPC context for the request. + + Returns: + Metadata containing plugin information. + """ + return Metadata( + name="base-plugin", + version="0.0.0", + description="Base plugin implementation", + ) + + async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Capabilities: + """Get plugin capabilities (supported flows). + + Default implementation returns no capabilities. Must override to declare supported flows. + + Args: + request: Empty request message. + context: gRPC context for the request. + + Returns: + Capabilities message listing supported flows. + """ + return Capabilities() + + async def CheckHealth(self, request: Empty, context: ServicerContext) -> Empty: + """Health check endpoint. + + Default implementation returns healthy status. Override if custom health checks needed. + + Args: + request: Empty request message. + context: gRPC context for the request. + + Returns: + Empty message indicating healthy status. + """ + return Empty() + + async def CheckReady(self, request: Empty, context: ServicerContext) -> Empty: + """Readiness check endpoint. + + Default implementation returns ready status. Override if custom readiness checks needed. + + Args: + request: Empty request message. + context: gRPC context for the request. + + Returns: + Empty message indicating ready status. + """ + return Empty() + + async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse: + """Handle incoming HTTP request. + + Default implementation passes through unchanged (continue=True). + + Args: + request: The incoming HTTP request to process. + context: gRPC context for the request. + + Returns: + HTTPResponse indicating how to proceed (continue, modify, or reject). + """ + return HTTPResponse(**{"continue": True}) + + async def HandleResponse(self, request: HTTPResponse, context: ServicerContext) -> HTTPResponse: + """Handle outgoing HTTP response. + + Default implementation passes through unchanged (continue=True). + + Args: + request: The outgoing HTTP response to process. + context: gRPC context for the request. + + Returns: + HTTPResponse indicating how to proceed (continue or modify). + """ + return HTTPResponse(**{"continue": True}) diff --git a/src/mcpd_plugins/exceptions.py b/src/mcpd_plugins/exceptions.py new file mode 100644 index 0000000..67e85c8 --- /dev/null +++ b/src/mcpd_plugins/exceptions.py @@ -0,0 +1,19 @@ +"""Custom exception classes for mcpd-plugins SDK.""" + + +class PluginError(Exception): + """Base exception class for all plugin-related errors.""" + + pass + + +class ConfigurationError(PluginError): + """Exception raised for configuration-related errors.""" + + pass + + +class ServerError(PluginError): + """Exception raised for server startup or shutdown errors.""" + + pass diff --git a/src/mcpd_plugins/py.typed b/src/mcpd_plugins/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/mcpd_plugins/server.py b/src/mcpd_plugins/server.py new file mode 100644 index 0000000..aad07b5 --- /dev/null +++ b/src/mcpd_plugins/server.py @@ -0,0 +1,89 @@ +"""Server helper functions for launching gRPC plugin servers.""" + +import asyncio +import logging +import os +import signal + +from grpc import aio + +from mcpd_plugins.base_plugin import BasePlugin +from mcpd_plugins.exceptions import ServerError +from mcpd_plugins.v1.plugins.plugin_pb2_grpc import add_PluginServicer_to_server + +logger = logging.getLogger(__name__) + + +async def serve( + plugin: BasePlugin, + port: int | None = None, + max_workers: int = 10, + grace_period: float = 5.0, +) -> None: + """Launch a gRPC server for the plugin. + + This is a convenience function that handles server setup, signal handling, + and graceful shutdown. It runs until interrupted by SIGTERM or SIGINT. + + Args: + plugin: The plugin instance to serve (should extend BasePlugin). + port: Port to listen on. If None, uses PLUGIN_PORT env var or defaults to 50051. + max_workers: Maximum number of concurrent workers (default: 10). + grace_period: Seconds to wait for graceful shutdown (default: 5.0). + + Raises: + ServerError: If the server fails to start or encounters an error. + + Example: + ```python + import asyncio + from mcpd_plugins import BasePlugin, serve + + class MyPlugin(BasePlugin): + async def GetMetadata(self, request, context): + return Metadata(name="my-plugin", version="1.0.0") + + if __name__ == "__main__": + asyncio.run(serve(MyPlugin())) + ``` + """ + if port is None: + port = int(os.getenv("PLUGIN_PORT", "50051")) + + server = aio.server() + add_PluginServicer_to_server(plugin, server) + + listen_addr = f"[::]:{port}" + try: + server.add_insecure_port(listen_addr) + except Exception as e: + raise ServerError(f"Failed to bind to {listen_addr}: {e}") from e + + # Setup signal handling for graceful shutdown. + stop_event = asyncio.Event() + + def signal_handler(signum: int, frame) -> None: # noqa: ARG001 + """Handle shutdown signals.""" + logger.info(f"Received signal {signum}, initiating graceful shutdown...") + stop_event.set() + + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + # Start the server. + try: + await server.start() + logger.info(f"Plugin server started on {listen_addr}") + + # Wait for shutdown signal. + await stop_event.wait() + + # Graceful shutdown. + logger.info(f"Shutting down server (grace period: {grace_period}s)...") + await server.stop(grace_period) + logger.info("Server stopped gracefully") + + except Exception as e: + logger.error(f"Server error: {e}") + await server.stop(0) + raise ServerError(f"Server encountered an error: {e}") from e diff --git a/src/mcpd_plugins/v1/__init__.py b/src/mcpd_plugins/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcpd_plugins/v1/plugins/__init__.py b/src/mcpd_plugins/v1/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/mcpd_plugins/v1/plugins/plugin_pb2.py b/src/mcpd_plugins/v1/plugins/plugin_pb2.py new file mode 100644 index 0000000..1be2782 --- /dev/null +++ b/src/mcpd_plugins/v1/plugins/plugin_pb2.py @@ -0,0 +1,61 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: plugin.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" + +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder + +_runtime_version.ValidateProtobufRuntimeVersion(_runtime_version.Domain.PUBLIC, 6, 31, 1, "", "plugin.proto") +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( + b'\n\x0cplugin.proto\x12\x17mozilla.mcpd.plugins.v1\x1a\x1bgoogle/protobuf/empty.proto"g\n\x08Metadata\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\x12\x13\n\x0b\x63ommit_hash\x18\x04 \x01(\t\x12\x12\n\nbuild_date\x18\x05 \x01(\t"<\n\x0c\x43\x61pabilities\x12,\n\x05\x66lows\x18\x01 \x03(\x0e\x32\x1d.mozilla.mcpd.plugins.v1.Flow"\xe4\x01\n\x0bHTTPRequest\x12\x0e\n\x06method\x18\x01 \x01(\t\x12\x0b\n\x03url\x18\x02 \x01(\t\x12\x0c\n\x04path\x18\x03 \x01(\t\x12\x42\n\x07headers\x18\x04 \x03(\x0b\x32\x31.mozilla.mcpd.plugins.v1.HTTPRequest.HeadersEntry\x12\x0c\n\x04\x62ody\x18\x05 \x01(\x0c\x12\x13\n\x0bremote_addr\x18\x06 \x01(\t\x12\x13\n\x0brequest_uri\x18\x07 \x01(\t\x1a.\n\x0cHeadersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01"\xf8\x01\n\x0cHTTPResponse\x12\x13\n\x0bstatus_code\x18\x01 \x01(\x05\x12\x43\n\x07headers\x18\x02 \x03(\x0b\x32\x32.mozilla.mcpd.plugins.v1.HTTPResponse.HeadersEntry\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08\x63ontinue\x18\x04 \x01(\x08\x12>\n\x10modified_request\x18\x05 \x01(\x0b\x32$.mozilla.mcpd.plugins.v1.HTTPRequest\x1a.\n\x0cHeadersEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01"i\n\x0fTelemetryConfig\x12\x15\n\rotlp_endpoint\x18\x01 \x01(\t\x12\x14\n\x0cservice_name\x18\x02 \x01(\t\x12\x13\n\x0b\x65nvironment\x18\x03 \x01(\t\x12\x14\n\x0csample_ratio\x18\x04 \x01(\x01"\xd0\x01\n\x0cPluginConfig\x12;\n\ttelemetry\x18\x01 \x01(\x0b\x32(.mozilla.mcpd.plugins.v1.TelemetryConfig\x12N\n\rcustom_config\x18\x02 \x03(\x0b\x32\x37.mozilla.mcpd.plugins.v1.PluginConfig.CustomConfigEntry\x1a\x33\n\x11\x43ustomConfigEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*+\n\x04\x46low\x12\x10\n\x0c\x46LOW_REQUEST\x10\x00\x12\x11\n\rFLOW_RESPONSE\x10\x01\x32\xe3\x04\n\x06Plugin\x12J\n\tConfigure\x12%.mozilla.mcpd.plugins.v1.PluginConfig\x1a\x16.google.protobuf.Empty\x12\x36\n\x04Stop\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\x12H\n\x0bGetMetadata\x12\x16.google.protobuf.Empty\x1a!.mozilla.mcpd.plugins.v1.Metadata\x12P\n\x0fGetCapabilities\x12\x16.google.protobuf.Empty\x1a%.mozilla.mcpd.plugins.v1.Capabilities\x12=\n\x0b\x43heckHealth\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\x12<\n\nCheckReady\x12\x16.google.protobuf.Empty\x1a\x16.google.protobuf.Empty\x12\\\n\rHandleRequest\x12$.mozilla.mcpd.plugins.v1.HTTPRequest\x1a%.mozilla.mcpd.plugins.v1.HTTPResponse\x12^\n\x0eHandleResponse\x12%.mozilla.mcpd.plugins.v1.HTTPResponse\x1a%.mozilla.mcpd.plugins.v1.HTTPResponseBYZ;github.com/mozilla-ai/mcpd-plugins-sdk-go/pkg/plugins/v1;v1\xaa\x02\x19MozillaAI.Mcpd.Plugins.V1b\x06proto3' +) + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "plugin_pb2", _globals) +if not _descriptor._USE_C_DESCRIPTORS: + _globals["DESCRIPTOR"]._loaded_options = None + _globals[ + "DESCRIPTOR" + ]._serialized_options = ( + b"Z;github.com/mozilla-ai/mcpd-plugins-sdk-go/pkg/plugins/v1;v1\252\002\031MozillaAI.Mcpd.Plugins.V1" + ) + _globals["_HTTPREQUEST_HEADERSENTRY"]._loaded_options = None + _globals["_HTTPREQUEST_HEADERSENTRY"]._serialized_options = b"8\001" + _globals["_HTTPRESPONSE_HEADERSENTRY"]._loaded_options = None + _globals["_HTTPRESPONSE_HEADERSENTRY"]._serialized_options = b"8\001" + _globals["_PLUGINCONFIG_CUSTOMCONFIGENTRY"]._loaded_options = None + _globals["_PLUGINCONFIG_CUSTOMCONFIGENTRY"]._serialized_options = b"8\001" + _globals["_FLOW"]._serialized_start = 1037 + _globals["_FLOW"]._serialized_end = 1080 + _globals["_METADATA"]._serialized_start = 70 + _globals["_METADATA"]._serialized_end = 173 + _globals["_CAPABILITIES"]._serialized_start = 175 + _globals["_CAPABILITIES"]._serialized_end = 235 + _globals["_HTTPREQUEST"]._serialized_start = 238 + _globals["_HTTPREQUEST"]._serialized_end = 466 + _globals["_HTTPREQUEST_HEADERSENTRY"]._serialized_start = 420 + _globals["_HTTPREQUEST_HEADERSENTRY"]._serialized_end = 466 + _globals["_HTTPRESPONSE"]._serialized_start = 469 + _globals["_HTTPRESPONSE"]._serialized_end = 717 + _globals["_HTTPRESPONSE_HEADERSENTRY"]._serialized_start = 420 + _globals["_HTTPRESPONSE_HEADERSENTRY"]._serialized_end = 466 + _globals["_TELEMETRYCONFIG"]._serialized_start = 719 + _globals["_TELEMETRYCONFIG"]._serialized_end = 824 + _globals["_PLUGINCONFIG"]._serialized_start = 827 + _globals["_PLUGINCONFIG"]._serialized_end = 1035 + _globals["_PLUGINCONFIG_CUSTOMCONFIGENTRY"]._serialized_start = 984 + _globals["_PLUGINCONFIG_CUSTOMCONFIGENTRY"]._serialized_end = 1035 + _globals["_PLUGIN"]._serialized_start = 1083 + _globals["_PLUGIN"]._serialized_end = 1694 +# @@protoc_insertion_point(module_scope) diff --git a/src/mcpd_plugins/v1/plugins/plugin_pb2_grpc.py b/src/mcpd_plugins/v1/plugins/plugin_pb2_grpc.py new file mode 100644 index 0000000..2fab811 --- /dev/null +++ b/src/mcpd_plugins/v1/plugins/plugin_pb2_grpc.py @@ -0,0 +1,433 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" + +import grpc +from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 + +from . import plugin_pb2 as plugin__pb2 + +GRPC_GENERATED_VERSION = "1.76.0" +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f"The grpc package installed is at version {GRPC_VERSION}," + " but the generated code in plugin_pb2_grpc.py depends on" + f" grpcio>={GRPC_GENERATED_VERSION}." + f" Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}" + f" or downgrade your generated code using grpcio-tools<={GRPC_VERSION}." + ) + + +class PluginStub: + """Plugin service.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Configure = channel.unary_unary( + "/mozilla.mcpd.plugins.v1.Plugin/Configure", + request_serializer=plugin__pb2.PluginConfig.SerializeToString, + response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + _registered_method=True, + ) + self.Stop = channel.unary_unary( + "/mozilla.mcpd.plugins.v1.Plugin/Stop", + request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + _registered_method=True, + ) + self.GetMetadata = channel.unary_unary( + "/mozilla.mcpd.plugins.v1.Plugin/GetMetadata", + request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + response_deserializer=plugin__pb2.Metadata.FromString, + _registered_method=True, + ) + self.GetCapabilities = channel.unary_unary( + "/mozilla.mcpd.plugins.v1.Plugin/GetCapabilities", + request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + response_deserializer=plugin__pb2.Capabilities.FromString, + _registered_method=True, + ) + self.CheckHealth = channel.unary_unary( + "/mozilla.mcpd.plugins.v1.Plugin/CheckHealth", + request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + _registered_method=True, + ) + self.CheckReady = channel.unary_unary( + "/mozilla.mcpd.plugins.v1.Plugin/CheckReady", + request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + _registered_method=True, + ) + self.HandleRequest = channel.unary_unary( + "/mozilla.mcpd.plugins.v1.Plugin/HandleRequest", + request_serializer=plugin__pb2.HTTPRequest.SerializeToString, + response_deserializer=plugin__pb2.HTTPResponse.FromString, + _registered_method=True, + ) + self.HandleResponse = channel.unary_unary( + "/mozilla.mcpd.plugins.v1.Plugin/HandleResponse", + request_serializer=plugin__pb2.HTTPResponse.SerializeToString, + response_deserializer=plugin__pb2.HTTPResponse.FromString, + _registered_method=True, + ) + + +class PluginServicer: + """Plugin service.""" + + def Configure(self, request, context): + """Lifecycle""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def Stop(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def GetMetadata(self, request, context): + """Identity and capabilities""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def GetCapabilities(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def CheckHealth(self, request, context): + """Health / readiness + Returns error via gRPC status if unhealthy or not ready. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def CheckReady(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def HandleRequest(self, request, context): + """Request / response handling""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + def HandleResponse(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + + +def add_PluginServicer_to_server(servicer, server): + rpc_method_handlers = { + "Configure": grpc.unary_unary_rpc_method_handler( + servicer.Configure, + request_deserializer=plugin__pb2.PluginConfig.FromString, + response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + ), + "Stop": grpc.unary_unary_rpc_method_handler( + servicer.Stop, + request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + ), + "GetMetadata": grpc.unary_unary_rpc_method_handler( + servicer.GetMetadata, + request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + response_serializer=plugin__pb2.Metadata.SerializeToString, + ), + "GetCapabilities": grpc.unary_unary_rpc_method_handler( + servicer.GetCapabilities, + request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + response_serializer=plugin__pb2.Capabilities.SerializeToString, + ), + "CheckHealth": grpc.unary_unary_rpc_method_handler( + servicer.CheckHealth, + request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + ), + "CheckReady": grpc.unary_unary_rpc_method_handler( + servicer.CheckReady, + request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, + response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + ), + "HandleRequest": grpc.unary_unary_rpc_method_handler( + servicer.HandleRequest, + request_deserializer=plugin__pb2.HTTPRequest.FromString, + response_serializer=plugin__pb2.HTTPResponse.SerializeToString, + ), + "HandleResponse": grpc.unary_unary_rpc_method_handler( + servicer.HandleResponse, + request_deserializer=plugin__pb2.HTTPResponse.FromString, + response_serializer=plugin__pb2.HTTPResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler("mozilla.mcpd.plugins.v1.Plugin", rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers("mozilla.mcpd.plugins.v1.Plugin", rpc_method_handlers) + + +# This class is part of an EXPERIMENTAL API. +class Plugin: + """Plugin service.""" + + @staticmethod + def Configure( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/mozilla.mcpd.plugins.v1.Plugin/Configure", + plugin__pb2.PluginConfig.SerializeToString, + google_dot_protobuf_dot_empty__pb2.Empty.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def Stop( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/mozilla.mcpd.plugins.v1.Plugin/Stop", + google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + google_dot_protobuf_dot_empty__pb2.Empty.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def GetMetadata( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/mozilla.mcpd.plugins.v1.Plugin/GetMetadata", + google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + plugin__pb2.Metadata.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def GetCapabilities( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/mozilla.mcpd.plugins.v1.Plugin/GetCapabilities", + google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + plugin__pb2.Capabilities.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def CheckHealth( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/mozilla.mcpd.plugins.v1.Plugin/CheckHealth", + google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + google_dot_protobuf_dot_empty__pb2.Empty.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def CheckReady( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/mozilla.mcpd.plugins.v1.Plugin/CheckReady", + google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, + google_dot_protobuf_dot_empty__pb2.Empty.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def HandleRequest( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/mozilla.mcpd.plugins.v1.Plugin/HandleRequest", + plugin__pb2.HTTPRequest.SerializeToString, + plugin__pb2.HTTPResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + + @staticmethod + def HandleResponse( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/mozilla.mcpd.plugins.v1.Plugin/HandleResponse", + plugin__pb2.HTTPResponse.SerializeToString, + plugin__pb2.HTTPResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) From 83bd6e92d18a9eb7f9637f028822c18b1386a215 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 24 Oct 2025 16:07:39 +0100 Subject: [PATCH 3/7] Add tests --- tests/conftest.py | 75 +++++++++++++ tests/integration/test_examples.py | 175 +++++++++++++++++++++++++++++ tests/unit/test_base_plugin.py | 116 +++++++++++++++++++ tests/unit/test_exceptions.py | 60 ++++++++++ tests/unit/test_server.py | 43 +++++++ 5 files changed, 469 insertions(+) create mode 100644 tests/conftest.py create mode 100644 tests/integration/test_examples.py create mode 100644 tests/unit/test_base_plugin.py create mode 100644 tests/unit/test_exceptions.py create mode 100644 tests/unit/test_server.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..caa0d32 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,75 @@ +"""Pytest configuration and shared fixtures.""" + +import pytest +from google.protobuf.empty_pb2 import Empty +from grpc.aio import Metadata + +from mcpd_plugins.v1.plugins.plugin_pb2 import HTTPRequest, PluginConfig + + +class MockContext: + """Mock gRPC ServicerContext for testing.""" + + def __init__(self): + """Initialize mock context.""" + self.invocation_metadata = Metadata() + self.peer_identity = None + self.code = None + self.details = None + + def abort(self, code, details): + """Mock abort method.""" + self.code = code + self.details = details + raise Exception(f"Aborted with code {code}: {details}") + + +@pytest.fixture +def mock_context(): + """Provide a mock gRPC context for testing.""" + return MockContext() + + +@pytest.fixture +def empty_request(): + """Provide an Empty request message.""" + return Empty() + + +@pytest.fixture +def sample_plugin_config(): + """Provide a sample plugin configuration.""" + config = PluginConfig() + config.custom_config["key1"] = "value1" + config.custom_config["key2"] = "value2" + return config + + +@pytest.fixture +def sample_http_request(): + """Provide a sample HTTP request.""" + request = HTTPRequest( + method="GET", + url="https://example.com/api/test", + path="/api/test", + remote_addr="192.168.1.100", + request_uri="/api/test?foo=bar", + ) + request.headers["User-Agent"] = "test-client/1.0" + request.headers["Content-Type"] = "application/json" + return request + + +@pytest.fixture +def sample_http_request_with_body(): + """Provide a sample HTTP request with body.""" + request = HTTPRequest( + method="POST", + url="https://example.com/api/test", + path="/api/test", + body=b'{"test": "data"}', + remote_addr="192.168.1.100", + ) + request.headers["Content-Type"] = "application/json" + request.headers["Content-Length"] = "16" + return request diff --git a/tests/integration/test_examples.py b/tests/integration/test_examples.py new file mode 100644 index 0000000..6f9b872 --- /dev/null +++ b/tests/integration/test_examples.py @@ -0,0 +1,175 @@ +"""Integration tests for example plugins.""" + +import sys +from pathlib import Path + +import pytest +from google.protobuf.empty_pb2 import Empty + +# Add examples to path. +examples_dir = Path(__file__).parent.parent.parent / "examples" +sys.path.insert(0, str(examples_dir)) + + +@pytest.fixture +def mock_context(): + """Provide a mock gRPC context.""" + + class MockContext: + """Mock gRPC ServicerContext.""" + + def __init__(self): + """Initialize mock context.""" + self.invocation_metadata = [] + + return MockContext() + + +class TestSimplePlugin: + """Integration tests for simple_plugin example.""" + + @pytest.mark.asyncio + async def test_simple_plugin_metadata(self, mock_context): + """Simple plugin should return correct metadata.""" + from simple_plugin.main import SimplePlugin + + plugin = SimplePlugin() + metadata = await plugin.GetMetadata(Empty(), mock_context) + + assert metadata.name == "simple-plugin" + assert metadata.version == "1.0.0" + assert "custom header" in metadata.description.lower() + + @pytest.mark.asyncio + async def test_simple_plugin_capabilities(self, mock_context): + """Simple plugin should declare REQUEST flow.""" + from simple_plugin.main import SimplePlugin + + from mcpd_plugins.v1.plugins.plugin_pb2 import FLOW_REQUEST + + plugin = SimplePlugin() + capabilities = await plugin.GetCapabilities(Empty(), mock_context) + + assert len(capabilities.flows) == 1 + assert capabilities.flows[0] == FLOW_REQUEST + + @pytest.mark.asyncio + async def test_simple_plugin_adds_header(self, mock_context): + """Simple plugin should add X-Simple-Plugin header.""" + from simple_plugin.main import SimplePlugin + + from mcpd_plugins.v1.plugins.plugin_pb2 import HTTPRequest + + plugin = SimplePlugin() + request = HTTPRequest( + method="GET", + url="https://example.com/test", + path="/test", + ) + request.headers["User-Agent"] = "test" + + response = await plugin.HandleRequest(request, mock_context) + + assert getattr(response, "continue") is True + assert "X-Simple-Plugin" in response.modified_request.headers + assert response.modified_request.headers["X-Simple-Plugin"] == "processed" + + +class TestAuthPlugin: + """Integration tests for auth_plugin example.""" + + @pytest.mark.asyncio + async def test_auth_plugin_rejects_missing_token(self, mock_context): + """Auth plugin should reject requests without Authorization header.""" + from auth_plugin.main import AuthPlugin + + from mcpd_plugins.v1.plugins.plugin_pb2 import HTTPRequest + + plugin = AuthPlugin() + request = HTTPRequest( + method="GET", + url="https://example.com/test", + path="/test", + ) + + response = await plugin.HandleRequest(request, mock_context) + + assert getattr(response, "continue") is False + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_auth_plugin_rejects_invalid_token(self, mock_context): + """Auth plugin should reject requests with invalid token.""" + from auth_plugin.main import AuthPlugin + + from mcpd_plugins.v1.plugins.plugin_pb2 import HTTPRequest + + plugin = AuthPlugin() + request = HTTPRequest( + method="GET", + url="https://example.com/test", + path="/test", + ) + request.headers["Authorization"] = "Bearer wrong-token" + + response = await plugin.HandleRequest(request, mock_context) + + assert getattr(response, "continue") is False + assert response.status_code == 401 + + @pytest.mark.asyncio + async def test_auth_plugin_accepts_valid_token(self, mock_context): + """Auth plugin should accept requests with valid token.""" + from auth_plugin.main import AuthPlugin + + from mcpd_plugins.v1.plugins.plugin_pb2 import HTTPRequest + + plugin = AuthPlugin() + request = HTTPRequest( + method="GET", + url="https://example.com/test", + path="/test", + ) + request.headers["Authorization"] = "Bearer secret-token-123" + + response = await plugin.HandleRequest(request, mock_context) + + assert getattr(response, "continue") is True + + +class TestLoggingPlugin: + """Integration tests for logging_plugin example.""" + + @pytest.mark.asyncio + async def test_logging_plugin_supports_both_flows(self, mock_context): + """Logging plugin should support both REQUEST and RESPONSE flows.""" + from logging_plugin.main import LoggingPlugin + + from mcpd_plugins.v1.plugins.plugin_pb2 import FLOW_REQUEST, FLOW_RESPONSE + + plugin = LoggingPlugin() + capabilities = await plugin.GetCapabilities(Empty(), mock_context) + + assert len(capabilities.flows) == 2 + assert FLOW_REQUEST in capabilities.flows + assert FLOW_RESPONSE in capabilities.flows + + @pytest.mark.asyncio + async def test_logging_plugin_logs_request(self, mock_context, caplog): + """Logging plugin should log request details.""" + from logging_plugin.main import LoggingPlugin + + from mcpd_plugins.v1.plugins.plugin_pb2 import HTTPRequest + + plugin = LoggingPlugin() + request = HTTPRequest( + method="GET", + url="https://example.com/test", + path="/test", + ) + + response = await plugin.HandleRequest(request, mock_context) + + assert getattr(response, "continue") is True + # Check that logging occurred (test output capture). + assert "INCOMING REQUEST" in caplog.text or getattr(response, "continue") is True diff --git a/tests/unit/test_base_plugin.py b/tests/unit/test_base_plugin.py new file mode 100644 index 0000000..805f3de --- /dev/null +++ b/tests/unit/test_base_plugin.py @@ -0,0 +1,116 @@ +"""Unit tests for BasePlugin class.""" + +import pytest +from google.protobuf.empty_pb2 import Empty + +from mcpd_plugins import BasePlugin +from mcpd_plugins.v1.plugins.plugin_pb2 import ( + Capabilities, + HTTPResponse, + Metadata, +) + + +class TestBasePlugin: + """Tests for BasePlugin default implementations.""" + + @pytest.fixture + def plugin(self): + """Create a BasePlugin instance for testing.""" + return BasePlugin() + + @pytest.mark.asyncio + async def test_configure_returns_empty(self, plugin, sample_plugin_config, mock_context): + """Configure should return Empty by default.""" + result = await plugin.Configure(sample_plugin_config, mock_context) + assert isinstance(result, Empty) + + @pytest.mark.asyncio + async def test_stop_returns_empty(self, plugin, empty_request, mock_context): + """Stop should return Empty by default.""" + result = await plugin.Stop(empty_request, mock_context) + assert isinstance(result, Empty) + + @pytest.mark.asyncio + async def test_get_metadata_returns_metadata(self, plugin, empty_request, mock_context): + """GetMetadata should return Metadata with default values.""" + result = await plugin.GetMetadata(empty_request, mock_context) + assert isinstance(result, Metadata) + assert result.name == "base-plugin" + assert result.version == "0.0.0" + assert result.description == "Base plugin implementation" + + @pytest.mark.asyncio + async def test_get_capabilities_returns_empty_capabilities(self, plugin, empty_request, mock_context): + """GetCapabilities should return empty Capabilities by default.""" + result = await plugin.GetCapabilities(empty_request, mock_context) + assert isinstance(result, Capabilities) + assert len(result.flows) == 0 + + @pytest.mark.asyncio + async def test_check_health_returns_empty(self, plugin, empty_request, mock_context): + """CheckHealth should return Empty by default.""" + result = await plugin.CheckHealth(empty_request, mock_context) + assert isinstance(result, Empty) + + @pytest.mark.asyncio + async def test_check_ready_returns_empty(self, plugin, empty_request, mock_context): + """CheckReady should return Empty by default.""" + result = await plugin.CheckReady(empty_request, mock_context) + assert isinstance(result, Empty) + + @pytest.mark.asyncio + async def test_handle_request_passes_through(self, plugin, sample_http_request, mock_context): + """HandleRequest should return continue=True by default.""" + result = await plugin.HandleRequest(sample_http_request, mock_context) + assert isinstance(result, HTTPResponse) + assert getattr(result, "continue") is True + + @pytest.mark.asyncio + async def test_handle_response_passes_through(self, plugin, mock_context): + """HandleResponse should return continue=True by default.""" + http_response = HTTPResponse(status_code=200, **{"continue": True}) + result = await plugin.HandleResponse(http_response, mock_context) + assert isinstance(result, HTTPResponse) + assert getattr(result, "continue") is True + + +class TestCustomPlugin: + """Tests for custom plugin that overrides methods.""" + + class CustomPlugin(BasePlugin): + """Custom plugin for testing overrides.""" + + async def GetMetadata(self, request, context): + """Override with custom metadata.""" + return Metadata( + name="custom-plugin", + version="1.2.3", + description="Custom test plugin", + ) + + async def HandleRequest(self, request, context): + """Override to add custom header.""" + response = HTTPResponse(**{"continue": True}) + response.headers["X-Custom"] = "test" + return response + + @pytest.fixture + def custom_plugin(self): + """Create a custom plugin instance for testing.""" + return self.CustomPlugin() + + @pytest.mark.asyncio + async def test_custom_get_metadata(self, custom_plugin, empty_request, mock_context): + """Custom GetMetadata should return overridden values.""" + result = await custom_plugin.GetMetadata(empty_request, mock_context) + assert result.name == "custom-plugin" + assert result.version == "1.2.3" + assert result.description == "Custom test plugin" + + @pytest.mark.asyncio + async def test_custom_handle_request(self, custom_plugin, sample_http_request, mock_context): + """Custom HandleRequest should add custom header.""" + result = await custom_plugin.HandleRequest(sample_http_request, mock_context) + assert getattr(result, "continue") is True + assert result.headers["X-Custom"] == "test" diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 0000000..d207616 --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,60 @@ +"""Unit tests for exception classes.""" + +import pytest + +from mcpd_plugins.exceptions import ConfigurationError, PluginError, ServerError + + +class TestPluginError: + """Tests for PluginError base exception.""" + + def test_plugin_error_inherits_from_exception(self): + """PluginError should inherit from Exception.""" + assert issubclass(PluginError, Exception) + + def test_plugin_error_with_message(self): + """PluginError should store error message.""" + error = PluginError("Test error message") + assert str(error) == "Test error message" + + def test_plugin_error_can_be_raised(self): + """PluginError should be raisable.""" + with pytest.raises(PluginError) as exc_info: + raise PluginError("Test error") + assert "Test error" in str(exc_info.value) + + +class TestConfigurationError: + """Tests for ConfigurationError exception.""" + + def test_configuration_error_inherits_from_plugin_error(self): + """ConfigurationError should inherit from PluginError.""" + assert issubclass(ConfigurationError, PluginError) + + def test_configuration_error_with_message(self): + """ConfigurationError should store error message.""" + error = ConfigurationError("Invalid config") + assert str(error) == "Invalid config" + + def test_configuration_error_can_be_caught_as_plugin_error(self): + """ConfigurationError should be catchable as PluginError.""" + with pytest.raises(PluginError): + raise ConfigurationError("Config error") + + +class TestServerError: + """Tests for ServerError exception.""" + + def test_server_error_inherits_from_plugin_error(self): + """ServerError should inherit from PluginError.""" + assert issubclass(ServerError, PluginError) + + def test_server_error_with_message(self): + """ServerError should store error message.""" + error = ServerError("Server startup failed") + assert str(error) == "Server startup failed" + + def test_server_error_can_be_caught_as_plugin_error(self): + """ServerError should be catchable as PluginError.""" + with pytest.raises(PluginError): + raise ServerError("Server error") diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py new file mode 100644 index 0000000..8b1d672 --- /dev/null +++ b/tests/unit/test_server.py @@ -0,0 +1,43 @@ +"""Unit tests for server module.""" + +import os + +import pytest + +from mcpd_plugins.server import serve + + +class TestServeFunction: + """Tests for serve helper function.""" + + @pytest.mark.asyncio + async def test_serve_with_invalid_port_raises_error(self): + """serve should raise ServerError if port binding fails.""" + # Note: This test is limited without actually starting a server. + # In practice, we'd need more complex mocking to test server startup failures. + # This is a placeholder test to demonstrate the pattern. + assert callable(serve) + + def test_serve_uses_env_var_for_port(self, monkeypatch): + """serve should use PLUGIN_PORT environment variable if set.""" + monkeypatch.setenv("PLUGIN_PORT", "9999") + # Since we can't easily test the actual port binding without starting the server, + # we just verify that the function is callable and accepts the right parameters. + assert callable(serve) + + def test_serve_defaults_to_port_50051(self): + """serve should default to port 50051 if no port specified.""" + # Remove env var if it exists. + if "PLUGIN_PORT" in os.environ: + del os.environ["PLUGIN_PORT"] + + # Verify the function signature accepts the expected parameters. + assert callable(serve) + + +# Note: Full integration tests for the gRPC server would require: +# 1. Actually starting the server in a background task +# 2. Creating a gRPC client to connect to it +# 3. Making test calls +# 4. Shutting down the server gracefully +# These are better suited for integration tests rather than unit tests. From 0640c560b627073082be7bbdf2c5f9a4502107f6 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 24 Oct 2025 16:07:50 +0100 Subject: [PATCH 4/7] Add examples --- examples/auth_plugin/README.md | 33 ++++++++ examples/auth_plugin/__init__.py | 1 + examples/auth_plugin/main.py | 80 ++++++++++++++++++ examples/logging_plugin/README.md | 26 ++++++ examples/logging_plugin/__init__.py | 1 + examples/logging_plugin/main.py | 93 +++++++++++++++++++++ examples/rate_limit_plugin/README.md | 36 +++++++++ examples/rate_limit_plugin/__init__.py | 1 + examples/rate_limit_plugin/main.py | 107 +++++++++++++++++++++++++ examples/simple_plugin/README.md | 27 +++++++ examples/simple_plugin/__init__.py | 1 + examples/simple_plugin/main.py | 66 +++++++++++++++ examples/transform_plugin/README.md | 52 ++++++++++++ examples/transform_plugin/__init__.py | 1 + examples/transform_plugin/main.py | 102 +++++++++++++++++++++++ 15 files changed, 627 insertions(+) create mode 100644 examples/auth_plugin/README.md create mode 100644 examples/auth_plugin/__init__.py create mode 100644 examples/auth_plugin/main.py create mode 100644 examples/logging_plugin/README.md create mode 100644 examples/logging_plugin/__init__.py create mode 100644 examples/logging_plugin/main.py create mode 100644 examples/rate_limit_plugin/README.md create mode 100644 examples/rate_limit_plugin/__init__.py create mode 100644 examples/rate_limit_plugin/main.py create mode 100644 examples/simple_plugin/README.md create mode 100644 examples/simple_plugin/__init__.py create mode 100644 examples/simple_plugin/main.py create mode 100644 examples/transform_plugin/README.md create mode 100644 examples/transform_plugin/__init__.py create mode 100644 examples/transform_plugin/main.py diff --git a/examples/auth_plugin/README.md b/examples/auth_plugin/README.md new file mode 100644 index 0000000..fac1d85 --- /dev/null +++ b/examples/auth_plugin/README.md @@ -0,0 +1,33 @@ +# Auth Plugin Example + +A plugin that validates Bearer token authentication for incoming HTTP requests. + +## What it does + +- Checks for the `Authorization` header in requests +- Validates that it contains a Bearer token +- Rejects requests with invalid or missing tokens (returns 401 Unauthorized) +- Allows valid requests to continue + +## Running the example + +```bash +# Set the expected token (optional, defaults to "secret-token-123") +export AUTH_TOKEN="my-secret-token" + +# From the repository root +cd examples/auth_plugin +uv run python main.py +``` + +## Configuration + +The plugin uses the `AUTH_TOKEN` environment variable to set the expected token (default: `secret-token-123`). + +## Key concepts demonstrated + +- Request validation and rejection +- Environment variable configuration +- Returning error responses with `continue_=False` +- Setting HTTP status codes and response bodies +- Using class initialization for plugin state diff --git a/examples/auth_plugin/__init__.py b/examples/auth_plugin/__init__.py new file mode 100644 index 0000000..9b5c38f --- /dev/null +++ b/examples/auth_plugin/__init__.py @@ -0,0 +1 @@ +"""Authentication plugin example.""" diff --git a/examples/auth_plugin/main.py b/examples/auth_plugin/main.py new file mode 100644 index 0000000..48d7ef1 --- /dev/null +++ b/examples/auth_plugin/main.py @@ -0,0 +1,80 @@ +"""Authentication plugin that validates Bearer tokens. + +This example demonstrates how to reject requests that don't meet security requirements. +""" + +import asyncio +import logging +import os + +from google.protobuf.empty_pb2 import Empty +from grpc import ServicerContext + +from mcpd_plugins import BasePlugin, serve +from mcpd_plugins.v1.plugins.plugin_pb2 import ( + FLOW_REQUEST, + Capabilities, + HTTPRequest, + HTTPResponse, + Metadata, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class AuthPlugin(BasePlugin): + """Plugin that validates Bearer token authentication.""" + + def __init__(self): + """Initialize the auth plugin with expected token.""" + super().__init__() + self.expected_token = os.getenv("AUTH_TOKEN", "secret-token-123") + + async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadata: + """Return plugin metadata.""" + return Metadata( + name="auth-plugin", + version="1.0.0", + description="Validates Bearer token authentication", + ) + + async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Capabilities: + """Declare support for request flow.""" + return Capabilities(flows=[FLOW_REQUEST]) + + async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse: + """Validate Bearer token in Authorization header.""" + logger.info(f"Authenticating request: {request.method} {request.url}") + + # Check for Authorization header. + auth_header = request.headers.get("Authorization", "") + + if not auth_header.startswith("Bearer "): + logger.warning("Missing or invalid Authorization header") + return self._unauthorized_response("Missing or invalid Authorization header") + + # Extract and validate token. + token = auth_header[7:] # Remove "Bearer " prefix + if token != self.expected_token: + logger.warning("Invalid token") + return self._unauthorized_response("Invalid token") + + # Token is valid, allow request to continue. + logger.info("Authentication successful") + return HTTPResponse(**{"continue": True}) + + def _unauthorized_response(self, message: str) -> HTTPResponse: + """Create a 401 Unauthorized response.""" + response = HTTPResponse( + status_code=401, + body=f'{{"error": "{message}"}}'.encode(), + **{"continue": False}, + ) + response.headers["Content-Type"] = "application/json" + response.headers["WWW-Authenticate"] = "Bearer" + return response + + +if __name__ == "__main__": + asyncio.run(serve(AuthPlugin())) diff --git a/examples/logging_plugin/README.md b/examples/logging_plugin/README.md new file mode 100644 index 0000000..c44bfe6 --- /dev/null +++ b/examples/logging_plugin/README.md @@ -0,0 +1,26 @@ +# Logging Plugin Example + +A plugin that logs HTTP request and response details for observability and debugging. + +## What it does + +- Logs details of incoming HTTP requests (method, URL, headers, body size) +- Logs details of outgoing HTTP responses (status code, headers, body size) +- Redacts sensitive headers (Authorization, Cookie) for security +- Supports both REQUEST and RESPONSE flows + +## Running the example + +```bash +# From the repository root +cd examples/logging_plugin +uv run python main.py +``` + +## Key concepts demonstrated + +- Implementing both `HandleRequest()` and `HandleResponse()` methods +- Declaring multiple flows in `GetCapabilities()` +- Structured logging for observability +- Handling sensitive data (header redaction) +- Pass-through behavior while gathering telemetry diff --git a/examples/logging_plugin/__init__.py b/examples/logging_plugin/__init__.py new file mode 100644 index 0000000..052765c --- /dev/null +++ b/examples/logging_plugin/__init__.py @@ -0,0 +1 @@ +"""Logging plugin example.""" diff --git a/examples/logging_plugin/main.py b/examples/logging_plugin/main.py new file mode 100644 index 0000000..dc7d230 --- /dev/null +++ b/examples/logging_plugin/main.py @@ -0,0 +1,93 @@ +"""Logging plugin that logs HTTP requests and responses. + +This example demonstrates implementing both request and response flows +for observability purposes. +""" + +import asyncio +import logging + +from google.protobuf.empty_pb2 import Empty +from grpc import ServicerContext + +from mcpd_plugins import BasePlugin, serve +from mcpd_plugins.v1.plugins.plugin_pb2 import ( + FLOW_REQUEST, + FLOW_RESPONSE, + Capabilities, + HTTPRequest, + HTTPResponse, + Metadata, +) + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +class LoggingPlugin(BasePlugin): + """Plugin that logs HTTP request and response details.""" + + async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadata: + """Return plugin metadata.""" + return Metadata( + name="logging-plugin", + version="1.0.0", + description="Logs HTTP request and response details for observability", + ) + + async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Capabilities: + """Declare support for both request and response flows.""" + return Capabilities(flows=[FLOW_REQUEST, FLOW_RESPONSE]) + + async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse: + """Log incoming request details.""" + logger.info("=" * 80) + logger.info("INCOMING REQUEST") + logger.info(f"Method: {request.method}") + logger.info(f"URL: {request.url}") + logger.info(f"Path: {request.path}") + logger.info(f"Remote Address: {request.remote_addr}") + + # Log headers. + logger.info("Headers:") + for key, value in request.headers.items(): + # Mask sensitive headers. + if key.lower() in ("authorization", "cookie"): + value = "***REDACTED***" + logger.info(f" {key}: {value}") + + # Log body size. + if request.body: + logger.info(f"Body size: {len(request.body)} bytes") + + logger.info("=" * 80) + + # Continue processing. + return HTTPResponse(**{"continue": True}) + + async def HandleResponse(self, request: HTTPResponse, context: ServicerContext) -> HTTPResponse: + """Log outgoing response details.""" + logger.info("=" * 80) + logger.info("OUTGOING RESPONSE") + logger.info(f"Status Code: {request.status_code}") + + # Log headers. + logger.info("Headers:") + for key, value in request.headers.items(): + logger.info(f" {key}: {value}") + + # Log body size. + if request.body: + logger.info(f"Body size: {len(request.body)} bytes") + + logger.info("=" * 80) + + # Continue processing. + return HTTPResponse(**{"continue": True}) + + +if __name__ == "__main__": + asyncio.run(serve(LoggingPlugin())) diff --git a/examples/rate_limit_plugin/README.md b/examples/rate_limit_plugin/README.md new file mode 100644 index 0000000..ec58283 --- /dev/null +++ b/examples/rate_limit_plugin/README.md @@ -0,0 +1,36 @@ +# Rate Limit Plugin Example + +A plugin that implements rate limiting using a token bucket algorithm to prevent clients from overwhelming the server. + +## What it does + +- Tracks requests per client IP address +- Limits each client to a configurable number of requests per minute (default: 60) +- Returns 429 Too Many Requests when limit is exceeded +- Adds rate limit headers to responses (`X-RateLimit-Limit`, `X-RateLimit-Remaining`) +- Includes `Retry-After` header when rate limit is exceeded + +## Running the example + +```bash +# From the repository root +cd examples/rate_limit_plugin +uv run python main.py +``` + +## Configuration + +You can customize the rate limit by modifying the `requests_per_minute` parameter when creating the plugin: + +```python +asyncio.run(serve(RateLimitPlugin(requests_per_minute=100))) +``` + +## Key concepts demonstrated + +- Stateful plugin implementation (tracking client buckets) +- Token bucket algorithm for rate limiting +- Custom response headers +- Calculating and returning `Retry-After` header +- Per-client tracking using remote IP address +- Request rejection with appropriate HTTP status codes diff --git a/examples/rate_limit_plugin/__init__.py b/examples/rate_limit_plugin/__init__.py new file mode 100644 index 0000000..e0709cc --- /dev/null +++ b/examples/rate_limit_plugin/__init__.py @@ -0,0 +1 @@ +"""Rate limiting plugin example.""" diff --git a/examples/rate_limit_plugin/main.py b/examples/rate_limit_plugin/main.py new file mode 100644 index 0000000..2cf2e63 --- /dev/null +++ b/examples/rate_limit_plugin/main.py @@ -0,0 +1,107 @@ +"""Rate limiting plugin using a simple token bucket algorithm. + +This example demonstrates stateful request processing and rate limiting. +""" + +import asyncio +import logging +import time +from collections import defaultdict + +from google.protobuf.empty_pb2 import Empty +from grpc import ServicerContext + +from mcpd_plugins import BasePlugin, serve +from mcpd_plugins.v1.plugins.plugin_pb2 import ( + FLOW_REQUEST, + Capabilities, + HTTPRequest, + HTTPResponse, + Metadata, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class RateLimitPlugin(BasePlugin): + """Plugin that implements rate limiting using token bucket algorithm.""" + + def __init__(self, requests_per_minute: int = 60): + """Initialize the rate limiter. + + Args: + requests_per_minute: Maximum requests allowed per minute per client. + """ + super().__init__() + self.requests_per_minute = requests_per_minute + self.rate_per_second = requests_per_minute / 60.0 + + # Track tokens for each client IP. + self.buckets: dict[str, float] = defaultdict(lambda: float(requests_per_minute)) + self.last_update: dict[str, float] = defaultdict(time.time) + + async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadata: + """Return plugin metadata.""" + return Metadata( + name="rate-limit-plugin", + version="1.0.0", + description=f"Rate limits requests to {self.requests_per_minute} per minute per client", + ) + + async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Capabilities: + """Declare support for request flow.""" + return Capabilities(flows=[FLOW_REQUEST]) + + async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse: + """Apply rate limiting based on client IP.""" + client_ip = request.remote_addr or "unknown" + logger.info(f"Rate limit check for {client_ip}: {request.method} {request.url}") + + # Refill tokens based on time elapsed. + now = time.time() + elapsed = now - self.last_update[client_ip] + self.buckets[client_ip] = min( + self.requests_per_minute, + self.buckets[client_ip] + elapsed * self.rate_per_second, + ) + self.last_update[client_ip] = now + + # Check if client has tokens available. + if self.buckets[client_ip] < 1.0: + logger.warning(f"Rate limit exceeded for {client_ip}") + return self._rate_limit_response(client_ip) + + # Consume one token. + self.buckets[client_ip] -= 1.0 + logger.info(f"Request allowed for {client_ip} (tokens remaining: {self.buckets[client_ip]:.2f})") + + # Add rate limit headers to response. + response = HTTPResponse(**{"continue": True}) + response.modified_request.CopyFrom(request) + response.headers["X-RateLimit-Limit"] = str(self.requests_per_minute) + response.headers["X-RateLimit-Remaining"] = str(int(self.buckets[client_ip])) + + return response + + def _rate_limit_response(self, client_ip: str) -> HTTPResponse: + """Create a 429 Too Many Requests response.""" + # Calculate retry-after in seconds. + tokens_needed = 1.0 - self.buckets[client_ip] + retry_after = int(tokens_needed / self.rate_per_second) + 1 + + response = HTTPResponse( + **{"continue": False}, + status_code=429, + body=b'{"error": "Rate limit exceeded"}', + ) + response.headers["Content-Type"] = "application/json" + response.headers["X-RateLimit-Limit"] = str(self.requests_per_minute) + response.headers["X-RateLimit-Remaining"] = "0" + response.headers["Retry-After"] = str(retry_after) + + return response + + +if __name__ == "__main__": + asyncio.run(serve(RateLimitPlugin(requests_per_minute=60))) diff --git a/examples/simple_plugin/README.md b/examples/simple_plugin/README.md new file mode 100644 index 0000000..77b82ea --- /dev/null +++ b/examples/simple_plugin/README.md @@ -0,0 +1,27 @@ +# Simple Plugin Example + +A minimal plugin that demonstrates basic functionality by adding a custom header to HTTP requests. + +## What it does + +- Implements the `HandleRequest` method to intercept incoming HTTP requests +- Adds a custom header `X-Simple-Plugin: processed` to all requests +- Passes the request through with `continue_=True` + +## Running the example + +```bash +# From the repository root +cd examples/simple_plugin +uv run python main.py +``` + +The plugin will start on port 50051 (or the port specified in `PLUGIN_PORT` environment variable). + +## Key concepts demonstrated + +- Extending `BasePlugin` class +- Implementing `GetMetadata()` to provide plugin information +- Implementing `GetCapabilities()` to declare supported flows +- Implementing `HandleRequest()` to process HTTP requests +- Using the `serve()` helper to launch the gRPC server diff --git a/examples/simple_plugin/__init__.py b/examples/simple_plugin/__init__.py new file mode 100644 index 0000000..3240900 --- /dev/null +++ b/examples/simple_plugin/__init__.py @@ -0,0 +1 @@ +"""Simple plugin example.""" diff --git a/examples/simple_plugin/main.py b/examples/simple_plugin/main.py new file mode 100644 index 0000000..04b553a --- /dev/null +++ b/examples/simple_plugin/main.py @@ -0,0 +1,66 @@ +"""Simple plugin that adds a custom header to HTTP requests. + +This example demonstrates the minimal implementation needed for a plugin. +It adds a custom header to all incoming requests. +""" + +import asyncio +import logging + +from google.protobuf.empty_pb2 import Empty +from grpc import ServicerContext + +from mcpd_plugins import BasePlugin, serve +from mcpd_plugins.v1.plugins.plugin_pb2 import ( + FLOW_REQUEST, + Capabilities, + HTTPRequest, + HTTPResponse, + Metadata, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class SimplePlugin(BasePlugin): + """A simple plugin that adds a custom header to requests.""" + + async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadata: + """Return plugin metadata.""" + return Metadata( + name="simple-plugin", + version="1.0.0", + description="Adds a custom header to HTTP requests", + ) + + async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Capabilities: + """Declare support for request flow.""" + return Capabilities(flows=[FLOW_REQUEST]) + + async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse: + """Add a custom header to the request.""" + logger.info(f"Processing request: {request.method} {request.url}") + + # Create response with Continue=True to pass the request through. + response = HTTPResponse(**{"continue": True}) + + # Copy original headers. + for key, value in request.headers.items(): + response.modified_request.headers[key] = value + + # Add custom header. + response.modified_request.headers["X-Simple-Plugin"] = "processed" + + # Copy other request fields. + response.modified_request.method = request.method + response.modified_request.url = request.url + response.modified_request.path = request.path + response.modified_request.body = request.body + + logger.info("Added X-Simple-Plugin header") + return response + + +if __name__ == "__main__": + asyncio.run(serve(SimplePlugin())) diff --git a/examples/transform_plugin/README.md b/examples/transform_plugin/README.md new file mode 100644 index 0000000..e8f9ad2 --- /dev/null +++ b/examples/transform_plugin/README.md @@ -0,0 +1,52 @@ +# Transform Plugin Example + +A plugin that transforms JSON request bodies by adding metadata fields. + +## What it does + +- Intercepts POST/PUT/PATCH requests with JSON content +- Parses the JSON body +- Adds a `_metadata` field with plugin information and client IP +- Returns the modified request with updated Content-Length header +- Returns 400 Bad Request if JSON is invalid +- Passes through non-JSON requests unchanged + +## Running the example + +```bash +# From the repository root +cd examples/transform_plugin +uv run python main.py +``` + +## Example transformation + +**Original request body:** +```json +{ + "name": "John Doe", + "email": "john@example.com" +} +``` + +**Transformed request body:** +```json +{ + "name": "John Doe", + "email": "john@example.com", + "_metadata": { + "processed_by": "transform-plugin", + "version": "1.0.0", + "client_ip": "192.168.1.100" + } +} +``` + +## Key concepts demonstrated + +- Request body modification +- JSON parsing and serialization +- Conditional processing based on HTTP method and Content-Type +- Error handling (returning 400 for invalid JSON) +- Updating Content-Length header after transformation +- Copying and modifying request fields diff --git a/examples/transform_plugin/__init__.py b/examples/transform_plugin/__init__.py new file mode 100644 index 0000000..28171c3 --- /dev/null +++ b/examples/transform_plugin/__init__.py @@ -0,0 +1 @@ +"""Content transformation plugin example.""" diff --git a/examples/transform_plugin/main.py b/examples/transform_plugin/main.py new file mode 100644 index 0000000..6bcf59a --- /dev/null +++ b/examples/transform_plugin/main.py @@ -0,0 +1,102 @@ +"""Content transformation plugin that modifies request bodies. + +This example demonstrates how to transform request content, such as +modifying JSON payloads or adding default fields. +""" + +import asyncio +import json +import logging + +from google.protobuf.empty_pb2 import Empty +from grpc import ServicerContext + +from mcpd_plugins import BasePlugin, serve +from mcpd_plugins.v1.plugins.plugin_pb2 import ( + FLOW_REQUEST, + Capabilities, + HTTPRequest, + HTTPResponse, + Metadata, +) + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class TransformPlugin(BasePlugin): + """Plugin that transforms JSON request bodies.""" + + async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadata: + """Return plugin metadata.""" + return Metadata( + name="transform-plugin", + version="1.0.0", + description="Transforms JSON request bodies by adding metadata fields", + ) + + async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Capabilities: + """Declare support for request flow.""" + return Capabilities(flows=[FLOW_REQUEST]) + + async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse: + """Transform JSON request bodies by adding metadata.""" + logger.info(f"Processing request: {request.method} {request.url}") + + # Only transform POST/PUT/PATCH requests with JSON content. + content_type = request.headers.get("Content-Type", "") + if request.method not in ("POST", "PUT", "PATCH") or "application/json" not in content_type: + logger.info("Skipping non-JSON or non-mutating request") + return HTTPResponse(**{"continue": True}) + + # Try to parse and transform the JSON body. + try: + if not request.body: + logger.info("Empty body, skipping transformation") + return HTTPResponse(**{"continue": True}) + + # Parse JSON. + data = json.loads(request.body.decode("utf-8")) + logger.info(f"Original payload: {data}") + + # Add metadata fields. + if isinstance(data, dict): + data["_metadata"] = { + "processed_by": "transform-plugin", + "version": "1.0.0", + "client_ip": request.remote_addr, + } + logger.info(f"Transformed payload: {data}") + + # Create modified request. + modified_body = json.dumps(data).encode("utf-8") + response = HTTPResponse(**{"continue": True}) + response.modified_request.CopyFrom(request) + response.modified_request.body = modified_body + + # Update Content-Length header. + response.modified_request.headers["Content-Length"] = str(len(modified_body)) + + logger.info("Request body transformed successfully") + return response + else: + logger.warning("JSON body is not a dict, skipping transformation") + return HTTPResponse(**{"continue": True}) + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse JSON body: {e}") + # Return 400 Bad Request for invalid JSON. + return HTTPResponse( + **{"continue": False}, + status_code=400, + body=b'{"error": "Invalid JSON"}', + headers={"Content-Type": "application/json"}, + ) + except Exception as e: + logger.error(f"Unexpected error during transformation: {e}") + # Allow request to continue on unexpected errors. + return HTTPResponse(**{"continue": True}) + + +if __name__ == "__main__": + asyncio.run(serve(TransformPlugin())) From a43e267e849e0168ee370dc7e20bb1ef7527eb83 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Fri, 24 Oct 2025 20:54:21 +0100 Subject: [PATCH 5/7] Update to support building --- BUILD.md | 259 ++++++++++++++++++++++++++ Makefile | 16 ++ README.md | 265 +++++++++++++++++++++++++++ examples/auth_plugin/README.md | 20 +- examples/auth_plugin/main.py | 3 +- examples/logging_plugin/README.md | 17 ++ examples/logging_plugin/main.py | 3 +- examples/rate_limit_plugin/README.md | 17 ++ examples/rate_limit_plugin/main.py | 3 +- examples/simple_plugin/README.md | 19 +- examples/simple_plugin/main.py | 3 +- examples/transform_plugin/README.md | 17 ++ examples/transform_plugin/main.py | 3 +- pyproject.toml | 5 + scripts/build_plugin.sh | 203 ++++++++++++++++++++ src/mcpd_plugins/server.py | 63 ++++++- uv.lock | 185 +++++++++++++++++++ 17 files changed, 1088 insertions(+), 13 deletions(-) create mode 100644 BUILD.md create mode 100644 README.md create mode 100755 scripts/build_plugin.sh diff --git a/BUILD.md b/BUILD.md new file mode 100644 index 0000000..5da532b --- /dev/null +++ b/BUILD.md @@ -0,0 +1,259 @@ +# Building Plugin Executables + +This guide explains how to build Python plugins into standalone single-file executables that can be used with [mcpd](https://github.com/mozilla-ai/mcpd). + +## Why Build Executables? + +`mcpd` requires plugins to be standalone executables that it can spawn as separate processes. Python plugins need to be packaged with their dependencies and the Python runtime into a single executable file. + +## Prerequisites + +Install build dependencies: + +```bash +uv sync --group build +``` + +This installs both PyInstaller (for development) and Nuitka (for production builds). + +## Quick Start + +Build any plugin executable: + +```bash +make build-plugin PLUGIN=examples/simple_plugin +``` + +The executable will be created in `dist/simple_plugin` (or `dist/simple_plugin.exe` on Windows). + +Run the built plugin: + +```bash +./dist/simple_plugin +``` + +## Build Tools + +We support two build tools with different trade-offs: + +### PyInstaller (Recommended for Development) + +**Pros:** +- Fast builds (~5-10 seconds) +- Mature and well-documented +- Good compatibility with most packages +- Single-file executables for easy distribution + +**Cons:** +- Slightly slower startup time (~100-200ms overhead) +- Runtime performance same as standard Python + +**When to use:** During development and testing when fast iteration is important. + +### Nuitka (Recommended for Production) + +**Pros:** +- 2-3x faster startup time +- Compiled to native C code +- Better performance for CPU-bound operations +- More secure (harder to decompile) + +**Cons:** +- Much longer build times (5-30 minutes) +- Larger executable sizes (~2x PyInstaller) +- Requires C compiler toolchain + +**When to use:** For production releases where performance and security matter. + +## Building with PyInstaller + +### Basic Build + +```bash +# Using Makefile +make build-plugin PLUGIN=examples/simple_plugin + +# Or directly +./scripts/build_plugin.sh examples/simple_plugin +``` + +### Advanced Options + +```bash +# Build with custom name +./scripts/build_plugin.sh examples/simple_plugin --name my-plugin + +# Build in debug mode (shows console output) +./scripts/build_plugin.sh examples/simple_plugin --debug +``` + +## Building with Nuitka + +### Basic Build + +```bash +# Using Makefile +make build-plugin-prod PLUGIN=examples/simple_plugin + +# Or directly +./scripts/build_plugin.sh examples/simple_plugin --nuitka +``` + +### Advanced Options + +```bash +# Optimize for size +./scripts/build_plugin.sh examples/simple_plugin --nuitka --optimize-size + +# Optimize for speed +./scripts/build_plugin.sh examples/simple_plugin --nuitka --optimize-speed + +# Enable all optimizations (slow build) +./scripts/build_plugin.sh examples/simple_plugin --nuitka --full-compat +``` + +## Platform-Specific Notes + +### macOS + +**Requirements:** +- Xcode Command Line Tools: `xcode-select --install` +- For Nuitka: Full Xcode (for compiling to native code) + +**Code Signing:** +If you need to distribute your plugin, you may need to sign it: + +```bash +codesign --force --sign - dist/simple_plugin +``` + +### Linux + +**Requirements:** +- GCC/G++ compiler: `sudo apt-get install build-essential` (Ubuntu/Debian) +- For Nuitka: Additional dev packages may be needed + +**Permissions:** +Make executable: + +```bash +chmod +x dist/simple_plugin +``` + +### Windows + +**Requirements:** +- For Nuitka: Visual Studio Build Tools or MinGW-w64 + +**Note:** Executables will have `.exe` extension automatically. + +## Troubleshooting + +### ImportError: No module named 'grpc' + +The gRPC library has native C extensions. PyInstaller should detect these automatically, but if you encounter issues: + +```bash +# Add hidden imports explicitly +./scripts/build_plugin.sh examples/simple_plugin --hidden-import=grpc._cython.cygrpc +``` + +### AsyncIO Runtime Errors + +If you see `RuntimeError: This event loop is already running`: + +```bash +# Rebuild with asyncio hooks +./scripts/build_plugin.sh examples/simple_plugin --collect-all=asyncio +``` + +### Missing Protobuf Files + +If protobuf definitions aren't found: + +```bash +# Ensure proto files are included as data files +./scripts/build_plugin.sh examples/simple_plugin --add-data "src/mcpd_plugins/v1/plugins:mcpd_plugins/v1/plugins" +``` + +### Large Executable Size + +To reduce size: + +1. Use `--exclude-module` to remove unused dependencies +2. Use `--strip` to remove debug symbols (Linux/macOS) +3. Use Nuitka with `--optimize-size` + +### Slow Nuitka Builds + +Nuitka builds can take 5-30 minutes. To speed up: + +1. Use `--show-progress` to see what's happening +2. Use `--jobs=N` to parallelize (N = CPU cores) +3. Cache compiled modules with `--module-cache-dir=.nuitka_cache` + +## How the Build Process Works + +### PyInstaller Process + +1. **Analysis:** PyInstaller analyzes your code to find all dependencies +2. **Collection:** Collects Python modules, native libraries, and data files +3. **Bundling:** Packages everything with a bootloader +4. **Output:** Creates executable that extracts and runs at startup + +### Nuitka Process + +1. **Transpilation:** Converts Python code to C code +2. **Compilation:** Compiles C code to native machine code +3. **Linking:** Links with Python runtime and dependencies +4. **Output:** Creates native executable + +## Testing Built Plugins + +After building, test your plugin: + +```bash +# Start the plugin server +./dist/simple_plugin + +# In another terminal, verify it's listening +lsof -i :50051 # macOS/Linux +netstat -an | grep 50051 # Windows + +# Test with grpcurl (if installed) +grpcurl -plaintext localhost:50051 list +``` + +## CI/CD Integration + +Example GitHub Actions workflow: + +```yaml +- name: Install build dependencies + run: uv sync --group build + +- name: Build plugin with PyInstaller + run: make build-plugin PLUGIN=examples/simple_plugin + +- name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: simple-plugin-${{ runner.os }} + path: dist/simple_plugin* +``` + +## Best Practices + +1. **Development:** Use PyInstaller for fast iteration +2. **Testing:** Test both source and built versions +3. **Production:** Use Nuitka for final releases +4. **Versioning:** Include version in executable name +5. **Documentation:** Update plugin README with build instructions +6. **Size:** Monitor executable size and optimize if needed +7. **Performance:** Benchmark critical paths in built executables + +## Further Reading + +- [PyInstaller Documentation](https://pyinstaller.org/en/stable/) +- [Nuitka Documentation](https://nuitka.net/doc/user-manual.html) +- [gRPC Python Performance Best Practices](https://grpc.io/docs/guides/performance/) diff --git a/Makefile b/Makefile index 521e224..53aab7b 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,22 @@ lint: ## Run pre-commit hooks on all files generate-protos: ensure-scripts-exec ## Download proto files and generate Python code ./scripts/generate_protos.sh +.PHONY: build-plugin +build-plugin: ensure-scripts-exec ## Build a plugin executable with PyInstaller (usage: make build-plugin PLUGIN=examples/simple_plugin) + @if [ -z "$(PLUGIN)" ]; then \ + echo "Error: PLUGIN variable not set. Usage: make build-plugin PLUGIN=examples/simple_plugin"; \ + exit 1; \ + fi + ./scripts/build_plugin.sh $(PLUGIN) + +.PHONY: build-plugin-prod +build-plugin-prod: ensure-scripts-exec ## Build a plugin with Nuitka for production (usage: make build-plugin-prod PLUGIN=examples/simple_plugin) + @if [ -z "$(PLUGIN)" ]; then \ + echo "Error: PLUGIN variable not set. Usage: make build-plugin-prod PLUGIN=examples/simple_plugin"; \ + exit 1; \ + fi + ./scripts/build_plugin.sh $(PLUGIN) --nuitka + .PHONY: clean clean: ## Clean generated files and caches rm -rf tmp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ef45b2 --- /dev/null +++ b/README.md @@ -0,0 +1,265 @@ +# mcpd-plugins: Python SDK for Building mcpd Plugins + +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) +[![Python Version](https://img.shields.io/badge/python-3.11%2B-blue)](https://www.python.org/downloads/) + +A Python SDK for building plugins for the [mcpd](https://github.com/mozilla-ai/mcpd) plugin system. This SDK provides a simple, async-first API for creating gRPC-based plugins that can intercept and transform HTTP requests and responses. + +## Features + +- **Simple API**: Extend `BasePlugin` and override only the methods you need +- **Async/Await**: Full async support using Python's asyncio +- **Type Hints**: Complete type annotations for better IDE support +- **gRPC-based**: Built on grpcio with protocol buffers +- **Minimal Dependencies**: Only requires `grpcio` and `protobuf` +- **Comprehensive Examples**: Five example plugins demonstrating common patterns + +## Installation + +```bash +# Using uv (recommended) +uv add mcpd-plugins + +# Using pip +pip install mcpd-plugins +``` + +## Quick Start + +Here's a minimal plugin that adds a custom header to HTTP requests: + +```python +import asyncio +import sys +from mcpd_plugins import BasePlugin, serve +from mcpd_plugins.v1.plugins.plugin_pb2 import ( + FLOW_REQUEST, + Capabilities, + HTTPRequest, + HTTPResponse, + Metadata, +) +from google.protobuf.empty_pb2 import Empty + + +class MyPlugin(BasePlugin): + async def GetMetadata(self, request: Empty, context) -> Metadata: + return Metadata( + name="my-plugin", + version="1.0.0", + description="Adds a custom header to requests" + ) + + async def GetCapabilities(self, request: Empty, context) -> Capabilities: + return Capabilities(flows=[FLOW_REQUEST]) + + async def HandleRequest(self, request: HTTPRequest, context) -> HTTPResponse: + response = HTTPResponse(**{"continue": True}) + response.modified_request.CopyFrom(request) + response.modified_request.headers["X-My-Plugin"] = "processed" + return response + + +if __name__ == "__main__": + # Pass sys.argv for mcpd compatibility (handles --address and --network flags) + asyncio.run(serve(MyPlugin(), sys.argv)) +``` + +Run your plugin: + +```bash +# For mcpd (with --address and --network arguments) +python my_plugin.py --address /tmp/my-plugin.sock --network unix + +# For standalone testing (defaults to TCP port 50051) +python my_plugin.py +``` + +When running under mcpd, the `--address` and `--network` flags are required and automatically passed by mcpd. For standalone testing without arguments, the plugin defaults to TCP on port 50051. + +## Core Concepts + +### BasePlugin + +The `BasePlugin` class provides default implementations for all plugin methods: + +- `Configure()` - Initialize plugin with configuration +- `Stop()` - Clean up resources on shutdown +- `GetMetadata()` - Return plugin name, version, and description +- `GetCapabilities()` - Declare which flows the plugin supports +- `CheckHealth()` - Health check endpoint +- `CheckReady()` - Readiness check endpoint +- `HandleRequest()` - Process incoming HTTP requests +- `HandleResponse()` - Process outgoing HTTP responses + +Override only the methods your plugin needs. + +### Flows + +Plugins can process two types of flows: + +- **FLOW_REQUEST**: Intercept and modify incoming HTTP requests +- **FLOW_RESPONSE**: Intercept and modify outgoing HTTP responses + +Declare your supported flows in `GetCapabilities()`. + +### Request/Response Handling + +When handling requests or responses, you can: + +1. **Pass through unchanged**: Return `HTTPResponse(**{"continue": True})` +2. **Modify and continue**: Set `**{"continue": True}` and modify fields +3. **Reject**: Set `**{"continue": False}` with a status code and body + +## Examples + +The SDK includes five example plugins demonstrating common patterns: + +### 1. Simple Plugin +Adds a custom header to all requests. + +```bash +cd examples/simple_plugin +uv run python main.py +``` + +### 2. Auth Plugin +Validates Bearer token authentication and rejects unauthorized requests. + +```bash +export AUTH_TOKEN="your-secret-token" +cd examples/auth_plugin +uv run python main.py +``` + +### 3. Logging Plugin +Logs HTTP request and response details for observability. + +```bash +cd examples/logging_plugin +uv run python main.py +``` + +### 4. Rate Limit Plugin +Implements token bucket rate limiting per client IP. + +```bash +cd examples/rate_limit_plugin +uv run python main.py +``` + +### 5. Transform Plugin +Transforms JSON request bodies by adding metadata fields. + +```bash +cd examples/transform_plugin +uv run python main.py +``` + +## Development + +### Setup + +```bash +# Clone the repository +git clone https://github.com/mozilla-ai/mcpd-plugins-sdk-python.git +cd mcpd-plugins-sdk-python + +# Setup development environment +make setup +``` + +### Running Tests + +```bash +# Run all tests +make test + +# Run with verbose output +uv run pytest -v + +# Run specific test file +uv run pytest tests/unit/test_base_plugin.py +``` + +### Linting + +```bash +# Run all pre-commit hooks +make lint + +# Run ruff directly +uv run ruff check src/ tests/ +uv run ruff format src/ tests/ +``` + +### Generating Protocol Buffers + +The proto files are automatically generated from the [mcpd-proto](https://github.com/mozilla-ai/mcpd-proto) repository and committed to this repo. To regenerate: + +```bash +make generate-protos +``` + +## API Reference + +### BasePlugin + +```python +class BasePlugin(PluginServicer): + async def Configure(self, request: PluginConfig, context) -> Empty + async def Stop(self, request: Empty, context) -> Empty + async def GetMetadata(self, request: Empty, context) -> Metadata + async def GetCapabilities(self, request: Empty, context) -> Capabilities + async def CheckHealth(self, request: Empty, context) -> Empty + async def CheckReady(self, request: Empty, context) -> Empty + async def HandleRequest(self, request: HTTPRequest, context) -> HTTPResponse + async def HandleResponse(self, request: HTTPResponse, context) -> HTTPResponse +``` + +### serve() + +```python +async def serve( + plugin: BasePlugin, + args: Optional[list[str]] = None, # Command-line arguments (typically sys.argv) + max_workers: int = 10, + grace_period: float = 5.0, +) -> None +``` + +**Parameters:** +- `plugin`: The plugin instance to serve +- `args`: Command-line arguments. When provided (e.g., `sys.argv`), enables mcpd compatibility by parsing `--address` and `--network` flags. When `None`, runs in standalone mode on TCP port 50051. +- `max_workers`: Maximum number of concurrent gRPC workers +- `grace_period`: Seconds to wait during graceful shutdown + +**Command-line flags** (when `args` is provided): +- `--address`: gRPC address (socket path for unix, host:port for tcp) - **required** +- `--network`: Network type (`unix` or `tcp`) - defaults to `unix` + +### Exceptions + +- `PluginError` - Base exception for all plugin errors +- `ConfigurationError` - Configuration-related errors +- `ServerError` - Server startup/shutdown errors + +## Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +## Security + +For security issues, please see [SECURITY.md](SECURITY.md). + +## License + +This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. + +## Related Projects + +- [mcpd](https://github.com/mozilla-ai/mcpd) - The mcpd daemon +- [mcpd-proto](https://github.com/mozilla-ai/mcpd-proto) - Protocol buffer definitions +- [mcpd-plugins-sdk-go](https://github.com/mozilla-ai/mcpd-plugins-sdk-go) - Go SDK for plugins +- [mcpd-plugins-sdk-dotnet](https://github.com/mozilla-ai/mcpd-plugins-sdk-dotnet) - .NET SDK for plugins +- [mcpd-sdk-python](https://github.com/mozilla-ai/mcpd-sdk-python) - Python SDK for mcpd clients diff --git a/examples/auth_plugin/README.md b/examples/auth_plugin/README.md index fac1d85..ccf9f9f 100644 --- a/examples/auth_plugin/README.md +++ b/examples/auth_plugin/README.md @@ -28,6 +28,24 @@ The plugin uses the `AUTH_TOKEN` environment variable to set the expected token - Request validation and rejection - Environment variable configuration -- Returning error responses with `continue_=False` +- Returning error responses with `**{"continue": False}` - Setting HTTP status codes and response bodies - Using class initialization for plugin state + +## Building + +See [BUILD.md](../../BUILD.md) for complete instructions on building standalone executables. + +Quick build: + +```bash +# Development build with PyInstaller (fast) +make build-plugin PLUGIN=examples/auth_plugin + +# Production build with Nuitka (optimized) +make build-plugin-prod PLUGIN=examples/auth_plugin + +# Run the executable with custom token +export AUTH_TOKEN="my-secret-token" +./dist/auth_plugin +``` diff --git a/examples/auth_plugin/main.py b/examples/auth_plugin/main.py index 48d7ef1..302ad4d 100644 --- a/examples/auth_plugin/main.py +++ b/examples/auth_plugin/main.py @@ -6,6 +6,7 @@ import asyncio import logging import os +import sys from google.protobuf.empty_pb2 import Empty from grpc import ServicerContext @@ -77,4 +78,4 @@ def _unauthorized_response(self, message: str) -> HTTPResponse: if __name__ == "__main__": - asyncio.run(serve(AuthPlugin())) + asyncio.run(serve(AuthPlugin(), sys.argv)) diff --git a/examples/logging_plugin/README.md b/examples/logging_plugin/README.md index c44bfe6..1092826 100644 --- a/examples/logging_plugin/README.md +++ b/examples/logging_plugin/README.md @@ -24,3 +24,20 @@ uv run python main.py - Structured logging for observability - Handling sensitive data (header redaction) - Pass-through behavior while gathering telemetry + +## Building + +See [BUILD.md](../../BUILD.md) for complete instructions on building standalone executables. + +Quick build: + +```bash +# Development build with PyInstaller (fast) +make build-plugin PLUGIN=examples/logging_plugin + +# Production build with Nuitka (optimized) +make build-plugin-prod PLUGIN=examples/logging_plugin + +# Run the executable +./dist/logging_plugin +``` diff --git a/examples/logging_plugin/main.py b/examples/logging_plugin/main.py index dc7d230..1d05951 100644 --- a/examples/logging_plugin/main.py +++ b/examples/logging_plugin/main.py @@ -6,6 +6,7 @@ import asyncio import logging +import sys from google.protobuf.empty_pb2 import Empty from grpc import ServicerContext @@ -90,4 +91,4 @@ async def HandleResponse(self, request: HTTPResponse, context: ServicerContext) if __name__ == "__main__": - asyncio.run(serve(LoggingPlugin())) + asyncio.run(serve(LoggingPlugin(), sys.argv)) diff --git a/examples/rate_limit_plugin/README.md b/examples/rate_limit_plugin/README.md index ec58283..63bdf81 100644 --- a/examples/rate_limit_plugin/README.md +++ b/examples/rate_limit_plugin/README.md @@ -34,3 +34,20 @@ asyncio.run(serve(RateLimitPlugin(requests_per_minute=100))) - Calculating and returning `Retry-After` header - Per-client tracking using remote IP address - Request rejection with appropriate HTTP status codes + +## Building + +See [BUILD.md](../../BUILD.md) for complete instructions on building standalone executables. + +Quick build: + +```bash +# Development build with PyInstaller (fast) +make build-plugin PLUGIN=examples/rate_limit_plugin + +# Production build with Nuitka (optimized) +make build-plugin-prod PLUGIN=examples/rate_limit_plugin + +# Run the executable +./dist/rate_limit_plugin +``` diff --git a/examples/rate_limit_plugin/main.py b/examples/rate_limit_plugin/main.py index 2cf2e63..5082f86 100644 --- a/examples/rate_limit_plugin/main.py +++ b/examples/rate_limit_plugin/main.py @@ -5,6 +5,7 @@ import asyncio import logging +import sys import time from collections import defaultdict @@ -104,4 +105,4 @@ def _rate_limit_response(self, client_ip: str) -> HTTPResponse: if __name__ == "__main__": - asyncio.run(serve(RateLimitPlugin(requests_per_minute=60))) + asyncio.run(serve(RateLimitPlugin(requests_per_minute=60), sys.argv)) diff --git a/examples/simple_plugin/README.md b/examples/simple_plugin/README.md index 77b82ea..469856d 100644 --- a/examples/simple_plugin/README.md +++ b/examples/simple_plugin/README.md @@ -6,7 +6,7 @@ A minimal plugin that demonstrates basic functionality by adding a custom header - Implements the `HandleRequest` method to intercept incoming HTTP requests - Adds a custom header `X-Simple-Plugin: processed` to all requests -- Passes the request through with `continue_=True` +- Passes the request through with `**{"continue": True}` ## Running the example @@ -25,3 +25,20 @@ The plugin will start on port 50051 (or the port specified in `PLUGIN_PORT` envi - Implementing `GetCapabilities()` to declare supported flows - Implementing `HandleRequest()` to process HTTP requests - Using the `serve()` helper to launch the gRPC server + +## Building + +See [BUILD.md](../../BUILD.md) for complete instructions on building standalone executables. + +Quick build: + +```bash +# Development build with PyInstaller (fast) +make build-plugin PLUGIN=examples/simple_plugin + +# Production build with Nuitka (optimized) +make build-plugin-prod PLUGIN=examples/simple_plugin + +# Run the executable +./dist/simple_plugin +``` diff --git a/examples/simple_plugin/main.py b/examples/simple_plugin/main.py index 04b553a..fc4d76e 100644 --- a/examples/simple_plugin/main.py +++ b/examples/simple_plugin/main.py @@ -6,6 +6,7 @@ import asyncio import logging +import sys from google.protobuf.empty_pb2 import Empty from grpc import ServicerContext @@ -63,4 +64,4 @@ async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> if __name__ == "__main__": - asyncio.run(serve(SimplePlugin())) + asyncio.run(serve(SimplePlugin(), sys.argv)) diff --git a/examples/transform_plugin/README.md b/examples/transform_plugin/README.md index e8f9ad2..f39a216 100644 --- a/examples/transform_plugin/README.md +++ b/examples/transform_plugin/README.md @@ -50,3 +50,20 @@ uv run python main.py - Error handling (returning 400 for invalid JSON) - Updating Content-Length header after transformation - Copying and modifying request fields + +## Building + +See [BUILD.md](../../BUILD.md) for complete instructions on building standalone executables. + +Quick build: + +```bash +# Development build with PyInstaller (fast) +make build-plugin PLUGIN=examples/transform_plugin + +# Production build with Nuitka (optimized) +make build-plugin-prod PLUGIN=examples/transform_plugin + +# Run the executable +./dist/transform_plugin +``` diff --git a/examples/transform_plugin/main.py b/examples/transform_plugin/main.py index 6bcf59a..a86a5a8 100644 --- a/examples/transform_plugin/main.py +++ b/examples/transform_plugin/main.py @@ -7,6 +7,7 @@ import asyncio import json import logging +import sys from google.protobuf.empty_pb2 import Empty from grpc import ServicerContext @@ -99,4 +100,4 @@ async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> if __name__ == "__main__": - asyncio.run(serve(TransformPlugin())) + asyncio.run(serve(TransformPlugin(), sys.argv)) diff --git a/pyproject.toml b/pyproject.toml index 050336a..cac2885 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,10 +25,15 @@ tests = [ "setuptools>=80.9.0", "setuptools-scm>=8.3.1", ] +build = [ + "pyinstaller>=6.11.0", + "nuitka>=2.5.7", +] all = [ { include-group = "dev" }, { include-group = "lint" }, { include-group = "tests" }, + { include-group = "build" }, ] [build-system] diff --git a/scripts/build_plugin.sh b/scripts/build_plugin.sh new file mode 100755 index 0000000..17d90ea --- /dev/null +++ b/scripts/build_plugin.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +# Script to build Python plugins into standalone executables +# Supports both PyInstaller (fast, for development) and Nuitka (optimized, for production) + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Configuration +USE_NUITKA=false +DEBUG=false +ONEFILE=true # Always build as single-file executable. +PLUGIN_DIR="" +PLUGIN_NAME="" +EXTRA_ARGS=() + +# Print usage +usage() { + cat << EOF +Usage: $(basename "$0") PLUGIN_DIR [OPTIONS] + +Build a Python plugin into a standalone single-file executable. + +Arguments: + PLUGIN_DIR Path to plugin directory (e.g., examples/simple_plugin) + +Options: + --pyinstaller Use PyInstaller (default, fast builds) + --nuitka Use Nuitka (slower builds, better performance) + --name NAME Custom name for executable (default: directory name) + --debug Enable debug mode (console output) + --optimize-size Optimize for smaller size (Nuitka only) + --optimize-speed Optimize for speed (Nuitka only) + --help Show this help message + +Examples: + # Quick development build + $(basename "$0") examples/simple_plugin + + # Production build with Nuitka + $(basename "$0") examples/simple_plugin --nuitka + + # Custom name + $(basename "$0") examples/auth_plugin --name auth-plugin-v1 + +EOF + exit 1 +} + +# Parse arguments +if [[ $# -lt 1 ]]; then + usage +fi + +PLUGIN_DIR="$1" +shift + +while [[ $# -gt 0 ]]; do + case $1 in + --pyinstaller) + USE_NUITKA=false + shift + ;; + --nuitka) + USE_NUITKA=true + shift + ;; + --name) + PLUGIN_NAME="$2" + shift 2 + ;; + --debug) + DEBUG=true + shift + ;; + --optimize-size|--optimize-speed) + EXTRA_ARGS+=("$1") + shift + ;; + --help) + usage + ;; + *) + echo -e "${RED}Error: Unknown option $1${NC}" + usage + ;; + esac +done + +# Validate plugin directory +if [[ ! -d "$PLUGIN_DIR" ]]; then + echo -e "${RED}Error: Plugin directory not found: $PLUGIN_DIR${NC}" + exit 1 +fi + +if [[ ! -f "$PLUGIN_DIR/main.py" ]]; then + echo -e "${RED}Error: main.py not found in $PLUGIN_DIR${NC}" + exit 1 +fi + +# Determine plugin name +if [[ -z "$PLUGIN_NAME" ]]; then + PLUGIN_NAME=$(basename "$PLUGIN_DIR") +fi + +echo -e "${GREEN}πŸ”¨ Building plugin: $PLUGIN_NAME${NC}" +echo -e " Plugin directory: $PLUGIN_DIR" +echo -e " Build tool: $([ "$USE_NUITKA" = true ] && echo "Nuitka" || echo "PyInstaller")" +echo "" + +# Create dist directory +mkdir -p dist + +if [[ "$USE_NUITKA" = true ]]; then + # Build with Nuitka + echo -e "${YELLOW}Building with Nuitka (this may take 5-30 minutes)...${NC}" + + NUITKA_ARGS=( + --standalone + --onefile + --follow-imports + --python-flag=no_site + --output-dir=dist + --output-filename="$PLUGIN_NAME" + ) + + # Add debug flag if requested + if [[ "$DEBUG" = true ]]; then + NUITKA_ARGS+=(--debug) + fi + + # Add optimization flags + for arg in "${EXTRA_ARGS[@]}"; do + case $arg in + --optimize-size) + NUITKA_ARGS+=(--lto=yes) + ;; + --optimize-speed) + NUITKA_ARGS+=(--lto=no) + NUITKA_ARGS+=(--remove-output) + ;; + esac + done + + # Include grpcio and protobuf data files + NUITKA_ARGS+=( + --include-package=grpc + --include-package=google.protobuf + --include-package-data=mcpd_plugins + ) + + uv run python -m nuitka "${NUITKA_ARGS[@]}" "$PLUGIN_DIR/main.py" + +else + # Build with PyInstaller + echo -e "${YELLOW}Building with PyInstaller...${NC}" + + PYINSTALLER_ARGS=( + --name="$PLUGIN_NAME" + --distpath=dist + --workpath=build + --specpath=build + ) + + # Add debug/console flag + if [[ "$DEBUG" = true ]]; then + PYINSTALLER_ARGS+=(--console) + else + PYINSTALLER_ARGS+=(--console) # Plugins need console for gRPC + fi + + # Onefile vs onedir + if [[ "$ONEFILE" = true ]]; then + PYINSTALLER_ARGS+=(--onefile) + fi + + # Hidden imports for grpc and asyncio + PYINSTALLER_ARGS+=( + --hidden-import=grpc + --hidden-import=grpc._cython.cygrpc + --hidden-import=google.protobuf + --hidden-import=asyncio + --collect-all=grpc + --copy-metadata=grpcio + --copy-metadata=protobuf + ) + + uv run pyinstaller "${PYINSTALLER_ARGS[@]}" "$PLUGIN_DIR/main.py" +fi + +echo "" +echo -e "${GREEN}βœ… Build complete!${NC}" +echo -e " Executable: dist/$PLUGIN_NAME" +echo "" +echo -e "To run your plugin:" +echo -e " ${YELLOW}./dist/$PLUGIN_NAME${NC}" +echo "" +echo -e "To test with mcpd, see: https://github.com/mozilla-ai/mcpd" diff --git a/src/mcpd_plugins/server.py b/src/mcpd_plugins/server.py index aad07b5..c5df418 100644 --- a/src/mcpd_plugins/server.py +++ b/src/mcpd_plugins/server.py @@ -1,5 +1,6 @@ """Server helper functions for launching gRPC plugin servers.""" +import argparse import asyncio import logging import os @@ -16,7 +17,7 @@ async def serve( plugin: BasePlugin, - port: int | None = None, + args: list[str] | None = None, max_workers: int = 10, grace_period: float = 5.0, ) -> None: @@ -25,9 +26,14 @@ async def serve( This is a convenience function that handles server setup, signal handling, and graceful shutdown. It runs until interrupted by SIGTERM or SIGINT. + When running under mcpd, the --address and --network flags are required and + passed by mcpd. For standalone testing, omit these flags and the server will + default to TCP on port 50051. + Args: plugin: The plugin instance to serve (should extend BasePlugin). - port: Port to listen on. If None, uses PLUGIN_PORT env var or defaults to 50051. + args: Command-line arguments (typically sys.argv). If None, runs in standalone + mode on TCP port 50051. When provided by mcpd, expects --address and --network. max_workers: Maximum number of concurrent workers (default: 10). grace_period: Seconds to wait for graceful shutdown (default: 5.0). @@ -37,6 +43,7 @@ async def serve( Example: ```python import asyncio + import sys from mcpd_plugins import BasePlugin, serve class MyPlugin(BasePlugin): @@ -44,18 +51,62 @@ async def GetMetadata(self, request, context): return Metadata(name="my-plugin", version="1.0.0") if __name__ == "__main__": - asyncio.run(serve(MyPlugin())) + # For mcpd: pass sys.argv to handle --address and --network + asyncio.run(serve(MyPlugin(), sys.argv)) + + # For standalone testing: omit args to use TCP :50051 + # asyncio.run(serve(MyPlugin())) ``` """ - if port is None: + # Parse command-line arguments if provided. + if args is not None: + parser = argparse.ArgumentParser(description="Plugin server for mcpd") + parser.add_argument( + "--address", + type=str, + required=False, + help="gRPC address (socket path for unix, host:port for tcp)", + ) + parser.add_argument( + "--network", + type=str, + default="unix", + choices=["unix", "tcp"], + help="Network type (unix or tcp)", + ) + parsed_args = parser.parse_args(args[1:]) # Skip program name. + + # Require --address when args are provided (mcpd mode). + if parsed_args.address is None: + raise ServerError( + "--address is required when running with command-line arguments. " + "For standalone testing, call serve() without args." + ) + + address = parsed_args.address + network = parsed_args.network + else: + # Standalone mode: use TCP with default port. + network = "tcp" port = int(os.getenv("PLUGIN_PORT", "50051")) + address = f"[::]:{port}" + + # Format the listen address based on network type. + listen_addr = ( + f"unix:///{address}" # Three slashes for Unix sockets. + if network == "unix" + else address + if ":" in address + else f"[::]:{address}" + ) server = aio.server() add_PluginServicer_to_server(plugin, server) - listen_addr = f"[::]:{port}" try: - server.add_insecure_port(listen_addr) + result = server.add_insecure_port(listen_addr) + if result == 0: + raise ServerError(f"Failed to bind to {listen_addr}") except Exception as e: raise ServerError(f"Failed to bind to {listen_addr}: {e}") from e diff --git a/uv.lock b/uv.lock index 9afc576..a32fdd9 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "altgraph" +version = "0.17.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/a8/7145824cf0b9e3c28046520480f207df47e927df83aa9555fb47f8505922/altgraph-0.17.4.tar.gz", hash = "sha256:1b5afbb98f6c4dcadb2e2ae6ab9fa994bbb8c1d75f4fa96d340f9437ae454406", size = 48418, upload-time = "2023-09-25T09:04:52.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/3f/3bc3f1d83f6e4a7fcb834d3720544ca597590425be5ba9db032b2bf322a2/altgraph-0.17.4-py2.py3-none-any.whl", hash = "sha256:642743b4750de17e655e6711601b077bc6598dbfa3ba5fa2b2a35ce12b508dff", size = 21212, upload-time = "2023-09-25T09:04:50.691Z" }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -185,6 +194,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "macholib" +version = "1.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/95/ee/af1a3842bdd5902ce133bd246eb7ffd4375c38642aeb5dc0ae3a0329dfa2/macholib-1.16.3.tar.gz", hash = "sha256:07ae9e15e8e4cd9a788013d81f5908b3609aa76f9b1421bae9c4d7606ec86a30", size = 59309, upload-time = "2023-09-25T09:10:16.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/5d/c059c180c84f7962db0aeae7c3b9303ed1d73d76f2bfbc32bc231c8be314/macholib-1.16.3-py2.py3-none-any.whl", hash = "sha256:0e315d7583d38b8c77e815b1ecbdbf504a8258d8b3e17b61165c6feb60d18f2c", size = 38094, upload-time = "2023-09-25T09:10:14.188Z" }, +] + [[package]] name = "mcpd-plugins" source = { editable = "." } @@ -197,13 +218,19 @@ dependencies = [ all = [ { name = "debugpy" }, { name = "grpcio-tools" }, + { name = "nuitka" }, { name = "pre-commit" }, + { name = "pyinstaller" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, { name = "setuptools" }, { name = "setuptools-scm" }, ] +build = [ + { name = "nuitka" }, + { name = "pyinstaller" }, +] dev = [ { name = "debugpy" }, { name = "grpcio-tools" }, @@ -229,13 +256,19 @@ requires-dist = [ all = [ { name = "debugpy", specifier = ">=1.8.14" }, { name = "grpcio-tools", specifier = ">=1.68.0" }, + { name = "nuitka", specifier = ">=2.5.7" }, { name = "pre-commit", specifier = ">=4.2.0" }, + { name = "pyinstaller", specifier = ">=6.11.0" }, { name = "pytest", specifier = ">=8.3.5" }, { name = "pytest-asyncio", specifier = ">=0.25.2" }, { name = "ruff", specifier = ">=0.11.13" }, { name = "setuptools", specifier = ">=80.9.0" }, { name = "setuptools-scm", specifier = ">=8.3.1" }, ] +build = [ + { name = "nuitka", specifier = ">=2.5.7" }, + { name = "pyinstaller", specifier = ">=6.11.0" }, +] dev = [ { name = "debugpy", specifier = ">=1.8.14" }, { name = "grpcio-tools", specifier = ">=1.68.0" }, @@ -260,6 +293,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, ] +[[package]] +name = "nuitka" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ordered-set" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/87/f20ffda1b6dc04361fa95390f4d47d974ee194e6e1e7688f13d324f3d89b/Nuitka-2.8.4.tar.gz", hash = "sha256:06b020ef33be97194f888dcfcd4c69c8452ceb61b31c7622e610d5156eb7923d", size = 3885111, upload-time = "2025-10-21T10:28:45.499Z" } + +[[package]] +name = "ordered-set" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -269,6 +321,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pefile" +version = "2023.2.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/c5/3b3c62223f72e2360737fd2a57c30e5b2adecd85e70276879609a7403334/pefile-2023.2.7.tar.gz", hash = "sha256:82e6114004b3d6911c77c3953e3838654b04511b8b66e8583db70c65998017dc", size = 74854, upload-time = "2023-02-07T12:23:55.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/26/d0ad8b448476d0a1e8d3ea5622dc77b916db84c6aa3cb1e1c0965af948fc/pefile-2023.2.7-py3-none-any.whl", hash = "sha256:da185cd2af68c08a6cd4481f7325ed600a88f6a813bad9dea07ab3ef73d8d8d6", size = 71791, upload-time = "2023-02-07T12:28:36.678Z" }, +] + [[package]] name = "platformdirs" version = "4.5.0" @@ -327,6 +388,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyinstaller" +version = "6.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altgraph" }, + { name = "macholib", marker = "sys_platform == 'darwin'" }, + { name = "packaging" }, + { name = "pefile", marker = "sys_platform == 'win32'" }, + { name = "pyinstaller-hooks-contrib" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/94/1f62e95e4a28b64cfbb5b922ef3046f968b47170d37a1e1a029f56ac9cb4/pyinstaller-6.16.0.tar.gz", hash = "sha256:53559fe1e041a234f2b4dcc3288ea8bdd57f7cad8a6644e422c27bb407f3edef", size = 4008473, upload-time = "2025-09-13T20:07:01.733Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/0a/c42ce6e5d3de287f2e9432a074fb209f1fb72a86a72f3903849fdb5e4829/pyinstaller-6.16.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:7fd1c785219a87ca747c21fa92f561b0d2926a7edc06d0a0fe37f3736e00bd7a", size = 1027899, upload-time = "2025-09-13T20:05:59.2Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d0/f18fedde32835d5a758f464c75924e2154065625f09d5456c3c303527654/pyinstaller-6.16.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:b756ddb9007b8141c5476b553351f9d97559b8af5d07f9460869bfae02be26b0", size = 727990, upload-time = "2025-09-13T20:06:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/7a/db/c8bb47514ce857b24bf9294cf1ff74844b6a489fa0ab4ef6f923288c4e38/pyinstaller-6.16.0-py3-none-manylinux2014_i686.whl", hash = "sha256:0a48f55b85ff60f83169e10050f2759019cf1d06773ad1c4da3a411cd8751058", size = 739238, upload-time = "2025-09-13T20:06:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/c6/3e/451dc784a8fcca0fe9f9b6b802d58555364a95b60f253613a2c83fc6b023/pyinstaller-6.16.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:73ba72e04fcece92e32518bbb1e1fb5ac2892677943dfdff38e01a06e8742851", size = 737142, upload-time = "2025-09-13T20:06:11.732Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/2f457479ef8fa2821cdb448acee2421dfb19fbe908bf5499d1930c164084/pyinstaller-6.16.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b1752488248f7899281b17ca3238eefb5410521291371a686a4f5830f29f52b3", size = 734133, upload-time = "2025-09-13T20:06:15.477Z" }, + { url = "https://files.pythonhosted.org/packages/63/c4/0f7daac4d062a4d1ac2571d8a8b9b5d6812094fcd914d139af591ca5e1ba/pyinstaller-6.16.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:ba618a61627ee674d6d68e5de084ba17c707b59a4f2a856084b3999bdffbd3f0", size = 733817, upload-time = "2025-09-13T20:06:19.683Z" }, + { url = "https://files.pythonhosted.org/packages/11/e4/b6127265b42bef883e8873d850becadf748bc5652e5a7029b059328f3c31/pyinstaller-6.16.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:c8b7ef536711617e12fef4673806198872033fa06fa92326ad7fd1d84a9fa454", size = 732912, upload-time = "2025-09-13T20:06:23.46Z" }, + { url = "https://files.pythonhosted.org/packages/2b/00/c6663107bdf814b2916e71563beabd09f693c47712213bc228994cb2cc65/pyinstaller-6.16.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d1ebf84d02c51fed19b82a8abb4df536923abd55bb684d694e1356e4ae2a0ce5", size = 732773, upload-time = "2025-09-13T20:06:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/a3/14/cabe9bc5f60b95d2e70e7d045ab94b0015ff8f6c8b16e2142d3597e30749/pyinstaller-6.16.0-py3-none-win32.whl", hash = "sha256:6d5f8617f3650ff9ef893e2ab4ddbf3c0d23d0c602ef74b5df8fbef4607840c8", size = 1313878, upload-time = "2025-09-13T20:06:33.234Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/2005efbc297e7813c1d6f18484aa94a1a81ce87b6a5b497c563681f4c4ea/pyinstaller-6.16.0-py3-none-win_amd64.whl", hash = "sha256:bc10eb1a787f99fea613509f55b902fbd2d8b73ff5f51ff245ea29a481d97d41", size = 1374706, upload-time = "2025-09-13T20:06:39.95Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f4/4dfcf69b86d60fcaae05a42bbff1616d48a91e71726e5ed795d773dae9b3/pyinstaller-6.16.0-py3-none-win_arm64.whl", hash = "sha256:d0af8a401de792c233c32c44b16d065ca9ab8262ee0c906835c12bdebc992a64", size = 1315923, upload-time = "2025-09-13T20:06:45.846Z" }, +] + +[[package]] +name = "pyinstaller-hooks-contrib" +version = "2025.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/83/be0f57c0b77b66c33c2283ebd4ea341022b5a743e97c5fb3bebab82b38b9/pyinstaller_hooks_contrib-2025.9.tar.gz", hash = "sha256:56e972bdaad4e9af767ed47d132362d162112260cbe488c9da7fee01f228a5a6", size = 165189, upload-time = "2025-09-24T11:21:35.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/26/23b4cfc77d7f808c69f59070e1e8293a579ec281a547c61562357160b346/pyinstaller_hooks_contrib-2025.9-py3-none-any.whl", hash = "sha256:ccbfaa49399ef6b18486a165810155e5a8d4c59b41f20dc5da81af7482aaf038", size = 444283, upload-time = "2025-09-24T11:21:33.67Z" }, +] + [[package]] name = "pytest" version = "8.4.2" @@ -356,6 +458,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -481,3 +592,77 @@ sdist = { url = "https://files.pythonhosted.org/packages/a4/d5/b0ccd381d55c8f45d wheels = [ { url = "https://files.pythonhosted.org/packages/27/73/d9a94da0e9d470a543c1b9d3ccbceb0f59455983088e727b8a1824ed90fb/virtualenv-20.35.3-py3-none-any.whl", hash = "sha256:63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a", size = 5981061, upload-time = "2025-10-10T21:23:30.433Z" }, ] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] From ba1409cdd710b1e50eb17960ce349cfbbc3450a7 Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Tue, 28 Oct 2025 12:58:18 +0000 Subject: [PATCH 6/7] PR feedback updates --- .gitignore | 5 +- Makefile | 27 +- README.md | 11 +- examples/auth_plugin/__init__.py | 4 + examples/auth_plugin/main.py | 20 +- examples/logging_plugin/__init__.py | 4 + examples/logging_plugin/main.py | 30 +- examples/rate_limit_plugin/__init__.py | 4 + examples/rate_limit_plugin/main.py | 60 ++-- examples/simple_plugin/__init__.py | 4 + examples/simple_plugin/main.py | 20 +- examples/transform_plugin/__init__.py | 4 + examples/transform_plugin/main.py | 27 +- pyproject.toml | 31 +- ruff.toml | 10 +- scripts/build_plugin.sh | 18 +- scripts/setup_uv.sh | 3 +- src/mcpd_plugins/__init__.py | 16 +- src/mcpd_plugins/base_plugin.py | 19 +- src/mcpd_plugins/server.py | 73 ++++- tests/conftest.py | 47 ++- tests/integration/test_examples.py | 24 +- tests/unit/test_server.py | 430 +++++++++++++++++++++++-- uv.lock | 40 ++- 24 files changed, 713 insertions(+), 218 deletions(-) diff --git a/.gitignore b/.gitignore index 483b058..3d63813 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ MANIFEST venv/ ENV/ env/ -.venv +.venv/ # IDE .vscode/ @@ -48,9 +48,6 @@ dmypy.json # Ruff .ruff_cache/ -# Distribution / packaging -*.egg-info/ - # Proto source files (downloaded during build, not committed) tmp/ diff --git a/Makefile b/Makefile index 53aab7b..9440272 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,12 @@ -.PHONY: help +.PHONY: all help +all: help ## Default target help: ## Show this help message @echo "Available targets:" @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' .PHONY: ensure-scripts-exec ensure-scripts-exec: ## Make scripts executable - chmod +x scripts/* + @if [ -d scripts ]; then chmod +x scripts/*.sh 2>/dev/null || true; fi .PHONY: setup setup: ensure-scripts-exec ## Setup development environment (installs uv and syncs dependencies) @@ -39,12 +40,20 @@ build-plugin-prod: ensure-scripts-exec ## Build a plugin with Nuitka for product fi ./scripts/build_plugin.sh $(PLUGIN) --nuitka -.PHONY: clean -clean: ## Clean generated files and caches - rm -rf tmp/ - find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true - find . -type f -name "*.pyc" -delete +.PHONY: clean clean-build clean-caches clean-pyc +clean: clean-build clean-caches clean-pyc ## Clean generated files and caches + +.PHONY: clean-build +clean-build: ## Clean build artifacts + rm -rf build/ dist/ tmp/ + find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true + +.PHONY: clean-caches +clean-caches: ## Clean cache directories find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true - find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true - rm -rf build/ dist/ + find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + +.PHONY: clean-pyc +clean-pyc: ## Clean Python bytecode files + find . -type f -name "*.pyc" -delete diff --git a/README.md b/README.md index 3ef45b2..b4e0cbf 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,7 @@ When handling requests or responses, you can: The SDK includes five example plugins demonstrating common patterns: ### 1. Simple Plugin + Adds a custom header to all requests. ```bash @@ -124,6 +125,7 @@ uv run python main.py ``` ### 2. Auth Plugin + Validates Bearer token authentication and rejects unauthorized requests. ```bash @@ -133,6 +135,7 @@ uv run python main.py ``` ### 3. Logging Plugin + Logs HTTP request and response details for observability. ```bash @@ -141,6 +144,7 @@ uv run python main.py ``` ### 4. Rate Limit Plugin + Implements token bucket rate limiting per client IP. ```bash @@ -149,6 +153,7 @@ uv run python main.py ``` ### 5. Transform Plugin + Transforms JSON request bodies by adding metadata fields. ```bash @@ -214,16 +219,15 @@ class BasePlugin(PluginServicer): async def CheckHealth(self, request: Empty, context) -> Empty async def CheckReady(self, request: Empty, context) -> Empty async def HandleRequest(self, request: HTTPRequest, context) -> HTTPResponse - async def HandleResponse(self, request: HTTPResponse, context) -> HTTPResponse + async def HandleResponse(self, response: HTTPResponse, context) -> HTTPResponse ``` -### serve() +### `serve()` ```python async def serve( plugin: BasePlugin, args: Optional[list[str]] = None, # Command-line arguments (typically sys.argv) - max_workers: int = 10, grace_period: float = 5.0, ) -> None ``` @@ -231,7 +235,6 @@ async def serve( **Parameters:** - `plugin`: The plugin instance to serve - `args`: Command-line arguments. When provided (e.g., `sys.argv`), enables mcpd compatibility by parsing `--address` and `--network` flags. When `None`, runs in standalone mode on TCP port 50051. -- `max_workers`: Maximum number of concurrent gRPC workers - `grace_period`: Seconds to wait during graceful shutdown **Command-line flags** (when `args` is provided): diff --git a/examples/auth_plugin/__init__.py b/examples/auth_plugin/__init__.py index 9b5c38f..a0ede7c 100644 --- a/examples/auth_plugin/__init__.py +++ b/examples/auth_plugin/__init__.py @@ -1 +1,5 @@ """Authentication plugin example.""" + +from .main import AuthPlugin + +__all__ = ["AuthPlugin"] diff --git a/examples/auth_plugin/main.py b/examples/auth_plugin/main.py index 302ad4d..e0fd9a8 100644 --- a/examples/auth_plugin/main.py +++ b/examples/auth_plugin/main.py @@ -4,12 +4,13 @@ """ import asyncio +import json import logging import os import sys from google.protobuf.empty_pb2 import Empty -from grpc import ServicerContext +from grpc.aio import ServicerContext from mcpd_plugins import BasePlugin, serve from mcpd_plugins.v1.plugins.plugin_pb2 import ( @@ -23,11 +24,14 @@ logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +# Authentication scheme. +BEARER_SCHEME = "Bearer" + class AuthPlugin(BasePlugin): """Plugin that validates Bearer token authentication.""" - def __init__(self): + def __init__(self) -> None: """Initialize the auth plugin with expected token.""" super().__init__() self.expected_token = os.getenv("AUTH_TOKEN", "secret-token-123") @@ -46,17 +50,17 @@ async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Cap async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse: """Validate Bearer token in Authorization header.""" - logger.info(f"Authenticating request: {request.method} {request.url}") + logger.info("Authenticating request: %s %s", request.method, request.url) # Check for Authorization header. auth_header = request.headers.get("Authorization", "") - if not auth_header.startswith("Bearer "): + if not auth_header.startswith(f"{BEARER_SCHEME} "): logger.warning("Missing or invalid Authorization header") return self._unauthorized_response("Missing or invalid Authorization header") # Extract and validate token. - token = auth_header[7:] # Remove "Bearer " prefix + token = auth_header.removeprefix(f"{BEARER_SCHEME} ") if token != self.expected_token: logger.warning("Invalid token") return self._unauthorized_response("Invalid token") @@ -69,11 +73,13 @@ def _unauthorized_response(self, message: str) -> HTTPResponse: """Create a 401 Unauthorized response.""" response = HTTPResponse( status_code=401, - body=f'{{"error": "{message}"}}'.encode(), + body=json.dumps({"error": message}).encode(), **{"continue": False}, ) response.headers["Content-Type"] = "application/json" - response.headers["WWW-Authenticate"] = "Bearer" + response.headers["WWW-Authenticate"] = ( + f'{BEARER_SCHEME} realm="mcpd", error="invalid_token", error_description="{message}"' + ) return response diff --git a/examples/logging_plugin/__init__.py b/examples/logging_plugin/__init__.py index 052765c..e6f8f91 100644 --- a/examples/logging_plugin/__init__.py +++ b/examples/logging_plugin/__init__.py @@ -1 +1,5 @@ """Logging plugin example.""" + +from .main import LoggingPlugin + +__all__ = ["LoggingPlugin"] diff --git a/examples/logging_plugin/main.py b/examples/logging_plugin/main.py index 1d05951..4bea176 100644 --- a/examples/logging_plugin/main.py +++ b/examples/logging_plugin/main.py @@ -9,7 +9,7 @@ import sys from google.protobuf.empty_pb2 import Empty -from grpc import ServicerContext +from grpc.aio import ServicerContext from mcpd_plugins import BasePlugin, serve from mcpd_plugins.v1.plugins.plugin_pb2 import ( @@ -33,6 +33,7 @@ class LoggingPlugin(BasePlugin): async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadata: """Return plugin metadata.""" + _ = (request, context) return Metadata( name="logging-plugin", version="1.0.0", @@ -41,16 +42,18 @@ async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadat async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Capabilities: """Declare support for both request and response flows.""" + _ = (request, context) return Capabilities(flows=[FLOW_REQUEST, FLOW_RESPONSE]) async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse: """Log incoming request details.""" + _ = context logger.info("=" * 80) logger.info("INCOMING REQUEST") - logger.info(f"Method: {request.method}") - logger.info(f"URL: {request.url}") - logger.info(f"Path: {request.path}") - logger.info(f"Remote Address: {request.remote_addr}") + logger.info("Method: %s", request.method) + logger.info("URL: %s", request.url) + logger.info("Path: %s", request.path) + logger.info("Remote Address: %s", request.remote_addr) # Log headers. logger.info("Headers:") @@ -58,31 +61,32 @@ async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> # Mask sensitive headers. if key.lower() in ("authorization", "cookie"): value = "***REDACTED***" - logger.info(f" {key}: {value}") + logger.info(" %s: %s", key, value) # Log body size. if request.body: - logger.info(f"Body size: {len(request.body)} bytes") + logger.info("Body size: %s bytes", len(request.body)) logger.info("=" * 80) # Continue processing. return HTTPResponse(**{"continue": True}) - async def HandleResponse(self, request: HTTPResponse, context: ServicerContext) -> HTTPResponse: + async def HandleResponse(self, response: HTTPResponse, context: ServicerContext) -> HTTPResponse: """Log outgoing response details.""" + _ = context logger.info("=" * 80) logger.info("OUTGOING RESPONSE") - logger.info(f"Status Code: {request.status_code}") + logger.info("Status Code: %s", response.status_code) # Log headers. logger.info("Headers:") - for key, value in request.headers.items(): - logger.info(f" {key}: {value}") + for key, value in response.headers.items(): + logger.info(" %s: %s", key, value) # Log body size. - if request.body: - logger.info(f"Body size: {len(request.body)} bytes") + if response.body: + logger.info("Body size: %s bytes", len(response.body)) logger.info("=" * 80) diff --git a/examples/rate_limit_plugin/__init__.py b/examples/rate_limit_plugin/__init__.py index e0709cc..c98fad7 100644 --- a/examples/rate_limit_plugin/__init__.py +++ b/examples/rate_limit_plugin/__init__.py @@ -1 +1,5 @@ """Rate limiting plugin example.""" + +from .main import RateLimitPlugin + +__all__ = ["RateLimitPlugin"] diff --git a/examples/rate_limit_plugin/main.py b/examples/rate_limit_plugin/main.py index 5082f86..c3019ce 100644 --- a/examples/rate_limit_plugin/main.py +++ b/examples/rate_limit_plugin/main.py @@ -10,7 +10,7 @@ from collections import defaultdict from google.protobuf.empty_pb2 import Empty -from grpc import ServicerContext +from grpc.aio import ServicerContext from mcpd_plugins import BasePlugin, serve from mcpd_plugins.v1.plugins.plugin_pb2 import ( @@ -28,7 +28,7 @@ class RateLimitPlugin(BasePlugin): """Plugin that implements rate limiting using token bucket algorithm.""" - def __init__(self, requests_per_minute: int = 60): + def __init__(self, requests_per_minute: int = 60) -> None: """Initialize the rate limiter. Args: @@ -41,6 +41,7 @@ def __init__(self, requests_per_minute: int = 60): # Track tokens for each client IP. self.buckets: dict[str, float] = defaultdict(lambda: float(requests_per_minute)) self.last_update: dict[str, float] = defaultdict(time.time) + self.locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadata: """Return plugin metadata.""" @@ -57,33 +58,34 @@ async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Cap async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse: """Apply rate limiting based on client IP.""" client_ip = request.remote_addr or "unknown" - logger.info(f"Rate limit check for {client_ip}: {request.method} {request.url}") - - # Refill tokens based on time elapsed. - now = time.time() - elapsed = now - self.last_update[client_ip] - self.buckets[client_ip] = min( - self.requests_per_minute, - self.buckets[client_ip] + elapsed * self.rate_per_second, - ) - self.last_update[client_ip] = now - - # Check if client has tokens available. - if self.buckets[client_ip] < 1.0: - logger.warning(f"Rate limit exceeded for {client_ip}") - return self._rate_limit_response(client_ip) - - # Consume one token. - self.buckets[client_ip] -= 1.0 - logger.info(f"Request allowed for {client_ip} (tokens remaining: {self.buckets[client_ip]:.2f})") - - # Add rate limit headers to response. - response = HTTPResponse(**{"continue": True}) - response.modified_request.CopyFrom(request) - response.headers["X-RateLimit-Limit"] = str(self.requests_per_minute) - response.headers["X-RateLimit-Remaining"] = str(int(self.buckets[client_ip])) - - return response + logger.info("Rate limit check for %s: %s %s", client_ip, request.method, request.url) + + async with self.locks[client_ip]: + # Refill tokens based on time elapsed. + now = time.time() + elapsed = now - self.last_update[client_ip] + self.buckets[client_ip] = min( + self.requests_per_minute, + self.buckets[client_ip] + elapsed * self.rate_per_second, + ) + self.last_update[client_ip] = now + + # Check if client has tokens available. + if self.buckets[client_ip] < 1.0: + logger.warning("Rate limit exceeded for %s", client_ip) + return self._rate_limit_response(client_ip) + + # Consume one token. + self.buckets[client_ip] -= 1.0 + logger.info("Request allowed for %s (tokens remaining: %.2f)", client_ip, self.buckets[client_ip]) + + # Add rate limit headers to response. + response = HTTPResponse(**{"continue": True}) + response.modified_request.CopyFrom(request) + response.headers["X-RateLimit-Limit"] = str(self.requests_per_minute) + response.headers["X-RateLimit-Remaining"] = str(int(self.buckets[client_ip])) + + return response def _rate_limit_response(self, client_ip: str) -> HTTPResponse: """Create a 429 Too Many Requests response.""" diff --git a/examples/simple_plugin/__init__.py b/examples/simple_plugin/__init__.py index 3240900..32c76db 100644 --- a/examples/simple_plugin/__init__.py +++ b/examples/simple_plugin/__init__.py @@ -1 +1,5 @@ """Simple plugin example.""" + +from .main import SimplePlugin + +__all__ = ["SimplePlugin"] diff --git a/examples/simple_plugin/main.py b/examples/simple_plugin/main.py index fc4d76e..f91608a 100644 --- a/examples/simple_plugin/main.py +++ b/examples/simple_plugin/main.py @@ -9,7 +9,7 @@ import sys from google.protobuf.empty_pb2 import Empty -from grpc import ServicerContext +from grpc.aio import ServicerContext from mcpd_plugins import BasePlugin, serve from mcpd_plugins.v1.plugins.plugin_pb2 import ( @@ -29,6 +29,7 @@ class SimplePlugin(BasePlugin): async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadata: """Return plugin metadata.""" + _ = (request, context) return Metadata( name="simple-plugin", version="1.0.0", @@ -37,28 +38,21 @@ async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadat async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Capabilities: """Declare support for request flow.""" + _ = (request, context) return Capabilities(flows=[FLOW_REQUEST]) async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse: """Add a custom header to the request.""" - logger.info(f"Processing request: {request.method} {request.url}") + _ = context + logger.info("Processing request: %s %s", request.method, request.url) # Create response with Continue=True to pass the request through. response = HTTPResponse(**{"continue": True}) - # Copy original headers. - for key, value in request.headers.items(): - response.modified_request.headers[key] = value - - # Add custom header. + # Start from the original request, then mutate headers. + response.modified_request.CopyFrom(request) response.modified_request.headers["X-Simple-Plugin"] = "processed" - # Copy other request fields. - response.modified_request.method = request.method - response.modified_request.url = request.url - response.modified_request.path = request.path - response.modified_request.body = request.body - logger.info("Added X-Simple-Plugin header") return response diff --git a/examples/transform_plugin/__init__.py b/examples/transform_plugin/__init__.py index 28171c3..137254c 100644 --- a/examples/transform_plugin/__init__.py +++ b/examples/transform_plugin/__init__.py @@ -1 +1,5 @@ """Content transformation plugin example.""" + +from .main import TransformPlugin + +__all__ = ["TransformPlugin"] diff --git a/examples/transform_plugin/main.py b/examples/transform_plugin/main.py index a86a5a8..68f1dca 100644 --- a/examples/transform_plugin/main.py +++ b/examples/transform_plugin/main.py @@ -10,7 +10,7 @@ import sys from google.protobuf.empty_pb2 import Empty -from grpc import ServicerContext +from grpc.aio import ServicerContext from mcpd_plugins import BasePlugin, serve from mcpd_plugins.v1.plugins.plugin_pb2 import ( @@ -42,7 +42,7 @@ async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Cap async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse: """Transform JSON request bodies by adding metadata.""" - logger.info(f"Processing request: {request.method} {request.url}") + logger.info("Processing request: %s %s", request.method, request.url) # Only transform POST/PUT/PATCH requests with JSON content. content_type = request.headers.get("Content-Type", "") @@ -56,9 +56,14 @@ async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> logger.info("Empty body, skipping transformation") return HTTPResponse(**{"continue": True}) + # Extract charset from Content-Type header (default to utf-8). + charset = "utf-8" + if "charset=" in content_type: + charset = content_type.split("charset=")[-1].split(";")[0].strip() + # Parse JSON. - data = json.loads(request.body.decode("utf-8")) - logger.info(f"Original payload: {data}") + data = json.loads(request.body.decode(charset)) + logger.info("Original payload: %s", data) # Add metadata fields. if isinstance(data, dict): @@ -67,7 +72,7 @@ async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> "version": "1.0.0", "client_ip": request.remote_addr, } - logger.info(f"Transformed payload: {data}") + logger.info("Transformed payload: %s", data) # Create modified request. modified_body = json.dumps(data).encode("utf-8") @@ -84,17 +89,17 @@ async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> logger.warning("JSON body is not a dict, skipping transformation") return HTTPResponse(**{"continue": True}) - except json.JSONDecodeError as e: - logger.error(f"Failed to parse JSON body: {e}") - # Return 400 Bad Request for invalid JSON. + except (UnicodeDecodeError, json.JSONDecodeError): + logger.exception("Failed to parse request body") + # Return 400 Bad Request for invalid JSON or encoding. return HTTPResponse( **{"continue": False}, status_code=400, - body=b'{"error": "Invalid JSON"}', + body=b'{"error": "Invalid JSON or encoding"}', headers={"Content-Type": "application/json"}, ) - except Exception as e: - logger.error(f"Unexpected error during transformation: {e}") + except Exception: + logger.exception("Unexpected error during transformation") # Allow request to continue on unexpected errors. return HTTPResponse(**{"continue": True}) diff --git a/pyproject.toml b/pyproject.toml index cac2885..79edcfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,38 +6,37 @@ readme = "README.md" license = { text = "Apache-2.0" } requires-python = ">=3.11" dependencies = [ - "grpcio>=1.68.0", - "protobuf>=5.29.0", + "grpcio>=1.76.0", + "protobuf>=6.33.0,<7", ] [dependency-groups] dev = [ - "grpcio-tools>=1.68.0", - "debugpy>=1.8.14", + "grpcio-tools>=1.76.0", + "debugpy>=1.8.17", ] lint = [ - "pre-commit>=4.2.0", - "ruff>=0.11.13", + "pre-commit>=4.3.0", + "ruff>=0.14.2", ] tests = [ - "pytest>=8.3.5", - "pytest-asyncio>=0.25.2", + "pytest>=8.4.2", + "pytest-asyncio>=1.2.0", "setuptools>=80.9.0", - "setuptools-scm>=8.3.1", + "setuptools-scm>=9.2.2", ] build = [ - "pyinstaller>=6.11.0", - "nuitka>=2.5.7", + "pyinstaller>=6.16.0", + "nuitka>=2.8.4", ] all = [ { include-group = "dev" }, { include-group = "lint" }, { include-group = "tests" }, - { include-group = "build" }, ] [build-system] -requires = ["setuptools>=48", "setuptools_scm[toml]>=6.3.1"] +requires = ["setuptools>=80.9.0", "setuptools_scm[toml]>=9.2.2"] build-backend = "setuptools.build_meta" [tool.setuptools] @@ -53,3 +52,9 @@ mcpd_plugins = ["py.typed"] [tool.setuptools_scm] write_to = "src/mcpd_plugins/_version.py" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +log_cli_level = "INFO" +log_level = "INFO" diff --git a/ruff.toml b/ruff.toml index 6b46191..7376269 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,11 +1,10 @@ # Ruff configuration for mcpd-plugins-sdk-python +# Follow deprecation table: https://numpy.org/neps/nep-0029-deprecation_policy.html#support-table target-version = "py311" src = ["src"] line-length = 120 include = ["*.py", "*.pyi"] -# Enable auto-fixing -fix = true # Exclude common directories exclude = [ @@ -45,11 +44,14 @@ select = [ "ISC", # flake8-implicit-str-concat "PTH", # flake8-use-pathlib "D", # pydocstyle + "G", # flake8-logging-format + "ARG", # flake8-unused-arguments + "ANN", # flake8-annotations ] # Rules to ignore ignore = [ - # No ignored rules - we want strict enforcement for a public SDK + "ARG002", # Unused method arguments (required by gRPC interface) ] # Never autofix these - require manual review @@ -60,7 +62,7 @@ unfixable = [ [lint.per-file-ignores] "__init__.py" = ["F401", "D104"] # Allow unused imports and missing docstrings in __init__.py -"tests/**" = ["D", "N"] # Skip docstrings and naming conventions in tests +"tests/**" = ["D", "N", "ANN"] # Skip docstrings, naming conventions, and annotations in tests "examples/**" = ["N802"] # Allow PascalCase method names (protobuf convention) "src/mcpd_plugins/base_plugin.py" = ["N802"] # Allow PascalCase methods (protobuf convention) "**/plugin_pb2*.py" = ["ALL"] # Ignore all linting in generated proto files diff --git a/scripts/build_plugin.sh b/scripts/build_plugin.sh index 17d90ea..64eb233 100755 --- a/scripts/build_plugin.sh +++ b/scripts/build_plugin.sh @@ -14,7 +14,6 @@ NC='\033[0m' # No Color # Configuration USE_NUITKA=false DEBUG=false -ONEFILE=true # Always build as single-file executable. PLUGIN_DIR="" PLUGIN_NAME="" EXTRA_ARGS=() @@ -141,8 +140,7 @@ if [[ "$USE_NUITKA" = true ]]; then NUITKA_ARGS+=(--lto=yes) ;; --optimize-speed) - NUITKA_ARGS+=(--lto=no) - NUITKA_ARGS+=(--remove-output) + NUITKA_ARGS+=(--lto=yes) ;; esac done @@ -167,17 +165,11 @@ else --specpath=build ) - # Add debug/console flag - if [[ "$DEBUG" = true ]]; then - PYINSTALLER_ARGS+=(--console) - else - PYINSTALLER_ARGS+=(--console) # Plugins need console for gRPC - fi + # Plugins need console for gRPC + PYINSTALLER_ARGS+=(--console) - # Onefile vs onedir - if [[ "$ONEFILE" = true ]]; then - PYINSTALLER_ARGS+=(--onefile) - fi + # Always build as single-file executable + PYINSTALLER_ARGS+=(--onefile) # Hidden imports for grpc and asyncio PYINSTALLER_ARGS+=( diff --git a/scripts/setup_uv.sh b/scripts/setup_uv.sh index 67030da..c518f53 100755 --- a/scripts/setup_uv.sh +++ b/scripts/setup_uv.sh @@ -12,6 +12,7 @@ LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}" # Ensure LOCAL_BIN exists and is on PATH mkdir -p "$LOCAL_BIN" +INITIAL_PATH="$PATH" if [[ ":$PATH:" != *":$LOCAL_BIN:"* ]]; then export PATH="$LOCAL_BIN:$PATH" fi @@ -47,7 +48,7 @@ echo "Installing pre-commit hooks" uv run pre-commit install # After detecting PATH lacked LOCAL_BIN… -if [[ ":$PATH:" != *":$LOCAL_BIN:"* ]]; then +if [[ ":$INITIAL_PATH:" != *":$LOCAL_BIN:"* ]]; then echo "Note: added $LOCAL_BIN to PATH for this session." echo "To make it permanent, add to your shell profile:" echo " export PATH=\"$LOCAL_BIN:\$PATH\"" diff --git a/src/mcpd_plugins/__init__.py b/src/mcpd_plugins/__init__.py index 278a7a7..3d9e9b4 100644 --- a/src/mcpd_plugins/__init__.py +++ b/src/mcpd_plugins/__init__.py @@ -6,8 +6,14 @@ Example: ```python import asyncio + import sys from mcpd_plugins import BasePlugin, serve - from mcpd_plugins.v1.plugins.plugin_pb2 import Capabilities, Flow, Metadata + from mcpd_plugins.v1.plugins.plugin_pb2 import ( + FLOW_REQUEST, + Capabilities, + HTTPResponse, + Metadata, + ) class MyPlugin(BasePlugin): async def GetMetadata(self, request, context): @@ -18,7 +24,7 @@ async def GetMetadata(self, request, context): ) async def GetCapabilities(self, request, context): - return Capabilities(flows=[Flow.FLOW_REQUEST]) + return Capabilities(flows=[FLOW_REQUEST]) async def HandleRequest(self, request, context): # Add custom header @@ -27,7 +33,7 @@ async def HandleRequest(self, request, context): return response if __name__ == "__main__": - asyncio.run(serve(MyPlugin())) + asyncio.run(serve(MyPlugin(), sys.argv)) ``` """ @@ -42,9 +48,9 @@ async def HandleRequest(self, request, context): __all__ = [ "BasePlugin", - "serve", - "PluginError", "ConfigurationError", + "PluginError", "ServerError", "__version__", + "serve", ] diff --git a/src/mcpd_plugins/base_plugin.py b/src/mcpd_plugins/base_plugin.py index d8c0c69..37d046d 100644 --- a/src/mcpd_plugins/base_plugin.py +++ b/src/mcpd_plugins/base_plugin.py @@ -1,7 +1,7 @@ """BasePlugin class providing default implementations for all plugin methods.""" from google.protobuf.empty_pb2 import Empty -from grpc import ServicerContext +from grpc.aio import ServicerContext from mcpd_plugins.v1.plugins.plugin_pb2 import ( Capabilities, @@ -49,6 +49,7 @@ async def Configure(self, request: PluginConfig, context: ServicerContext) -> Em Returns: Empty message indicating successful configuration. """ + _ = (request, context) # Required by gRPC interface. return Empty() async def Stop(self, request: Empty, context: ServicerContext) -> Empty: @@ -63,6 +64,7 @@ async def Stop(self, request: Empty, context: ServicerContext) -> Empty: Returns: Empty message indicating successful shutdown. """ + _ = (request, context) # Required by gRPC interface. return Empty() async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadata: @@ -77,6 +79,7 @@ async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadat Returns: Metadata containing plugin information. """ + _ = (request, context) # Required by gRPC interface. return Metadata( name="base-plugin", version="0.0.0", @@ -95,6 +98,7 @@ async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Cap Returns: Capabilities message listing supported flows. """ + _ = (request, context) # Required by gRPC interface. return Capabilities() async def CheckHealth(self, request: Empty, context: ServicerContext) -> Empty: @@ -109,6 +113,7 @@ async def CheckHealth(self, request: Empty, context: ServicerContext) -> Empty: Returns: Empty message indicating healthy status. """ + _ = (request, context) # Required by gRPC interface. return Empty() async def CheckReady(self, request: Empty, context: ServicerContext) -> Empty: @@ -123,6 +128,7 @@ async def CheckReady(self, request: Empty, context: ServicerContext) -> Empty: Returns: Empty message indicating ready status. """ + _ = (request, context) # Required by gRPC interface. return Empty() async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse: @@ -137,18 +143,25 @@ async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> Returns: HTTPResponse indicating how to proceed (continue, modify, or reject). """ + _ = context # Required by gRPC interface. return HTTPResponse(**{"continue": True}) - async def HandleResponse(self, request: HTTPResponse, context: ServicerContext) -> HTTPResponse: + async def HandleResponse(self, response: HTTPResponse, context: ServicerContext) -> HTTPResponse: """Handle outgoing HTTP response. Default implementation passes through unchanged (continue=True). + Note: + The parameter is named 'response' for clarity, even though the generated + gRPC stub names it 'request' (gRPC convention). This semantic naming + improves code readability. + Args: - request: The outgoing HTTP response to process. + response: The outgoing HTTP response to process. context: gRPC context for the request. Returns: HTTPResponse indicating how to proceed (continue or modify). """ + _ = context # Required by gRPC interface. return HTTPResponse(**{"continue": True}) diff --git a/src/mcpd_plugins/server.py b/src/mcpd_plugins/server.py index c5df418..d4512d2 100644 --- a/src/mcpd_plugins/server.py +++ b/src/mcpd_plugins/server.py @@ -5,6 +5,8 @@ import logging import os import signal +import types +from pathlib import Path from grpc import aio @@ -14,11 +16,35 @@ logger = logging.getLogger(__name__) +# Network type constants. +NETWORK_UNIX = "unix" +NETWORK_TCP = "tcp" + + +def _cleanup_unix_socket(network: str, address: str, *, raise_on_error: bool = False) -> None: + """Clean up Unix socket file if it exists. + + Args: + network: Network type (unix or tcp). + address: Socket file path. + raise_on_error: If True, raise ServerError on cleanup failure. If False, log warning. + """ + if network != NETWORK_UNIX: + return + + try: + socket_path = Path(address) + if socket_path.exists(): + socket_path.unlink() + except OSError as e: + if raise_on_error: + raise ServerError(f"Failed to prepare unix socket {address}: {e}") from e + logger.warning("Failed to unlink unix socket: %s", address) + async def serve( plugin: BasePlugin, args: list[str] | None = None, - max_workers: int = 10, grace_period: float = 5.0, ) -> None: """Launch a gRPC server for the plugin. @@ -34,7 +60,6 @@ async def serve( plugin: The plugin instance to serve (should extend BasePlugin). args: Command-line arguments (typically sys.argv). If None, runs in standalone mode on TCP port 50051. When provided by mcpd, expects --address and --network. - max_workers: Maximum number of concurrent workers (default: 10). grace_period: Seconds to wait for graceful shutdown (default: 5.0). Raises: @@ -58,8 +83,8 @@ async def GetMetadata(self, request, context): # asyncio.run(serve(MyPlugin())) ``` """ - # Parse command-line arguments if provided. - if args is not None: + # Parse command-line arguments if provided (and there are actual arguments beyond program name). + if args is not None and len(args) > 1: parser = argparse.ArgumentParser(description="Plugin server for mcpd") parser.add_argument( "--address", @@ -70,36 +95,42 @@ async def GetMetadata(self, request, context): parser.add_argument( "--network", type=str, - default="unix", - choices=["unix", "tcp"], + default=NETWORK_UNIX, + choices=[NETWORK_UNIX, NETWORK_TCP], help="Network type (unix or tcp)", ) - parsed_args = parser.parse_args(args[1:]) # Skip program name. + # Be tolerant of extra flags passed by hosts (e.g. mcpd). + parsed_args, _unknown = parser.parse_known_args(args[1:]) # Skip program name. + if _unknown: + logger.debug("Ignoring unknown args: %s", _unknown) # Require --address when args are provided (mcpd mode). if parsed_args.address is None: raise ServerError( "--address is required when running with command-line arguments. " - "For standalone testing, call serve() without args." + "For standalone testing, run without arguments." ) address = parsed_args.address network = parsed_args.network else: # Standalone mode: use TCP with default port. - network = "tcp" + network = NETWORK_TCP port = int(os.getenv("PLUGIN_PORT", "50051")) address = f"[::]:{port}" # Format the listen address based on network type. listen_addr = ( f"unix:///{address}" # Three slashes for Unix sockets. - if network == "unix" + if network == NETWORK_UNIX else address if ":" in address else f"[::]:{address}" ) + # If using a Unix socket, remove any stale socket file before binding. + _cleanup_unix_socket(network, address, raise_on_error=True) + server = aio.server() add_PluginServicer_to_server(plugin, server) @@ -113,28 +144,36 @@ async def GetMetadata(self, request, context): # Setup signal handling for graceful shutdown. stop_event = asyncio.Event() - def signal_handler(signum: int, frame) -> None: # noqa: ARG001 + def signal_handler(signum: int, frame: types.FrameType | None) -> None: # noqa: ARG001 """Handle shutdown signals.""" - logger.info(f"Received signal {signum}, initiating graceful shutdown...") + logger.info("Received signal %s, initiating graceful shutdown...", signum) stop_event.set() - signal.signal(signal.SIGTERM, signal_handler) - signal.signal(signal.SIGINT, signal_handler) + loop = asyncio.get_running_loop() + try: + loop.add_signal_handler(signal.SIGTERM, signal_handler, signal.SIGTERM, None) + loop.add_signal_handler(signal.SIGINT, signal_handler, signal.SIGINT, None) + except (NotImplementedError, RuntimeError): + # Fallback (e.g., on Windows or non-main thread). + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) # Start the server. try: await server.start() - logger.info(f"Plugin server started on {listen_addr}") + logger.info("Plugin server started on %s", listen_addr) # Wait for shutdown signal. await stop_event.wait() # Graceful shutdown. - logger.info(f"Shutting down server (grace period: {grace_period}s)...") + logger.info("Shutting down server (grace period: %ss)...", grace_period) await server.stop(grace_period) logger.info("Server stopped gracefully") + _cleanup_unix_socket(network, address) except Exception as e: - logger.error(f"Server error: {e}") + logger.exception("Server error") await server.stop(0) + _cleanup_unix_socket(network, address) raise ServerError(f"Server encountered an error: {e}") from e diff --git a/tests/conftest.py b/tests/conftest.py index caa0d32..afb2070 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,21 +7,60 @@ from mcpd_plugins.v1.plugins.plugin_pb2 import HTTPRequest, PluginConfig +class AbortedError(Exception): + """Exception raised when gRPC context abort is called.""" + + def __init__(self, code, details: str) -> None: + """Initialize with gRPC status code and details. + + Args: + code: gRPC status code. + details: Error details message. + """ + self.code = code + self.details = details + super().__init__(f"Aborted with code {code}: {details}") + + class MockContext: """Mock gRPC ServicerContext for testing.""" - def __init__(self): + def __init__(self) -> None: """Initialize mock context.""" self.invocation_metadata = Metadata() self.peer_identity = None self.code = None self.details = None - def abort(self, code, details): - """Mock abort method.""" + def set_code(self, code) -> None: + """Set the gRPC status code. + + Args: + code: gRPC status code. + """ + self.code = code + + def set_details(self, details: str) -> None: + """Set the error details. + + Args: + details: Error details message. + """ + self.details = details + + def abort(self, code, details: str): + """Mock abort method. + + Args: + code: gRPC status code. + details: Error details message. + + Raises: + AbortedError: Always raised to simulate gRPC abort. + """ self.code = code self.details = details - raise Exception(f"Aborted with code {code}: {details}") + raise AbortedError(code, details) @pytest.fixture diff --git a/tests/integration/test_examples.py b/tests/integration/test_examples.py index 6f9b872..8c4bdc4 100644 --- a/tests/integration/test_examples.py +++ b/tests/integration/test_examples.py @@ -11,20 +11,6 @@ sys.path.insert(0, str(examples_dir)) -@pytest.fixture -def mock_context(): - """Provide a mock gRPC context.""" - - class MockContext: - """Mock gRPC ServicerContext.""" - - def __init__(self): - """Initialize mock context.""" - self.invocation_metadata = [] - - return MockContext() - - class TestSimplePlugin: """Integration tests for simple_plugin example.""" @@ -96,6 +82,10 @@ async def test_auth_plugin_rejects_missing_token(self, mock_context): assert getattr(response, "continue") is False assert response.status_code == 401 + assert response.headers.get("Content-Type") == "application/json" + assert "WWW-Authenticate" in response.headers + assert response.headers["WWW-Authenticate"].startswith("Bearer") + assert b"error" in response.body @pytest.mark.asyncio async def test_auth_plugin_rejects_invalid_token(self, mock_context): @@ -116,6 +106,10 @@ async def test_auth_plugin_rejects_invalid_token(self, mock_context): assert getattr(response, "continue") is False assert response.status_code == 401 + assert response.headers.get("Content-Type") == "application/json" + assert "WWW-Authenticate" in response.headers + assert response.headers["WWW-Authenticate"].startswith("Bearer") + assert b"error" in response.body @pytest.mark.asyncio async def test_auth_plugin_accepts_valid_token(self, mock_context): @@ -172,4 +166,4 @@ async def test_logging_plugin_logs_request(self, mock_context, caplog): assert getattr(response, "continue") is True # Check that logging occurred (test output capture). - assert "INCOMING REQUEST" in caplog.text or getattr(response, "continue") is True + assert "INCOMING REQUEST" in caplog.text diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index 8b1d672..dcc4498 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -1,43 +1,411 @@ """Unit tests for server module.""" -import os +import asyncio +import signal +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from mcpd_plugins.base_plugin import BasePlugin +from mcpd_plugins.exceptions import ServerError from mcpd_plugins.server import serve -class TestServeFunction: - """Tests for serve helper function.""" +@pytest.fixture +def mock_plugin(): + """Provide a mock plugin instance.""" + return MagicMock(spec=BasePlugin) + + +@pytest.fixture +def mock_server(): + """Provide a mock gRPC server.""" + server = MagicMock() + server.add_insecure_port = MagicMock(return_value=50051) + server.start = AsyncMock() + server.stop = AsyncMock() + return server + + +@pytest.fixture +def mock_stop_event(): + """Provide a mock asyncio Event that immediately sets.""" + event = MagicMock(spec=asyncio.Event) + event.wait = AsyncMock() + event.set = MagicMock() + return event + + +class TestServeStandaloneMode: + """Tests for serve function in standalone mode (no args).""" @pytest.mark.asyncio - async def test_serve_with_invalid_port_raises_error(self): - """serve should raise ServerError if port binding fails.""" - # Note: This test is limited without actually starting a server. - # In practice, we'd need more complex mocking to test server startup failures. - # This is a placeholder test to demonstrate the pattern. - assert callable(serve) + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + @patch("mcpd_plugins.server.signal.signal") + @patch("mcpd_plugins.server.asyncio.Event") + async def test_standalone_uses_default_port_50051( + self, + mock_event_class, + mock_signal, + mock_add_servicer, + mock_aio_server, + mock_plugin, + mock_server, + mock_stop_event, + ): + """Standalone mode should default to port 50051 on TCP.""" + mock_aio_server.return_value = mock_server + mock_event_class.return_value = mock_stop_event + mock_stop_event.wait = AsyncMock() + + await serve(mock_plugin) - def test_serve_uses_env_var_for_port(self, monkeypatch): - """serve should use PLUGIN_PORT environment variable if set.""" + # Verify server was created and bound to correct address + mock_aio_server.assert_called_once() + mock_add_servicer.assert_called_once_with(mock_plugin, mock_server) + mock_server.add_insecure_port.assert_called_once_with("[::]:50051") + + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + @patch("mcpd_plugins.server.signal.signal") + @patch("mcpd_plugins.server.asyncio.Event") + async def test_standalone_respects_plugin_port_env( + self, + mock_event_class, + mock_signal, + mock_add_servicer, + mock_aio_server, + mock_plugin, + mock_server, + mock_stop_event, + monkeypatch, + ): + """Standalone mode should use PLUGIN_PORT environment variable when set.""" monkeypatch.setenv("PLUGIN_PORT", "9999") - # Since we can't easily test the actual port binding without starting the server, - # we just verify that the function is callable and accepts the right parameters. - assert callable(serve) - - def test_serve_defaults_to_port_50051(self): - """serve should default to port 50051 if no port specified.""" - # Remove env var if it exists. - if "PLUGIN_PORT" in os.environ: - del os.environ["PLUGIN_PORT"] - - # Verify the function signature accepts the expected parameters. - assert callable(serve) - - -# Note: Full integration tests for the gRPC server would require: -# 1. Actually starting the server in a background task -# 2. Creating a gRPC client to connect to it -# 3. Making test calls -# 4. Shutting down the server gracefully -# These are better suited for integration tests rather than unit tests. + mock_aio_server.return_value = mock_server + mock_event_class.return_value = mock_stop_event + mock_stop_event.wait = AsyncMock() + + await serve(mock_plugin) + + mock_server.add_insecure_port.assert_called_once_with("[::]:9999") + + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + @patch("mcpd_plugins.server.signal.signal") + @patch("mcpd_plugins.server.asyncio.Event") + async def test_standalone_formats_address_correctly( + self, + mock_event_class, + mock_signal, + mock_add_servicer, + mock_aio_server, + mock_plugin, + mock_server, + mock_stop_event, + ): + """Standalone mode should format address as [::]:port.""" + mock_aio_server.return_value = mock_server + mock_event_class.return_value = mock_stop_event + mock_stop_event.wait = AsyncMock() + + await serve(mock_plugin, args=None) + + # Verify IPv6 wildcard format + call_args = mock_server.add_insecure_port.call_args[0][0] + assert call_args.startswith("[::]:") + + +class TestServeMcpdMode: + """Tests for serve function in mcpd mode (with args).""" + + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + @patch("mcpd_plugins.server.signal.signal") + @patch("mcpd_plugins.server.asyncio.Event") + async def test_mcpd_mode_parses_address_and_network( + self, + mock_event_class, + mock_signal, + mock_add_servicer, + mock_aio_server, + mock_plugin, + mock_server, + mock_stop_event, + tmp_path, + ): + """mcpd mode should parse --address and --network flags.""" + mock_aio_server.return_value = mock_server + mock_event_class.return_value = mock_stop_event + mock_stop_event.wait = AsyncMock() + + socket_path = str(tmp_path / "plugin.sock") + await serve(mock_plugin, args=["program", "--address", socket_path, "--network", "unix"]) + + # Note: unix:/// + socket_path = unix:/// (4 slashes for absolute paths) + mock_server.add_insecure_port.assert_called_once_with(f"unix:///{socket_path}") + + @pytest.mark.asyncio + async def test_mcpd_mode_requires_address(self, mock_plugin): + """mcpd mode should raise ServerError if --address not provided.""" + with pytest.raises(ServerError, match="--address is required"): + await serve(mock_plugin, args=["program", "--network", "tcp"]) + + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + @patch("mcpd_plugins.server.signal.signal") + @patch("mcpd_plugins.server.asyncio.Event") + async def test_mcpd_mode_defaults_network_to_unix( + self, + mock_event_class, + mock_signal, + mock_add_servicer, + mock_aio_server, + mock_plugin, + mock_server, + mock_stop_event, + tmp_path, + ): + """mcpd mode should default network to unix when not specified.""" + mock_aio_server.return_value = mock_server + mock_event_class.return_value = mock_stop_event + mock_stop_event.wait = AsyncMock() + + socket_path = str(tmp_path / "plugin.sock") + await serve(mock_plugin, args=["program", "--address", socket_path]) + + # Should use unix:// prefix (default network) + call_args = mock_server.add_insecure_port.call_args[0][0] + assert call_args.startswith("unix:///") + + @pytest.mark.asyncio + async def test_mcpd_mode_validates_network_choices(self, mock_plugin): + """mcpd mode should only accept unix or tcp for --network.""" + with pytest.raises(SystemExit): # argparse raises SystemExit for invalid choice + await serve(mock_plugin, args=["program", "--address", "addr", "--network", "invalid"]) + + +class TestAddressFormatting: + """Tests for address formatting logic.""" + + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + @patch("mcpd_plugins.server.signal.signal") + @patch("mcpd_plugins.server.asyncio.Event") + async def test_unix_socket_address_format( + self, + mock_event_class, + mock_signal, + mock_add_servicer, + mock_aio_server, + mock_plugin, + mock_server, + mock_stop_event, + tmp_path, + ): + """Unix socket should format as unix:///{path} - results in 4 slashes for absolute paths.""" + mock_aio_server.return_value = mock_server + mock_event_class.return_value = mock_stop_event + mock_stop_event.wait = AsyncMock() + + socket_path = str(tmp_path / "plugin.sock") + await serve(mock_plugin, args=["program", "--address", socket_path, "--network", "unix"]) + + # unix:/// prefix + socket_path = unix:/// (4 slashes for absolute paths) + mock_server.add_insecure_port.assert_called_once_with(f"unix:///{socket_path}") + + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + @patch("mcpd_plugins.server.signal.signal") + @patch("mcpd_plugins.server.asyncio.Event") + async def test_tcp_address_with_colon_passthrough( + self, + mock_event_class, + mock_signal, + mock_add_servicer, + mock_aio_server, + mock_plugin, + mock_server, + mock_stop_event, + ): + """TCP address with colon should be used as-is.""" + mock_aio_server.return_value = mock_server + mock_event_class.return_value = mock_stop_event + mock_stop_event.wait = AsyncMock() + + await serve(mock_plugin, args=["program", "--address", "localhost:8080", "--network", "tcp"]) + + mock_server.add_insecure_port.assert_called_once_with("localhost:8080") + + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + @patch("mcpd_plugins.server.signal.signal") + @patch("mcpd_plugins.server.asyncio.Event") + async def test_tcp_address_without_colon_wraps( + self, + mock_event_class, + mock_signal, + mock_add_servicer, + mock_aio_server, + mock_plugin, + mock_server, + mock_stop_event, + ): + """TCP port-only address should be wrapped as [::]:port.""" + mock_aio_server.return_value = mock_server + mock_event_class.return_value = mock_stop_event + mock_stop_event.wait = AsyncMock() + + await serve(mock_plugin, args=["program", "--address", "8080", "--network", "tcp"]) + + mock_server.add_insecure_port.assert_called_once_with("[::]:8080") + + +class TestServerBinding: + """Tests for server creation and binding.""" + + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + @patch("mcpd_plugins.server.signal.signal") + @patch("mcpd_plugins.server.asyncio.Event") + async def test_server_creation_and_binding( + self, + mock_event_class, + mock_signal, + mock_add_servicer, + mock_aio_server, + mock_plugin, + mock_server, + mock_stop_event, + ): + """Server should be created and servicer added.""" + mock_aio_server.return_value = mock_server + mock_event_class.return_value = mock_stop_event + mock_stop_event.wait = AsyncMock() + + await serve(mock_plugin) + + # Verify proper call sequence + mock_aio_server.assert_called_once() + mock_add_servicer.assert_called_once_with(mock_plugin, mock_server) + mock_server.add_insecure_port.assert_called_once() + + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + async def test_bind_failure_returns_zero(self, mock_add_servicer, mock_aio_server, mock_plugin, mock_server): + """Should raise ServerError when add_insecure_port returns 0.""" + mock_server.add_insecure_port.return_value = 0 + mock_aio_server.return_value = mock_server + + with pytest.raises(ServerError, match=r"Failed to bind"): + await serve(mock_plugin) + + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + async def test_bind_failure_raises_exception(self, mock_add_servicer, mock_aio_server, mock_plugin, mock_server): + """Should raise ServerError when add_insecure_port raises exception.""" + mock_server.add_insecure_port.side_effect = OSError("Address already in use") + mock_aio_server.return_value = mock_server + + with pytest.raises(ServerError, match=r"Failed to bind.*Address already in use"): + await serve(mock_plugin) + + +class TestSignalHandlers: + """Tests for signal handler registration.""" + + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + @patch("mcpd_plugins.server.signal.signal") + @patch("mcpd_plugins.server.asyncio.Event") + async def test_registers_signal_handlers( + self, + mock_event_class, + mock_signal, + mock_add_servicer, + mock_aio_server, + mock_plugin, + mock_server, + mock_stop_event, + ): + """Should register SIGTERM and SIGINT handlers.""" + mock_aio_server.return_value = mock_server + mock_event_class.return_value = mock_stop_event + mock_stop_event.wait = AsyncMock() + + await serve(mock_plugin) + + # Verify both signal handlers were registered + assert mock_signal.call_count == 2 + signal_calls = [call_args[0][0] for call_args in mock_signal.call_args_list] + assert signal.SIGTERM in signal_calls + assert signal.SIGINT in signal_calls + + +class TestServerLifecycle: + """Tests for server startup, shutdown, and error handling.""" + + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + @patch("mcpd_plugins.server.signal.signal") + @patch("mcpd_plugins.server.asyncio.Event") + async def test_server_starts_and_stops_gracefully( + self, + mock_event_class, + mock_signal, + mock_add_servicer, + mock_aio_server, + mock_plugin, + mock_server, + mock_stop_event, + ): + """Server should start, wait for stop event, then stop with grace period.""" + mock_aio_server.return_value = mock_server + mock_event_class.return_value = mock_stop_event + mock_stop_event.wait = AsyncMock() + + await serve(mock_plugin, grace_period=10.0) + + # Verify lifecycle calls + mock_server.start.assert_called_once() + mock_stop_event.wait.assert_called_once() + mock_server.stop.assert_called_once_with(10.0) + + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + @patch("mcpd_plugins.server.signal.signal") + @patch("mcpd_plugins.server.asyncio.Event") + async def test_server_error_handling( + self, + mock_event_class, + mock_signal, + mock_add_servicer, + mock_aio_server, + mock_plugin, + mock_server, + mock_stop_event, + ): + """Server should handle exceptions during startup and call stop(0).""" + mock_aio_server.return_value = mock_server + mock_event_class.return_value = mock_stop_event + mock_server.start.side_effect = RuntimeError("Server startup failed") + + with pytest.raises(ServerError, match="Server encountered an error"): + await serve(mock_plugin) + + # Verify stop(0) was called for immediate shutdown + mock_server.stop.assert_called_once_with(0) diff --git a/uv.lock b/uv.lock index a32fdd9..84a58ce 100644 --- a/uv.lock +++ b/uv.lock @@ -218,9 +218,7 @@ dependencies = [ all = [ { name = "debugpy" }, { name = "grpcio-tools" }, - { name = "nuitka" }, { name = "pre-commit" }, - { name = "pyinstaller" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -248,40 +246,38 @@ tests = [ [package.metadata] requires-dist = [ - { name = "grpcio", specifier = ">=1.68.0" }, - { name = "protobuf", specifier = ">=5.29.0" }, + { name = "grpcio", specifier = ">=1.76.0" }, + { name = "protobuf", specifier = ">=6.33.0,<7" }, ] [package.metadata.requires-dev] all = [ - { name = "debugpy", specifier = ">=1.8.14" }, - { name = "grpcio-tools", specifier = ">=1.68.0" }, - { name = "nuitka", specifier = ">=2.5.7" }, - { name = "pre-commit", specifier = ">=4.2.0" }, - { name = "pyinstaller", specifier = ">=6.11.0" }, - { name = "pytest", specifier = ">=8.3.5" }, - { name = "pytest-asyncio", specifier = ">=0.25.2" }, - { name = "ruff", specifier = ">=0.11.13" }, + { name = "debugpy", specifier = ">=1.8.17" }, + { name = "grpcio-tools", specifier = ">=1.76.0" }, + { name = "pre-commit", specifier = ">=4.3.0" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, + { name = "ruff", specifier = ">=0.14.2" }, { name = "setuptools", specifier = ">=80.9.0" }, - { name = "setuptools-scm", specifier = ">=8.3.1" }, + { name = "setuptools-scm", specifier = ">=9.2.2" }, ] build = [ - { name = "nuitka", specifier = ">=2.5.7" }, - { name = "pyinstaller", specifier = ">=6.11.0" }, + { name = "nuitka", specifier = ">=2.8.4" }, + { name = "pyinstaller", specifier = ">=6.16.0" }, ] dev = [ - { name = "debugpy", specifier = ">=1.8.14" }, - { name = "grpcio-tools", specifier = ">=1.68.0" }, + { name = "debugpy", specifier = ">=1.8.17" }, + { name = "grpcio-tools", specifier = ">=1.76.0" }, ] lint = [ - { name = "pre-commit", specifier = ">=4.2.0" }, - { name = "ruff", specifier = ">=0.11.13" }, + { name = "pre-commit", specifier = ">=4.3.0" }, + { name = "ruff", specifier = ">=0.14.2" }, ] tests = [ - { name = "pytest", specifier = ">=8.3.5" }, - { name = "pytest-asyncio", specifier = ">=0.25.2" }, + { name = "pytest", specifier = ">=8.4.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, { name = "setuptools", specifier = ">=80.9.0" }, - { name = "setuptools-scm", specifier = ">=8.3.1" }, + { name = "setuptools-scm", specifier = ">=9.2.2" }, ] [[package]] From db9b3ba6cca5e5a66e5eac0b64bd1a04f256535d Mon Sep 17 00:00:00 2001 From: Peter Wilson Date: Tue, 4 Nov 2025 09:18:28 +0000 Subject: [PATCH 7/7] PR feedback --- tests/integration/test_examples.py | 3 ++ tests/unit/test_server.py | 46 ++++++++++++++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_examples.py b/tests/integration/test_examples.py index 8c4bdc4..1f3d5f2 100644 --- a/tests/integration/test_examples.py +++ b/tests/integration/test_examples.py @@ -1,5 +1,6 @@ """Integration tests for example plugins.""" +import logging import sys from pathlib import Path @@ -59,6 +60,7 @@ async def test_simple_plugin_adds_header(self, mock_context): assert getattr(response, "continue") is True assert "X-Simple-Plugin" in response.modified_request.headers assert response.modified_request.headers["X-Simple-Plugin"] == "processed" + assert response.modified_request.headers.get("User-Agent") == "test" class TestAuthPlugin: @@ -151,6 +153,7 @@ async def test_logging_plugin_supports_both_flows(self, mock_context): @pytest.mark.asyncio async def test_logging_plugin_logs_request(self, mock_context, caplog): """Logging plugin should log request details.""" + caplog.set_level(logging.INFO) from logging_plugin.main import LoggingPlugin from mcpd_plugins.v1.plugins.plugin_pb2 import HTTPRequest diff --git a/tests/unit/test_server.py b/tests/unit/test_server.py index dcc4498..07e0be5 100644 --- a/tests/unit/test_server.py +++ b/tests/unit/test_server.py @@ -325,13 +325,48 @@ async def test_bind_failure_raises_exception(self, mock_add_servicer, mock_aio_s class TestSignalHandlers: """Tests for signal handler registration.""" + @pytest.mark.asyncio + @patch("mcpd_plugins.server.aio.server") + @patch("mcpd_plugins.server.add_PluginServicer_to_server") + @patch("mcpd_plugins.server.asyncio.Event") + @patch("mcpd_plugins.server.asyncio.get_running_loop") + async def test_registers_signal_handlers_via_loop( + self, + mock_get_loop, + mock_event_class, + mock_add_servicer, + mock_aio_server, + mock_plugin, + mock_server, + mock_stop_event, + ): + """Should register SIGTERM and SIGINT handlers via loop.add_signal_handler (primary path).""" + mock_aio_server.return_value = mock_server + mock_event_class.return_value = mock_stop_event + mock_stop_event.wait = AsyncMock() + + # Mock the event loop. + mock_loop = MagicMock() + mock_loop.add_signal_handler = MagicMock() + mock_get_loop.return_value = mock_loop + + await serve(mock_plugin) + + # Verify both signal handlers were registered via loop. + assert mock_loop.add_signal_handler.call_count == 2 + signal_calls = [call_args[0][0] for call_args in mock_loop.add_signal_handler.call_args_list] + assert signal.SIGTERM in signal_calls + assert signal.SIGINT in signal_calls + @pytest.mark.asyncio @patch("mcpd_plugins.server.aio.server") @patch("mcpd_plugins.server.add_PluginServicer_to_server") @patch("mcpd_plugins.server.signal.signal") @patch("mcpd_plugins.server.asyncio.Event") - async def test_registers_signal_handlers( + @patch("mcpd_plugins.server.asyncio.get_running_loop") + async def test_registers_signal_handlers_fallback( self, + mock_get_loop, mock_event_class, mock_signal, mock_add_servicer, @@ -340,14 +375,19 @@ async def test_registers_signal_handlers( mock_server, mock_stop_event, ): - """Should register SIGTERM and SIGINT handlers.""" + """Should fall back to signal.signal when loop.add_signal_handler fails (e.g., Windows).""" mock_aio_server.return_value = mock_server mock_event_class.return_value = mock_stop_event mock_stop_event.wait = AsyncMock() + # Mock the event loop to raise NotImplementedError (simulates Windows or non-main thread). + mock_loop = MagicMock() + mock_loop.add_signal_handler = MagicMock(side_effect=NotImplementedError) + mock_get_loop.return_value = mock_loop + await serve(mock_plugin) - # Verify both signal handlers were registered + # Verify both signal handlers were registered via signal.signal fallback. assert mock_signal.call_count == 2 signal_calls = [call_args[0][0] for call_args in mock_signal.call_args_list] assert signal.SIGTERM in signal_calls