diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 5fc9637..6a7973b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -7,8 +7,8 @@ on: env: FORCE_COLOR: 1 jobs: - tests: - name: Run Tests + pre-commit: + name: Pre-commit runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -23,8 +23,32 @@ jobs: with: path: ~/.cache/pre-commit key: pre-commit|${{ hashFiles('.pre-commit-config.yaml') }} + - name: Run pre-commit + run: uv run pre-commit run --all-files + tests: + name: Run Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: pre-commit + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + pytest_args: -v --cov --cov-append --cov-report=xml + - os: macos-latest + pytest_args: -m "not integration" -v --cov --cov-append --cov-report=xml + - os: windows-latest + pytest_args: -m "not integration" -v --cov --cov-append --cov-report=xml + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install uv + uses: astral-sh/setup-uv@v6 + - name: Install the project + run: uv sync --locked --all-extras --dev - name: Run tests - run: uv run tox + run: uv run tox -- ${{ matrix.pytest_args }} - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v5 with: diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..e0ee9de --- /dev/null +++ b/conftest.py @@ -0,0 +1,6 @@ +from typing import Any + + +def pytest_sessionfinish(session: Any, exitstatus: int) -> None: + if exitstatus == 5: + session.exitstatus = 0 diff --git a/packages/amgi-aiobotocore/tests_amgi_aiobotocore/test_sqs_message_integration.py b/packages/amgi-aiobotocore/tests_amgi_aiobotocore/test_sqs_message_integration.py index e1de592..fea521d 100644 --- a/packages/amgi-aiobotocore/tests_amgi_aiobotocore/test_sqs_message_integration.py +++ b/packages/amgi-aiobotocore/tests_amgi_aiobotocore/test_sqs_message_integration.py @@ -72,6 +72,7 @@ async def app( yield app +@pytest.mark.integration async def test_message( app: MockApp, queue_url: str, queue_name: str, sqs_client: Any ) -> None: @@ -115,6 +116,7 @@ async def test_message( ) +@pytest.mark.integration async def test_message_nack( app: MockApp, queue_url: str, queue_name: str, sqs_client: Any ) -> None: @@ -159,6 +161,7 @@ async def test_message_nack( assert len(messages_response["Messages"]) == 1 +@pytest.mark.integration async def test_message_send( app: MockApp, queue_url: str, queue_name: str, sqs_client: Any ) -> None: @@ -191,6 +194,7 @@ async def test_message_send( } +@pytest.mark.integration async def test_message_send_invalid_message( app: MockApp, queue_url: str, queue_name: str, sqs_client: Any ) -> None: @@ -215,6 +219,7 @@ async def test_message_send_invalid_message( ) +@pytest.mark.integration async def test_message_send_does_not_cache_invalid_queue_url( app: MockApp, queue_url: str, queue_name: str, sqs_client: Any ) -> None: @@ -258,6 +263,7 @@ async def test_message_send_does_not_cache_invalid_queue_url( assert message["MessageAttributes"] == {} +@pytest.mark.integration async def test_lifespan( queue_url: str, queue_name: str, @@ -296,6 +302,7 @@ async def test_lifespan( } +@pytest.mark.integration def test_run(queue_name: str, localstack_container: LocalStackContainer) -> None: assert_run_can_terminate( run, @@ -307,6 +314,7 @@ def test_run(queue_name: str, localstack_container: LocalStackContainer) -> None ) +@pytest.mark.integration def test_run_cli(queue_name: str, localstack_container: LocalStackContainer) -> None: assert_run_can_terminate( _run_cli, diff --git a/packages/amgi-aiokafka/tests_amgi_aiokafka/test_kafka_message_integration.py b/packages/amgi-aiokafka/tests_amgi_aiokafka/test_kafka_message_integration.py index 315291c..8a55a6b 100644 --- a/packages/amgi-aiokafka/tests_amgi_aiokafka/test_kafka_message_integration.py +++ b/packages/amgi-aiokafka/tests_amgi_aiokafka/test_kafka_message_integration.py @@ -43,8 +43,8 @@ async def app(bootstrap_server: str, topic: str) -> AsyncGenerator[MockApp, None yield app +@pytest.mark.integration async def test_message(bootstrap_server: str, app: MockApp, topic: str) -> None: - producer = AIOKafkaProducer(bootstrap_servers=bootstrap_server) await producer.start() @@ -77,6 +77,7 @@ async def test_message(bootstrap_server: str, app: MockApp, topic: str) -> None: await producer.stop() +@pytest.mark.integration async def test_message_send(bootstrap_server: str, app: MockApp, topic: str) -> None: producer = AIOKafkaProducer(bootstrap_servers=bootstrap_server) await producer.start() @@ -105,6 +106,7 @@ async def test_message_send(bootstrap_server: str, app: MockApp, topic: str) -> await producer.stop() +@pytest.mark.integration async def test_message_send_kafka_key( bootstrap_server: str, app: MockApp, topic: str ) -> None: @@ -134,6 +136,7 @@ async def test_message_send_kafka_key( await producer.stop() +@pytest.mark.integration async def test_lifespan(bootstrap_server: str, topic: str) -> None: app = MockApp() server = Server( @@ -161,9 +164,11 @@ async def test_lifespan(bootstrap_server: str, topic: str) -> None: } +@pytest.mark.integration def test_run(bootstrap_server: str, topic: str) -> None: assert_run_can_terminate(run, topic, bootstrap_servers=bootstrap_server) +@pytest.mark.integration def test_run_cli(bootstrap_server: str, topic: str) -> None: assert_run_can_terminate(_run_cli, [topic], bootstrap_servers=bootstrap_server) diff --git a/packages/amgi-paho-mqtt/tests_amgi_paho_mqtt/test_mqtt_message_integration.py b/packages/amgi-paho-mqtt/tests_amgi_paho_mqtt/test_mqtt_message_integration.py index 77ab4a2..953a866 100644 --- a/packages/amgi-paho-mqtt/tests_amgi_paho_mqtt/test_mqtt_message_integration.py +++ b/packages/amgi-paho-mqtt/tests_amgi_paho_mqtt/test_mqtt_message_integration.py @@ -53,6 +53,7 @@ async def app( yield app +@pytest.mark.integration async def test_message( app: MockApp, topic: str, mosquitto_container: MosquittoContainer ) -> None: @@ -75,6 +76,7 @@ async def test_message( } +@pytest.mark.integration async def test_message_send( app: MockApp, topic: str, mosquitto_container: MosquittoContainer ) -> None: @@ -126,6 +128,7 @@ def _message_callback( client.disconnect() +@pytest.mark.integration async def test_lifespan(topic: str, mosquitto_container: MosquittoContainer) -> None: app = MockApp() server = Server( @@ -150,6 +153,7 @@ async def test_lifespan(topic: str, mosquitto_container: MosquittoContainer) -> } +@pytest.mark.integration async def test_message_send_deny( app: MockApp, topic: str, mosquitto_container: MosquittoContainer ) -> None: @@ -168,6 +172,7 @@ async def test_message_send_deny( ) +@pytest.mark.integration def test_run(topic: str, mosquitto_container: MosquittoContainer) -> None: assert_run_can_terminate( run, diff --git a/packages/amgi-redis/tests_amgi_redis/test_redis_message_integration.py b/packages/amgi-redis/tests_amgi_redis/test_redis_message_integration.py index 691799f..1c03b5e 100644 --- a/packages/amgi-redis/tests_amgi_redis/test_redis_message_integration.py +++ b/packages/amgi-redis/tests_amgi_redis/test_redis_message_integration.py @@ -37,6 +37,7 @@ async def app( yield app +@pytest.mark.integration async def test_message( app: MockApp, channel: str, redis_container: AsyncRedisContainer ) -> None: @@ -69,6 +70,7 @@ async def _get_message(pubsub: PubSub) -> Any: return message +@pytest.mark.integration async def test_message_send( app: MockApp, channel: str, redis_container: AsyncRedisContainer ) -> None: @@ -100,6 +102,7 @@ async def test_message_send( } +@pytest.mark.integration async def test_lifespan(redis_container: AsyncRedisContainer, channel: str) -> None: client = await redis_container.get_async_client() @@ -123,6 +126,7 @@ async def test_lifespan(redis_container: AsyncRedisContainer, channel: str) -> N } +@pytest.mark.integration def test_run(redis_container: AsyncRedisContainer, channel: str) -> None: host = redis_container.get_container_host_ip() port = redis_container.get_exposed_port(redis_container.port) @@ -130,6 +134,7 @@ def test_run(redis_container: AsyncRedisContainer, channel: str) -> None: assert_run_can_terminate(run, channel, url=f"redis://{host}:{port}") +@pytest.mark.integration def test_run_cli(redis_container: AsyncRedisContainer, channel: str) -> None: host = redis_container.get_container_host_ip() port = redis_container.get_exposed_port(redis_container.port) diff --git a/packages/amgi-sqs-event-source-mapping/src/amgi_sqs_event_source_mapping/__init__.py b/packages/amgi-sqs-event-source-mapping/src/amgi_sqs_event_source_mapping/__init__.py index 06fd338..c73a8e3 100644 --- a/packages/amgi-sqs-event-source-mapping/src/amgi_sqs_event_source_mapping/__init__.py +++ b/packages/amgi-sqs-event-source-mapping/src/amgi_sqs_event_source_mapping/__init__.py @@ -226,7 +226,11 @@ def __init__( self._lifespan_context: Lifespan | None = None self._state: dict[str, Any] = {} self._client_instantiated = False - self._loop.add_signal_handler(signal.SIGTERM, self._sigterm_handler) + try: + self._loop.add_signal_handler(signal.SIGTERM, self._sigterm_handler) + except NotImplementedError: + # Windows / non-main thread: no signal handlers via asyncio + pass @cached_property def _client(self) -> Any: diff --git a/packages/amgi-sqs-event-source-mapping/tests_amgi_sqs_event_source_mapping/test_sqs_handler_integration.py b/packages/amgi-sqs-event-source-mapping/tests_amgi_sqs_event_source_mapping/test_sqs_handler_integration.py index 1917f1f..eb9d944 100644 --- a/packages/amgi-sqs-event-source-mapping/tests_amgi_sqs_event_source_mapping/test_sqs_handler_integration.py +++ b/packages/amgi-sqs-event-source-mapping/tests_amgi_sqs_event_source_mapping/test_sqs_handler_integration.py @@ -17,6 +17,7 @@ async def localstack_container() -> AsyncGenerator[LocalStackContainer, None]: yield localstack_container +@pytest.mark.integration async def test_sqs_handler_record_send( localstack_container: LocalStackContainer, ) -> None: @@ -89,6 +90,7 @@ async def test_sqs_handler_record_send( await call_task +@pytest.mark.integration async def test_sqs_handler_record_send_invalid_message( localstack_container: LocalStackContainer, ) -> None: diff --git a/packages/asyncfast-cli/pyproject.toml b/packages/asyncfast-cli/pyproject.toml index 66cb452..a46767b 100644 --- a/packages/asyncfast-cli/pyproject.toml +++ b/packages/asyncfast-cli/pyproject.toml @@ -30,5 +30,13 @@ dependencies = [ "typer>=0.16.0", ] +[dependency-groups] +dev = [ + "pytest>=8.4.1", + "pytest-asyncio>=1.3.0", + "pytest-cov>=7.0.0", + "pytest-timeout>=2.4.0", +] + [tool.uv.sources.amgi-types] workspace = true diff --git a/packages/asyncfast-cli/src/asyncfast_cli/cli.py b/packages/asyncfast-cli/src/asyncfast_cli/cli.py index 2286e3c..c55fa54 100644 --- a/packages/asyncfast-cli/src/asyncfast_cli/cli.py +++ b/packages/asyncfast-cli/src/asyncfast_cli/cli.py @@ -55,25 +55,26 @@ def callback() -> None: pass +run_app = typer.Typer() +app.add_typer(run_app, name="run") + +for entry_point in entry_points().select(group="amgi_server"): + try: + test_app = typer.Typer() + function = entry_point.load() + + for name, annotation in function.__annotations__.items(): + if annotation is AMGIApplication: + function.__annotations__[name] = Annotated[ + AMGIApplication, typer.Argument(parser=import_from_string) + ] + test_app.command(entry_point.name)(function) + get_command(test_app) + run_app.command(entry_point.name)(function) + except RuntimeError: + pass + + def main() -> None: sys.path.insert(0, os.getcwd()) - - run_app = typer.Typer() - app.add_typer(run_app, name="run") - - for entry_point in entry_points().get("amgi_server", ()): - try: - test_app = typer.Typer() - function = entry_point.load() - - for name, annotation in function.__annotations__.items(): - if annotation is AMGIApplication: - function.__annotations__[name] = Annotated[ - AMGIApplication, typer.Argument(parser=import_from_string) - ] - test_app.command(entry_point.name)(function) - get_command(test_app) - run_app.command(entry_point.name)(function) - except RuntimeError: - pass app() diff --git a/packages/asyncfast-cli/tests_asyncfast_cli/__init__.py b/packages/asyncfast-cli/tests_asyncfast_cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/asyncfast-cli/tests_asyncfast_cli/main.py b/packages/asyncfast-cli/tests_asyncfast_cli/main.py new file mode 100644 index 0000000..a505a83 --- /dev/null +++ b/packages/asyncfast-cli/tests_asyncfast_cli/main.py @@ -0,0 +1,11 @@ +from unittest.mock import Mock + +app = Mock() + +app.asyncapi.return_value = { + "asyncapi": "3.0.0", + "info": {"title": "AsyncFast", "version": "0.1.0"}, + "channels": {}, + "operations": {}, + "components": {"messages": {}}, +} diff --git a/packages/asyncfast-cli/tests_asyncfast_cli/test_asyncapi.py b/packages/asyncfast-cli/tests_asyncfast_cli/test_asyncapi.py new file mode 100644 index 0000000..337972c --- /dev/null +++ b/packages/asyncfast-cli/tests_asyncfast_cli/test_asyncapi.py @@ -0,0 +1,21 @@ +import json +import sys +from pathlib import Path + +from asyncfast_cli.cli import app +from typer.testing import CliRunner + +runner = CliRunner() + + +def test_asyncapi() -> None: + sys.path.insert(0, str(Path(__file__).parent)) + result = runner.invoke(app, ["asyncapi", "main:app"]) + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "asyncapi": "3.0.0", + "info": {"title": "AsyncFast", "version": "0.1.0"}, + "channels": {}, + "operations": {}, + "components": {"messages": {}}, + } diff --git a/packages/asyncfast-cli/tests_asyncfast_cli/test_run.py b/packages/asyncfast-cli/tests_asyncfast_cli/test_run.py new file mode 100644 index 0000000..21563eb --- /dev/null +++ b/packages/asyncfast-cli/tests_asyncfast_cli/test_run.py @@ -0,0 +1,63 @@ +import sys +from importlib import metadata +from pathlib import Path +from typing import Generator +from unittest.mock import Mock +from unittest.mock import patch + +import pytest +from amgi_types import AMGIApplication +from typer.testing import CliRunner + + +@pytest.fixture +def mock_entry_points_select() -> Generator[Mock, None, None]: + with patch.object(metadata, "entry_points") as mock_entry_points: + yield mock_entry_points.return_value.select + + +runner = CliRunner() + + +def test_run_app(mock_entry_points_select: Mock) -> None: + sys.path.insert(0, str(Path(__file__).parent)) + + mock_run = Mock() + + def _run(app: AMGIApplication) -> None: + mock_run(app) + + mock_entry_point = Mock() + mock_entry_point.name = "test" + mock_entry_point.load.return_value = _run + + mock_entry_points_select.return_value = [mock_entry_point] + + sys.modules.pop("asyncfast_cli.cli", None) + + from asyncfast_cli.cli import app + + result = runner.invoke(app, ["run", "test", "main:app"]) + assert result.exit_code == 0 + + assert mock_run.mock_calls[0].args[0].asyncapi() == { + "asyncapi": "3.0.0", + "channels": {}, + "components": {"messages": {}}, + "info": {"title": "AsyncFast", "version": "0.1.0"}, + "operations": {}, + } + + +def test_run_app_load_failure(mock_entry_points_select: Mock) -> None: + mock_entry_point = Mock() + mock_entry_point.load.side_effect = RuntimeError + + mock_entry_points_select.return_value = [mock_entry_point] + + sys.modules.pop("asyncfast_cli.cli", None) + + from asyncfast_cli.cli import app + + result = runner.invoke(app, ["run", "test", "main:app"]) + assert result.exit_code != 0 diff --git a/pyproject.toml b/pyproject.toml index 688f002..f2ffa21 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,10 +42,14 @@ members = [ [tool.pytest.ini_options] asyncio_mode = "auto" timeout = 60 +timeout_func_only = true filterwarnings = [ "ignore:^The wait_for_logs function with string or callable predicates is deprecated:DeprecationWarning", "ignore:^The @wait_container_is_ready decorator is deprecated:DeprecationWarning", ] +markers = [ + "integration: tests that require external services (Docker/Testcontainers)", +] [tool.mypy] warn_unused_configs = true diff --git a/tox.ini b/tox.ini index 5722a1a..9159f3f 100644 --- a/tox.ini +++ b/tox.ini @@ -4,8 +4,7 @@ requires = env_list = py313-asyncfast-pydantic2{8-12} clean - pre-commit - py3{10-13}-{amgi-aiobotocore, amgi-aiokafka, amgi-common, amgi-paho-mqtt, amgi-redis, amgi-sqs-event-source-mapping} + py3{10-13}-{amgi-aiobotocore, amgi-aiokafka, amgi-common, amgi-paho-mqtt, amgi-redis, amgi-sqs-event-source-mapping, asyncfast-cli} py3{10-12}-asyncfast-pydantic2{0-12} py3{10-13}-{amgi-aiobotocore, amgi-aiokafka, amgi-common, amgi-paho-mqtt, amgi-redis, amgi-sqs-event-source-mapping, amgi-types, asyncfast, asyncfast-cli}-import @@ -31,11 +30,6 @@ commands = coverage erase uv_sync_flags = --all-packages -[testenv:pre-commit] -description = run pre-commit -commands = - pre-commit run --all-files --show-diff-on-failure - [testenv:py3{10-13}-asyncfast-pydantic2{0-12}] commands_pre = pydantic20: uv pip install "pydantic>=2.0,<2.1" @@ -142,6 +136,11 @@ uv_sync_flags = --package=asyncfast --no-dev +[testenv:py3{10-13}-asyncfast-cli] +commands = + {[testenv]commands} packages/asyncfast-cli +uv_sync_flags = --package=asyncfast-cli + [testenv:py3{10-13}-asyncfast-cli-import] commands = python -c "import asyncfast_cli" diff --git a/uv.lock b/uv.lock index 4fa4a30..2a5c204 100644 --- a/uv.lock +++ b/uv.lock @@ -596,12 +596,28 @@ dependencies = [ { name = "typer" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-timeout" }, +] + [package.metadata] requires-dist = [ { name = "amgi-types", editable = "packages/amgi-types" }, { name = "typer", specifier = ">=0.16.0" }, ] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-timeout", specifier = ">=2.4.0" }, +] + [[package]] name = "attrs" version = "25.4.0"