Skip to content

Commit f7e4c3a

Browse files
committed
tests: extend install smoke test with a runtime call
This a follow-up for #1019 to prevent similar regressions. Existing install smoke tests are updated to actually run some code with the dependencies listed in the profile. This is different from the unit tests which are executed with dev deps and all extras. In addition this PR restructures this test harness and moves it under `tests/` folder. Although those files do not use pytest and are not executed by pytest their current location under `scripts/` makes less sense as other files in this folder are helpers for local runs. A proper readme is added to clarify how it should be used.
1 parent 7af6050 commit f7e4c3a

7 files changed

Lines changed: 201 additions & 50 deletions

File tree

.github/workflows/install-smoke.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ on:
88
- 'src/**'
99
- 'pyproject.toml'
1010
- 'uv.lock'
11-
- 'scripts/test_install_smoke.py'
11+
- 'tests/install_smoke/**'
1212
- 'scripts/test_install_smoke.sh'
1313
# Self-callout: re-run when this workflow changes so YAML edits are validated in PRs.
1414
- '.github/workflows/install-smoke.yml'
@@ -58,5 +58,5 @@ jobs:
5858
- name: List installed packages
5959
run: VIRTUAL_ENV=.venv-smoke uv pip list
6060

61-
- name: Run import smoke test
62-
run: .venv-smoke/bin/python scripts/test_install_smoke.py ${{ matrix.profile.name }}
61+
- name: Run smoke test (imports + runtime checks)
62+
run: .venv-smoke/bin/python -m tests.install_smoke ${{ matrix.profile.name }}

scripts/test_install_smoke.sh

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
# Local equivalent of .github/workflows/install-smoke.yml.
33
#
44
# For each install profile, builds the wheel and installs it into a
5-
# clean venv (no dev deps), then runs the import smoke test for that
6-
# profile. By default runs every known profile; pass a profile name
7-
# to run just one.
5+
# clean venv (no dev deps), then runs the smoke test for that profile
6+
# (imports + any per-profile runtime checks). By default runs every
7+
# known profile; pass a profile name to run just one.
88
#
9-
# Available profiles (must match those in scripts/test_install_smoke.py):
9+
# Available profiles (must match those in tests/install_smoke/profiles.py):
1010
# base -- `pip install a2a-sdk`
1111
# http-server -- `pip install a2a-sdk[http-server]`
1212
# grpc -- `pip install a2a-sdk[grpc]`
@@ -87,8 +87,10 @@ for profile in "${PROFILES[@]}"; do
8787
echo "--- Installed packages ---"
8888
VIRTUAL_ENV="$venv_dir" uv pip list
8989

90-
echo "--- Running import smoke test ---"
91-
if ! "$venv_dir/bin/python" scripts/test_install_smoke.py "$profile"; then
90+
echo "--- Running smoke test (imports + runtime checks) ---"
91+
# Invoked as `python -m` from REPO_ROOT so the harness package is
92+
# importable from the smoke venv (which has no pytest / dev deps).
93+
if ! "$venv_dir/bin/python" -m tests.install_smoke "$profile"; then
9294
FAILED_PROFILES+=("$profile")
9395
fi
9496
done

tests/install_smoke/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Install-smoke harness
2+
3+
This package is **not** a pytest test suite. It is invoked as a
4+
standalone module from a freshly-installed, dev-deps-free venv:
5+
6+
```bash
7+
python -m tests.install_smoke <profile>
8+
```
9+
10+
The smoke venv is created by either
11+
[`scripts/test_install_smoke.sh`](../../scripts/test_install_smoke.sh)
12+
(local) or
13+
[`.github/workflows/install-smoke.yml`](../../.github/workflows/install-smoke.yml)
14+
(CI). The harness has no pytest dependency and uses only the Python
15+
standard library plus the freshly-installed `a2a-sdk` wheel for the
16+
profile under test.
17+
18+
For a given install profile (`base`, `http-server`, `grpc`,
19+
`telemetry`, `sql`) it runs two phases:
20+
21+
1. **Imports** — every module listed for the profile in `__main__.py`
22+
must import cleanly. Catches missing deps and accidental top-level
23+
imports of optional extras.
24+
2. **Runtime checks** (`runtime/`) — small public-API exercises that
25+
actually call into the SDK. These catch regressions where imports
26+
succeed but a real call fails.
27+
28+
## Adding a new runtime check
29+
30+
1. Drop a module under `tests/install_smoke/runtime/` exposing two
31+
names:
32+
- `NAME: str` — short human-readable label.
33+
- `check() -> None` — callable that raises on failure.
34+
2. Register it in `RUNTIME_CHECKS` in
35+
[`__main__.py`](./__main__.py) under each profile whose extras it
36+
needs.
37+
38+
Use only the dependencies guaranteed by the target profile. Do not import
39+
`pytest` or any dev-deps.

