From d57f8882e69e422e6ba1603b46e1fa290e41dcc8 Mon Sep 17 00:00:00 2001 From: Ivan Shymko Date: Wed, 22 Apr 2026 07:15:45 +0000 Subject: [PATCH] fix: no module named 'grpc' --- .github/actions/spelling/allow.txt | 11 ++- ...{minimal-install.yml => install-smoke.yml} | 27 +++-- ...nimal_install.py => test_install_smoke.py} | 55 +++++++++-- scripts/test_install_smoke.sh | 98 +++++++++++++++++++ src/a2a/compat/v0_3/context_builders.py | 15 +-- 5 files changed, 171 insertions(+), 35 deletions(-) rename .github/workflows/{minimal-install.yml => install-smoke.yml} (60%) rename scripts/{test_minimal_install.py => test_install_smoke.py} (51%) create mode 100755 scripts/test_install_smoke.sh diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 03774d1f0..73818db59 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -101,6 +101,8 @@ openapiv2 opensource otherurl pb2 +podman +Podman poolclass postgres POSTGRES @@ -128,6 +130,8 @@ socio sse starlette Starlette +subgids +subuids sut SUT swagger @@ -139,9 +143,6 @@ tiangolo TResponse typ typeerror -vulnz -Podman -podman UIDs -subuids -subgids +vulnz +whl diff --git a/.github/workflows/minimal-install.yml b/.github/workflows/install-smoke.yml similarity index 60% rename from .github/workflows/minimal-install.yml rename to .github/workflows/install-smoke.yml index 27afebe7e..0b9781b2c 100644 --- a/.github/workflows/minimal-install.yml +++ b/.github/workflows/install-smoke.yml @@ -1,5 +1,5 @@ --- -name: Minimal Install Smoke Test +name: Install Smoke Test on: push: branches: [main, 1.0-dev] @@ -30,13 +30,18 @@ permissions: contents: read jobs: - minimal-install: - name: Verify base-only install + install-smoke: + name: Verify ${{ matrix.profile.name }} install runs-on: ubuntu-latest if: github.repository == 'a2aproject/a2a-python' strategy: matrix: python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + profile: + - name: base + extras: '' + - name: http-server + extras: '[http-server]' steps: - name: Checkout code uses: actions/checkout@v6 @@ -49,15 +54,17 @@ jobs: - name: Build package run: uv build --wheel - - name: Install with base dependencies only + - name: Install with ${{ matrix.profile.name }} dependencies only run: | - uv venv .venv-minimal - # Install only the built wheel -- no extras, no dev deps. - # This simulates what an end-user gets with `pip install a2a-sdk`. - VIRTUAL_ENV=.venv-minimal uv pip install dist/*.whl + uv venv .venv-smoke + # Install only the built wheel + the profile's extras -- no + # dev deps. This simulates what an end-user gets with + # `pip install a2a-sdk${{ matrix.profile.extras }}`. + WHEEL=$(ls dist/*.whl) + VIRTUAL_ENV=.venv-smoke uv pip install "${WHEEL}${{ matrix.profile.extras }}" - name: List installed packages - run: VIRTUAL_ENV=.venv-minimal uv pip list + run: VIRTUAL_ENV=.venv-smoke uv pip list - name: Run import smoke test - run: .venv-minimal/bin/python scripts/test_minimal_install.py + run: .venv-smoke/bin/python scripts/test_install_smoke.py ${{ matrix.profile.name }} diff --git a/scripts/test_minimal_install.py b/scripts/test_install_smoke.py similarity index 51% rename from scripts/test_minimal_install.py rename to scripts/test_install_smoke.py index 84e3ee3fc..df33c8386 100755 --- a/scripts/test_minimal_install.py +++ b/scripts/test_install_smoke.py @@ -1,18 +1,24 @@ #!/usr/bin/env python3 -"""Smoke test for minimal (base-only) installation of a2a-sdk. +"""Smoke test for installations of a2a-sdk with various extras. -This script verifies that all core public API modules can be imported -when only the base dependencies are installed (no optional extras). +This script verifies that the public API modules associated with a +given installation profile can be imported without pulling in modules +that belong to other (uninstalled) optional extras. It is designed to run WITHOUT pytest or any dev dependencies -- just -a clean venv with `pip install a2a-sdk`. +a clean venv with `pip install a2a-sdk[]`. Usage: - python scripts/test_minimal_install.py + python scripts/test_install_smoke.py [profile] + + profile defaults to "base" and selects which set of modules to + smoke-test. Available profiles: + base -- `pip install a2a-sdk` + http-server -- `pip install a2a-sdk[http-server]` Exit codes: - 0 - All core imports succeeded - 1 - One or more core imports failed + 0 - All imports for the profile succeeded + 1 - One or more imports failed """ from __future__ import annotations @@ -58,19 +64,48 @@ 'a2a.helpers.proto_helpers', ] +# Modules that MUST be importable with only the base + `http-server` +# extras installed (no `grpc`, `sql`, `signing`, `telemetry`, etc.). +# +# A user building a Starlette/FastAPI A2A server with +# `pip install a2a-sdk[http-server]` should be able to import these +# without the gRPC stack being present on the system. +HTTP_SERVER_MODULES = [ + 'a2a.server.routes', + 'a2a.server.routes.agent_card_routes', + 'a2a.server.routes.common', + 'a2a.server.routes.jsonrpc_dispatcher', + 'a2a.server.routes.jsonrpc_routes', + 'a2a.server.routes.rest_dispatcher', + 'a2a.server.routes.rest_routes', +] + + +PROFILES: dict[str, list[str]] = { + 'base': CORE_MODULES, + 'http-server': CORE_MODULES + HTTP_SERVER_MODULES, +} + def main() -> int: + profile = sys.argv[1] if len(sys.argv) > 1 else 'base' + if profile not in PROFILES: + print(f'Unknown profile {profile!r}. Available: {sorted(PROFILES)}') + return 1 + + modules = PROFILES[profile] failures: list[str] = [] successes: list[str] = [] - for module_name in CORE_MODULES: + for module_name in modules: try: importlib.import_module(module_name) successes.append(module_name) except Exception as e: # noqa: BLE001, PERF203 failures.append(f'{module_name}: {e}') - print(f'Tested {len(CORE_MODULES)} core modules') + print(f'Profile: {profile}') + print(f'Tested {len(modules)} modules') print(f' Passed: {len(successes)}') print(f' Failed: {len(failures)}') @@ -80,7 +115,7 @@ def main() -> int: print(f' - {failure}') return 1 - print('\nAll core modules imported successfully.') + print('\nAll modules imported successfully.') return 0 diff --git a/scripts/test_install_smoke.sh b/scripts/test_install_smoke.sh new file mode 100755 index 000000000..863f9c12c --- /dev/null +++ b/scripts/test_install_smoke.sh @@ -0,0 +1,98 @@ +#!/bin/bash +# Local equivalent of .github/workflows/install-smoke.yml. +# +# For each install profile, builds the wheel and installs it into a +# clean venv (no dev deps), then runs the import smoke test for that +# profile. By default runs every known profile; pass a profile name +# to run just one. +# +# Available profiles (must match those in scripts/test_install_smoke.py): +# base -- `pip install a2a-sdk` +# http-server -- `pip install a2a-sdk[http-server]` +# +# Usage: +# scripts/test_install_smoke.sh [profile] [python-version] +# +# Examples: +# scripts/test_install_smoke.sh # all profiles, default python +# scripts/test_install_smoke.sh '' 3.13 # all profiles on python 3.13 +# scripts/test_install_smoke.sh http-server # http-server only +# scripts/test_install_smoke.sh http-server 3.13 # http-server on python 3.13 +set -e +set -o pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +ALL_PROFILES=(base http-server) + +PROFILE_ARG="${1:-}" +PYTHON_VERSION="${2:-}" + +if [ -z "$PROFILE_ARG" ]; then + PROFILES=("${ALL_PROFILES[@]}") +else + PROFILES=("$PROFILE_ARG") +fi + +extras_for_profile() { + case "$1" in + base) echo "" ;; + http-server) echo "[http-server]" ;; + *) + echo "Unknown profile '$1'. Available: ${ALL_PROFILES[*]}" >&2 + return 1 + ;; + esac +} + +# Validate profiles up-front so we fail fast. +for profile in "${PROFILES[@]}"; do + extras_for_profile "$profile" >/dev/null +done + +echo "--- Building wheel ---" +rm -rf dist +uv build --wheel +WHEEL=$(ls dist/*.whl) + +FAILED_PROFILES=() + +for profile in "${PROFILES[@]}"; do + extras=$(extras_for_profile "$profile") + venv_dir=".venv-smoke-${profile}" + + echo + echo "==================================================================" + echo " Profile: $profile (extras='$extras')" + echo "==================================================================" + + echo "--- Creating clean venv at $venv_dir ---" + rm -rf "$venv_dir" + if [ -n "$PYTHON_VERSION" ]; then + uv venv "$venv_dir" --python "$PYTHON_VERSION" + else + uv venv "$venv_dir" + fi + + echo "--- Installing built wheel with '$profile' dependencies only ---" + VIRTUAL_ENV="$venv_dir" uv pip install "${WHEEL}${extras}" + + echo "--- Installed packages ---" + VIRTUAL_ENV="$venv_dir" uv pip list + + echo "--- Running import smoke test ---" + if ! "$venv_dir/bin/python" scripts/test_install_smoke.py "$profile"; then + FAILED_PROFILES+=("$profile") + fi +done + +echo +echo "==================================================================" +if [ ${#FAILED_PROFILES[@]} -eq 0 ]; then + echo " All profiles passed: ${PROFILES[*]}" + exit 0 +fi + +echo " Failed profiles: ${FAILED_PROFILES[*]}" >&2 +exit 1 diff --git a/src/a2a/compat/v0_3/context_builders.py b/src/a2a/compat/v0_3/context_builders.py index 2f2eec362..1874853f5 100644 --- a/src/a2a/compat/v0_3/context_builders.py +++ b/src/a2a/compat/v0_3/context_builders.py @@ -5,9 +5,7 @@ adapters wrap the default builders with these classes to recognize both names. """ -from typing import TYPE_CHECKING, Any - -import grpc +from typing import TYPE_CHECKING from a2a.compat.v0_3.extension_headers import LEGACY_HTTP_EXTENSION_HEADER from a2a.extensions.common import get_requested_extensions @@ -15,21 +13,18 @@ if TYPE_CHECKING: + import grpc + from starlette.requests import Request from a2a.server.request_handlers.grpc_handler import ( GrpcServerCallContextBuilder, ) from a2a.server.routes.common import ServerCallContextBuilder -else: - try: - from starlette.requests import Request - except ImportError: - Request = Any def _get_legacy_grpc_extensions( - context: grpc.aio.ServicerContext, + context: 'grpc.aio.ServicerContext', ) -> list[str]: md = context.invocation_metadata() if md is None: @@ -71,7 +66,7 @@ class V03GrpcServerCallContextBuilder: def __init__(self, inner: 'GrpcServerCallContextBuilder') -> None: self._inner = inner - def build(self, context: grpc.aio.ServicerContext) -> ServerCallContext: + def build(self, context: 'grpc.aio.ServicerContext') -> ServerCallContext: """Builds a ServerCallContext, merging legacy extension metadata.""" server_context = self._inner.build(context) server_context.requested_extensions |= get_requested_extensions(