tests/install_smoke/__init__.py

Whitespace-only changes.
Lines changed: 63 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,34 @@
1-
#!/usr/bin/env python3
2-
"""Smoke test for installations of a2a-sdk with various extras.
3-
4-
This script verifies that the public API modules associated with a
5-
given installation profile can be imported without pulling in modules
6-
that belong to other (uninstalled) optional extras.
7-
8-
It is designed to run WITHOUT pytest or any dev dependencies -- just
9-
a clean venv with `pip install a2a-sdk[<profile>]`.
10-
11-
Usage:
12-
python scripts/test_install_smoke.py [profile]
13-
14-
profile defaults to "base" and selects which set of modules to
15-
smoke-test. Available profiles:
16-
base -- `pip install a2a-sdk`
17-
http-server -- `pip install a2a-sdk[http-server]`
18-
grpc -- `pip install a2a-sdk[grpc]`
19-
telemetry -- `pip install a2a-sdk[telemetry]`
20-
sql -- `pip install a2a-sdk[sql]`
1+
"""Entry point: ``python -m tests.install_smoke <profile>``.
212
223
Exit codes:
23-
0 - All imports for the profile succeeded
24-
1 - One or more imports failed
4+
0 - All imports and runtime checks for the profile succeeded.
5+
1 - One or more imports or runtime checks failed.
6+
7+
See README.md for design notes and how to add new runtime checks.
258
"""
269

2710
from __future__ import annotations
2811

2912
import importlib
3013
import sys
3114

15+
from typing import TYPE_CHECKING
16+
17+
from tests.install_smoke.runtime import base_send_message
18+
19+
20+
if TYPE_CHECKING:
21+
from collections.abc import Callable
22+
3223

3324
# Core modules that MUST be importable with only base dependencies.
3425
# These are the public API surface that every user gets with
3526
# `pip install a2a-sdk` (no extras).
3627
#
3728
# Do NOT add modules here that require optional extras (grpc,
38-
# http-server, sql, signing, telemetry, vertex, etc.).
39-
# Those modules are expected to fail without their extras installed
40-
# and should use try/except ImportError guards internally.
29+
# http-server, sql, signing, telemetry, vertex, etc.). Those modules
30+
# are expected to fail without their extras installed and should use
31+
# try/except ImportError guards internally.
4132
CORE_MODULES = [
4233
'a2a',
4334
'a2a.client',
@@ -69,10 +60,6 @@
6960

7061
# Modules that MUST be importable with only the base + `http-server`
7162
# extras installed (no `grpc`, `sql`, `signing`, `telemetry`, etc.).
72-
#
73-
# A user building a Starlette/FastAPI A2A server with
74-
# `pip install a2a-sdk[http-server]` should be able to import these
75-
# without the gRPC stack being present on the system.
7663
HTTP_SERVER_MODULES = [
7764
'a2a.server.routes',
7865
'a2a.server.routes.agent_card_routes',
@@ -116,37 +103,72 @@
116103
}
117104

118105

119-
def main() -> int:
120-
profile = sys.argv[1] if len(sys.argv) > 1 else 'base'
106+
# Per-profile runtime exercises. Each callable raises on failure and
107+
# returns None on success. These run after the import smoke succeeds
108+
# and are meant to invoke real public-API code paths against the
109+
# dependency versions resolved at install time.
110+
#
111+
# To add a new check: drop a module under `tests.install_smoke.runtime`
112+
# exposing `NAME` and `check()`, then add a tuple here for the
113+
# profile(s) whose extras it needs. See README.md.
114+
RUNTIME_CHECKS: dict[str, list[tuple[str, Callable[[], None]]]] = {
115+
'base': [
116+
(base_send_message.NAME, base_send_message.check),
117+
],
118+
}
119+
120+
121+
def main(argv: list[str]) -> int:
122+
profile = argv[1] if len(argv) > 1 else 'base'
121123
if profile not in PROFILES:
122124
print(f'Unknown profile {profile!r}. Available: {sorted(PROFILES)}')
123125
return 1
124126

125127
modules = PROFILES[profile]
126-
failures: list[str] = []
127-
successes: list[str] = []
128-
128+
import_failures: list[str] = []
129129
for module_name in modules:
130130
try:
131131
importlib.import_module(module_name)
132-
successes.append(module_name)
133132
except Exception as e: # noqa: BLE001, PERF203
134-
failures.append(f'{module_name}: {e}')
133+
import_failures.append(f'{module_name}: {e}')
135134

136135
print(f'Profile: {profile}')
137136
print(f'Tested {len(modules)} modules')
138-
print(f' Passed: {len(successes)}')
139-
print(f' Failed: {len(failures)}')
137+
print(f' Passed: {len(modules) - len(import_failures)}')
138+
print(f' Failed: {len(import_failures)}')
140139

141-
if failures:
140+
if import_failures:
142141
print('\nFAILED imports:')
143-
for failure in failures:
142+
for failure in import_failures:
144143
print(f' - {failure}')
145144
return 1
146145

147146
print('\nAll modules imported successfully.')
147+
148+
runtime_checks = RUNTIME_CHECKS.get(profile, [])
149+
if not runtime_checks:
150+
return 0
151+
152+
print(f'\nRunning {len(runtime_checks)} runtime check(s):')
153+
runtime_failures: list[str] = []
154+
for name, check in runtime_checks:
155+
try:
156+
check()
157+
except Exception as e: # noqa: BLE001, PERF203
158+
runtime_failures.append(f'{name}: {type(e).__name__}: {e}')
159+
print(f' - FAIL: {name}')
160+
else:
161+
print(f' - OK: {name}')
162+
163+
if runtime_failures:
164+
print('\nFAILED runtime checks:')
165+
for failure in runtime_failures:
166+
print(f' - {failure}')
167+
return 1
168+
169+
print('\nAll runtime checks passed.')
148170
return 0
149171

150172

151173
if __name__ == '__main__':
152-
sys.exit(main())
174+
sys.exit(main(sys.argv))

tests/install_smoke/runtime/__init__.py

Whitespace-only changes.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Drive DefaultRequestHandler.on_message_send end-to-end with no transport.
2+
3+
Uses only base dependencies (no http-server / gRPC transport).
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import asyncio
9+
10+
from a2a.helpers.proto_helpers import new_task_from_user_message
11+
from a2a.server.agent_execution import AgentExecutor
12+
from a2a.server.context import ServerCallContext
13+
from a2a.server.request_handlers import DefaultRequestHandler
14+
from a2a.server.tasks import InMemoryTaskStore
15+
from a2a.server.tasks.task_updater import TaskUpdater
16+
from a2a.types.a2a_pb2 import (
17+
AgentCapabilities,
18+
AgentCard,
19+
Message,
20+
Part,
21+
Role,
22+
SendMessageConfiguration,
23+
SendMessageRequest,
24+
Task,
25+
TaskState,
26+
)
27+
28+
NAME = 'DefaultRequestHandler.on_message_send roundtrip'
29+
30+
31+
class _HelloAgentExecutor(AgentExecutor):
32+
async def execute(self, context, event_queue) -> None: # type: ignore[no-untyped-def]
33+
task = context.current_task
34+
if not task:
35+
assert context.message is not None
36+
task = new_task_from_user_message(context.message)
37+
await event_queue.enqueue_event(task)
38+
updater = TaskUpdater(event_queue, task.id, task.context_id)
39+
await updater.update_status(
40+
TaskState.TASK_STATE_WORKING,
41+
message=updater.new_agent_message([Part(text='I am working')]),
42+
)
43+
await updater.add_artifact(
44+
[Part(text='Hello world!')], name='conversion_result'
45+
)
46+
await updater.complete()
47+
48+
async def cancel(self, context, event_queue) -> None: # type: ignore[no-untyped-def]
49+
pass
50+
51+
52+
async def _run() -> None:
53+
handler = DefaultRequestHandler(
54+
agent_executor=_HelloAgentExecutor(),
55+
task_store=InMemoryTaskStore(),
56+
agent_card=AgentCard(
57+
name='smoke',
58+
version='1.0',
59+
capabilities=AgentCapabilities(
60+
streaming=True, push_notifications=False
61+
),
62+
),
63+
)
64+
params = SendMessageRequest(
65+
message=Message(
66+
role=Role.ROLE_USER,
67+
message_id='m1',
68+
parts=[Part(text='hi')],
69+
),
70+
configuration=SendMessageConfiguration(
71+
accepted_output_modes=['text/plain']
72+
),
73+
)
74+
result = await handler.on_message_send(params, ServerCallContext())
75+
if not isinstance(result, Task):
76+
raise AssertionError( # noqa: TRY004
77+
f'expected Task result, got {type(result).__name__}'
78+
)
79+
if result.status.state != TaskState.TASK_STATE_COMPLETED:
80+
raise AssertionError(
81+
f'expected TASK_STATE_COMPLETED, got '
82+
f'{TaskState.Name(result.status.state)}'
83+
)
84+
85+
86+
def check() -> None:
87+
"""Run the roundtrip; raises on failure."""
88+
asyncio.run(_run())

0 commit comments

Comments
 (0)