From 657b52f165ec9f8fe0af542fa57730579af1a9ce Mon Sep 17 00:00:00 2001 From: Stavros Michalovits Date: Tue, 27 Oct 2026 18:04:15 +0000 Subject: [PATCH 1/5] Add amqp-imposter --- Makefile | 14 +- dockerfiles/dev.Dockerfile | 3 + src/tests/amqp_imposter/.gitattributes | 4 + src/tests/amqp_imposter/.gitignore | 27 + src/tests/amqp_imposter/README.md | 170 + .../amqp_imposter/amqp_imposter/__init__.py | 0 .../amqp_imposter/amqp/__init__.py | 0 .../amqp_imposter/amqp/method_framing.py | 727 +++++ .../amqp_imposter/amqp/server.py | 155 + .../amqp_imposter/amqp/session.py | 319 ++ .../amqp_imposter/amqp/transcript.py | 80 + .../amqp_imposter/control/__init__.py | 0 .../amqp_imposter/control/__main__.py | 21 + .../amqp_imposter/control/cli.py | 135 + .../amqp_imposter/control/client/__init__.py | 65 + .../control/client/configuration_util.py | 79 + .../amqp_imposter/control/client/imposter.py | 149 + .../control/client/transcript_util.py | 152 + .../amqp_imposter/control/config_proto.py | 630 ++++ .../amqp_imposter/control/helpers.py | 46 + .../amqp_imposter/control/method_proto.py | 689 +++++ .../amqp_imposter/control/service.py | 112 + .../amqp_imposter/control/transport.py | 54 + .../amqp_imposter/control/v1/__init__.py | 0 .../amqp_imposter/control/v1/imposter_pb2.py | 229 ++ .../amqp_imposter/control/v1/imposter_pb2.pyi | 2727 +++++++++++++++++ .../control/v1/imposter_pb2_grpc.py | 226 ++ .../control/v1/imposter_pb2_grpc.pyi | 90 + .../amqp_imposter/helpers/nonempty_tuple.py | 16 + .../amqp_imposter/amqp_imposter/imposter.py | 29 + .../amqp_imposter/model/__init__.py | 75 + .../amqp_imposter/model/constants.py | 54 + .../amqp_imposter/model/docs/ERRORS.md | 55 + .../amqp_imposter/model/docs/GENERATORS.md | 24 + .../amqp_imposter/model/docs/INTRO.md | 22 + .../amqp_imposter/model/docs/PIPELINING.md | 5 + .../amqp_imposter/model/docs/REVEAL.md | 43 + .../amqp_imposter/model/docs/WEIGHTS.md | 152 + .../amqp_imposter/model/error_replies.py | 107 + .../amqp_imposter/model/generators.py | 127 + .../model/helpers/revealing_line.py | 89 + .../amqp_imposter/model/method_bag.py | 254 ++ .../amqp_imposter/model/method_fields.py | 268 ++ .../amqp_imposter/model/method_sampling.py | 93 + .../amqp_imposter/model/method_scripts.py | 50 + .../amqp_imposter/model/method_unification.py | 78 + .../amqp_imposter/model/method_weights.py | 67 + .../amqp_imposter/model/methods.py | 483 +++ .../amqp_imposter/model/model_server.py | 145 + .../amqp_imposter/model/model_state.py | 831 +++++ .../model/negotiation_options.py | 48 + .../amqp_imposter/model/transition_receive.py | 530 ++++ .../amqp_imposter/model/transition_send.py | 294 ++ .../amqp_imposter/amqp_imposter/py.typed | 0 .../amqp_imposter/control/v1/imposter.proto | 562 ++++ src/tests/amqp_imposter/pyproject.toml | 111 + src/tests/amqp_imposter/requirements-dev.txt | 12 + .../amqp_imposter/tests/amqp/__init__.py | 0 src/tests/amqp_imposter/tests/amqp/helpers.py | 170 + .../tests/amqp/test_method_framing.py | 236 ++ .../amqp_imposter/tests/amqp/test_server.py | 427 +++ .../amqp_imposter/tests/amqp/test_session.py | 436 +++ .../tests/amqp/test_transcript.py | 63 + .../amqp_imposter/tests/control/__init__.py | 0 .../amqp_imposter/tests/control/conftest.py | 30 + .../amqp_imposter/tests/control/helpers.py | 35 + .../amqp_imposter/tests/control/test_cli.py | 252 ++ .../tests/control/test_config_proto.py | 256 ++ .../tests/control/test_method_proto.py | 200 ++ .../amqp_imposter/tests/control/test_serve.py | 172 ++ .../tests/control/test_servicer.py | 152 + .../tests/control/test_transport.py | 35 + src/tests/amqp_imposter/tests/e2e/README.md | 10 + src/tests/amqp_imposter/tests/e2e/__init__.py | 0 .../amqp_imposter/tests/e2e/generators.py | 56 + src/tests/amqp_imposter/tests/e2e/property.py | 60 + .../tests/e2e/resilient_consumer/consumer.py | 109 + .../resilient_consumer/consumer_properties.py | 184 ++ .../e2e/resilient_consumer/consumer_test.py | 214 ++ .../tests/e2e/resilient_producer/producer.py | 152 + .../resilient_producer/producer_properties.py | 148 + .../e2e/resilient_producer/producer_test.py | 92 + src/tests/amqp_imposter/tests/helpers.py | 53 + .../amqp_imposter/tests/model/__init__.py | 0 .../tests/model/class_basic/__init__.py | 0 .../tests/model/class_basic/test_cancel.py | 195 ++ .../tests/model/class_basic/test_consume.py | 200 ++ .../tests/model/class_basic/test_deliver.py | 34 + .../tests/model/class_basic/test_get.py | 345 +++ .../model/class_basic/test_many_unacked.py | 74 + .../class_basic/test_many_unconfirmed.py | 135 + .../class_basic/test_message_properties.py | 112 + .../model/class_basic/test_multiple_ack.py | 163 + .../class_basic/test_multiple_confirm.py | 58 + .../tests/model/class_basic/test_no_ack.py | 161 + .../model/class_basic/test_no_confirms.py | 80 + .../tests/model/class_basic/test_publish.py | 198 ++ .../tests/model/class_basic/test_qos.py | 147 + .../model/class_basic/test_reject_nack.py | 186 ++ .../tests/model/class_basic/test_return.py | 203 ++ .../tests/model/class_channel/__init__.py | 0 .../tests/model/class_channel/test_channel.py | 76 + .../model/class_channel/test_teardown.py | 175 ++ .../tests/model/class_confirm/__init__.py | 0 .../tests/model/class_confirm/test_select.py | 150 + .../tests/model/class_connection/__init__.py | 0 .../model/class_connection/test_connection.py | 112 + .../model/class_connection/test_handshake.py | 146 + .../model/class_connection/test_teardown.py | 169 + .../tests/model/class_exchange/__init__.py | 0 .../tests/model/class_exchange/test_bind.py | 131 + .../model/class_exchange/test_declare.py | 125 + .../tests/model/class_queue/__init__.py | 0 .../tests/model/class_queue/test_declare.py | 194 ++ .../tests/model/generative/test_generators.py | 104 + .../tests/model/generative/test_sampler.py | 259 ++ .../tests/model/generative/test_script.py | 201 ++ .../tests/model/generative/test_weights.py | 64 + .../amqp_imposter/tests/model/helpers.py | 394 +++ .../model/model_server/test_enumeration.py | 66 + .../tests/model/model_server/test_receive.py | 39 + .../tests/model/model_server/test_send.py | 127 + .../tests/model/scenarios/__init__.py | 0 .../tests/model/scenarios/test_consumer.py | 136 + .../model/scenarios/test_pipelined_methods.py | 349 +++ .../tests/model/scenarios/test_poller.py | 69 + .../model/scenarios/test_produce_consume.py | 36 + .../tests/model/scenarios/test_publisher.py | 121 + .../tests/model/scenarios/test_trace.py | 120 + .../tests/model/test_reply_codes.py | 22 + .../tests/model/test_revealing_line.py | 88 + .../model/unification/test_concretise.py | 79 + .../tests/model/unification/test_unify.py | 94 + .../amqp_imposter/tests/test_banned_style.py | 50 + 134 files changed, 21054 insertions(+), 2 deletions(-) create mode 100644 src/tests/amqp_imposter/.gitattributes create mode 100644 src/tests/amqp_imposter/.gitignore create mode 100644 src/tests/amqp_imposter/README.md create mode 100644 src/tests/amqp_imposter/amqp_imposter/__init__.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/amqp/__init__.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/amqp/method_framing.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/amqp/server.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/amqp/session.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/amqp/transcript.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/__init__.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/__main__.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/cli.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/client/__init__.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/client/configuration_util.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/client/imposter.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/client/transcript_util.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/config_proto.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/helpers.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/method_proto.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/service.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/transport.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/v1/__init__.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2.pyi create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2_grpc.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2_grpc.pyi create mode 100644 src/tests/amqp_imposter/amqp_imposter/helpers/nonempty_tuple.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/imposter.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/__init__.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/constants.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/docs/ERRORS.md create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/docs/GENERATORS.md create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/docs/INTRO.md create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/docs/PIPELINING.md create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/docs/REVEAL.md create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/docs/WEIGHTS.md create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/error_replies.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/generators.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/helpers/revealing_line.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/method_bag.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/method_fields.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/method_sampling.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/method_scripts.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/method_unification.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/method_weights.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/methods.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/model_server.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/model_state.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/negotiation_options.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/transition_receive.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/model/transition_send.py create mode 100644 src/tests/amqp_imposter/amqp_imposter/py.typed create mode 100644 src/tests/amqp_imposter/proto/amqp_imposter/control/v1/imposter.proto create mode 100644 src/tests/amqp_imposter/pyproject.toml create mode 100644 src/tests/amqp_imposter/requirements-dev.txt create mode 100644 src/tests/amqp_imposter/tests/amqp/__init__.py create mode 100644 src/tests/amqp_imposter/tests/amqp/helpers.py create mode 100644 src/tests/amqp_imposter/tests/amqp/test_method_framing.py create mode 100644 src/tests/amqp_imposter/tests/amqp/test_server.py create mode 100644 src/tests/amqp_imposter/tests/amqp/test_session.py create mode 100644 src/tests/amqp_imposter/tests/amqp/test_transcript.py create mode 100644 src/tests/amqp_imposter/tests/control/__init__.py create mode 100644 src/tests/amqp_imposter/tests/control/conftest.py create mode 100644 src/tests/amqp_imposter/tests/control/helpers.py create mode 100644 src/tests/amqp_imposter/tests/control/test_cli.py create mode 100644 src/tests/amqp_imposter/tests/control/test_config_proto.py create mode 100644 src/tests/amqp_imposter/tests/control/test_method_proto.py create mode 100644 src/tests/amqp_imposter/tests/control/test_serve.py create mode 100644 src/tests/amqp_imposter/tests/control/test_servicer.py create mode 100644 src/tests/amqp_imposter/tests/control/test_transport.py create mode 100644 src/tests/amqp_imposter/tests/e2e/README.md create mode 100644 src/tests/amqp_imposter/tests/e2e/__init__.py create mode 100644 src/tests/amqp_imposter/tests/e2e/generators.py create mode 100644 src/tests/amqp_imposter/tests/e2e/property.py create mode 100644 src/tests/amqp_imposter/tests/e2e/resilient_consumer/consumer.py create mode 100644 src/tests/amqp_imposter/tests/e2e/resilient_consumer/consumer_properties.py create mode 100644 src/tests/amqp_imposter/tests/e2e/resilient_consumer/consumer_test.py create mode 100644 src/tests/amqp_imposter/tests/e2e/resilient_producer/producer.py create mode 100644 src/tests/amqp_imposter/tests/e2e/resilient_producer/producer_properties.py create mode 100644 src/tests/amqp_imposter/tests/e2e/resilient_producer/producer_test.py create mode 100644 src/tests/amqp_imposter/tests/helpers.py create mode 100644 src/tests/amqp_imposter/tests/model/__init__.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/__init__.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_cancel.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_consume.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_deliver.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_get.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_many_unacked.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_many_unconfirmed.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_message_properties.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_multiple_ack.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_multiple_confirm.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_no_ack.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_no_confirms.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_publish.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_qos.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_reject_nack.py create mode 100644 src/tests/amqp_imposter/tests/model/class_basic/test_return.py create mode 100644 src/tests/amqp_imposter/tests/model/class_channel/__init__.py create mode 100644 src/tests/amqp_imposter/tests/model/class_channel/test_channel.py create mode 100644 src/tests/amqp_imposter/tests/model/class_channel/test_teardown.py create mode 100644 src/tests/amqp_imposter/tests/model/class_confirm/__init__.py create mode 100644 src/tests/amqp_imposter/tests/model/class_confirm/test_select.py create mode 100644 src/tests/amqp_imposter/tests/model/class_connection/__init__.py create mode 100644 src/tests/amqp_imposter/tests/model/class_connection/test_connection.py create mode 100644 src/tests/amqp_imposter/tests/model/class_connection/test_handshake.py create mode 100644 src/tests/amqp_imposter/tests/model/class_connection/test_teardown.py create mode 100644 src/tests/amqp_imposter/tests/model/class_exchange/__init__.py create mode 100644 src/tests/amqp_imposter/tests/model/class_exchange/test_bind.py create mode 100644 src/tests/amqp_imposter/tests/model/class_exchange/test_declare.py create mode 100644 src/tests/amqp_imposter/tests/model/class_queue/__init__.py create mode 100644 src/tests/amqp_imposter/tests/model/class_queue/test_declare.py create mode 100644 src/tests/amqp_imposter/tests/model/generative/test_generators.py create mode 100644 src/tests/amqp_imposter/tests/model/generative/test_sampler.py create mode 100644 src/tests/amqp_imposter/tests/model/generative/test_script.py create mode 100644 src/tests/amqp_imposter/tests/model/generative/test_weights.py create mode 100644 src/tests/amqp_imposter/tests/model/helpers.py create mode 100644 src/tests/amqp_imposter/tests/model/model_server/test_enumeration.py create mode 100644 src/tests/amqp_imposter/tests/model/model_server/test_receive.py create mode 100644 src/tests/amqp_imposter/tests/model/model_server/test_send.py create mode 100644 src/tests/amqp_imposter/tests/model/scenarios/__init__.py create mode 100644 src/tests/amqp_imposter/tests/model/scenarios/test_consumer.py create mode 100644 src/tests/amqp_imposter/tests/model/scenarios/test_pipelined_methods.py create mode 100644 src/tests/amqp_imposter/tests/model/scenarios/test_poller.py create mode 100644 src/tests/amqp_imposter/tests/model/scenarios/test_produce_consume.py create mode 100644 src/tests/amqp_imposter/tests/model/scenarios/test_publisher.py create mode 100644 src/tests/amqp_imposter/tests/model/scenarios/test_trace.py create mode 100644 src/tests/amqp_imposter/tests/model/test_reply_codes.py create mode 100644 src/tests/amqp_imposter/tests/model/test_revealing_line.py create mode 100644 src/tests/amqp_imposter/tests/model/unification/test_concretise.py create mode 100644 src/tests/amqp_imposter/tests/model/unification/test_unify.py create mode 100644 src/tests/amqp_imposter/tests/test_banned_style.py diff --git a/Makefile b/Makefile index 1cd473d7..6a7fb28b 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,9 @@ CMAKE_PRESET?=macos-arm64-vcpkg RMQ_HOSTNAME?=localhost RMQ_MGMT?="http://localhost:15672" +IMPOSTER_FOLDER?=$(SRC_FOLDER)/src/tests/amqp_imposter +IMPOSTER_VENV?=$(BUILD_FOLDER)/imposter-venv + default: build unit @@ -22,12 +25,19 @@ unit: full: init build -full-test: full unit integration +full-test: full unit integration imposter-test integration: RMQ_USER=guest RMQ_PWD=guest RMQ_USER_MGMT="guest" RMQ_PWD_MGMT="guest" RMQ_PORT="5672" RMQ_TLS_PORT="5671" RMQ_PROXY_CONTROL_PORT="5700" \ cmake --build $(BUILD_FOLDER) -t test_integration +imposter-venv: + uv venv --allow-existing --python 3.15 $(IMPOSTER_VENV) + uv pip install --python $(IMPOSTER_VENV) -e $(IMPOSTER_FOLDER) -r $(IMPOSTER_FOLDER)/requirements-dev.txt + +imposter-test: imposter-venv + cd $(IMPOSTER_FOLDER) && $(abspath $(IMPOSTER_VENV))/bin/python -m pytest -n auto + clean: rm -rf $(BUILD_FOLDER) @@ -43,4 +53,4 @@ docker-test: docker-shell: cd $(SRC_FOLDER)/dockerfiles && docker compose run dev -.PHONY: build init unit integration clean full full-test +.PHONY: build init unit integration clean full full-test imposter-venv imposter-test diff --git a/dockerfiles/dev.Dockerfile b/dockerfiles/dev.Dockerfile index 7c7b353b..b744dde6 100644 --- a/dockerfiles/dev.Dockerfile +++ b/dockerfiles/dev.Dockerfile @@ -26,6 +26,9 @@ RUN apt-get update && apt-get install -y \ RUN pip3 install --break-system-packages --no-cache-dir "cmake>=4.3,<5" +RUN pip3 install --break-system-packages --no-cache-dir "uv>=0.12,<0.14" && \ + uv python install 3.15 + ENV VCPKG_FORCE_SYSTEM_BINARIES=1 # clone and install vcpkg diff --git a/src/tests/amqp_imposter/.gitattributes b/src/tests/amqp_imposter/.gitattributes new file mode 100644 index 00000000..1945517b --- /dev/null +++ b/src/tests/amqp_imposter/.gitattributes @@ -0,0 +1,4 @@ +*_pb2.py linguist-generated=true +*_pb2.pyi linguist-generated=true +*_pb2_grpc.py linguist-generated=true +*_pb2_grpc.pyi linguist-generated=true diff --git a/src/tests/amqp_imposter/.gitignore b/src/tests/amqp_imposter/.gitignore new file mode 100644 index 00000000..096dd938 --- /dev/null +++ b/src/tests/amqp_imposter/.gitignore @@ -0,0 +1,27 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# *egg* files, which can be generated with pip install -e . +eggs/ +.eggs/ +*.egg +*.egg-info/ + + +# Unit test / coverage reports +.coverage +coverage.xml +coverage-*.xml +junit-*.xml +tests.xml +unit-test.xml +integration-test.xml +system-test.xml + +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ + +*.lock diff --git a/src/tests/amqp_imposter/README.md b/src/tests/amqp_imposter/README.md new file mode 100644 index 00000000..9b857ccd --- /dev/null +++ b/src/tests/amqp_imposter/README.md @@ -0,0 +1,170 @@ +# AMQP Imposter + +AMQP clients are difficult to test thoroughly against a real broker like RabbitMQ. +Many bugs lurk in rare method sequences that are difficult to reproduce deterministically or cover exhaustively with hand-written tests. +How does your client juggle multiple `basic.return -> basic.ack` interleaved with broker-initiated `connection.close`? + +This imposter lets you ask these questions of your code, discover violations of properties you expect to hold across _every_ interaction, and reliably reproduce known tricky interactions. + +It is an AMQP 0-9-1 server with a model of the legal actions available to it. +A small configuration language lets you tune its behaviour with: + +- Injected `when-then`-style rules on model state transitions, to construct particular decision sequences, layered on top of... +- Weighted sampling from the legal actions using a seed, to let the imposter make its own decisions. + +The imposter fills in the blanks (fields of a method or whole methods) with values it knows are valid in the current state, in pattern-matching style. + +It supports one channel over one connection at a time. + +## Quick start + +### Python + +The `amqp_imposter` context manager runs and configures an imposter subprocess on available ports. +An example test written using the imposter: + + +```py +import pytest +from amqp_imposter.control.client import amqp_imposter, pb + + +@pytest.mark.parametrize("seed", range(500)) +def test_my_client(seed: int) -> None: + """ + Interact with the imposter, then check a desired property of the interaction. + """ + config = pb.SessionConfig(seed=seed) + + with amqp_imposter(config) as imposter: + run_my_client(imposter.amqp_port) + + transcript = imposter.transcript() + assert check_my_property(transcript), str(transcript) +``` + +The runs above each spawn their own imposter process, so they can be parallelised, for example using `pytest-xdist`. +If running serially, calling `imposter.configure(config)` will disconnect the client and prepare a new session on the same imposter process. + +Optionally tune the behaviour of the imposter by pattern matching. + +```py +from amqp_imposter.control.client import ( + pb, + weighted, + when_receive, +) + +script = ( + when_receive(pb.BasicPublishPattern(), then_send=pb.BasicReturnPattern()), +) +weights = ( + weighted(pb.BasicAckPattern(), pb.BASE_WEIGHT // 2), + weighted(pb.ConnectionClosePattern(class_id=0, method_id=0), pb.BASE_WEIGHT // 20), +) + +config = pb.SessionConfig(script=script, weights=weights, seed=seed) +``` + + +### Other languages + +Run the imposter as a foreground process: + +``` +uvx amqp-imposter@ [--socket ] [--amqp-port ] +``` + +then read the first line of `stdout` for the control socket and AMQP port. +If the imposter created the socket, it will be cleaned up on `SIGINT`/`SIGTERM`. + +```json +{"socket": "/tmp/amqp-imposter-/control.sock", "amqp_port": 5672} +``` + +Configure the imposter over gRPC on the socket using the proto schema (see [Control](#control)). + +## How does the AMQP server work? + +The actual AMQP server is thin and sits atop the more complex logic model. +It frames its output, puts it on the wire, and tracks session state (the model state machine, a transcript of the interaction, etc.). +`pika` is used for framing. +The connection is in plaintext. + +If the server detects that the client-server interaction has stalled (timed out idling since the last ingress method), it samples egress methods from the model to jumpstart it. +If there are no samples, then the client must owe us ingress. + +## How does the control server work? + +The control plane to manage the server speaks gRPC over a Unix domain socket. + +Talk to it in gRPC to: + +- Configure: set the model parameters, reset the transcript and disconnect the client +- Reset: default the model parameters, reset the transcript and disconnect the client +- Status: see the AMQP port, whether there is an active client, the model parameters +- Stream the transcript: see the events and methods exchanged + +This lets us generate typed messages for all of these to use with the language of the client under test. +It also makes it easy to represent some more complex AMQP data types, such as tables. +Generate client stubs from `proto/amqp_imposter/control/v1/imposter.proto`. + +Note on development: `tox -e proto` regenerates types from the schema. + +## How does the model work? + +During a session, what the imposter needs to track is the methods it may send next. +The server model is responsible for tracking these sendables. +It is a pure core and strongly decoupled from the imposter harness. + +A good rule of thumb for maintaining this separation is "can I interact/play with the model nicely from a REPL." + +### Methods and method patterns + +Because the sendable methods are infinite, we have to represent this bag in terms of partially-specified methods. +A method field belongs to one of two categories: + +- **Exact**. It is always fixed and determined by the model. If I ask "is this method legal now", the answer partially depends on these fields. Example: `basic.getok(delivery-tag=1)`. +- **Arbitrary**. It can be empty or fixed. If I ask "is this method legal now", the answer ignores any empty fields. When sending a method, empty arbitraries can be filled in with any value. For example, `basic.getok(body="a")` is as legal as `basic.getok(body="bc")`. The field `basic.getok(redelivered=False)` is as legal as `basic.getok(redelivered=True)` on a first-time delivery to a channel, but `True` is fixed in the representation of a known redelivery. + +### Model sampling + +Sampling is configured with patterns on methods and their exact fields. + +For example, you may set a high weight on `basic.return(*)` and some on `connection.close(class-id=0, method-id=0, *)` to see that your producer republishes correctly. +The sampling algorithm collects the matches for all patterns in order, picks the lucky pattern, samples one of its methods uniformly, and fills in the empty arbitraries. +You can weight `channel.close(reply-text="NOT_FORCED", *)` and `connection.close(reply-text="NOT_FORCED", *)` to configure separate probabilities for forced and unforced errors. + +There is one special pattern, _stop_. +The model keeps generating methods for a burst until no methods are left or this pattern is sampled. + +You do not have to manually weight everything -- there is a set of implicit patterns at the bottom of the order, weighting each method and the stop at 100 and all server-initiated teardown and non-forced errors at 0. + +On idle timeout, the imposter will also pass the special _idle weights_ to the model, at the the top of the order. +For example, you can use this to lower the chance of broker-initiated teardown during longer pauses by the client. + +### Model scripting + +Scripting is configured with patterns that activate after any send or receive transition. + +They sit above weighted patterns in the resolution order, including above the stop pattern. +Therefore, you can configure the rules you want and let the imposter fall through to its own judgement when there is no match. + +For example, configuring `when=received(basic.publish(*)), then_send=basic.ack(*)` means that after applying a received publish, the model should send an ack. +If the ack is not valid at that point, it will try the next matching pattern, in order. + +Use the configurable _max sends_ to bound the bursts, 1,000 by default. +It is always a finite value, as a misconfigured ruleset could produce infinite loops: `when=sent(basic.deliver(*)), then_send=basic.deliver(*)`. + +### Representing topology + +There is no persistent broker state; anything is fair game on a connection as long as it does not contradict the state of the interaction. +If your client declares an exchange, that does not permanently make the exchange exist inside the AMQP server you are talking to. +For example, in the imposter, publishing to an undeclared exchange will not deterministically produce channel error and publishing to a declared one may still 404. + +In practice, vhosts are shared resources and their topologies volatile. +All those states -- existence and non-existence, changed arguments, changed message counts, etc. -- need to be represented by the model, so it does not need to track past declarations and bindings. + +## Decision register + +See [model/docs](amqp_imposter/model/docs/). diff --git a/src/tests/amqp_imposter/amqp_imposter/__init__.py b/src/tests/amqp_imposter/amqp_imposter/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/amqp_imposter/amqp/__init__.py b/src/tests/amqp_imposter/amqp_imposter/amqp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/amqp_imposter/amqp/method_framing.py b/src/tests/amqp_imposter/amqp_imposter/amqp/method_framing.py new file mode 100644 index 00000000..05ad17cc --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/amqp/method_framing.py @@ -0,0 +1,727 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import struct +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Any, TypeIs, assert_never, cast + +import pika.frame as pf +import pika.spec as ps +from pika.exceptions import InvalidFrameError + +from ..model import method_fields as mf +from ..model import method_unification as mu +from ..model import methods as m +from ..model.generators import DEFAULT_GEN_STATE + +PROTOCOL_HEADER_091 = b"AMQP\x00\x00\x09\x01" +AMQP_TUPLE_DELIMITER = " " + +CAPABILITIES = "capabilities" +PUBLISHER_CONFIRMS = "publisher_confirms" +BASIC_NACK = "basic.nack" +CONSUMER_CANCEL_NOTIFY = "consumer_cancel_notify" + + +@dataclass(frozen=True, slots=True) +class ProtocolHeader: + wire_bytes: bytes + + +@dataclass(frozen=True, slots=True) +class MethodOnChannel: + channel: int + method: m.Method + + +@dataclass(frozen=True, slots=True) +class Heartbeat: + pass + + +@dataclass(frozen=True, slots=True) +class Undecodable: + reason: str + + +type Ingress = ProtocolHeader | MethodOnChannel | Heartbeat | Undecodable +type Egress = Heartbeat | MethodOnChannel + + +def is_content_method( + method: m.Method, +) -> TypeIs[m.BasicPublish | m.BasicReturn | m.BasicDeliver | m.BasicGetOk]: + match method: + case m.BasicPublish() | m.BasicReturn() | m.BasicDeliver() | m.BasicGetOk(): + return True + case _: + return False + + +def is_pika_content_method( + spec: Any, +) -> TypeIs[ps.Basic.Publish | ps.Basic.Return | ps.Basic.Deliver | ps.Basic.GetOk]: + match spec: + case ( + ps.Basic.Publish() + | ps.Basic.Return() + | ps.Basic.Deliver() + | ps.Basic.GetOk() + ): + return True + case _: + return False + + +def to_pika_value(value: Any) -> Any: + match value: + case mf.Void(): + return None + case frozendict(): + return {key: to_pika_value(one) for key, one in value.items()} + case tuple(): + return [to_pika_value(one) for one in value] + case _: + return value + + +def from_pika_value(value: Any) -> Any: + match value: + case None: + return mf.Void() + case dict(): + return frozendict((key, from_pika_value(one)) for key, one in value.items()) + case list(): + return tuple(from_pika_value(one) for one in value) + case _: + return value + + +def to_text(value: str | bytes | None) -> str: + match value: + case None: + return "" + case bytes(): + return value.decode() + case _: + return value + + +def from_amqp_tuple(value: str | bytes | None) -> tuple[str, ...]: + text = to_text(value) + + return tuple(text.split(AMQP_TUPLE_DELIMITER)) if text else () + + +def capabilities_of(spec: Any) -> Mapping[str, Any]: + match (spec.server_properties or {}).get(CAPABILITIES): + case Mapping() as capabilities: + return capabilities + case _: + return {} + + +def to_pika_properties(properties: mf.MessageProperties | None) -> ps.BasicProperties: + if properties is None: + return ps.BasicProperties() + + return ps.BasicProperties( + content_type=properties.content_type, + content_encoding=properties.content_encoding, + delivery_mode=cast(Any, properties.delivery_mode), + priority=properties.priority, + correlation_id=properties.correlation_id, + reply_to=properties.reply_to, + expiration=properties.expiration, + message_id=properties.message_id, + timestamp=properties.timestamp, + type=properties.type, + user_id=properties.user_id, + app_id=properties.app_id, + headers=to_pika_value(properties.headers) + if properties.headers is not None + else None, + ) + + +def from_properties(properties: ps.BasicProperties | None) -> mf.MessageProperties: + if properties is None: + return mf.MessageProperties() + + return mf.MessageProperties( + content_type=properties.content_type, + content_encoding=properties.content_encoding, + delivery_mode=cast(Any, properties.delivery_mode), + priority=properties.priority, + correlation_id=properties.correlation_id, + reply_to=properties.reply_to, + expiration=properties.expiration, + message_id=properties.message_id, + timestamp=properties.timestamp, + type=properties.type, + user_id=properties.user_id, + app_id=properties.app_id, + headers=from_pika_value(properties.headers) + if properties.headers is not None + else None, + ) + + +def to_pika(method: m.Method) -> Any: + match method: + case m.ConnectionStart(): + return ps.Connection.Start( + version_major=mf.assert_concrete(method.version_major), + version_minor=mf.assert_concrete(method.version_minor), + server_properties={ + CAPABILITIES: { + PUBLISHER_CONFIRMS: mf.assert_concrete( + method.publisher_confirms + ), + BASIC_NACK: mf.assert_concrete(method.basic_nack), + CONSUMER_CANCEL_NOTIFY: mf.assert_concrete( + method.consumer_cancel_notify + ), + } + }, + mechanisms=AMQP_TUPLE_DELIMITER.join( + mf.assert_concrete(method.mechanisms) + ), + locales=AMQP_TUPLE_DELIMITER.join(mf.assert_concrete(method.locales)), + ) + case m.ConnectionStartOk(): + return ps.Connection.StartOk( + mechanism=mf.assert_concrete(method.mechanism), + locale=mf.assert_concrete(method.locale), + response=mf.assert_concrete(method.response), + ) + case m.ConnectionTune(): + return ps.Connection.Tune( + channel_max=mf.assert_concrete(method.channel_max), + frame_max=mf.assert_concrete(method.frame_max), + heartbeat=mf.assert_concrete(method.heartbeat), + ) + case m.ConnectionTuneOk(): + return ps.Connection.TuneOk( + channel_max=mf.assert_concrete(method.channel_max), + frame_max=mf.assert_concrete(method.frame_max), + heartbeat=mf.assert_concrete(method.heartbeat), + ) + case m.ConnectionOpen(): + return ps.Connection.Open( + virtual_host=mf.assert_concrete(method.vhost), + ) + case m.ConnectionOpenOk(): + return ps.Connection.OpenOk() + case m.ConnectionClose(): + return ps.Connection.Close( + reply_code=mf.assert_concrete(method.reply_code), + reply_text=mf.assert_concrete(method.reply_text), + class_id=mf.assert_concrete(method.class_id), + method_id=mf.assert_concrete(method.method_id), + ) + case m.ConnectionCloseOk(): + return ps.Connection.CloseOk() + case m.ChannelOpen(): + return ps.Channel.Open() + case m.ChannelOpenOk(): + return ps.Channel.OpenOk() + case m.ChannelClose(): + return ps.Channel.Close( + reply_code=mf.assert_concrete(method.reply_code), + reply_text=mf.assert_concrete(method.reply_text), + class_id=mf.assert_concrete(method.class_id), + method_id=mf.assert_concrete(method.method_id), + ) + case m.ChannelCloseOk(): + return ps.Channel.CloseOk() + case m.ConfirmSelect(): + return ps.Confirm.Select( + nowait=mf.assert_concrete(method.no_wait), + ) + case m.ConfirmSelectOk(): + return ps.Confirm.SelectOk() + case m.ExchangeDeclare(): + return ps.Exchange.Declare( + exchange=mf.assert_concrete(method.exchange), + type=mf.assert_concrete(method.exchange_type), + passive=mf.assert_concrete(method.passive), + durable=mf.assert_concrete(method.durable), + auto_delete=mf.assert_concrete(method.auto_delete), + internal=mf.assert_concrete(method.internal), + nowait=mf.assert_concrete(method.no_wait), + arguments=to_pika_value(mf.assert_concrete(method.arguments)), + ) + case m.ExchangeDeclareOk(): + return ps.Exchange.DeclareOk() + case m.ExchangeBind(): + return ps.Exchange.Bind( + destination=mf.assert_concrete(method.destination), + source=mf.assert_concrete(method.source), + routing_key=mf.assert_concrete(method.routing_key), + nowait=mf.assert_concrete(method.no_wait), + arguments=to_pika_value(mf.assert_concrete(method.arguments)), + ) + case m.ExchangeBindOk(): + return ps.Exchange.BindOk() + case m.QueueDeclare(): + return ps.Queue.Declare( + queue=mf.assert_concrete(method.queue), + passive=mf.assert_concrete(method.passive), + durable=mf.assert_concrete(method.durable), + exclusive=mf.assert_concrete(method.exclusive), + auto_delete=mf.assert_concrete(method.auto_delete), + nowait=mf.assert_concrete(method.no_wait), + arguments=to_pika_value(mf.assert_concrete(method.arguments)), + ) + case m.QueueDeclareOk(): + return ps.Queue.DeclareOk( + queue=mf.assert_concrete(method.queue), + message_count=mf.assert_concrete(method.message_count), + consumer_count=mf.assert_concrete(method.consumer_count), + ) + case m.QueueBind(): + return ps.Queue.Bind( + queue=mf.assert_concrete(method.queue), + exchange=mf.assert_concrete(method.exchange), + routing_key=mf.assert_concrete(method.routing_key), + nowait=mf.assert_concrete(method.no_wait), + arguments=to_pika_value(mf.assert_concrete(method.arguments)), + ) + case m.QueueBindOk(): + return ps.Queue.BindOk() + case m.BasicQos(): + return ps.Basic.Qos( + prefetch_count=mf.assert_concrete(method.prefetch_count), + prefetch_size=mf.assert_concrete(method.prefetch_size), + global_qos=mf.assert_concrete(method.global_qos), + ) + case m.BasicQosOk(): + return ps.Basic.QosOk() + case m.BasicConsume(): + return ps.Basic.Consume( + queue=mf.assert_concrete(method.queue), + consumer_tag=mf.assert_concrete(method.consumer_tag), + no_local=mf.assert_concrete(method.no_local), + no_ack=mf.assert_concrete(method.no_ack), + nowait=mf.assert_concrete(method.no_wait), + exclusive=mf.assert_concrete(method.exclusive), + arguments=to_pika_value(mf.assert_concrete(method.arguments)), + ) + case m.BasicConsumeOk(): + return ps.Basic.ConsumeOk( + consumer_tag=mf.assert_concrete(method.consumer_tag), + ) + case m.BasicCancel(): + return ps.Basic.Cancel( + consumer_tag=mf.assert_concrete(method.consumer_tag), + nowait=mf.assert_concrete(method.no_wait), + ) + case m.BasicCancelOk(): + return ps.Basic.CancelOk( + consumer_tag=mf.assert_concrete(method.consumer_tag), + ) + case m.BasicPublish(): + return ps.Basic.Publish( + exchange=mf.assert_concrete(method.exchange), + routing_key=mf.assert_concrete(method.routing_key), + mandatory=mf.assert_concrete(method.mandatory), + immediate=mf.assert_concrete(method.immediate), + ) + case m.BasicReturn(): + return ps.Basic.Return( + reply_code=mf.assert_concrete(method.reply_code), + reply_text=mf.assert_concrete(method.reply_text), + exchange=mf.assert_concrete(method.exchange), + routing_key=mf.assert_concrete(method.routing_key), + ) + case m.BasicAck(): + return ps.Basic.Ack( + delivery_tag=mf.assert_concrete(method.delivery_tag), + multiple=mf.assert_concrete(method.multiple), + ) + case m.BasicNack(): + return ps.Basic.Nack( + delivery_tag=mf.assert_concrete(method.delivery_tag), + multiple=mf.assert_concrete(method.multiple), + requeue=mf.assert_concrete(method.requeue), + ) + case m.BasicReject(): + return ps.Basic.Reject( + delivery_tag=mf.assert_concrete(method.delivery_tag), + requeue=mf.assert_concrete(method.requeue), + ) + case m.BasicDeliver(): + return ps.Basic.Deliver( + consumer_tag=mf.assert_concrete(method.consumer_tag), + delivery_tag=mf.assert_concrete(method.delivery_tag), + redelivered=mf.assert_concrete(method.redelivered), + exchange=mf.assert_concrete(method.exchange), + routing_key=mf.assert_concrete(method.routing_key), + ) + case m.BasicGet(): + return ps.Basic.Get( + queue=mf.assert_concrete(method.queue), + no_ack=mf.assert_concrete(method.no_ack), + ) + case m.BasicGetOk(): + return ps.Basic.GetOk( + delivery_tag=mf.assert_concrete(method.delivery_tag), + redelivered=mf.assert_concrete(method.redelivered), + exchange=mf.assert_concrete(method.exchange), + routing_key=mf.assert_concrete(method.routing_key), + message_count=mf.assert_concrete(method.message_count), + ) + case m.BasicGetEmpty(): + return ps.Basic.GetEmpty() + case _: + assert_never(method) + + +def from_pika( + spec: Any, properties: ps.BasicProperties | None = None, body: bytes = b"" +) -> m.Method | None: + match spec: + case ps.Connection.Start(): + capabilities = capabilities_of(spec) + + return m.ConnectionStart( + version_major=spec.version_major, + version_minor=spec.version_minor, + mechanisms=from_amqp_tuple(spec.mechanisms), + locales=from_amqp_tuple(spec.locales), + publisher_confirms=bool(capabilities.get(PUBLISHER_CONFIRMS, False)), + basic_nack=bool(capabilities.get(BASIC_NACK, False)), + consumer_cancel_notify=bool( + capabilities.get(CONSUMER_CANCEL_NOTIFY, False) + ), + ) + case ps.Connection.StartOk(): + return m.ConnectionStartOk( + mechanism=spec.mechanism, + locale=spec.locale, + response=to_text(spec.response), + ) + case ps.Connection.Tune(): + return m.ConnectionTune( + channel_max=spec.channel_max, + frame_max=spec.frame_max, + heartbeat=spec.heartbeat, + ) + case ps.Connection.TuneOk(): + return m.ConnectionTuneOk( + channel_max=spec.channel_max, + frame_max=spec.frame_max, + heartbeat=spec.heartbeat, + ) + case ps.Connection.Open(): + return m.ConnectionOpen( + vhost=spec.virtual_host, + ) + case ps.Connection.OpenOk(): + return m.ConnectionOpenOk() + case ps.Connection.Close(): + return m.ConnectionClose( + reply_code=spec.reply_code, + reply_text=spec.reply_text, + class_id=spec.class_id, + method_id=spec.method_id, + ) + case ps.Connection.CloseOk(): + return m.ConnectionCloseOk() + case ps.Channel.Open(): + return m.ChannelOpen() + case ps.Channel.OpenOk(): + return m.ChannelOpenOk() + case ps.Channel.Close(): + return m.ChannelClose( + reply_code=spec.reply_code, + reply_text=spec.reply_text, + class_id=spec.class_id, + method_id=spec.method_id, + ) + case ps.Channel.CloseOk(): + return m.ChannelCloseOk() + case ps.Confirm.Select(): + return m.ConfirmSelect( + no_wait=spec.nowait, + ) + case ps.Confirm.SelectOk(): + return m.ConfirmSelectOk() + case ps.Exchange.Declare(): + return m.ExchangeDeclare( + exchange=spec.exchange, + exchange_type=spec.type, + passive=spec.passive, + durable=spec.durable, + auto_delete=spec.auto_delete, + internal=spec.internal, + no_wait=spec.nowait, + arguments=from_pika_value(spec.arguments), + ) + case ps.Exchange.DeclareOk(): + return m.ExchangeDeclareOk() + case ps.Exchange.Bind(): + return m.ExchangeBind( + destination=to_text(spec.destination), + source=to_text(spec.source), + routing_key=spec.routing_key, + no_wait=spec.nowait, + arguments=from_pika_value(spec.arguments), + ) + case ps.Exchange.BindOk(): + return m.ExchangeBindOk() + case ps.Queue.Declare(): + return m.QueueDeclare( + queue=spec.queue, + passive=spec.passive, + durable=spec.durable, + exclusive=spec.exclusive, + auto_delete=spec.auto_delete, + no_wait=spec.nowait, + arguments=from_pika_value(spec.arguments), + ) + case ps.Queue.DeclareOk(): + return m.QueueDeclareOk( + queue=spec.queue, + message_count=spec.message_count, + consumer_count=spec.consumer_count, + ) + case ps.Queue.Bind(): + return m.QueueBind( + queue=spec.queue, + exchange=spec.exchange, + routing_key=spec.routing_key, + no_wait=spec.nowait, + arguments=from_pika_value(spec.arguments), + ) + case ps.Queue.BindOk(): + return m.QueueBindOk() + case ps.Basic.Qos(): + return m.BasicQos( + prefetch_count=spec.prefetch_count, + prefetch_size=spec.prefetch_size, + global_qos=spec.global_qos, + ) + case ps.Basic.QosOk(): + return m.BasicQosOk() + case ps.Basic.Consume(): + return m.BasicConsume( + queue=spec.queue, + consumer_tag=spec.consumer_tag, + no_local=spec.no_local, + no_ack=spec.no_ack, + no_wait=spec.nowait, + exclusive=spec.exclusive, + arguments=from_pika_value(spec.arguments), + ) + case ps.Basic.ConsumeOk(): + return m.BasicConsumeOk( + consumer_tag=spec.consumer_tag, + ) + case ps.Basic.Cancel(): + return m.BasicCancel( + consumer_tag=spec.consumer_tag, + no_wait=spec.nowait, + ) + case ps.Basic.CancelOk(): + return m.BasicCancelOk( + consumer_tag=spec.consumer_tag, + ) + case ps.Basic.Publish(): + return m.BasicPublish( + exchange=spec.exchange, + routing_key=spec.routing_key, + mandatory=spec.mandatory, + immediate=spec.immediate, + properties=from_properties(properties), + body=body, + ) + case ps.Basic.Return(): + return m.BasicReturn( + reply_code=spec.reply_code, + reply_text=spec.reply_text, + exchange=spec.exchange, + routing_key=spec.routing_key, + properties=from_properties(properties), + body=body, + ) + case ps.Basic.Ack(): + return m.BasicAck( + delivery_tag=spec.delivery_tag, + multiple=spec.multiple, + ) + case ps.Basic.Nack(): + return m.BasicNack( + delivery_tag=spec.delivery_tag, + multiple=spec.multiple, + requeue=spec.requeue, + ) + case ps.Basic.Reject(): + return m.BasicReject( + delivery_tag=spec.delivery_tag, + requeue=spec.requeue, + ) + case ps.Basic.Deliver(): + return m.BasicDeliver( + consumer_tag=spec.consumer_tag, + delivery_tag=spec.delivery_tag, + redelivered=spec.redelivered, + exchange=spec.exchange, + routing_key=spec.routing_key, + properties=from_properties(properties), + body=body, + ) + case ps.Basic.Get(): + return m.BasicGet( + queue=spec.queue, + no_ack=spec.no_ack, + ) + case ps.Basic.GetOk(): + return m.BasicGetOk( + delivery_tag=spec.delivery_tag, + redelivered=spec.redelivered, + exchange=spec.exchange, + routing_key=spec.routing_key, + message_count=spec.message_count, + properties=from_properties(properties), + body=body, + ) + case ps.Basic.GetEmpty(): + return m.BasicGetEmpty() + case _: + return None + + +def encode(egress: Egress, frame_max: int) -> bytes: + match egress: + case Heartbeat(): + return cast(bytes, pf.Heartbeat().marshal()) + + case MethodOnChannel(channel, partial_method): + method, _ = mu.concretise(partial_method, DEFAULT_GEN_STATE) + encoded = cast(bytes, pf.Method(channel, to_pika(method)).marshal()) + + if not is_content_method(method): + return encoded + + body = mf.assert_concrete(method.body) + properties = to_pika_properties(mf.assert_concrete(method.properties)) + + encoded += cast(bytes, pf.Header(channel, len(body), properties).marshal()) + limit = frame_max - ps.FRAME_HEADER_SIZE - ps.FRAME_END_SIZE + + for start in range(0, len(body), limit): + encoded += cast( + bytes, pf.Body(channel, body[start : start + limit]).marshal() + ) + + return encoded + + +def validate_buffer_length(buffer: bytes, max_buffer: int) -> Undecodable | None: + if len(buffer) > max_buffer: + return Undecodable(f"buffer exceeds {max_buffer} bytes") + return None + + +def decode_pika_frame(buffer: bytes) -> tuple[int, Any]: + try: + return cast(tuple[int, Any], pf.decode_frame(buffer)) + except (KeyError, struct.error) as bad: + raise InvalidFrameError(f"undecodable frame: {bad!r}") from bad + + +def decode_content_method( + buffer: bytes, offset: int, frame: Any, max_buffer: int +) -> tuple[Ingress | None, int]: + consumed, header = decode_pika_frame(buffer[offset:]) + + if header is None: + return validate_buffer_length(buffer, max_buffer), len(buffer) + + if not isinstance(header, pf.Header): + return Undecodable("expected a content header"), len(buffer) + + offset += consumed + body = b"" + + while len(body) < header.body_size: + consumed, fragment = decode_pika_frame(buffer[offset:]) + + if fragment is None: + return validate_buffer_length(buffer, max_buffer), len(buffer) + + if not isinstance(fragment, pf.Body): + return Undecodable("expected a content body"), len(buffer) + + body += fragment.fragment + offset += consumed + + return decoded(frame, from_pika(frame.method, header.properties, body), offset) + + +def decoded(frame: Any, method: m.Method | None, offset: int) -> tuple[Ingress, int]: + if method is None: + return Undecodable(f"unsupported method: {frame.method.NAME}"), offset + + return MethodOnChannel(frame.channel_number, method), offset + + +def decode_method( + buffer: bytes, offset: int, frame: Any, max_buffer: int +) -> tuple[Ingress | None, int]: + if is_pika_content_method(frame.method): + return decode_content_method(buffer, offset, frame, max_buffer) + + return decoded(frame, from_pika(frame.method), offset) + + +def decode_one(buffer: bytes, max_buffer: int) -> tuple[Ingress | None, int]: + try: + consumed, frame = decode_pika_frame(buffer) + + match frame: + case None: + return validate_buffer_length(buffer, max_buffer), len(buffer) + case pf.ProtocolHeader(): + return ProtocolHeader(buffer[:consumed]), consumed + case pf.Heartbeat(): + return Heartbeat(), consumed + case pf.Method(): + return decode_method(buffer, consumed, frame, max_buffer) + case _: + reason = f"unexpected frame: {type(frame).__name__}" + return Undecodable(reason), len(buffer) + + except InvalidFrameError as bad: + return Undecodable(str(bad)), len(buffer) + + +def decode(buffer: bytes, max_buffer: int) -> tuple[tuple[Ingress, ...], bytes]: + items: list[Ingress] = [] + rest = buffer + + while rest: + item, consumed = decode_one(rest, max_buffer) + + if item is None: + return tuple(items), rest + + items.append(item) + rest = b"" if isinstance(item, Undecodable) else rest[consumed:] + + return tuple(items), rest diff --git a/src/tests/amqp_imposter/amqp_imposter/amqp/server.py b/src/tests/amqp_imposter/amqp_imposter/amqp/server.py new file mode 100644 index 00000000..3e77bef2 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/amqp/server.py @@ -0,0 +1,155 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager, suppress +from dataclasses import dataclass, field + +from .session import Drop, Session, SocketAction, Write + +HOST = "127.0.0.1" +EPHEMERAL_PORT = 0 +READ_BYTES = 64 * 1024 +MILLIS = 1_000.0 + + +@dataclass(slots=True) +class AmqpServer: + session: Session + + server: asyncio.Server | None = field(default=None, init=False) + writer: asyncio.StreamWriter | None = field(default=None, init=False) + connection: asyncio.Task[None] | None = field(default=None, init=False) + accepting_connections: bool = field(default=True, init=False) + port: int | None = field(default=None, init=False) + + async def start(self, port: int = EPHEMERAL_PORT, host: str = HOST) -> int: + self.server = await asyncio.start_server(self.serve, host, port) + self.port = int(self.server.sockets[0].getsockname()[1]) + return self.port + + async def stop(self) -> None: + async with self.rejecting_connections(): + if self.server is not None: + self.server.close() + + await self.disconnect() + + if self.server is not None: + await self.server.wait_closed() + self.server = None + + async def disconnect(self) -> None: + connection = self.connection + + if self.writer is not None: + self.writer.transport.abort() + + if connection is not None: + await asyncio.wait((connection,)) + + @asynccontextmanager + async def rejecting_connections(self) -> AsyncGenerator[None]: + self.accepting_connections = False + try: + yield + finally: + self.accepting_connections = True + + async def shutdown(self, writer: asyncio.StreamWriter) -> None: + with suppress(OSError): + writer.close() + await writer.wait_closed() + + async def do_actions( + self, actions: tuple[SocketAction, ...], writer: asyncio.StreamWriter + ) -> bool: + for action in actions: + match action: + case Write(payload): + writer.write(payload) + case Drop(): + with suppress(OSError): + await writer.drain() + return True + + with suppress(OSError): + await writer.drain() + + return False + + async def timebound_read( + self, reader: asyncio.StreamReader, time_s: float + ) -> bytes | None: + if time_s <= 0.0: + return None + + try: + async with asyncio.timeout(time_s): + return await reader.read(READ_BYTES) + except TimeoutError: + return None + except OSError: + return b"" # EOF + + async def loop( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + loop = asyncio.get_running_loop() + idle = self.session.config.idle_millis / MILLIS + deadline = loop.time() + idle + + while True: + match await self.timebound_read(reader, deadline - loop.time()): + case None: + actions = self.session.on_idle() + refreshed = True + case b"": # EOF + return + case chunk: + before = self.session.total_ingress_methods + actions = self.session.on_ingress(chunk) + refreshed = self.session.total_ingress_methods > before + + if await self.do_actions(actions, writer): + return + + if refreshed: + deadline = loop.time() + idle + + async def serve( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + if not self.accepting_connections: + await self.shutdown(writer) + return + + if self.writer is not None: + await self.do_actions(self.session.on_rejected(), writer) + await self.shutdown(writer) + return + + self.writer = writer + self.connection = asyncio.current_task() + await self.do_actions(self.session.on_connected(), writer) + + try: + await self.loop(reader, writer) + finally: + self.writer = None + self.connection = None + self.session.on_disconnected() + await self.shutdown(writer) diff --git a/src/tests/amqp_imposter/amqp_imposter/amqp/session.py b/src/tests/amqp_imposter/amqp_imposter/amqp/session.py new file mode 100644 index 00000000..48d463a9 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/amqp/session.py @@ -0,0 +1,319 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time +from dataclasses import dataclass, field + +from ..model import generators as g +from ..model import method_fields as mf +from ..model import methods as m +from ..model import model_state as ms +from ..model.generators import DEFAULT_GEN_STATE +from ..model.method_sampling import DEFAULT_MAX_SENDS +from ..model.method_scripts import ScriptRule +from ..model.method_weights import ( + DEFAULT_WEIGHTS, + STOP_WEIGHT, + WeightedPattern, +) +from ..model.model_server import ModelServer +from ..model.negotiation_options import ( + DEFAULT_NEGOTIATION_OPTIONS, + NegotiationOptions, +) +from . import method_framing as fr +from .transcript import ( + DEFAULT_MAX_TRANSCRIPT_LINES, + Transcript, + TranscriptEvent, + TranscriptLine, +) + +DEFAULT_IDLE_MILLIS = 5 +DEFAULT_MAX_BUFFER = 16 * 1024 * 1024 + +GLOBAL_CHANNEL = 0 + +REASON_SIMULTANEOUS_CONNECTION = "a connection is already active" +REASON_DUPE_PROTOCOL_HEADER = "the protocol header was already given" +REASON_UNSUPPORTED_PROTOCOL = "only AMQP 0-9-1 is supported" +REASON_NOT_GREETED = "no protocol header was given" +REASON_CONNECTION_NOT_ON_GLOBAL = "a connection method must be on the global channel" +REASON_CHANNEL_ON_GLOBAL = "a channel method cannot be on the global channel" +REASON_SIMULTANOUS_CHANNELS = "another channel is already open" +REASON_UNREPRESENTABLE = "the model cannot represent this method" +REASON_NO_AMQP_CONNECTION = "the connection is closed" + + +@dataclass(frozen=True, slots=True) +class SessionConfig: + seed: int = DEFAULT_GEN_STATE.seed + negotiation_options: NegotiationOptions = DEFAULT_NEGOTIATION_OPTIONS + script: tuple[ScriptRule, ...] = () + weights: tuple[WeightedPattern, ...] = DEFAULT_WEIGHTS + idle_weights: tuple[WeightedPattern, ...] = () + idle_millis: int = DEFAULT_IDLE_MILLIS + stop_weight: int = STOP_WEIGHT + max_sends: int = DEFAULT_MAX_SENDS + max_buffer: int = DEFAULT_MAX_BUFFER + max_transcript_lines: int = DEFAULT_MAX_TRANSCRIPT_LINES + + +@dataclass(frozen=True, slots=True) +class Write: + payload: bytes + + +@dataclass(frozen=True, slots=True) +class Drop: + reason: str = "" + + +type SocketAction = Write | Drop + +DEFAULT_SESSION_CONFIG = SessionConfig() + + +@dataclass(slots=True) +class Session: + config: SessionConfig = DEFAULT_SESSION_CONFIG + model: ModelServer = field(init=False) + lines: Transcript = field(init=False) + buffer: bytes = field(init=False, default=b"") + channel: int | None = field(init=False, default=None) + greeted: bool = field(init=False, default=False) + total_ingress_methods: int = field(init=False, default=0) + started_ns: int = field(init=False, default=0) + + def __post_init__(self) -> None: + self.reset(self.config) + + def fresh_model(self, gen_state: g.GenState) -> ModelServer: + return ModelServer( + negotiation_options=self.config.negotiation_options, gen_state=gen_state + ) + + def transcribe( + self, + event: TranscriptEvent, + method: m.Method | None = None, + detail: str = "", + wire_bytes: bytes = b"", + ) -> None: + self.lines.add( + micros=(time.monotonic_ns() - self.started_ns) // 1_000, + epoch_micros=time.time_ns() // 1_000, + event=event, + method=method, + detail=detail, + wire_bytes=wire_bytes, + ) + + def reset(self, config: SessionConfig | None = None) -> None: + self.config = self.config if config is None else config + self.model = self.fresh_model(g.GenState(self.config.seed)) + self.lines = Transcript(max_lines=self.config.max_transcript_lines) + self.buffer = b"" + self.channel = None + self.greeted = False + self.started_ns = time.monotonic_ns() + self.transcribe(TranscriptEvent.SESSION_STARTED) + + def transcript(self) -> tuple[TranscriptLine, ...]: + return tuple(self.lines.lines) + + def is_greeted(self) -> bool: + return self.greeted + + def on_connected(self) -> tuple[SocketAction, ...]: + self.transcribe(TranscriptEvent.TCP_CONNECTED) + return () + + def on_rejected(self) -> tuple[SocketAction, ...]: + self.transcribe( + TranscriptEvent.TCP_REJECTED, detail=REASON_SIMULTANEOUS_CONNECTION + ) + return (Drop(REASON_SIMULTANEOUS_CONNECTION),) + + def on_disconnected(self) -> tuple[SocketAction, ...]: + self.transcribe(TranscriptEvent.TCP_DOWN) + self.model = self.fresh_model(self.model.gen_state) + self.buffer = b"" + self.channel = None + self.greeted = False + return () + + def on_idle(self) -> tuple[SocketAction, ...]: + methods = self.sample_method_burst( + (*self.config.idle_weights, *self.config.weights) + ) + if methods: + # only record effectful idle timeouts in the transcript + # if someone really wants to they can predict where the no-op timeouts are anyway + self.transcribe(TranscriptEvent.IDLE_TIMEOUT) + + return self.egress(methods) + + def on_ingress(self, chunk: bytes) -> tuple[SocketAction, ...]: + items, self.buffer = fr.decode(self.buffer + chunk, self.config.max_buffer) + actions: list[SocketAction] = [] + + for item in items: + actions.extend(self.on_item(item)) + + if any(isinstance(action, Drop) for action in actions): + break + + return tuple(actions) + + def on_item(self, item: fr.Ingress) -> tuple[SocketAction, ...]: + match item: + case fr.ProtocolHeader(wire_bytes): + return self.on_protocol_header(wire_bytes) + case fr.Heartbeat(): + self.transcribe(TranscriptEvent.FRAME_HEARTBEAT) + return () + case fr.Undecodable(reason): + self.transcribe(TranscriptEvent.FRAME_MALFORMED, detail=reason) + return (Drop(reason),) + case fr.MethodOnChannel(channel, method): + return self.on_method(channel, method) + + def on_protocol_header(self, wire_bytes: bytes) -> tuple[SocketAction, ...]: + if self.greeted: + self.transcribe( + TranscriptEvent.METHOD_UNREPRESENTABLE, + detail=REASON_DUPE_PROTOCOL_HEADER, + ) + return (Drop(REASON_DUPE_PROTOCOL_HEADER),) + + if wire_bytes != fr.PROTOCOL_HEADER_091: + self.transcribe( + TranscriptEvent.METHOD_UNREPRESENTABLE, + detail=REASON_UNSUPPORTED_PROTOCOL, + wire_bytes=wire_bytes, + ) + return (Write(fr.PROTOCOL_HEADER_091), Drop(REASON_UNSUPPORTED_PROTOCOL)) + + self.transcribe(TranscriptEvent.PROTOCOL_HEADER, wire_bytes=wire_bytes) + + if self.model.receive_protocol_header() is not None: + self.transcribe( + TranscriptEvent.METHOD_UNREPRESENTABLE, detail=REASON_UNREPRESENTABLE + ) + return (Drop(REASON_UNREPRESENTABLE),) + + self.greeted = True + methods = self.sample_method_burst(self.config.weights) + + return self.egress(methods) + + def validate_channel(self, channel: int, method: m.Method) -> str | None: + if not self.greeted: + return REASON_NOT_GREETED + + if method.CLASS_ID == mf.ClassId.CONNECTION: + if channel == GLOBAL_CHANNEL: + return None + else: + return REASON_CONNECTION_NOT_ON_GLOBAL + + if channel == GLOBAL_CHANNEL: + return REASON_CHANNEL_ON_GLOBAL + + if self.channel is not None and channel != self.channel: + return REASON_SIMULTANOUS_CHANNELS + + return None + + def on_method(self, channel: int, method: m.Method) -> tuple[SocketAction, ...]: + if (reason := self.validate_channel(channel, method)) is not None: + self.transcribe( + TranscriptEvent.METHOD_UNREPRESENTABLE, + method=method, + detail=reason, + ) + return (Drop(reason),) + + match self.model.receive(method): + case ms.Unrepresentable(): + self.transcribe( + TranscriptEvent.METHOD_UNREPRESENTABLE, + method=method, + detail=REASON_UNREPRESENTABLE, + ) + return (Drop(REASON_UNREPRESENTABLE),) + + case concrete_method: + self.transcribe(TranscriptEvent.METHOD_INGRESS, method=concrete_method) + self.total_ingress_methods += 1 + + if channel != GLOBAL_CHANNEL: + self.channel = channel + + methods = self.sample_method_burst(self.config.weights) + actions = self.egress(methods) + self.release_channel() + + return ( + (*actions, Drop(REASON_NO_AMQP_CONNECTION)) + if self.finished() + else actions + ) + + def finished(self) -> bool: + return self.greeted and isinstance( + self.model.model_state.connection_state, ms.ClosedConnectionState + ) + + def release_channel(self) -> None: + match self.model.model_state.connection_state: + case ms.OpenConnectionState(channel_state=ms.OpenChannelState()): + return + case ms.OpenConnectionState(channel_state=ms.ClosingChannelState()): + return + case _: + self.channel = None + + def egress_channel(self, method: m.Method) -> int: + if method.CLASS_ID == mf.ClassId.CONNECTION or self.channel is None: + return GLOBAL_CHANNEL + + return self.channel + + def sample_method_burst( + self, weighted_patterns: tuple[WeightedPattern, ...] + ) -> list[m.Method]: + return self.model.send_gens( + self.config.script, + weighted_patterns, + self.config.stop_weight, + self.config.max_sends, + ) + + def egress(self, methods: list[m.Method]) -> tuple[SocketAction, ...]: + payload = b"" + + for method in methods: + encoded = fr.encode( + fr.MethodOnChannel(self.egress_channel(method), method), + self.config.negotiation_options.frame_max, + ) + self.transcribe( + TranscriptEvent.METHOD_EGRESS, method=method, wire_bytes=encoded + ) + payload += encoded + + return (Write(payload),) if payload else () diff --git a/src/tests/amqp_imposter/amqp_imposter/amqp/transcript.py b/src/tests/amqp_imposter/amqp_imposter/amqp/transcript.py new file mode 100644 index 00000000..900152c9 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/amqp/transcript.py @@ -0,0 +1,80 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections import deque +from dataclasses import dataclass, field +from enum import StrEnum + +from ..model import methods as m + +HARD_MAX_TRANSCRIPT_LINES = 1_000_000 +DEFAULT_MAX_TRANSCRIPT_LINES = HARD_MAX_TRANSCRIPT_LINES + + +class TranscriptEvent(StrEnum): + SESSION_STARTED = "SESSION_STARTED" + TCP_CONNECTED = "TCP_CONNECTED" + TCP_REJECTED = "TCP_REJECTED" + TCP_DOWN = "TCP_DOWN" + IDLE_TIMEOUT = "IDLE_TIMEOUT" + PROTOCOL_HEADER = "PROTOCOL_HEADER" + FRAME_MALFORMED = "FRAME_MALFORMED" + FRAME_HEARTBEAT = "FRAME_HEARTBEAT" + METHOD_INGRESS = "METHOD_INGRESS" + METHOD_EGRESS = "METHOD_EGRESS" + METHOD_UNREPRESENTABLE = "METHOD_UNREPRESENTABLE" + + +@dataclass(frozen=True, slots=True) +class TranscriptLine: + index: int + micros: int + epoch_micros: int + event: TranscriptEvent + method: m.Method | None = None + detail: str = "" + wire_bytes: bytes = b"" + + +@dataclass(slots=True) +class Transcript: + max_lines: int = DEFAULT_MAX_TRANSCRIPT_LINES + lines: deque[TranscriptLine] = field(init=False) + recorded: int = field(default=0, init=False) + + def __post_init__(self) -> None: + self.lines = deque(maxlen=self.max_lines) + + def add( + self, + micros: int, + epoch_micros: int, + event: TranscriptEvent, + method: m.Method | None = None, + detail: str = "", + wire_bytes: bytes = b"", + ) -> None: + self.lines.append( + TranscriptLine( + index=self.recorded, + micros=micros, + epoch_micros=epoch_micros, + event=event, + method=method, + detail=detail, + wire_bytes=wire_bytes, + ) + ) + self.recorded += 1 diff --git a/src/tests/amqp_imposter/amqp_imposter/control/__init__.py b/src/tests/amqp_imposter/amqp_imposter/control/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/amqp_imposter/control/__main__.py b/src/tests/amqp_imposter/amqp_imposter/control/__main__.py new file mode 100644 index 00000000..0f7c11c8 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/__main__.py @@ -0,0 +1,21 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys + +from .cli import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/tests/amqp_imposter/amqp_imposter/control/cli.py b/src/tests/amqp_imposter/amqp_imposter/control/cli.py new file mode 100644 index 00000000..7346a8d6 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/cli.py @@ -0,0 +1,135 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import argparse +import asyncio +import shutil +import signal +import socket +import stat +import sys +import tempfile +from collections.abc import Generator +from contextlib import contextmanager, suppress +from enum import IntEnum +from pathlib import Path + +from ..amqp.server import EPHEMERAL_PORT, AmqpServer +from ..amqp.session import Session +from ..imposter import Imposter +from . import service as sv +from . import transport as tp + + +class ReturnCodes(IntEnum): + OK = 0 + RUNTIME_ERROR = 1 + USAGE_ERROR = 2 + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="amqp-imposter", + description="Foreground an AMQP 0-9-1 imposter and its gRPC control socket.", + ) + parser.add_argument("--socket", type=Path, default=None) + parser.add_argument("--amqp-port", type=int, default=EPHEMERAL_PORT) + + return parser.parse_args(argv) + + +def sock_write_init(sock_path: Path, amqp_port: int) -> None: + sys.stdout.write(tp.ImposterEndpoints(sock_path, amqp_port).to_json_line()) + sys.stdout.flush() + + +def sock_unlink(sock_path: Path) -> None: + with suppress(OSError): + sock_path.unlink(missing_ok=True) + + +def sock_acquire(sock_path: Path) -> bool: + try: + mode = sock_path.lstat().st_mode + except FileNotFoundError: + return True + + if not stat.S_ISSOCK(mode): + return False + + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + try: + sock.connect(str(sock_path)) + except ConnectionRefusedError: + sock_unlink(sock_path) + return True + + return False + + +@contextmanager +def control_sock(custom_sock_path: Path | None) -> Generator[Path]: + sock_path = ( + Path(tempfile.mkdtemp(prefix="amqp-imposter-", dir=tp.CONTROL_SOCK_DIR_PARENT)) + / tp.CONTROL_SOCK_NAME + if custom_sock_path is None + else custom_sock_path + ) + if not sock_acquire(sock_path): + raise OSError(f"control socket path unavailable: {sock_path}") + + try: + yield sock_path + finally: + sock_unlink(sock_path) + + if custom_sock_path is None: + shutil.rmtree(sock_path.parent, ignore_errors=True) + + +async def run(custom_sock_path: Path | None, amqp_port: int) -> int: + session = Session() + imposter = Imposter(session=session, amqp_server=AmqpServer(session)) + + try: + port = await imposter.amqp_server.start(port=amqp_port) + except OSError: + return ReturnCodes.RUNTIME_ERROR + + try: + with control_sock(custom_sock_path) as sock_path: + server = await sv.serve(sock_path, imposter) + stopping = asyncio.Event() + + loop = asyncio.get_running_loop() + loop.add_signal_handler(signal.SIGINT, stopping.set) + loop.add_signal_handler(signal.SIGTERM, stopping.set) + + sock_write_init(sock_path, port) + try: + await stopping.wait() + finally: + await server.stop(None) + except OSError: + return ReturnCodes.RUNTIME_ERROR + finally: + await imposter.amqp_server.stop() + + return ReturnCodes.OK + + +def main(argv: list[str] | None = None) -> int: + parsed = parse_args(argv) + return asyncio.run(run(parsed.socket, parsed.amqp_port)) diff --git a/src/tests/amqp_imposter/amqp_imposter/control/client/__init__.py b/src/tests/amqp_imposter/amqp_imposter/control/client/__init__.py new file mode 100644 index 00000000..a3647f73 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/client/__init__.py @@ -0,0 +1,65 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ...model.method_fields import FORCED_REPLY_TEXT, NOT_FORCED_REPLY_TEXT +from ..transport import ImposterEndpoints +from ..v1 import imposter_pb2 as pb +from .configuration_util import ( + PatternMethod, + pattern, + weighted, + when_receive, + when_send, +) +from .imposter import ( + AmqpImposter, + ImposterShutdownError, + ImposterStartupError, + amqp_imposter, + connect_imposter, + parse_imposter_endpoints, + run_imposter, +) +from .transcript_util import ( + Transcript, + format_transcript, + format_transcript_around, + format_transcript_line, + transcript_line_method, +) + +__all__ = [ + "FORCED_REPLY_TEXT", + "NOT_FORCED_REPLY_TEXT", + "AmqpImposter", + "ImposterEndpoints", + "ImposterShutdownError", + "ImposterStartupError", + "PatternMethod", + "Transcript", + "amqp_imposter", + "connect_imposter", + "format_transcript", + "format_transcript_around", + "format_transcript_line", + "parse_imposter_endpoints", + "pattern", + "pb", + "run_imposter", + "transcript_line_method", + "weighted", + "when_receive", + "when_send", +] diff --git a/src/tests/amqp_imposter/amqp_imposter/control/client/configuration_util.py b/src/tests/amqp_imposter/amqp_imposter/control/client/configuration_util.py new file mode 100644 index 00000000..455de58f --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/client/configuration_util.py @@ -0,0 +1,79 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from ..v1 import imposter_pb2 as pb + +type PatternMethod = ( + pb.ConnectionStartPattern + | pb.ConnectionStartOkPattern + | pb.ConnectionTunePattern + | pb.ConnectionTuneOkPattern + | pb.ConnectionOpenPattern + | pb.ConnectionOpenOkPattern + | pb.ConnectionClosePattern + | pb.ConnectionCloseOkPattern + | pb.ChannelOpenPattern + | pb.ChannelOpenOkPattern + | pb.ChannelClosePattern + | pb.ChannelCloseOkPattern + | pb.ConfirmSelectPattern + | pb.ConfirmSelectOkPattern + | pb.ExchangeDeclarePattern + | pb.ExchangeDeclareOkPattern + | pb.ExchangeBindPattern + | pb.ExchangeBindOkPattern + | pb.QueueDeclarePattern + | pb.QueueDeclareOkPattern + | pb.QueueBindPattern + | pb.QueueBindOkPattern + | pb.BasicQosPattern + | pb.BasicQosOkPattern + | pb.BasicConsumePattern + | pb.BasicConsumeOkPattern + | pb.BasicCancelPattern + | pb.BasicCancelOkPattern + | pb.BasicPublishPattern + | pb.BasicReturnPattern + | pb.BasicAckPattern + | pb.BasicNackPattern + | pb.BasicRejectPattern + | pb.BasicDeliverPattern + | pb.BasicGetPattern + | pb.BasicGetOkPattern + | pb.BasicGetEmptyPattern +) + +PATTERN_FIELD_NAMES: frozendict[type[PatternMethod], str] = frozendict( + (type(getattr(pb.Pattern(), field.name)), field.name) + for field in pb.Pattern.DESCRIPTOR.oneofs_by_name["method"].fields +) + + +def pattern(method: PatternMethod) -> pb.Pattern: + wrapped = pb.Pattern() + getattr(wrapped, PATTERN_FIELD_NAMES[type(method)]).CopyFrom(method) + return wrapped + + +def weighted(method: PatternMethod, weight: int | None = None) -> pb.Weighted: + return pb.Weighted(pattern=pattern(method), weight=weight) + + +def when_receive(method: PatternMethod, then_send: PatternMethod) -> pb.Scripted: + return pb.Scripted(received=pattern(method), then_send=pattern(then_send)) + + +def when_send(method: PatternMethod, then_send: PatternMethod) -> pb.Scripted: + return pb.Scripted(sent=pattern(method), then_send=pattern(then_send)) diff --git a/src/tests/amqp_imposter/amqp_imposter/control/client/imposter.py b/src/tests/amqp_imposter/amqp_imposter/control/client/imposter.py new file mode 100644 index 00000000..7fd120ab --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/client/imposter.py @@ -0,0 +1,149 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import select +import subprocess +import sys +import tempfile +from collections.abc import Generator +from contextlib import contextmanager, suppress +from dataclasses import dataclass +from pathlib import Path + +import grpc + +from .. import transport as tp +from ..transport import ImposterEndpoints +from ..v1 import imposter_pb2 as pb +from ..v1 import imposter_pb2_grpc as pb_grpc +from .transcript_util import Transcript + +TIMEOUT_STARTUP = 10.0 +TIMEOUT_CONTROL = 10.0 +TIMEOUT_SHUTDOWN = 10.0 + +IMPOSTER_MODULE = "amqp_imposter.control" + + +class ImposterStartupError(RuntimeError): + pass + + +class ImposterShutdownError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class AmqpImposter: + endpoints: ImposterEndpoints + stub: pb_grpc.ImposterStub + + @property + def amqp_port(self) -> int: + return self.endpoints.amqp_port + + def configure(self, config: pb.SessionConfig) -> pb.StatusReply: + return self.stub.Configure( + pb.ConfigureRequest(config=config), timeout=TIMEOUT_CONTROL + ) + + def status(self) -> pb.StatusReply: + return self.stub.Status(pb.StatusRequest(), timeout=TIMEOUT_CONTROL) + + def reset(self) -> pb.StatusReply: + return self.stub.Reset(pb.ResetRequest(), timeout=TIMEOUT_CONTROL) + + def transcript(self) -> Transcript: + return Transcript( + self.stub.Transcript(pb.TranscriptRequest(), timeout=TIMEOUT_CONTROL) + ) + + +def parse_imposter_endpoints(line: str | bytes) -> ImposterEndpoints: + if (endpoints := tp.endpoints_from_json_line(line)) is not None: + return endpoints + + raise ImposterStartupError(f"could not parse imposter endpoints: {line!r}") + + +def read_imposter_endpoints(process: subprocess.Popen[bytes]) -> ImposterEndpoints: + assert process.stdout is not None + if not select.select([process.stdout], [], [], TIMEOUT_STARTUP)[0]: + raise ImposterStartupError( + f"Failed to read endpoints from stdout within {TIMEOUT_STARTUP}s" + ) + + line = process.stdout.readline() + if not line: + return_code = process.wait(timeout=TIMEOUT_SHUTDOWN) + raise ImposterStartupError(f"imposter exited with code {return_code}") + + return parse_imposter_endpoints(line) + + +def stop_imposter(process: subprocess.Popen[bytes]) -> None: + process.terminate() + + try: + process.wait(timeout=TIMEOUT_SHUTDOWN) + except subprocess.TimeoutExpired as e: + process.kill() + process.wait() + raise ImposterShutdownError( + f"Failed to terminate imposter within {TIMEOUT_SHUTDOWN}s" + ) from e + + +@contextmanager +def run_imposter() -> Generator[ImposterEndpoints]: + with tempfile.TemporaryDirectory( + dir=tp.CONTROL_SOCK_DIR_PARENT, ignore_cleanup_errors=True + ) as socket_dir: + command = ( + sys.executable, + "-m", + IMPOSTER_MODULE, + "--socket", + str(Path(socket_dir) / tp.CONTROL_SOCK_NAME), + ) + + with subprocess.Popen(command, stdout=subprocess.PIPE) as process: + try: + yield read_imposter_endpoints(process) + except BaseException: + with suppress(ImposterShutdownError): + stop_imposter(process) + raise + + stop_imposter(process) + + +@contextmanager +def connect_imposter(endpoints: ImposterEndpoints) -> Generator[AmqpImposter]: + with grpc.insecure_channel( + tp.uds_target(endpoints.socket_path), options=tp.GRPC_CHANNEL_OPTS + ) as grpc_channel: + yield AmqpImposter(endpoints, pb_grpc.ImposterStub(grpc_channel)) + + +@contextmanager +def amqp_imposter( + config: pb.SessionConfig | None = None, +) -> Generator[AmqpImposter]: + with run_imposter() as endpoints, connect_imposter(endpoints) as imposter: + if config is not None: + imposter.configure(config) + + yield imposter diff --git a/src/tests/amqp_imposter/amqp_imposter/control/client/transcript_util.py b/src/tests/amqp_imposter/amqp_imposter/control/client/transcript_util.py new file mode 100644 index 00000000..1557cf6f --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/client/transcript_util.py @@ -0,0 +1,152 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import textwrap +from bisect import bisect_left +from typing import cast + +from google.protobuf import text_format +from google.protobuf.message import Message + +from ..v1 import imposter_pb2 as pb + + +class Transcript(tuple[pb.TranscriptLine, ...]): + __slots__ = () + + def __str__(self) -> str: + return format_transcript(self) + + +def format_direction_arrow(event: pb.TranscriptEvent.ValueType) -> str: + EGRESS_ARROW = "<--" # pretend client on the left, server on the right + INGRESS_ARROW = "-->" + NO_ARROW = " " + + assert len(EGRESS_ARROW) == len(INGRESS_ARROW) + assert len(EGRESS_ARROW) == len(NO_ARROW) + + # all ingress + if event in ( + pb.TRANSCRIPT_EVENT_PROTOCOL_HEADER, + pb.TRANSCRIPT_EVENT_FRAME_HEARTBEAT, + pb.TRANSCRIPT_EVENT_FRAME_MALFORMED, + pb.TRANSCRIPT_EVENT_METHOD_INGRESS, + pb.TRANSCRIPT_EVENT_METHOD_UNREPRESENTABLE, + ): + return INGRESS_ARROW + + # all egress + if event in (pb.TRANSCRIPT_EVENT_METHOD_EGRESS,): + return EGRESS_ARROW + + return NO_ARROW + + +def format_event_name(event: pb.TranscriptEvent.ValueType) -> str: + return pb.TranscriptEvent.Name(event).removeprefix("TRANSCRIPT_EVENT_") + + +def proto_without_empty_submessages(message: Message) -> Message: + without_empties = type(message)() + without_empties.CopyFrom(message) + + for field, value in message.ListFields(): + is_submessage = field.message_type is not None and not field.is_repeated + + if is_submessage and not value.ListFields(): + without_empties.ClearField(field.name) + + return without_empties + + +def format_proto_message(message: Message) -> str: + return text_format.MessageToString( + proto_without_empty_submessages(message), as_one_line=True + ) + + +def format_line_name(line: pb.TranscriptLine) -> str: + return line.method.WhichOneof("kind") or format_event_name(line.event) + + +W_EVENT_NAME = max( + len(format_event_name(event)) for event in pb.TranscriptEvent.values() +) +W_METHOD_KIND = max( + len(field.name) for field in pb.Method.DESCRIPTOR.oneofs_by_name["kind"].fields +) +W_LINE_NAME = max(W_EVENT_NAME, W_METHOD_KIND) +W_METHOD_FIELDS = 100 + + +def transcript_line_method(line: pb.TranscriptLine) -> Message | None: + if (kind := line.method.WhichOneof("kind")) is not None: + return cast(Message, getattr(line.method, kind)) + return None + + +def format_transcript_line(line: pb.TranscriptLine) -> str: + method = transcript_line_method(line) + method_fields = ( + format_proto_message(method) if method is not None else "" # not a method + ) + return ( + f"{line.index:>6} " + f"{line.micros / 1_000_000:>10.6f} " + f"{format_direction_arrow(line.event)} " + f"{format_line_name(line):<{W_LINE_NAME}} " + f"{textwrap.shorten(method_fields, W_METHOD_FIELDS)} {line.detail}" + ).rstrip() + + +def format_transcript(transcript: Transcript) -> str: + return "\n".join(map(format_transcript_line, transcript)) + + +def format_transcript_around( + transcript: Transcript, + line_index: int | None, + n_lines: int = 50, +) -> str: + # N.b. line_index doesn't necessarily correspond to a list index + # because the transcript can be truncated + midpoint = ( + bisect_left(transcript, line_index, key=lambda line: line.index) + if line_index is not None + else len(transcript) + ) + found_midpoint = ( + midpoint < len(transcript) and transcript[midpoint].index == line_index + ) + + start = max(0, min(midpoint - n_lines // 2, len(transcript) - n_lines)) + lines = transcript[start : start + n_lines] + + match (line_index, found_midpoint): + case None, _: + header = f"last {len(lines)} lines ({len(transcript)} total):" + case _, True: + header = ( + f"{len(lines)} lines around index {line_index} " + f"({len(transcript)} total):" + ) + case _, False: + header = ( + f"{len(lines)} lines nearest index {line_index}, " + f"which is not in the transcript ({len(transcript)} total):" + ) + + return "\n".join((header, *map(format_transcript_line, lines))) diff --git a/src/tests/amqp_imposter/amqp_imposter/control/config_proto.py b/src/tests/amqp_imposter/amqp_imposter/control/config_proto.py new file mode 100644 index 00000000..2f5a9677 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/config_proto.py @@ -0,0 +1,630 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections.abc import Iterable, Sequence + +from ..amqp.session import DEFAULT_SESSION_CONFIG, SessionConfig +from ..amqp.transcript import HARD_MAX_TRANSCRIPT_LINES +from ..helpers.nonempty_tuple import NonEmptyTuple +from ..imposter import ImposterError +from ..model import constants as c +from ..model import method_fields as mf +from ..model import methods as m +from ..model.method_scripts import Received, ScriptRule, Sent +from ..model.method_weights import BASE_WEIGHT, BadWeight, WeightedPattern +from ..model.negotiation_options import ( + DEFAULT_NEGOTIATION_OPTIONS, + NegotiationOptions, +) +from .helpers import concrete_or, cond_exact_field +from .v1 import imposter_pb2 as pb + + +def string_list_to_proto(value: mf.Exact[tuple[str, ...]]) -> pb.StringList | None: + if (items := concrete_or(value, None)) is not None: + return pb.StringList(items=items) + else: + return None + + +def validated_nonempty_tuple(values: Iterable[str], name: str) -> NonEmptyTuple[str]: + match list(values): + case [head, *rest]: + return (head, *rest) + case _: + raise ImposterError(f"{name} cannot be empty") + + +def to_proto(config: SessionConfig) -> pb.SessionConfig: + return pb.SessionConfig( + seed=config.seed, + negotiation_options=negotiation_to_proto(config.negotiation_options), + weights=[weighted_to_proto(one) for one in config.weights], + idle_weights=[weighted_to_proto(one) for one in config.idle_weights], + script=[script_rule_to_proto(one) for one in config.script], + stop_weight=config.stop_weight, + max_sends=config.max_sends, + idle_millis=config.idle_millis, + max_buffer=config.max_buffer, + max_transcript_lines=config.max_transcript_lines, + ) + + +def weights_from_proto( + messages: Sequence[pb.Weighted], default: tuple[WeightedPattern, ...] +) -> tuple[WeightedPattern, ...]: + if not messages: + return default + + return tuple(weighted_from_proto(one) for one in messages) + + +def scripts_from_proto(messages: Sequence[pb.Scripted]) -> tuple[ScriptRule, ...]: + return tuple(script_rule_from_proto(one) for one in messages) + + +def from_proto(message: pb.SessionConfig) -> SessionConfig: + default = DEFAULT_SESSION_CONFIG + + return validated_config( + SessionConfig( + seed=message.seed if message.HasField("seed") else default.seed, + negotiation_options=negotiation_from_proto(message.negotiation_options) + if message.HasField("negotiation_options") + else default.negotiation_options, + weights=weights_from_proto(message.weights, default.weights), + idle_weights=weights_from_proto(message.idle_weights, default.idle_weights), + script=scripts_from_proto(message.script), + stop_weight=message.stop_weight + if message.HasField("stop_weight") + else default.stop_weight, + max_sends=message.max_sends + if message.HasField("max_sends") + else default.max_sends, + idle_millis=message.idle_millis + if message.HasField("idle_millis") + else default.idle_millis, + max_buffer=message.max_buffer + if message.HasField("max_buffer") + else default.max_buffer, + max_transcript_lines=message.max_transcript_lines + if message.HasField("max_transcript_lines") + else default.max_transcript_lines, + ) + ) + + +def validated_config(config: SessionConfig) -> SessionConfig: + if config.stop_weight <= 0: + raise ImposterError( + f"stop_weight must be positive: {config.stop_weight}", + ) + if config.max_sends <= 0: + raise ImposterError( + f"max_sends must be positive: {config.max_sends}", + ) + if config.idle_millis <= 0: + raise ImposterError( + f"idle_millis must be positive: {config.idle_millis}", + ) + if config.max_buffer <= 0: + raise ImposterError( + f"max_buffer must be positive: {config.max_buffer}", + ) + if not 0 < config.max_transcript_lines <= HARD_MAX_TRANSCRIPT_LINES: + raise ImposterError( + f"max_transcript_lines out of range: {config.max_transcript_lines}", + ) + return config + + +def negotiation_to_proto(options: NegotiationOptions) -> pb.NegotiationOptions: + return pb.NegotiationOptions( + mechanisms=pb.StringList(items=options.mechanisms), + locales=pb.StringList(items=options.locales), + publisher_confirms=options.publisher_confirms, + basic_nack=options.basic_nack, + consumer_cancel_notify=options.consumer_cancel_notify, + channel_max=options.channel_max, + frame_max=options.frame_max, + heartbeat=options.heartbeat, + ) + + +def negotiation_from_proto(message: pb.NegotiationOptions) -> NegotiationOptions: + default = DEFAULT_NEGOTIATION_OPTIONS + + return validated_negotiation_options( + NegotiationOptions( + mechanisms=validated_nonempty_tuple(message.mechanisms.items, "mechanisms") + if message.HasField("mechanisms") + else default.mechanisms, + locales=validated_nonempty_tuple(message.locales.items, "locales") + if message.HasField("locales") + else default.locales, + publisher_confirms=message.publisher_confirms + if message.HasField("publisher_confirms") + else default.publisher_confirms, + basic_nack=message.basic_nack + if message.HasField("basic_nack") + else default.basic_nack, + consumer_cancel_notify=message.consumer_cancel_notify + if message.HasField("consumer_cancel_notify") + else default.consumer_cancel_notify, + channel_max=message.channel_max + if message.HasField("channel_max") + else default.channel_max, + frame_max=message.frame_max + if message.HasField("frame_max") + else default.frame_max, + heartbeat=message.heartbeat + if message.HasField("heartbeat") + else default.heartbeat, + ) + ) + + +def validated_negotiation_options(options: NegotiationOptions) -> NegotiationOptions: + if not 0 <= options.channel_max <= c.CHANNEL_MAX_CEIL: + raise ImposterError( + f"channel_max out of range: {options.channel_max}", + ) + if not c.FRAME_MIN_FLOOR <= options.frame_max <= c.FRAME_MAX_CEIL: + raise ImposterError( + f"frame_max out of range: {options.frame_max}", + ) + if not 0 <= options.heartbeat <= c.HEARTBEAT_MAX: + raise ImposterError( + f"heartbeat out of range: {options.heartbeat}", + ) + return options + + +def weighted_to_proto(weighted: WeightedPattern) -> pb.Weighted: + return pb.Weighted( + pattern=pattern_to_proto(weighted.pattern), weight=weighted.weight + ) + + +def weighted_from_proto(message: pb.Weighted) -> WeightedPattern: + weight = message.weight if message.HasField("weight") else BASE_WEIGHT + + try: + return WeightedPattern(pattern_from_proto(message.pattern), weight) + except (BadWeight, m.BadPattern) as bad: + raise ImposterError(str(bad)) from bad + + +def script_rule_to_proto(rule: ScriptRule) -> pb.Scripted: + then_send = pattern_to_proto(rule.then_send) + + match rule.when: + case Received(method): + return pb.Scripted(received=pattern_to_proto(method), then_send=then_send) + case Sent(method): + return pb.Scripted(sent=pattern_to_proto(method), then_send=then_send) + + +def when_from_proto(message: pb.Scripted) -> Received | Sent: + match message.WhichOneof("when"): + case None: + raise ImposterError("no when given") + case "received": + return Received(pattern_from_proto(message.received)) + case "sent": + return Sent(pattern_from_proto(message.sent)) + + +def script_rule_from_proto(message: pb.Scripted) -> ScriptRule: + when = when_from_proto(message) + then_send = pattern_from_proto(message.then_send) + + try: + return ScriptRule(when, then_send) + except m.BadPattern as bad: + raise ImposterError(str(bad)) from bad + + +def pattern_to_proto(pattern: m.Method) -> pb.Pattern: + match pattern: + case m.ConnectionStart(): + return pb.Pattern( + connection_start=pb.ConnectionStartPattern( + version_major=concrete_or(pattern.version_major), + version_minor=concrete_or(pattern.version_minor), + mechanisms=string_list_to_proto(pattern.mechanisms), + locales=string_list_to_proto(pattern.locales), + publisher_confirms=concrete_or(pattern.publisher_confirms), + basic_nack=concrete_or(pattern.basic_nack), + consumer_cancel_notify=concrete_or(pattern.consumer_cancel_notify), + ), + ) + case m.ConnectionStartOk(): + return pb.Pattern( + connection_start_ok=pb.ConnectionStartOkPattern( + mechanism=concrete_or(pattern.mechanism), + locale=concrete_or(pattern.locale), + ), + ) + case m.ConnectionTune(): + return pb.Pattern( + connection_tune=pb.ConnectionTunePattern( + channel_max=concrete_or(pattern.channel_max), + frame_max=concrete_or(pattern.frame_max), + heartbeat=concrete_or(pattern.heartbeat), + ), + ) + case m.ConnectionTuneOk(): + return pb.Pattern( + connection_tune_ok=pb.ConnectionTuneOkPattern( + channel_max=concrete_or(pattern.channel_max), + frame_max=concrete_or(pattern.frame_max), + heartbeat=concrete_or(pattern.heartbeat), + ), + ) + case m.ConnectionOpen(): + return pb.Pattern(connection_open=pb.ConnectionOpenPattern()) + case m.ConnectionOpenOk(): + return pb.Pattern(connection_open_ok=pb.ConnectionOpenOkPattern()) + case m.ConnectionClose(): + return pb.Pattern( + connection_close=pb.ConnectionClosePattern( + reply_code=concrete_or(pattern.reply_code), + reply_text=concrete_or(pattern.reply_text), + class_id=concrete_or(pattern.class_id), + method_id=concrete_or(pattern.method_id), + ), + ) + case m.ConnectionCloseOk(): + return pb.Pattern(connection_close_ok=pb.ConnectionCloseOkPattern()) + case m.ChannelOpen(): + return pb.Pattern(channel_open=pb.ChannelOpenPattern()) + case m.ChannelOpenOk(): + return pb.Pattern(channel_open_ok=pb.ChannelOpenOkPattern()) + case m.ChannelClose(): + return pb.Pattern( + channel_close=pb.ChannelClosePattern( + reply_code=concrete_or(pattern.reply_code), + reply_text=concrete_or(pattern.reply_text), + class_id=concrete_or(pattern.class_id), + method_id=concrete_or(pattern.method_id), + ), + ) + case m.ChannelCloseOk(): + return pb.Pattern(channel_close_ok=pb.ChannelCloseOkPattern()) + case m.ConfirmSelect(): + return pb.Pattern(confirm_select=pb.ConfirmSelectPattern()) + case m.ConfirmSelectOk(): + return pb.Pattern(confirm_select_ok=pb.ConfirmSelectOkPattern()) + case m.ExchangeDeclare(): + return pb.Pattern( + exchange_declare=pb.ExchangeDeclarePattern( + exchange=concrete_or(pattern.exchange), + ), + ) + case m.ExchangeDeclareOk(): + return pb.Pattern(exchange_declare_ok=pb.ExchangeDeclareOkPattern()) + case m.ExchangeBind(): + return pb.Pattern(exchange_bind=pb.ExchangeBindPattern()) + case m.ExchangeBindOk(): + return pb.Pattern(exchange_bind_ok=pb.ExchangeBindOkPattern()) + case m.QueueDeclare(): + return pb.Pattern(queue_declare=pb.QueueDeclarePattern()) + case m.QueueDeclareOk(): + return pb.Pattern(queue_declare_ok=pb.QueueDeclareOkPattern()) + case m.QueueBind(): + return pb.Pattern(queue_bind=pb.QueueBindPattern()) + case m.QueueBindOk(): + return pb.Pattern(queue_bind_ok=pb.QueueBindOkPattern()) + case m.BasicQos(): + return pb.Pattern(basic_qos=pb.BasicQosPattern()) + case m.BasicQosOk(): + return pb.Pattern(basic_qos_ok=pb.BasicQosOkPattern()) + case m.BasicConsume(): + return pb.Pattern(basic_consume=pb.BasicConsumePattern()) + case m.BasicConsumeOk(): + return pb.Pattern(basic_consume_ok=pb.BasicConsumeOkPattern()) + case m.BasicCancel(): + return pb.Pattern( + basic_cancel=pb.BasicCancelPattern( + consumer_tag=concrete_or(pattern.consumer_tag), + ), + ) + case m.BasicCancelOk(): + return pb.Pattern( + basic_cancel_ok=pb.BasicCancelOkPattern( + consumer_tag=concrete_or(pattern.consumer_tag), + ), + ) + case m.BasicPublish(): + return pb.Pattern(basic_publish=pb.BasicPublishPattern()) + case m.BasicReturn(): + return pb.Pattern( + basic_return=pb.BasicReturnPattern( + reply_code=concrete_or(pattern.reply_code), + exchange=concrete_or(pattern.exchange), + routing_key=concrete_or(pattern.routing_key), + ), + ) + case m.BasicAck(): + return pb.Pattern( + basic_ack=pb.BasicAckPattern( + delivery_tag=concrete_or(pattern.delivery_tag), + ), + ) + case m.BasicNack(): + return pb.Pattern( + basic_nack=pb.BasicNackPattern( + delivery_tag=concrete_or(pattern.delivery_tag), + ), + ) + case m.BasicReject(): + return pb.Pattern( + basic_reject=pb.BasicRejectPattern( + delivery_tag=concrete_or(pattern.delivery_tag), + ), + ) + case m.BasicDeliver(): + return pb.Pattern( + basic_deliver=pb.BasicDeliverPattern( + consumer_tag=concrete_or(pattern.consumer_tag), + delivery_tag=concrete_or(pattern.delivery_tag), + ), + ) + case m.BasicGet(): + return pb.Pattern(basic_get=pb.BasicGetPattern()) + case m.BasicGetOk(): + return pb.Pattern( + basic_get_ok=pb.BasicGetOkPattern( + delivery_tag=concrete_or(pattern.delivery_tag), + ), + ) + case m.BasicGetEmpty(): + return pb.Pattern(basic_get_empty=pb.BasicGetEmptyPattern()) + + +def pattern_from_proto(message: pb.Pattern) -> m.Method: + match message.WhichOneof("method"): + case None: + raise ImposterError("no pattern given") + case "connection_start": + return m.ConnectionStart( + version_major=cond_exact_field( + message.connection_start.HasField("version_major"), + message.connection_start.version_major, + ), + version_minor=cond_exact_field( + message.connection_start.HasField("version_minor"), + message.connection_start.version_minor, + ), + mechanisms=cond_exact_field( + message.connection_start.HasField("mechanisms"), + tuple(message.connection_start.mechanisms.items), + ), + locales=cond_exact_field( + message.connection_start.HasField("locales"), + tuple(message.connection_start.locales.items), + ), + publisher_confirms=cond_exact_field( + message.connection_start.HasField("publisher_confirms"), + message.connection_start.publisher_confirms, + ), + basic_nack=cond_exact_field( + message.connection_start.HasField("basic_nack"), + message.connection_start.basic_nack, + ), + consumer_cancel_notify=cond_exact_field( + message.connection_start.HasField("consumer_cancel_notify"), + message.connection_start.consumer_cancel_notify, + ), + ) + case "connection_start_ok": + return m.ConnectionStartOk( + mechanism=cond_exact_field( + message.connection_start_ok.HasField("mechanism"), + message.connection_start_ok.mechanism, + ), + locale=cond_exact_field( + message.connection_start_ok.HasField("locale"), + message.connection_start_ok.locale, + ), + ) + case "connection_tune": + return m.ConnectionTune( + channel_max=cond_exact_field( + message.connection_tune.HasField("channel_max"), + message.connection_tune.channel_max, + ), + frame_max=cond_exact_field( + message.connection_tune.HasField("frame_max"), + message.connection_tune.frame_max, + ), + heartbeat=cond_exact_field( + message.connection_tune.HasField("heartbeat"), + message.connection_tune.heartbeat, + ), + ) + case "connection_tune_ok": + return m.ConnectionTuneOk( + channel_max=cond_exact_field( + message.connection_tune_ok.HasField("channel_max"), + message.connection_tune_ok.channel_max, + ), + frame_max=cond_exact_field( + message.connection_tune_ok.HasField("frame_max"), + message.connection_tune_ok.frame_max, + ), + heartbeat=cond_exact_field( + message.connection_tune_ok.HasField("heartbeat"), + message.connection_tune_ok.heartbeat, + ), + ) + case "connection_open": + return m.ConnectionOpen() + case "connection_open_ok": + return m.ConnectionOpenOk() + case "connection_close": + return m.ConnectionClose( + reply_code=cond_exact_field( + message.connection_close.HasField("reply_code"), + message.connection_close.reply_code, + ), + reply_text=cond_exact_field( + message.connection_close.HasField("reply_text"), + message.connection_close.reply_text, + ), + class_id=cond_exact_field( + message.connection_close.HasField("class_id"), + message.connection_close.class_id, + ), + method_id=cond_exact_field( + message.connection_close.HasField("method_id"), + message.connection_close.method_id, + ), + ) + case "connection_close_ok": + return m.ConnectionCloseOk() + case "channel_open": + return m.ChannelOpen() + case "channel_open_ok": + return m.ChannelOpenOk() + case "channel_close": + return m.ChannelClose( + reply_code=cond_exact_field( + message.channel_close.HasField("reply_code"), + message.channel_close.reply_code, + ), + reply_text=cond_exact_field( + message.channel_close.HasField("reply_text"), + message.channel_close.reply_text, + ), + class_id=cond_exact_field( + message.channel_close.HasField("class_id"), + message.channel_close.class_id, + ), + method_id=cond_exact_field( + message.channel_close.HasField("method_id"), + message.channel_close.method_id, + ), + ) + case "channel_close_ok": + return m.ChannelCloseOk() + case "confirm_select": + return m.ConfirmSelect() + case "confirm_select_ok": + return m.ConfirmSelectOk() + case "exchange_declare": + return m.ExchangeDeclare( + exchange=cond_exact_field( + message.exchange_declare.HasField("exchange"), + message.exchange_declare.exchange, + ), + ) + case "exchange_declare_ok": + return m.ExchangeDeclareOk() + case "exchange_bind": + return m.ExchangeBind() + case "exchange_bind_ok": + return m.ExchangeBindOk() + case "queue_declare": + return m.QueueDeclare() + case "queue_declare_ok": + return m.QueueDeclareOk() + case "queue_bind": + return m.QueueBind() + case "queue_bind_ok": + return m.QueueBindOk() + case "basic_qos": + return m.BasicQos() + case "basic_qos_ok": + return m.BasicQosOk() + case "basic_consume": + return m.BasicConsume() + case "basic_consume_ok": + return m.BasicConsumeOk() + case "basic_cancel": + return m.BasicCancel( + consumer_tag=cond_exact_field( + message.basic_cancel.HasField("consumer_tag"), + message.basic_cancel.consumer_tag, + ), + ) + case "basic_cancel_ok": + return m.BasicCancelOk( + consumer_tag=cond_exact_field( + message.basic_cancel_ok.HasField("consumer_tag"), + message.basic_cancel_ok.consumer_tag, + ), + ) + case "basic_publish": + return m.BasicPublish() + case "basic_return": + return m.BasicReturn( + reply_code=cond_exact_field( + message.basic_return.HasField("reply_code"), + message.basic_return.reply_code, + ), + exchange=cond_exact_field( + message.basic_return.HasField("exchange"), + message.basic_return.exchange, + ), + routing_key=cond_exact_field( + message.basic_return.HasField("routing_key"), + message.basic_return.routing_key, + ), + ) + case "basic_ack": + return m.BasicAck( + delivery_tag=cond_exact_field( + message.basic_ack.HasField("delivery_tag"), + message.basic_ack.delivery_tag, + ), + ) + case "basic_nack": + return m.BasicNack( + delivery_tag=cond_exact_field( + message.basic_nack.HasField("delivery_tag"), + message.basic_nack.delivery_tag, + ), + ) + case "basic_reject": + return m.BasicReject( + delivery_tag=cond_exact_field( + message.basic_reject.HasField("delivery_tag"), + message.basic_reject.delivery_tag, + ), + ) + case "basic_deliver": + return m.BasicDeliver( + consumer_tag=cond_exact_field( + message.basic_deliver.HasField("consumer_tag"), + message.basic_deliver.consumer_tag, + ), + delivery_tag=cond_exact_field( + message.basic_deliver.HasField("delivery_tag"), + message.basic_deliver.delivery_tag, + ), + ) + case "basic_get": + return m.BasicGet() + case "basic_get_ok": + return m.BasicGetOk( + delivery_tag=cond_exact_field( + message.basic_get_ok.HasField("delivery_tag"), + message.basic_get_ok.delivery_tag, + ), + ) + case "basic_get_empty": + return m.BasicGetEmpty() diff --git a/src/tests/amqp_imposter/amqp_imposter/control/helpers.py b/src/tests/amqp_imposter/amqp_imposter/control/helpers.py new file mode 100644 index 00000000..3b092e37 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/helpers.py @@ -0,0 +1,46 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections.abc import Callable + +from ..model import method_fields as mf + + +def cond_value[T](cond: bool, value: T) -> T | None: + return value if cond else None + + +def cond_exact_field[T](cond: bool, value: T) -> mf.Exact[T]: + return value if cond else mf.HOLE + + +def cond_map[P, T](cond: bool, value: P, f: Callable[[P], T]) -> T | None: + return f(value) if cond else None + + +def concrete_or[T, D = None]( + value: mf.Exact[T] | mf.Arbitrary[T], + fallback: D = None, # type: ignore[assignment] +) -> T | D: + return value if mf.is_concrete(value) else fallback + + +def concrete_map_or[T, D]( + value: mf.Arbitrary[T | None], f: Callable[[T], D] +) -> D | None: + if (concrete := concrete_or(value, None)) is not None: + return f(concrete) + else: + return None diff --git a/src/tests/amqp_imposter/amqp_imposter/control/method_proto.py b/src/tests/amqp_imposter/amqp_imposter/control/method_proto.py new file mode 100644 index 00000000..edd87c91 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/method_proto.py @@ -0,0 +1,689 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import UTC, datetime +from decimal import Decimal + +from ..amqp.transcript import TranscriptEvent, TranscriptLine +from ..imposter import ImposterError +from ..model import method_fields as mf +from ..model import methods as m +from .helpers import ( + concrete_map_or, + concrete_or, + cond_map, + cond_value, +) +from .v1 import imposter_pb2 as pb + + +def to_proto(method: m.Method) -> pb.Method: + match method: + case m.ConnectionStart(): + return pb.Method( + connection_start=pb.ConnectionStart( + version_major=concrete_or(method.version_major, 0), + version_minor=concrete_or(method.version_minor, 0), + mechanisms=concrete_or(method.mechanisms), + locales=concrete_or(method.locales), + publisher_confirms=concrete_or(method.publisher_confirms, False), + basic_nack=concrete_or(method.basic_nack, False), + consumer_cancel_notify=concrete_or( + method.consumer_cancel_notify, False + ), + ) + ) + case m.ConnectionStartOk(): + return pb.Method( + connection_start_ok=pb.ConnectionStartOk( + mechanism=concrete_or(method.mechanism, ""), + locale=concrete_or(method.locale, ""), + response=concrete_or(method.response, ""), + ) + ) + case m.ConnectionTune(): + return pb.Method( + connection_tune=pb.ConnectionTune( + channel_max=concrete_or(method.channel_max, 0), + frame_max=concrete_or(method.frame_max, 0), + heartbeat=concrete_or(method.heartbeat, 0), + ) + ) + case m.ConnectionTuneOk(): + return pb.Method( + connection_tune_ok=pb.ConnectionTuneOk( + channel_max=concrete_or(method.channel_max, 0), + frame_max=concrete_or(method.frame_max, 0), + heartbeat=concrete_or(method.heartbeat, 0), + ) + ) + case m.ConnectionOpen(): + return pb.Method( + connection_open=pb.ConnectionOpen( + vhost=concrete_or(method.vhost, ""), + ) + ) + case m.ConnectionOpenOk(): + return pb.Method(connection_open_ok=pb.ConnectionOpenOk()) + case m.ConnectionClose(): + return pb.Method( + connection_close=pb.ConnectionClose( + reply_code=concrete_or(method.reply_code, 0), + reply_text=concrete_or(method.reply_text, ""), + class_id=concrete_or(method.class_id, 0), + method_id=concrete_or(method.method_id, 0), + ) + ) + case m.ConnectionCloseOk(): + return pb.Method(connection_close_ok=pb.ConnectionCloseOk()) + case m.ChannelOpen(): + return pb.Method(channel_open=pb.ChannelOpen()) + case m.ChannelOpenOk(): + return pb.Method(channel_open_ok=pb.ChannelOpenOk()) + case m.ChannelClose(): + return pb.Method( + channel_close=pb.ChannelClose( + reply_code=concrete_or(method.reply_code, 0), + reply_text=concrete_or(method.reply_text, ""), + class_id=concrete_or(method.class_id, 0), + method_id=concrete_or(method.method_id, 0), + ) + ) + case m.ChannelCloseOk(): + return pb.Method(channel_close_ok=pb.ChannelCloseOk()) + case m.ConfirmSelect(): + return pb.Method( + confirm_select=pb.ConfirmSelect( + no_wait=concrete_or(method.no_wait, False), + ) + ) + case m.ConfirmSelectOk(): + return pb.Method(confirm_select_ok=pb.ConfirmSelectOk()) + case m.ExchangeDeclare(): + return pb.Method( + exchange_declare=pb.ExchangeDeclare( + exchange=concrete_or(method.exchange, ""), + exchange_type=concrete_or(method.exchange_type, ""), + passive=concrete_or(method.passive, False), + durable=concrete_or(method.durable, False), + auto_delete=concrete_or(method.auto_delete, False), + internal=concrete_or(method.internal, False), + no_wait=concrete_or(method.no_wait, False), + arguments=concrete_map_or(method.arguments, table_to_proto), + ) + ) + case m.ExchangeDeclareOk(): + return pb.Method(exchange_declare_ok=pb.ExchangeDeclareOk()) + case m.ExchangeBind(): + return pb.Method( + exchange_bind=pb.ExchangeBind( + destination=concrete_or(method.destination, ""), + source=concrete_or(method.source, ""), + routing_key=concrete_or(method.routing_key, ""), + no_wait=concrete_or(method.no_wait, False), + arguments=concrete_map_or(method.arguments, table_to_proto), + ) + ) + case m.ExchangeBindOk(): + return pb.Method(exchange_bind_ok=pb.ExchangeBindOk()) + case m.QueueDeclare(): + return pb.Method( + queue_declare=pb.QueueDeclare( + queue=concrete_or(method.queue, ""), + passive=concrete_or(method.passive, False), + durable=concrete_or(method.durable, False), + exclusive=concrete_or(method.exclusive, False), + auto_delete=concrete_or(method.auto_delete, False), + no_wait=concrete_or(method.no_wait, False), + arguments=concrete_map_or(method.arguments, table_to_proto), + ) + ) + case m.QueueDeclareOk(): + return pb.Method( + queue_declare_ok=pb.QueueDeclareOk( + queue=concrete_or(method.queue, ""), + message_count=concrete_or(method.message_count, 0), + consumer_count=concrete_or(method.consumer_count, 0), + ) + ) + case m.QueueBind(): + return pb.Method( + queue_bind=pb.QueueBind( + queue=concrete_or(method.queue, ""), + exchange=concrete_or(method.exchange, ""), + routing_key=concrete_or(method.routing_key, ""), + no_wait=concrete_or(method.no_wait, False), + arguments=concrete_map_or(method.arguments, table_to_proto), + ) + ) + case m.QueueBindOk(): + return pb.Method(queue_bind_ok=pb.QueueBindOk()) + case m.BasicQos(): + return pb.Method( + basic_qos=pb.BasicQos( + prefetch_count=concrete_or(method.prefetch_count, 0), + prefetch_size=concrete_or(method.prefetch_size, 0), + global_qos=concrete_or(method.global_qos, False), + ) + ) + case m.BasicQosOk(): + return pb.Method(basic_qos_ok=pb.BasicQosOk()) + case m.BasicConsume(): + return pb.Method( + basic_consume=pb.BasicConsume( + queue=concrete_or(method.queue, ""), + consumer_tag=concrete_or(method.consumer_tag, ""), + no_local=concrete_or(method.no_local, False), + no_ack=concrete_or(method.no_ack, False), + no_wait=concrete_or(method.no_wait, False), + exclusive=concrete_or(method.exclusive, False), + arguments=concrete_map_or(method.arguments, table_to_proto), + ) + ) + case m.BasicConsumeOk(): + return pb.Method( + basic_consume_ok=pb.BasicConsumeOk( + consumer_tag=concrete_or(method.consumer_tag, ""), + ) + ) + case m.BasicCancel(): + return pb.Method( + basic_cancel=pb.BasicCancel( + consumer_tag=concrete_or(method.consumer_tag, ""), + no_wait=concrete_or(method.no_wait, False), + ) + ) + case m.BasicCancelOk(): + return pb.Method( + basic_cancel_ok=pb.BasicCancelOk( + consumer_tag=concrete_or(method.consumer_tag, ""), + ) + ) + case m.BasicPublish(): + return pb.Method( + basic_publish=pb.BasicPublish( + exchange=concrete_or(method.exchange, ""), + routing_key=concrete_or(method.routing_key, ""), + mandatory=concrete_or(method.mandatory, False), + immediate=concrete_or(method.immediate, False), + properties=concrete_map_or(method.properties, properties_to_proto), + body=concrete_or(method.body, b""), + ) + ) + case m.BasicReturn(): + return pb.Method( + basic_return=pb.BasicReturn( + reply_code=concrete_or(method.reply_code, 0), + reply_text=concrete_or(method.reply_text, ""), + exchange=concrete_or(method.exchange, ""), + routing_key=concrete_or(method.routing_key, ""), + properties=concrete_map_or(method.properties, properties_to_proto), + body=concrete_or(method.body, b""), + ) + ) + case m.BasicAck(): + return pb.Method( + basic_ack=pb.BasicAck( + delivery_tag=concrete_or(method.delivery_tag, 0), + multiple=concrete_or(method.multiple, False), + ) + ) + case m.BasicNack(): + return pb.Method( + basic_nack=pb.BasicNack( + delivery_tag=concrete_or(method.delivery_tag, 0), + multiple=concrete_or(method.multiple, False), + requeue=concrete_or(method.requeue, False), + ) + ) + case m.BasicReject(): + return pb.Method( + basic_reject=pb.BasicReject( + delivery_tag=concrete_or(method.delivery_tag, 0), + requeue=concrete_or(method.requeue, False), + ) + ) + case m.BasicDeliver(): + return pb.Method( + basic_deliver=pb.BasicDeliver( + consumer_tag=concrete_or(method.consumer_tag, ""), + delivery_tag=concrete_or(method.delivery_tag, 0), + redelivered=concrete_or(method.redelivered, False), + exchange=concrete_or(method.exchange, ""), + routing_key=concrete_or(method.routing_key, ""), + properties=concrete_map_or(method.properties, properties_to_proto), + body=concrete_or(method.body, b""), + ) + ) + case m.BasicGet(): + return pb.Method( + basic_get=pb.BasicGet( + queue=concrete_or(method.queue, ""), + no_ack=concrete_or(method.no_ack, False), + ) + ) + case m.BasicGetOk(): + return pb.Method( + basic_get_ok=pb.BasicGetOk( + delivery_tag=concrete_or(method.delivery_tag, 0), + redelivered=concrete_or(method.redelivered, False), + exchange=concrete_or(method.exchange, ""), + routing_key=concrete_or(method.routing_key, ""), + message_count=concrete_or(method.message_count, 0), + properties=concrete_map_or(method.properties, properties_to_proto), + body=concrete_or(method.body, b""), + ) + ) + case m.BasicGetEmpty(): + return pb.Method(basic_get_empty=pb.BasicGetEmpty()) + + +def from_proto(message: pb.Method) -> m.Method: + match message.WhichOneof("kind"): + case None: + raise ImposterError("no method given") + case "connection_start": + return m.ConnectionStart( + version_major=message.connection_start.version_major, + version_minor=message.connection_start.version_minor, + mechanisms=tuple(message.connection_start.mechanisms), + locales=tuple(message.connection_start.locales), + publisher_confirms=message.connection_start.publisher_confirms, + basic_nack=message.connection_start.basic_nack, + consumer_cancel_notify=message.connection_start.consumer_cancel_notify, + ) + case "connection_start_ok": + return m.ConnectionStartOk( + mechanism=message.connection_start_ok.mechanism, + locale=message.connection_start_ok.locale, + response=message.connection_start_ok.response, + ) + case "connection_tune": + return m.ConnectionTune( + channel_max=message.connection_tune.channel_max, + frame_max=message.connection_tune.frame_max, + heartbeat=message.connection_tune.heartbeat, + ) + case "connection_tune_ok": + return m.ConnectionTuneOk( + channel_max=message.connection_tune_ok.channel_max, + frame_max=message.connection_tune_ok.frame_max, + heartbeat=message.connection_tune_ok.heartbeat, + ) + case "connection_open": + return m.ConnectionOpen( + vhost=message.connection_open.vhost, + ) + case "connection_open_ok": + return m.ConnectionOpenOk() + case "connection_close": + return m.ConnectionClose( + reply_code=message.connection_close.reply_code, + reply_text=message.connection_close.reply_text, + class_id=message.connection_close.class_id, + method_id=message.connection_close.method_id, + ) + case "connection_close_ok": + return m.ConnectionCloseOk() + case "channel_open": + return m.ChannelOpen() + case "channel_open_ok": + return m.ChannelOpenOk() + case "channel_close": + return m.ChannelClose( + reply_code=message.channel_close.reply_code, + reply_text=message.channel_close.reply_text, + class_id=message.channel_close.class_id, + method_id=message.channel_close.method_id, + ) + case "channel_close_ok": + return m.ChannelCloseOk() + case "confirm_select": + return m.ConfirmSelect( + no_wait=message.confirm_select.no_wait, + ) + case "confirm_select_ok": + return m.ConfirmSelectOk() + case "exchange_declare": + return m.ExchangeDeclare( + exchange=message.exchange_declare.exchange, + exchange_type=message.exchange_declare.exchange_type, + passive=message.exchange_declare.passive, + durable=message.exchange_declare.durable, + auto_delete=message.exchange_declare.auto_delete, + internal=message.exchange_declare.internal, + no_wait=message.exchange_declare.no_wait, + arguments=cond_map( + message.exchange_declare.HasField("arguments"), + message.exchange_declare.arguments, + table_from_proto, + ), + ) + case "exchange_declare_ok": + return m.ExchangeDeclareOk() + case "exchange_bind": + return m.ExchangeBind( + destination=message.exchange_bind.destination, + source=message.exchange_bind.source, + routing_key=message.exchange_bind.routing_key, + no_wait=message.exchange_bind.no_wait, + arguments=cond_map( + message.exchange_bind.HasField("arguments"), + message.exchange_bind.arguments, + table_from_proto, + ), + ) + case "exchange_bind_ok": + return m.ExchangeBindOk() + case "queue_declare": + return m.QueueDeclare( + queue=message.queue_declare.queue, + passive=message.queue_declare.passive, + durable=message.queue_declare.durable, + exclusive=message.queue_declare.exclusive, + auto_delete=message.queue_declare.auto_delete, + no_wait=message.queue_declare.no_wait, + arguments=cond_map( + message.queue_declare.HasField("arguments"), + message.queue_declare.arguments, + table_from_proto, + ), + ) + case "queue_declare_ok": + return m.QueueDeclareOk( + queue=message.queue_declare_ok.queue, + message_count=message.queue_declare_ok.message_count, + consumer_count=message.queue_declare_ok.consumer_count, + ) + case "queue_bind": + return m.QueueBind( + queue=message.queue_bind.queue, + exchange=message.queue_bind.exchange, + routing_key=message.queue_bind.routing_key, + no_wait=message.queue_bind.no_wait, + arguments=cond_map( + message.queue_bind.HasField("arguments"), + message.queue_bind.arguments, + table_from_proto, + ), + ) + case "queue_bind_ok": + return m.QueueBindOk() + case "basic_qos": + return m.BasicQos( + prefetch_count=message.basic_qos.prefetch_count, + prefetch_size=message.basic_qos.prefetch_size, + global_qos=message.basic_qos.global_qos, + ) + case "basic_qos_ok": + return m.BasicQosOk() + case "basic_consume": + return m.BasicConsume( + queue=message.basic_consume.queue, + consumer_tag=message.basic_consume.consumer_tag, + no_local=message.basic_consume.no_local, + no_ack=message.basic_consume.no_ack, + no_wait=message.basic_consume.no_wait, + exclusive=message.basic_consume.exclusive, + arguments=cond_map( + message.basic_consume.HasField("arguments"), + message.basic_consume.arguments, + table_from_proto, + ), + ) + case "basic_consume_ok": + return m.BasicConsumeOk( + consumer_tag=message.basic_consume_ok.consumer_tag, + ) + case "basic_cancel": + return m.BasicCancel( + consumer_tag=message.basic_cancel.consumer_tag, + no_wait=message.basic_cancel.no_wait, + ) + case "basic_cancel_ok": + return m.BasicCancelOk( + consumer_tag=message.basic_cancel_ok.consumer_tag, + ) + case "basic_publish": + return m.BasicPublish( + exchange=message.basic_publish.exchange, + routing_key=message.basic_publish.routing_key, + mandatory=message.basic_publish.mandatory, + immediate=message.basic_publish.immediate, + properties=cond_map( + message.basic_publish.HasField("properties"), + message.basic_publish.properties, + properties_from_proto, + ), + body=message.basic_publish.body, + ) + case "basic_return": + return m.BasicReturn( + reply_code=message.basic_return.reply_code, + reply_text=message.basic_return.reply_text, + exchange=message.basic_return.exchange, + routing_key=message.basic_return.routing_key, + properties=cond_map( + message.basic_return.HasField("properties"), + message.basic_return.properties, + properties_from_proto, + ), + body=message.basic_return.body, + ) + case "basic_ack": + return m.BasicAck( + delivery_tag=message.basic_ack.delivery_tag, + multiple=message.basic_ack.multiple, + ) + case "basic_nack": + return m.BasicNack( + delivery_tag=message.basic_nack.delivery_tag, + multiple=message.basic_nack.multiple, + requeue=message.basic_nack.requeue, + ) + case "basic_reject": + return m.BasicReject( + delivery_tag=message.basic_reject.delivery_tag, + requeue=message.basic_reject.requeue, + ) + case "basic_deliver": + return m.BasicDeliver( + consumer_tag=message.basic_deliver.consumer_tag, + delivery_tag=message.basic_deliver.delivery_tag, + redelivered=message.basic_deliver.redelivered, + exchange=message.basic_deliver.exchange, + routing_key=message.basic_deliver.routing_key, + properties=cond_map( + message.basic_deliver.HasField("properties"), + message.basic_deliver.properties, + properties_from_proto, + ), + body=message.basic_deliver.body, + ) + case "basic_get": + return m.BasicGet( + queue=message.basic_get.queue, + no_ack=message.basic_get.no_ack, + ) + case "basic_get_ok": + return m.BasicGetOk( + delivery_tag=message.basic_get_ok.delivery_tag, + redelivered=message.basic_get_ok.redelivered, + exchange=message.basic_get_ok.exchange, + routing_key=message.basic_get_ok.routing_key, + message_count=message.basic_get_ok.message_count, + properties=cond_map( + message.basic_get_ok.HasField("properties"), + message.basic_get_ok.properties, + properties_from_proto, + ), + body=message.basic_get_ok.body, + ) + case "basic_get_empty": + return m.BasicGetEmpty() + + +def value_to_proto(value: mf.TableValue) -> pb.AmqpValue: + message = pb.AmqpValue() + + match value: + case bool(): + message.boolean = value + case int(): + message.long = value + case str(): + message.string = value + case bytes(): + message.bytes = value + case Decimal(): + message.decimal = str(value) + case datetime(): + message.timestamp.FromDatetime(value) + case mf.Void(): + message.void.SetInParent() + case frozendict(): + message.table.CopyFrom(table_to_proto(value)) + case tuple(): + message.array.SetInParent() + message.array.items.extend(value_to_proto(item) for item in value) + + return message + + +def value_from_proto(message: pb.AmqpValue) -> mf.TableValue: + match message.WhichOneof("value"): + case None: + raise ImposterError("no table value given") + case "boolean": + return message.boolean + case "long": + return message.long + case "string": + return message.string + case "bytes": + return message.bytes + case "decimal": + return Decimal(message.decimal) + case "timestamp": + return message.timestamp.ToDatetime(tzinfo=UTC) + case "void": + return mf.Void() + case "table": + return table_from_proto(message.table) + case _: + return tuple(value_from_proto(item) for item in message.array.items) + + +def table_to_proto(table: mf.Table) -> pb.AmqpTable: + message = pb.AmqpTable() + + for key, value in table.items(): + message.entries[key].CopyFrom(value_to_proto(value)) + + return message + + +def table_from_proto(message: pb.AmqpTable) -> mf.Table: + return frozendict( + (key, value_from_proto(value)) for key, value in message.entries.items() + ) + + +def properties_to_proto(properties: mf.MessageProperties) -> pb.MessageProperties: + return pb.MessageProperties( + content_type=properties.content_type, + content_encoding=properties.content_encoding, + headers=None + if properties.headers is None + else table_to_proto(properties.headers), + delivery_mode=properties.delivery_mode, + priority=properties.priority, + correlation_id=properties.correlation_id, + reply_to=properties.reply_to, + expiration=properties.expiration, + message_id=properties.message_id, + timestamp=properties.timestamp, + type=properties.type, + user_id=properties.user_id, + app_id=properties.app_id, + ) + + +def properties_from_proto(message: pb.MessageProperties) -> mf.MessageProperties: + return mf.MessageProperties( + content_type=cond_value(message.HasField("content_type"), message.content_type), + content_encoding=cond_value( + message.HasField("content_encoding"), message.content_encoding + ), + headers=cond_map( + message.HasField("headers"), message.headers, table_from_proto + ), + delivery_mode=cond_value( + message.HasField("delivery_mode"), message.delivery_mode + ), + priority=cond_value(message.HasField("priority"), message.priority), + correlation_id=cond_value( + message.HasField("correlation_id"), message.correlation_id + ), + reply_to=cond_value(message.HasField("reply_to"), message.reply_to), + expiration=cond_value(message.HasField("expiration"), message.expiration), + message_id=cond_value(message.HasField("message_id"), message.message_id), + timestamp=cond_value(message.HasField("timestamp"), message.timestamp), + type=cond_value(message.HasField("type"), message.type), + user_id=cond_value(message.HasField("user_id"), message.user_id), + app_id=cond_value(message.HasField("app_id"), message.app_id), + ) + + +def transcript_event_to_proto(event: TranscriptEvent) -> pb.TranscriptEvent.ValueType: + match event: + case TranscriptEvent.SESSION_STARTED: + return pb.TRANSCRIPT_EVENT_SESSION_STARTED + case TranscriptEvent.TCP_CONNECTED: + return pb.TRANSCRIPT_EVENT_TCP_CONNECTED + case TranscriptEvent.TCP_REJECTED: + return pb.TRANSCRIPT_EVENT_TCP_REJECTED + case TranscriptEvent.TCP_DOWN: + return pb.TRANSCRIPT_EVENT_TCP_DOWN + case TranscriptEvent.IDLE_TIMEOUT: + return pb.TRANSCRIPT_EVENT_IDLE_TIMEOUT + case TranscriptEvent.PROTOCOL_HEADER: + return pb.TRANSCRIPT_EVENT_PROTOCOL_HEADER + case TranscriptEvent.FRAME_MALFORMED: + return pb.TRANSCRIPT_EVENT_FRAME_MALFORMED + case TranscriptEvent.FRAME_HEARTBEAT: + return pb.TRANSCRIPT_EVENT_FRAME_HEARTBEAT + case TranscriptEvent.METHOD_INGRESS: + return pb.TRANSCRIPT_EVENT_METHOD_INGRESS + case TranscriptEvent.METHOD_EGRESS: + return pb.TRANSCRIPT_EVENT_METHOD_EGRESS + case TranscriptEvent.METHOD_UNREPRESENTABLE: + return pb.TRANSCRIPT_EVENT_METHOD_UNREPRESENTABLE + + +def line_to_proto(line: TranscriptLine) -> pb.TranscriptLine: + message = pb.TranscriptLine( + index=line.index, + micros=line.micros, + epoch_micros=line.epoch_micros, + event=transcript_event_to_proto(line.event), + detail=line.detail, + wire_bytes=line.wire_bytes, + ) + + if line.method is not None: + message.method.CopyFrom(to_proto(line.method)) + + return message diff --git a/src/tests/amqp_imposter/amqp_imposter/control/service.py b/src/tests/amqp_imposter/amqp_imposter/control/service.py new file mode 100644 index 00000000..6a641028 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/service.py @@ -0,0 +1,112 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import time +from collections.abc import AsyncIterator +from pathlib import Path + +import grpc +from grpc import aio +from grpc_reflection.v1alpha import reflection + +from ..imposter import Imposter, ImposterError +from . import config_proto as cp +from . import method_proto as mp +from . import transport as tp +from .v1 import imposter_pb2 as pb +from .v1 import imposter_pb2_grpc as pb_grpc + +GRPC_SERVICE = "Imposter" + + +def now_micros() -> int: + return time.time_ns() // 1_000 + + +def status_reply(imposter: Imposter, epoch_micros: int) -> pb.StatusReply: + reply = pb.StatusReply( + amqp_greeted=imposter.session.is_greeted(), + transcript_lines=imposter.session.lines.recorded, + epoch_micros=epoch_micros, + config=cp.to_proto(imposter.session.config), + ) + + if imposter.amqp_server.port is not None: + reply.amqp_port = imposter.amqp_server.port + + return reply + + +async def configure(imposter: Imposter, message: pb.SessionConfig) -> None: + config = cp.from_proto(message) + + async with imposter.amqp_server.rejecting_connections(): + await imposter.amqp_server.disconnect() + imposter.session.reset(config) + + +class ImposterServicer(pb_grpc.ImposterServicer): + def __init__(self, imposter: Imposter) -> None: + self.imposter = imposter + + async def reconfigured( + self, message: pb.SessionConfig, context: aio.ServicerContext[object, object] + ) -> pb.StatusReply: + try: + await configure(self.imposter, message) + except ImposterError as bad: + await context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(bad)) + + return status_reply(self.imposter, now_micros()) + + async def Configure( + self, request: pb.ConfigureRequest, context: aio.ServicerContext[object, object] + ) -> pb.StatusReply: + return await self.reconfigured(request.config, context) + + async def Status( + self, request: pb.StatusRequest, context: aio.ServicerContext[object, object] + ) -> pb.StatusReply: + return status_reply(self.imposter, now_micros()) + + async def Reset( + self, request: pb.ResetRequest, context: aio.ServicerContext[object, object] + ) -> pb.StatusReply: + return await self.reconfigured(pb.SessionConfig(), context) + + async def Transcript( + self, + request: pb.TranscriptRequest, + context: aio.ServicerContext[object, object], + ) -> AsyncIterator[pb.TranscriptLine]: + for line in self.imposter.session.transcript(): + yield mp.line_to_proto(line) + + +async def serve(path: Path, imposter: Imposter) -> aio.Server: + server = aio.server(options=tp.GRPC_CHANNEL_OPTS) + pb_grpc.add_ImposterServicer_to_server(ImposterServicer(imposter), server) + reflection.enable_server_reflection( + ( + pb.DESCRIPTOR.services_by_name[GRPC_SERVICE].full_name, + reflection.SERVICE_NAME, + ), + server, + ) + server.add_insecure_port(tp.uds_target(path)) + + await server.start() + + return server diff --git a/src/tests/amqp_imposter/amqp_imposter/control/transport.py b/src/tests/amqp_imposter/amqp_imposter/control/transport.py new file mode 100644 index 00000000..1aaf943e --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/transport.py @@ -0,0 +1,54 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +from contextlib import suppress +from dataclasses import dataclass +from pathlib import Path + +UDS_SCHEME = "unix://" +CONTROL_SOCK_NAME = "control.sock" +CONTROL_SOCK_DIR_PARENT = Path("/tmp") + +GRPC_MAX_MESSAGE_BYTES = 64 * 1024 * 1024 # 64 MiB +GRPC_CHANNEL_OPTS = ( + ("grpc.max_send_message_length", GRPC_MAX_MESSAGE_BYTES), + ("grpc.max_receive_message_length", GRPC_MAX_MESSAGE_BYTES), +) + + +def uds_target(socket_path: Path) -> str: + return f"{UDS_SCHEME}{socket_path}" + + +@dataclass(frozen=True, slots=True) +class ImposterEndpoints: + socket_path: Path + amqp_port: int + + def to_json_line(self) -> str: + return ( + json.dumps({"socket": str(self.socket_path), "amqp_port": self.amqp_port}) + + "\n" + ) + + +def endpoints_from_json_line(line: str | bytes) -> ImposterEndpoints | None: + with suppress(ValueError): + match json.loads(line): + case {"socket": str(socket_path), "amqp_port": int(amqp_port)}: + return ImposterEndpoints(Path(socket_path), amqp_port) + + return None diff --git a/src/tests/amqp_imposter/amqp_imposter/control/v1/__init__.py b/src/tests/amqp_imposter/amqp_imposter/control/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2.py b/src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2.py new file mode 100644 index 00000000..5c2eefa0 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2.py @@ -0,0 +1,229 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: amqp_imposter/control/v1/imposter.proto +# Protobuf Python Version: 7.35.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, + 7, + 35, + 1, + '', + 'amqp_imposter/control/v1/imposter.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\'amqp_imposter/control/v1/imposter.proto\x12\x18\x61mqp_imposter.control.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x06\n\x04Void\"\x1b\n\nStringList\x12\r\n\x05items\x18\x01 \x03(\t\"\xba\x02\n\tAmqpValue\x12\x11\n\x07\x62oolean\x18\x01 \x01(\x08H\x00\x12\x0e\n\x04long\x18\x02 \x01(\x03H\x00\x12\x10\n\x06string\x18\x03 \x01(\tH\x00\x12\x0f\n\x05\x62ytes\x18\x04 \x01(\x0cH\x00\x12\x11\n\x07\x64\x65\x63imal\x18\x05 \x01(\tH\x00\x12/\n\ttimestamp\x18\x06 \x01(\x0b\x32\x1a.google.protobuf.TimestampH\x00\x12.\n\x04void\x18\x07 \x01(\x0b\x32\x1e.amqp_imposter.control.v1.VoidH\x00\x12\x34\n\x05table\x18\x08 \x01(\x0b\x32#.amqp_imposter.control.v1.AmqpTableH\x00\x12\x34\n\x05\x61rray\x18\t \x01(\x0b\x32#.amqp_imposter.control.v1.AmqpArrayH\x00\x42\x07\n\x05value\"\xa3\x01\n\tAmqpTable\x12\x41\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x30.amqp_imposter.control.v1.AmqpTable.EntriesEntry\x1aS\n\x0c\x45ntriesEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x32\n\x05value\x18\x02 \x01(\x0b\x32#.amqp_imposter.control.v1.AmqpValue:\x02\x38\x01\"?\n\tAmqpArray\x12\x32\n\x05items\x18\x01 \x03(\x0b\x32#.amqp_imposter.control.v1.AmqpValue\"\xb4\x04\n\x11MessageProperties\x12\x19\n\x0c\x63ontent_type\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x1d\n\x10\x63ontent_encoding\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x39\n\x07headers\x18\x03 \x01(\x0b\x32#.amqp_imposter.control.v1.AmqpTableH\x02\x88\x01\x01\x12\x1a\n\rdelivery_mode\x18\x04 \x01(\rH\x03\x88\x01\x01\x12\x15\n\x08priority\x18\x05 \x01(\rH\x04\x88\x01\x01\x12\x1b\n\x0e\x63orrelation_id\x18\x06 \x01(\tH\x05\x88\x01\x01\x12\x15\n\x08reply_to\x18\x07 \x01(\tH\x06\x88\x01\x01\x12\x17\n\nexpiration\x18\x08 \x01(\tH\x07\x88\x01\x01\x12\x17\n\nmessage_id\x18\t \x01(\tH\x08\x88\x01\x01\x12\x16\n\ttimestamp\x18\n \x01(\x04H\t\x88\x01\x01\x12\x11\n\x04type\x18\x0b \x01(\tH\n\x88\x01\x01\x12\x14\n\x07user_id\x18\x0c \x01(\tH\x0b\x88\x01\x01\x12\x13\n\x06\x61pp_id\x18\r \x01(\tH\x0c\x88\x01\x01\x42\x0f\n\r_content_typeB\x13\n\x11_content_encodingB\n\n\x08_headersB\x10\n\x0e_delivery_modeB\x0b\n\t_priorityB\x11\n\x0f_correlation_idB\x0b\n\t_reply_toB\r\n\x0b_expirationB\r\n\x0b_message_idB\x0c\n\n_timestampB\x07\n\x05_typeB\n\n\x08_user_idB\t\n\x07_app_id\"\xc0\x03\n\x12NegotiationOptions\x12=\n\nmechanisms\x18\x01 \x01(\x0b\x32$.amqp_imposter.control.v1.StringListH\x00\x88\x01\x01\x12:\n\x07locales\x18\x02 \x01(\x0b\x32$.amqp_imposter.control.v1.StringListH\x01\x88\x01\x01\x12\x1f\n\x12publisher_confirms\x18\x03 \x01(\x08H\x02\x88\x01\x01\x12\x17\n\nbasic_nack\x18\x04 \x01(\x08H\x03\x88\x01\x01\x12#\n\x16\x63onsumer_cancel_notify\x18\x05 \x01(\x08H\x04\x88\x01\x01\x12\x18\n\x0b\x63hannel_max\x18\x06 \x01(\rH\x05\x88\x01\x01\x12\x16\n\tframe_max\x18\x07 \x01(\rH\x06\x88\x01\x01\x12\x16\n\theartbeat\x18\x08 \x01(\rH\x07\x88\x01\x01\x42\r\n\x0b_mechanismsB\n\n\x08_localesB\x15\n\x13_publisher_confirmsB\r\n\x0b_basic_nackB\x19\n\x17_consumer_cancel_notifyB\x0e\n\x0c_channel_maxB\x0c\n\n_frame_maxB\x0c\n\n_heartbeat\"\xb4\x01\n\x0f\x43onnectionStart\x12\x15\n\rversion_major\x18\x01 \x01(\r\x12\x15\n\rversion_minor\x18\x02 \x01(\r\x12\x12\n\nmechanisms\x18\x03 \x03(\t\x12\x0f\n\x07locales\x18\x04 \x03(\t\x12\x1a\n\x12publisher_confirms\x18\x05 \x01(\x08\x12\x12\n\nbasic_nack\x18\x06 \x01(\x08\x12\x1e\n\x16\x63onsumer_cancel_notify\x18\x07 \x01(\x08\"\xaa\x03\n\x16\x43onnectionStartPattern\x12\x1a\n\rversion_major\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x1a\n\rversion_minor\x18\x02 \x01(\rH\x01\x88\x01\x01\x12=\n\nmechanisms\x18\x03 \x01(\x0b\x32$.amqp_imposter.control.v1.StringListH\x02\x88\x01\x01\x12:\n\x07locales\x18\x04 \x01(\x0b\x32$.amqp_imposter.control.v1.StringListH\x03\x88\x01\x01\x12\x1f\n\x12publisher_confirms\x18\x05 \x01(\x08H\x04\x88\x01\x01\x12\x17\n\nbasic_nack\x18\x06 \x01(\x08H\x05\x88\x01\x01\x12#\n\x16\x63onsumer_cancel_notify\x18\x07 \x01(\x08H\x06\x88\x01\x01\x42\x10\n\x0e_version_majorB\x10\n\x0e_version_minorB\r\n\x0b_mechanismsB\n\n\x08_localesB\x15\n\x13_publisher_confirmsB\r\n\x0b_basic_nackB\x19\n\x17_consumer_cancel_notify\"H\n\x11\x43onnectionStartOk\x12\x11\n\tmechanism\x18\x01 \x01(\t\x12\x0e\n\x06locale\x18\x02 \x01(\t\x12\x10\n\x08response\x18\x03 \x01(\t\"f\n\x18\x43onnectionStartOkPattern\x12\x16\n\tmechanism\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x13\n\x06locale\x18\x02 \x01(\tH\x01\x88\x01\x01\x42\x0c\n\n_mechanismB\t\n\x07_localeJ\x04\x08\x03\x10\x04\"K\n\x0e\x43onnectionTune\x12\x13\n\x0b\x63hannel_max\x18\x01 \x01(\r\x12\x11\n\tframe_max\x18\x02 \x01(\r\x12\x11\n\theartbeat\x18\x03 \x01(\r\"\x8d\x01\n\x15\x43onnectionTunePattern\x12\x18\n\x0b\x63hannel_max\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x16\n\tframe_max\x18\x02 \x01(\rH\x01\x88\x01\x01\x12\x16\n\theartbeat\x18\x03 \x01(\rH\x02\x88\x01\x01\x42\x0e\n\x0c_channel_maxB\x0c\n\n_frame_maxB\x0c\n\n_heartbeat\"M\n\x10\x43onnectionTuneOk\x12\x13\n\x0b\x63hannel_max\x18\x01 \x01(\r\x12\x11\n\tframe_max\x18\x02 \x01(\r\x12\x11\n\theartbeat\x18\x03 \x01(\r\"\x8f\x01\n\x17\x43onnectionTuneOkPattern\x12\x18\n\x0b\x63hannel_max\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x16\n\tframe_max\x18\x02 \x01(\rH\x01\x88\x01\x01\x12\x16\n\theartbeat\x18\x03 \x01(\rH\x02\x88\x01\x01\x42\x0e\n\x0c_channel_maxB\x0c\n\n_frame_maxB\x0c\n\n_heartbeat\"\x1f\n\x0e\x43onnectionOpen\x12\r\n\x05vhost\x18\x01 \x01(\t\"\x1d\n\x15\x43onnectionOpenPatternJ\x04\x08\x01\x10\x02\"\x12\n\x10\x43onnectionOpenOk\"\x19\n\x17\x43onnectionOpenOkPattern\"^\n\x0f\x43onnectionClose\x12\x12\n\nreply_code\x18\x01 \x01(\r\x12\x12\n\nreply_text\x18\x02 \x01(\t\x12\x10\n\x08\x63lass_id\x18\x03 \x01(\r\x12\x11\n\tmethod_id\x18\x04 \x01(\r\"\xb2\x01\n\x16\x43onnectionClosePattern\x12\x17\n\nreply_code\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x17\n\nreply_text\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x15\n\x08\x63lass_id\x18\x03 \x01(\rH\x02\x88\x01\x01\x12\x16\n\tmethod_id\x18\x04 \x01(\rH\x03\x88\x01\x01\x42\r\n\x0b_reply_codeB\r\n\x0b_reply_textB\x0b\n\t_class_idB\x0c\n\n_method_id\"\x13\n\x11\x43onnectionCloseOk\"\x1a\n\x18\x43onnectionCloseOkPattern\"\r\n\x0b\x43hannelOpen\"\x14\n\x12\x43hannelOpenPattern\"\x0f\n\rChannelOpenOk\"\x16\n\x14\x43hannelOpenOkPattern\"[\n\x0c\x43hannelClose\x12\x12\n\nreply_code\x18\x01 \x01(\r\x12\x12\n\nreply_text\x18\x02 \x01(\t\x12\x10\n\x08\x63lass_id\x18\x03 \x01(\r\x12\x11\n\tmethod_id\x18\x04 \x01(\r\"\xaf\x01\n\x13\x43hannelClosePattern\x12\x17\n\nreply_code\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x17\n\nreply_text\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x15\n\x08\x63lass_id\x18\x03 \x01(\rH\x02\x88\x01\x01\x12\x16\n\tmethod_id\x18\x04 \x01(\rH\x03\x88\x01\x01\x42\r\n\x0b_reply_codeB\r\n\x0b_reply_textB\x0b\n\t_class_idB\x0c\n\n_method_id\"\x10\n\x0e\x43hannelCloseOk\"\x17\n\x15\x43hannelCloseOkPattern\" \n\rConfirmSelect\x12\x0f\n\x07no_wait\x18\x01 \x01(\x08\"\x1c\n\x14\x43onfirmSelectPatternJ\x04\x08\x01\x10\x02\"\x11\n\x0f\x43onfirmSelectOk\"\x18\n\x16\x43onfirmSelectOkPattern\"\xdf\x01\n\x0f\x45xchangeDeclare\x12\x10\n\x08\x65xchange\x18\x01 \x01(\t\x12\x15\n\rexchange_type\x18\x02 \x01(\t\x12\x0f\n\x07passive\x18\x03 \x01(\x08\x12\x0f\n\x07\x64urable\x18\x04 \x01(\x08\x12\x13\n\x0b\x61uto_delete\x18\x05 \x01(\x08\x12\x10\n\x08internal\x18\x06 \x01(\x08\x12\x0f\n\x07no_wait\x18\x07 \x01(\x08\x12;\n\targuments\x18\x08 \x01(\x0b\x32#.amqp_imposter.control.v1.AmqpTableH\x00\x88\x01\x01\x42\x0c\n\n_arguments\"B\n\x16\x45xchangeDeclarePattern\x12\x15\n\x08\x65xchange\x18\x01 \x01(\tH\x00\x88\x01\x01\x42\x0b\n\t_exchangeJ\x04\x08\x02\x10\t\"\x13\n\x11\x45xchangeDeclareOk\"\x1a\n\x18\x45xchangeDeclareOkPattern\"\xa4\x01\n\x0c\x45xchangeBind\x12\x13\n\x0b\x64\x65stination\x18\x01 \x01(\t\x12\x0e\n\x06source\x18\x02 \x01(\t\x12\x13\n\x0brouting_key\x18\x03 \x01(\t\x12\x0f\n\x07no_wait\x18\x04 \x01(\x08\x12;\n\targuments\x18\x05 \x01(\x0b\x32#.amqp_imposter.control.v1.AmqpTableH\x00\x88\x01\x01\x42\x0c\n\n_arguments\"\x1b\n\x13\x45xchangeBindPatternJ\x04\x08\x01\x10\x06\"\x10\n\x0e\x45xchangeBindOk\"\x17\n\x15\x45xchangeBindOkPattern\"\xc3\x01\n\x0cQueueDeclare\x12\r\n\x05queue\x18\x01 \x01(\t\x12\x0f\n\x07passive\x18\x02 \x01(\x08\x12\x0f\n\x07\x64urable\x18\x03 \x01(\x08\x12\x11\n\texclusive\x18\x04 \x01(\x08\x12\x13\n\x0b\x61uto_delete\x18\x05 \x01(\x08\x12\x0f\n\x07no_wait\x18\x06 \x01(\x08\x12;\n\targuments\x18\x07 \x01(\x0b\x32#.amqp_imposter.control.v1.AmqpTableH\x00\x88\x01\x01\x42\x0c\n\n_arguments\"\x1b\n\x13QueueDeclarePatternJ\x04\x08\x01\x10\x08\"N\n\x0eQueueDeclareOk\x12\r\n\x05queue\x18\x01 \x01(\t\x12\x15\n\rmessage_count\x18\x02 \x01(\r\x12\x16\n\x0e\x63onsumer_count\x18\x03 \x01(\r\"\x1d\n\x15QueueDeclareOkPatternJ\x04\x08\x01\x10\x04\"\x9d\x01\n\tQueueBind\x12\r\n\x05queue\x18\x01 \x01(\t\x12\x10\n\x08\x65xchange\x18\x02 \x01(\t\x12\x13\n\x0brouting_key\x18\x03 \x01(\t\x12\x0f\n\x07no_wait\x18\x04 \x01(\x08\x12;\n\targuments\x18\x05 \x01(\x0b\x32#.amqp_imposter.control.v1.AmqpTableH\x00\x88\x01\x01\x42\x0c\n\n_arguments\"\x18\n\x10QueueBindPatternJ\x04\x08\x01\x10\x06\"\r\n\x0bQueueBindOk\"\x14\n\x12QueueBindOkPattern\"M\n\x08\x42\x61sicQos\x12\x16\n\x0eprefetch_count\x18\x01 \x01(\r\x12\x15\n\rprefetch_size\x18\x02 \x01(\r\x12\x12\n\nglobal_qos\x18\x03 \x01(\x08\"\x17\n\x0f\x42\x61sicQosPatternJ\x04\x08\x01\x10\x04\"\x0c\n\nBasicQosOk\"\x13\n\x11\x42\x61sicQosOkPattern\"\xc4\x01\n\x0c\x42\x61sicConsume\x12\r\n\x05queue\x18\x01 \x01(\t\x12\x14\n\x0c\x63onsumer_tag\x18\x02 \x01(\t\x12\x10\n\x08no_local\x18\x03 \x01(\x08\x12\x0e\n\x06no_ack\x18\x04 \x01(\x08\x12\x0f\n\x07no_wait\x18\x05 \x01(\x08\x12\x11\n\texclusive\x18\x06 \x01(\x08\x12;\n\targuments\x18\x07 \x01(\x0b\x32#.amqp_imposter.control.v1.AmqpTableH\x00\x88\x01\x01\x42\x0c\n\n_arguments\"\x1b\n\x13\x42\x61sicConsumePatternJ\x04\x08\x01\x10\x08\"&\n\x0e\x42\x61sicConsumeOk\x12\x14\n\x0c\x63onsumer_tag\x18\x01 \x01(\t\"\x1d\n\x15\x42\x61sicConsumeOkPatternJ\x04\x08\x01\x10\x02\"4\n\x0b\x42\x61sicCancel\x12\x14\n\x0c\x63onsumer_tag\x18\x01 \x01(\t\x12\x0f\n\x07no_wait\x18\x02 \x01(\x08\"F\n\x12\x42\x61sicCancelPattern\x12\x19\n\x0c\x63onsumer_tag\x18\x01 \x01(\tH\x00\x88\x01\x01\x42\x0f\n\r_consumer_tagJ\x04\x08\x02\x10\x03\"%\n\rBasicCancelOk\x12\x14\n\x0c\x63onsumer_tag\x18\x01 \x01(\t\"B\n\x14\x42\x61sicCancelOkPattern\x12\x19\n\x0c\x63onsumer_tag\x18\x01 \x01(\tH\x00\x88\x01\x01\x42\x0f\n\r_consumer_tag\"\xbe\x01\n\x0c\x42\x61sicPublish\x12\x10\n\x08\x65xchange\x18\x01 \x01(\t\x12\x13\n\x0brouting_key\x18\x02 \x01(\t\x12\x11\n\tmandatory\x18\x03 \x01(\x08\x12\x11\n\timmediate\x18\x04 \x01(\x08\x12\x44\n\nproperties\x18\x05 \x01(\x0b\x32+.amqp_imposter.control.v1.MessagePropertiesH\x00\x88\x01\x01\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x42\r\n\x0b_properties\"\x1b\n\x13\x42\x61sicPublishPatternJ\x04\x08\x01\x10\x07\"\xbf\x01\n\x0b\x42\x61sicReturn\x12\x12\n\nreply_code\x18\x01 \x01(\r\x12\x12\n\nreply_text\x18\x02 \x01(\t\x12\x10\n\x08\x65xchange\x18\x03 \x01(\t\x12\x13\n\x0brouting_key\x18\x04 \x01(\t\x12\x44\n\nproperties\x18\x05 \x01(\x0b\x32+.amqp_imposter.control.v1.MessagePropertiesH\x00\x88\x01\x01\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x42\r\n\x0b_properties\"\x9c\x01\n\x12\x42\x61sicReturnPattern\x12\x17\n\nreply_code\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x15\n\x08\x65xchange\x18\x03 \x01(\tH\x01\x88\x01\x01\x12\x18\n\x0brouting_key\x18\x04 \x01(\tH\x02\x88\x01\x01\x42\r\n\x0b_reply_codeB\x0b\n\t_exchangeB\x0e\n\x0c_routing_keyJ\x04\x08\x02\x10\x03J\x04\x08\x05\x10\x06J\x04\x08\x06\x10\x07\"2\n\x08\x42\x61sicAck\x12\x14\n\x0c\x64\x65livery_tag\x18\x01 \x01(\x04\x12\x10\n\x08multiple\x18\x02 \x01(\x08\"C\n\x0f\x42\x61sicAckPattern\x12\x19\n\x0c\x64\x65livery_tag\x18\x01 \x01(\x04H\x00\x88\x01\x01\x42\x0f\n\r_delivery_tagJ\x04\x08\x02\x10\x03\"D\n\tBasicNack\x12\x14\n\x0c\x64\x65livery_tag\x18\x01 \x01(\x04\x12\x10\n\x08multiple\x18\x02 \x01(\x08\x12\x0f\n\x07requeue\x18\x03 \x01(\x08\"J\n\x10\x42\x61sicNackPattern\x12\x19\n\x0c\x64\x65livery_tag\x18\x01 \x01(\x04H\x00\x88\x01\x01\x42\x0f\n\r_delivery_tagJ\x04\x08\x02\x10\x03J\x04\x08\x03\x10\x04\"4\n\x0b\x42\x61sicReject\x12\x14\n\x0c\x64\x65livery_tag\x18\x01 \x01(\x04\x12\x0f\n\x07requeue\x18\x02 \x01(\x08\"F\n\x12\x42\x61sicRejectPattern\x12\x19\n\x0c\x64\x65livery_tag\x18\x01 \x01(\x04H\x00\x88\x01\x01\x42\x0f\n\r_delivery_tagJ\x04\x08\x02\x10\x03\"\xd9\x01\n\x0c\x42\x61sicDeliver\x12\x14\n\x0c\x63onsumer_tag\x18\x01 \x01(\t\x12\x14\n\x0c\x64\x65livery_tag\x18\x02 \x01(\x04\x12\x13\n\x0bredelivered\x18\x03 \x01(\x08\x12\x10\n\x08\x65xchange\x18\x04 \x01(\t\x12\x13\n\x0brouting_key\x18\x05 \x01(\t\x12\x44\n\nproperties\x18\x06 \x01(\x0b\x32+.amqp_imposter.control.v1.MessagePropertiesH\x00\x88\x01\x01\x12\x0c\n\x04\x62ody\x18\x07 \x01(\x0c\x42\r\n\x0b_properties\"s\n\x13\x42\x61sicDeliverPattern\x12\x19\n\x0c\x63onsumer_tag\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x19\n\x0c\x64\x65livery_tag\x18\x02 \x01(\x04H\x01\x88\x01\x01\x42\x0f\n\r_consumer_tagB\x0f\n\r_delivery_tagJ\x04\x08\x03\x10\x08\")\n\x08\x42\x61sicGet\x12\r\n\x05queue\x18\x01 \x01(\t\x12\x0e\n\x06no_ack\x18\x02 \x01(\x08\"\x1d\n\x0f\x42\x61sicGetPatternJ\x04\x08\x01\x10\x02J\x04\x08\x02\x10\x03\"\xd8\x01\n\nBasicGetOk\x12\x14\n\x0c\x64\x65livery_tag\x18\x01 \x01(\x04\x12\x13\n\x0bredelivered\x18\x02 \x01(\x08\x12\x10\n\x08\x65xchange\x18\x03 \x01(\t\x12\x13\n\x0brouting_key\x18\x04 \x01(\t\x12\x15\n\rmessage_count\x18\x05 \x01(\r\x12\x44\n\nproperties\x18\x06 \x01(\x0b\x32+.amqp_imposter.control.v1.MessagePropertiesH\x00\x88\x01\x01\x12\x0c\n\x04\x62ody\x18\x07 \x01(\x0c\x42\r\n\x0b_properties\"E\n\x11\x42\x61sicGetOkPattern\x12\x19\n\x0c\x64\x65livery_tag\x18\x01 \x01(\x04H\x00\x88\x01\x01\x42\x0f\n\r_delivery_tagJ\x04\x08\x02\x10\x08\"\x0f\n\rBasicGetEmpty\"\x16\n\x14\x42\x61sicGetEmptyPattern\"\xb5\x13\n\x06Method\x12\x45\n\x10\x63onnection_start\x18\x01 \x01(\x0b\x32).amqp_imposter.control.v1.ConnectionStartH\x00\x12J\n\x13\x63onnection_start_ok\x18\x02 \x01(\x0b\x32+.amqp_imposter.control.v1.ConnectionStartOkH\x00\x12\x43\n\x0f\x63onnection_tune\x18\x03 \x01(\x0b\x32(.amqp_imposter.control.v1.ConnectionTuneH\x00\x12H\n\x12\x63onnection_tune_ok\x18\x04 \x01(\x0b\x32*.amqp_imposter.control.v1.ConnectionTuneOkH\x00\x12\x43\n\x0f\x63onnection_open\x18\x05 \x01(\x0b\x32(.amqp_imposter.control.v1.ConnectionOpenH\x00\x12H\n\x12\x63onnection_open_ok\x18\x06 \x01(\x0b\x32*.amqp_imposter.control.v1.ConnectionOpenOkH\x00\x12\x45\n\x10\x63onnection_close\x18\x07 \x01(\x0b\x32).amqp_imposter.control.v1.ConnectionCloseH\x00\x12J\n\x13\x63onnection_close_ok\x18\x08 \x01(\x0b\x32+.amqp_imposter.control.v1.ConnectionCloseOkH\x00\x12=\n\x0c\x63hannel_open\x18\t \x01(\x0b\x32%.amqp_imposter.control.v1.ChannelOpenH\x00\x12\x42\n\x0f\x63hannel_open_ok\x18\n \x01(\x0b\x32\'.amqp_imposter.control.v1.ChannelOpenOkH\x00\x12?\n\rchannel_close\x18\x0b \x01(\x0b\x32&.amqp_imposter.control.v1.ChannelCloseH\x00\x12\x44\n\x10\x63hannel_close_ok\x18\x0c \x01(\x0b\x32(.amqp_imposter.control.v1.ChannelCloseOkH\x00\x12\x41\n\x0e\x63onfirm_select\x18\r \x01(\x0b\x32\'.amqp_imposter.control.v1.ConfirmSelectH\x00\x12\x46\n\x11\x63onfirm_select_ok\x18\x0e \x01(\x0b\x32).amqp_imposter.control.v1.ConfirmSelectOkH\x00\x12\x45\n\x10\x65xchange_declare\x18\x0f \x01(\x0b\x32).amqp_imposter.control.v1.ExchangeDeclareH\x00\x12J\n\x13\x65xchange_declare_ok\x18\x10 \x01(\x0b\x32+.amqp_imposter.control.v1.ExchangeDeclareOkH\x00\x12?\n\rqueue_declare\x18\x11 \x01(\x0b\x32&.amqp_imposter.control.v1.QueueDeclareH\x00\x12\x44\n\x10queue_declare_ok\x18\x12 \x01(\x0b\x32(.amqp_imposter.control.v1.QueueDeclareOkH\x00\x12\x39\n\nqueue_bind\x18\x13 \x01(\x0b\x32#.amqp_imposter.control.v1.QueueBindH\x00\x12>\n\rqueue_bind_ok\x18\x14 \x01(\x0b\x32%.amqp_imposter.control.v1.QueueBindOkH\x00\x12\x37\n\tbasic_qos\x18\x15 \x01(\x0b\x32\".amqp_imposter.control.v1.BasicQosH\x00\x12<\n\x0c\x62\x61sic_qos_ok\x18\x16 \x01(\x0b\x32$.amqp_imposter.control.v1.BasicQosOkH\x00\x12?\n\rbasic_consume\x18\x17 \x01(\x0b\x32&.amqp_imposter.control.v1.BasicConsumeH\x00\x12\x44\n\x10\x62\x61sic_consume_ok\x18\x18 \x01(\x0b\x32(.amqp_imposter.control.v1.BasicConsumeOkH\x00\x12=\n\x0c\x62\x61sic_cancel\x18\x19 \x01(\x0b\x32%.amqp_imposter.control.v1.BasicCancelH\x00\x12\x42\n\x0f\x62\x61sic_cancel_ok\x18\x1a \x01(\x0b\x32\'.amqp_imposter.control.v1.BasicCancelOkH\x00\x12?\n\rbasic_publish\x18\x1b \x01(\x0b\x32&.amqp_imposter.control.v1.BasicPublishH\x00\x12=\n\x0c\x62\x61sic_return\x18\x1c \x01(\x0b\x32%.amqp_imposter.control.v1.BasicReturnH\x00\x12\x37\n\tbasic_ack\x18\x1d \x01(\x0b\x32\".amqp_imposter.control.v1.BasicAckH\x00\x12\x39\n\nbasic_nack\x18\x1e \x01(\x0b\x32#.amqp_imposter.control.v1.BasicNackH\x00\x12=\n\x0c\x62\x61sic_reject\x18\x1f \x01(\x0b\x32%.amqp_imposter.control.v1.BasicRejectH\x00\x12?\n\rbasic_deliver\x18 \x01(\x0b\x32&.amqp_imposter.control.v1.BasicDeliverH\x00\x12\x37\n\tbasic_get\x18! \x01(\x0b\x32\".amqp_imposter.control.v1.BasicGetH\x00\x12<\n\x0c\x62\x61sic_get_ok\x18\" \x01(\x0b\x32$.amqp_imposter.control.v1.BasicGetOkH\x00\x12\x42\n\x0f\x62\x61sic_get_empty\x18# \x01(\x0b\x32\'.amqp_imposter.control.v1.BasicGetEmptyH\x00\x12?\n\rexchange_bind\x18$ \x01(\x0b\x32&.amqp_imposter.control.v1.ExchangeBindH\x00\x12\x44\n\x10\x65xchange_bind_ok\x18% \x01(\x0b\x32(.amqp_imposter.control.v1.ExchangeBindOkH\x00\x42\x06\n\x04kind\"\xbb\x15\n\x07Pattern\x12L\n\x10\x63onnection_start\x18\x01 \x01(\x0b\x32\x30.amqp_imposter.control.v1.ConnectionStartPatternH\x00\x12Q\n\x13\x63onnection_start_ok\x18\x02 \x01(\x0b\x32\x32.amqp_imposter.control.v1.ConnectionStartOkPatternH\x00\x12J\n\x0f\x63onnection_tune\x18\x03 \x01(\x0b\x32/.amqp_imposter.control.v1.ConnectionTunePatternH\x00\x12O\n\x12\x63onnection_tune_ok\x18\x04 \x01(\x0b\x32\x31.amqp_imposter.control.v1.ConnectionTuneOkPatternH\x00\x12J\n\x0f\x63onnection_open\x18\x05 \x01(\x0b\x32/.amqp_imposter.control.v1.ConnectionOpenPatternH\x00\x12O\n\x12\x63onnection_open_ok\x18\x06 \x01(\x0b\x32\x31.amqp_imposter.control.v1.ConnectionOpenOkPatternH\x00\x12L\n\x10\x63onnection_close\x18\x07 \x01(\x0b\x32\x30.amqp_imposter.control.v1.ConnectionClosePatternH\x00\x12Q\n\x13\x63onnection_close_ok\x18\x08 \x01(\x0b\x32\x32.amqp_imposter.control.v1.ConnectionCloseOkPatternH\x00\x12\x44\n\x0c\x63hannel_open\x18\t \x01(\x0b\x32,.amqp_imposter.control.v1.ChannelOpenPatternH\x00\x12I\n\x0f\x63hannel_open_ok\x18\n \x01(\x0b\x32..amqp_imposter.control.v1.ChannelOpenOkPatternH\x00\x12\x46\n\rchannel_close\x18\x0b \x01(\x0b\x32-.amqp_imposter.control.v1.ChannelClosePatternH\x00\x12K\n\x10\x63hannel_close_ok\x18\x0c \x01(\x0b\x32/.amqp_imposter.control.v1.ChannelCloseOkPatternH\x00\x12H\n\x0e\x63onfirm_select\x18\r \x01(\x0b\x32..amqp_imposter.control.v1.ConfirmSelectPatternH\x00\x12M\n\x11\x63onfirm_select_ok\x18\x0e \x01(\x0b\x32\x30.amqp_imposter.control.v1.ConfirmSelectOkPatternH\x00\x12L\n\x10\x65xchange_declare\x18\x0f \x01(\x0b\x32\x30.amqp_imposter.control.v1.ExchangeDeclarePatternH\x00\x12Q\n\x13\x65xchange_declare_ok\x18\x10 \x01(\x0b\x32\x32.amqp_imposter.control.v1.ExchangeDeclareOkPatternH\x00\x12\x46\n\rexchange_bind\x18\x11 \x01(\x0b\x32-.amqp_imposter.control.v1.ExchangeBindPatternH\x00\x12K\n\x10\x65xchange_bind_ok\x18\x12 \x01(\x0b\x32/.amqp_imposter.control.v1.ExchangeBindOkPatternH\x00\x12\x46\n\rqueue_declare\x18\x13 \x01(\x0b\x32-.amqp_imposter.control.v1.QueueDeclarePatternH\x00\x12K\n\x10queue_declare_ok\x18\x14 \x01(\x0b\x32/.amqp_imposter.control.v1.QueueDeclareOkPatternH\x00\x12@\n\nqueue_bind\x18\x15 \x01(\x0b\x32*.amqp_imposter.control.v1.QueueBindPatternH\x00\x12\x45\n\rqueue_bind_ok\x18\x16 \x01(\x0b\x32,.amqp_imposter.control.v1.QueueBindOkPatternH\x00\x12>\n\tbasic_qos\x18\x17 \x01(\x0b\x32).amqp_imposter.control.v1.BasicQosPatternH\x00\x12\x43\n\x0c\x62\x61sic_qos_ok\x18\x18 \x01(\x0b\x32+.amqp_imposter.control.v1.BasicQosOkPatternH\x00\x12\x46\n\rbasic_consume\x18\x19 \x01(\x0b\x32-.amqp_imposter.control.v1.BasicConsumePatternH\x00\x12K\n\x10\x62\x61sic_consume_ok\x18\x1a \x01(\x0b\x32/.amqp_imposter.control.v1.BasicConsumeOkPatternH\x00\x12\x44\n\x0c\x62\x61sic_cancel\x18\x1b \x01(\x0b\x32,.amqp_imposter.control.v1.BasicCancelPatternH\x00\x12I\n\x0f\x62\x61sic_cancel_ok\x18\x1c \x01(\x0b\x32..amqp_imposter.control.v1.BasicCancelOkPatternH\x00\x12\x46\n\rbasic_publish\x18\x1d \x01(\x0b\x32-.amqp_imposter.control.v1.BasicPublishPatternH\x00\x12\x44\n\x0c\x62\x61sic_return\x18\x1e \x01(\x0b\x32,.amqp_imposter.control.v1.BasicReturnPatternH\x00\x12>\n\tbasic_ack\x18\x1f \x01(\x0b\x32).amqp_imposter.control.v1.BasicAckPatternH\x00\x12@\n\nbasic_nack\x18 \x01(\x0b\x32*.amqp_imposter.control.v1.BasicNackPatternH\x00\x12\x44\n\x0c\x62\x61sic_reject\x18! \x01(\x0b\x32,.amqp_imposter.control.v1.BasicRejectPatternH\x00\x12\x46\n\rbasic_deliver\x18\" \x01(\x0b\x32-.amqp_imposter.control.v1.BasicDeliverPatternH\x00\x12>\n\tbasic_get\x18# \x01(\x0b\x32).amqp_imposter.control.v1.BasicGetPatternH\x00\x12\x43\n\x0c\x62\x61sic_get_ok\x18$ \x01(\x0b\x32+.amqp_imposter.control.v1.BasicGetOkPatternH\x00\x12I\n\x0f\x62\x61sic_get_empty\x18% \x01(\x0b\x32..amqp_imposter.control.v1.BasicGetEmptyPatternH\x00\x42\x08\n\x06method\"^\n\x08Weighted\x12\x32\n\x07pattern\x18\x01 \x01(\x0b\x32!.amqp_imposter.control.v1.Pattern\x12\x13\n\x06weight\x18\x02 \x01(\rH\x00\x88\x01\x01\x42\t\n\x07_weight\"\xb2\x01\n\x08Scripted\x12\x35\n\x08received\x18\x01 \x01(\x0b\x32!.amqp_imposter.control.v1.PatternH\x00\x12\x31\n\x04sent\x18\x02 \x01(\x0b\x32!.amqp_imposter.control.v1.PatternH\x00\x12\x34\n\tthen_send\x18\x03 \x01(\x0b\x32!.amqp_imposter.control.v1.PatternB\x06\n\x04when\"\x94\x04\n\rSessionConfig\x12\x11\n\x04seed\x18\x01 \x01(\x04H\x00\x88\x01\x01\x12N\n\x13negotiation_options\x18\x02 \x01(\x0b\x32,.amqp_imposter.control.v1.NegotiationOptionsH\x01\x88\x01\x01\x12\x33\n\x07weights\x18\x03 \x03(\x0b\x32\".amqp_imposter.control.v1.Weighted\x12\x38\n\x0cidle_weights\x18\x04 \x03(\x0b\x32\".amqp_imposter.control.v1.Weighted\x12\x32\n\x06script\x18\x05 \x03(\x0b\x32\".amqp_imposter.control.v1.Scripted\x12\x18\n\x0bstop_weight\x18\x06 \x01(\rH\x02\x88\x01\x01\x12\x16\n\tmax_sends\x18\x07 \x01(\rH\x03\x88\x01\x01\x12\x18\n\x0bidle_millis\x18\x08 \x01(\rH\x04\x88\x01\x01\x12\x17\n\nmax_buffer\x18\t \x01(\rH\x05\x88\x01\x01\x12!\n\x14max_transcript_lines\x18\n \x01(\rH\x06\x88\x01\x01\x42\x07\n\x05_seedB\x16\n\x14_negotiation_optionsB\x0e\n\x0c_stop_weightB\x0c\n\n_max_sendsB\x0e\n\x0c_idle_millisB\r\n\x0b_max_bufferB\x17\n\x15_max_transcript_lines\"\xe5\x01\n\x0eTranscriptLine\x12\r\n\x05index\x18\x01 \x01(\x04\x12\x0e\n\x06micros\x18\x02 \x01(\x03\x12\x14\n\x0c\x65poch_micros\x18\x03 \x01(\x03\x12\x38\n\x05\x65vent\x18\x04 \x01(\x0e\x32).amqp_imposter.control.v1.TranscriptEvent\x12\x35\n\x06method\x18\x05 \x01(\x0b\x32 .amqp_imposter.control.v1.MethodH\x00\x88\x01\x01\x12\x0e\n\x06\x64\x65tail\x18\x06 \x01(\t\x12\x12\n\nwire_bytes\x18\x07 \x01(\x0c\x42\t\n\x07_method\"\xb2\x01\n\x0bStatusReply\x12\x16\n\tamqp_port\x18\x01 \x01(\rH\x00\x88\x01\x01\x12\x14\n\x0c\x61mqp_greeted\x18\x02 \x01(\x08\x12\x18\n\x10transcript_lines\x18\x03 \x01(\x04\x12\x14\n\x0c\x65poch_micros\x18\x04 \x01(\x03\x12\x37\n\x06\x63onfig\x18\x05 \x01(\x0b\x32\'.amqp_imposter.control.v1.SessionConfigB\x0c\n\n_amqp_port\"K\n\x10\x43onfigureRequest\x12\x37\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\'.amqp_imposter.control.v1.SessionConfig\"\x0f\n\rStatusRequest\"\x0e\n\x0cResetRequest\"\x13\n\x11TranscriptRequest*:\n\nBaseWeight\x12\x1b\n\x17\x42\x41SE_WEIGHT_UNSPECIFIED\x10\x00\x12\x0f\n\x0b\x42\x41SE_WEIGHT\x10\x64*\xca\x03\n\x0fTranscriptEvent\x12 \n\x1cTRANSCRIPT_EVENT_UNSPECIFIED\x10\x00\x12$\n TRANSCRIPT_EVENT_SESSION_STARTED\x10\x01\x12\"\n\x1eTRANSCRIPT_EVENT_TCP_CONNECTED\x10\x02\x12!\n\x1dTRANSCRIPT_EVENT_TCP_REJECTED\x10\x03\x12\x1d\n\x19TRANSCRIPT_EVENT_TCP_DOWN\x10\x04\x12!\n\x1dTRANSCRIPT_EVENT_IDLE_TIMEOUT\x10\x05\x12$\n TRANSCRIPT_EVENT_PROTOCOL_HEADER\x10\x06\x12$\n TRANSCRIPT_EVENT_FRAME_MALFORMED\x10\x07\x12$\n TRANSCRIPT_EVENT_FRAME_HEARTBEAT\x10\x08\x12#\n\x1fTRANSCRIPT_EVENT_METHOD_INGRESS\x10\t\x12\"\n\x1eTRANSCRIPT_EVENT_METHOD_EGRESS\x10\n\x12+\n\'TRANSCRIPT_EVENT_METHOD_UNREPRESENTABLE\x10\x0b\x32\x83\x03\n\x08Imposter\x12^\n\tConfigure\x12*.amqp_imposter.control.v1.ConfigureRequest\x1a%.amqp_imposter.control.v1.StatusReply\x12X\n\x06Status\x12\'.amqp_imposter.control.v1.StatusRequest\x1a%.amqp_imposter.control.v1.StatusReply\x12V\n\x05Reset\x12&.amqp_imposter.control.v1.ResetRequest\x1a%.amqp_imposter.control.v1.StatusReply\x12\x65\n\nTranscript\x12+.amqp_imposter.control.v1.TranscriptRequest\x1a(.amqp_imposter.control.v1.TranscriptLine0\x01\x62\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'amqp_imposter.control.v1.imposter_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_AMQPTABLE_ENTRIESENTRY']._loaded_options = None + _globals['_AMQPTABLE_ENTRIESENTRY']._serialized_options = b'8\001' + _globals['_BASEWEIGHT']._serialized_start=13997 + _globals['_BASEWEIGHT']._serialized_end=14055 + _globals['_TRANSCRIPTEVENT']._serialized_start=14058 + _globals['_TRANSCRIPTEVENT']._serialized_end=14516 + _globals['_VOID']._serialized_start=102 + _globals['_VOID']._serialized_end=108 + _globals['_STRINGLIST']._serialized_start=110 + _globals['_STRINGLIST']._serialized_end=137 + _globals['_AMQPVALUE']._serialized_start=140 + _globals['_AMQPVALUE']._serialized_end=454 + _globals['_AMQPTABLE']._serialized_start=457 + _globals['_AMQPTABLE']._serialized_end=620 + _globals['_AMQPTABLE_ENTRIESENTRY']._serialized_start=537 + _globals['_AMQPTABLE_ENTRIESENTRY']._serialized_end=620 + _globals['_AMQPARRAY']._serialized_start=622 + _globals['_AMQPARRAY']._serialized_end=685 + _globals['_MESSAGEPROPERTIES']._serialized_start=688 + _globals['_MESSAGEPROPERTIES']._serialized_end=1252 + _globals['_NEGOTIATIONOPTIONS']._serialized_start=1255 + _globals['_NEGOTIATIONOPTIONS']._serialized_end=1703 + _globals['_CONNECTIONSTART']._serialized_start=1706 + _globals['_CONNECTIONSTART']._serialized_end=1886 + _globals['_CONNECTIONSTARTPATTERN']._serialized_start=1889 + _globals['_CONNECTIONSTARTPATTERN']._serialized_end=2315 + _globals['_CONNECTIONSTARTOK']._serialized_start=2317 + _globals['_CONNECTIONSTARTOK']._serialized_end=2389 + _globals['_CONNECTIONSTARTOKPATTERN']._serialized_start=2391 + _globals['_CONNECTIONSTARTOKPATTERN']._serialized_end=2493 + _globals['_CONNECTIONTUNE']._serialized_start=2495 + _globals['_CONNECTIONTUNE']._serialized_end=2570 + _globals['_CONNECTIONTUNEPATTERN']._serialized_start=2573 + _globals['_CONNECTIONTUNEPATTERN']._serialized_end=2714 + _globals['_CONNECTIONTUNEOK']._serialized_start=2716 + _globals['_CONNECTIONTUNEOK']._serialized_end=2793 + _globals['_CONNECTIONTUNEOKPATTERN']._serialized_start=2796 + _globals['_CONNECTIONTUNEOKPATTERN']._serialized_end=2939 + _globals['_CONNECTIONOPEN']._serialized_start=2941 + _globals['_CONNECTIONOPEN']._serialized_end=2972 + _globals['_CONNECTIONOPENPATTERN']._serialized_start=2974 + _globals['_CONNECTIONOPENPATTERN']._serialized_end=3003 + _globals['_CONNECTIONOPENOK']._serialized_start=3005 + _globals['_CONNECTIONOPENOK']._serialized_end=3023 + _globals['_CONNECTIONOPENOKPATTERN']._serialized_start=3025 + _globals['_CONNECTIONOPENOKPATTERN']._serialized_end=3050 + _globals['_CONNECTIONCLOSE']._serialized_start=3052 + _globals['_CONNECTIONCLOSE']._serialized_end=3146 + _globals['_CONNECTIONCLOSEPATTERN']._serialized_start=3149 + _globals['_CONNECTIONCLOSEPATTERN']._serialized_end=3327 + _globals['_CONNECTIONCLOSEOK']._serialized_start=3329 + _globals['_CONNECTIONCLOSEOK']._serialized_end=3348 + _globals['_CONNECTIONCLOSEOKPATTERN']._serialized_start=3350 + _globals['_CONNECTIONCLOSEOKPATTERN']._serialized_end=3376 + _globals['_CHANNELOPEN']._serialized_start=3378 + _globals['_CHANNELOPEN']._serialized_end=3391 + _globals['_CHANNELOPENPATTERN']._serialized_start=3393 + _globals['_CHANNELOPENPATTERN']._serialized_end=3413 + _globals['_CHANNELOPENOK']._serialized_start=3415 + _globals['_CHANNELOPENOK']._serialized_end=3430 + _globals['_CHANNELOPENOKPATTERN']._serialized_start=3432 + _globals['_CHANNELOPENOKPATTERN']._serialized_end=3454 + _globals['_CHANNELCLOSE']._serialized_start=3456 + _globals['_CHANNELCLOSE']._serialized_end=3547 + _globals['_CHANNELCLOSEPATTERN']._serialized_start=3550 + _globals['_CHANNELCLOSEPATTERN']._serialized_end=3725 + _globals['_CHANNELCLOSEOK']._serialized_start=3727 + _globals['_CHANNELCLOSEOK']._serialized_end=3743 + _globals['_CHANNELCLOSEOKPATTERN']._serialized_start=3745 + _globals['_CHANNELCLOSEOKPATTERN']._serialized_end=3768 + _globals['_CONFIRMSELECT']._serialized_start=3770 + _globals['_CONFIRMSELECT']._serialized_end=3802 + _globals['_CONFIRMSELECTPATTERN']._serialized_start=3804 + _globals['_CONFIRMSELECTPATTERN']._serialized_end=3832 + _globals['_CONFIRMSELECTOK']._serialized_start=3834 + _globals['_CONFIRMSELECTOK']._serialized_end=3851 + _globals['_CONFIRMSELECTOKPATTERN']._serialized_start=3853 + _globals['_CONFIRMSELECTOKPATTERN']._serialized_end=3877 + _globals['_EXCHANGEDECLARE']._serialized_start=3880 + _globals['_EXCHANGEDECLARE']._serialized_end=4103 + _globals['_EXCHANGEDECLAREPATTERN']._serialized_start=4105 + _globals['_EXCHANGEDECLAREPATTERN']._serialized_end=4171 + _globals['_EXCHANGEDECLAREOK']._serialized_start=4173 + _globals['_EXCHANGEDECLAREOK']._serialized_end=4192 + _globals['_EXCHANGEDECLAREOKPATTERN']._serialized_start=4194 + _globals['_EXCHANGEDECLAREOKPATTERN']._serialized_end=4220 + _globals['_EXCHANGEBIND']._serialized_start=4223 + _globals['_EXCHANGEBIND']._serialized_end=4387 + _globals['_EXCHANGEBINDPATTERN']._serialized_start=4389 + _globals['_EXCHANGEBINDPATTERN']._serialized_end=4416 + _globals['_EXCHANGEBINDOK']._serialized_start=4418 + _globals['_EXCHANGEBINDOK']._serialized_end=4434 + _globals['_EXCHANGEBINDOKPATTERN']._serialized_start=4436 + _globals['_EXCHANGEBINDOKPATTERN']._serialized_end=4459 + _globals['_QUEUEDECLARE']._serialized_start=4462 + _globals['_QUEUEDECLARE']._serialized_end=4657 + _globals['_QUEUEDECLAREPATTERN']._serialized_start=4659 + _globals['_QUEUEDECLAREPATTERN']._serialized_end=4686 + _globals['_QUEUEDECLAREOK']._serialized_start=4688 + _globals['_QUEUEDECLAREOK']._serialized_end=4766 + _globals['_QUEUEDECLAREOKPATTERN']._serialized_start=4768 + _globals['_QUEUEDECLAREOKPATTERN']._serialized_end=4797 + _globals['_QUEUEBIND']._serialized_start=4800 + _globals['_QUEUEBIND']._serialized_end=4957 + _globals['_QUEUEBINDPATTERN']._serialized_start=4959 + _globals['_QUEUEBINDPATTERN']._serialized_end=4983 + _globals['_QUEUEBINDOK']._serialized_start=4985 + _globals['_QUEUEBINDOK']._serialized_end=4998 + _globals['_QUEUEBINDOKPATTERN']._serialized_start=5000 + _globals['_QUEUEBINDOKPATTERN']._serialized_end=5020 + _globals['_BASICQOS']._serialized_start=5022 + _globals['_BASICQOS']._serialized_end=5099 + _globals['_BASICQOSPATTERN']._serialized_start=5101 + _globals['_BASICQOSPATTERN']._serialized_end=5124 + _globals['_BASICQOSOK']._serialized_start=5126 + _globals['_BASICQOSOK']._serialized_end=5138 + _globals['_BASICQOSOKPATTERN']._serialized_start=5140 + _globals['_BASICQOSOKPATTERN']._serialized_end=5159 + _globals['_BASICCONSUME']._serialized_start=5162 + _globals['_BASICCONSUME']._serialized_end=5358 + _globals['_BASICCONSUMEPATTERN']._serialized_start=5360 + _globals['_BASICCONSUMEPATTERN']._serialized_end=5387 + _globals['_BASICCONSUMEOK']._serialized_start=5389 + _globals['_BASICCONSUMEOK']._serialized_end=5427 + _globals['_BASICCONSUMEOKPATTERN']._serialized_start=5429 + _globals['_BASICCONSUMEOKPATTERN']._serialized_end=5458 + _globals['_BASICCANCEL']._serialized_start=5460 + _globals['_BASICCANCEL']._serialized_end=5512 + _globals['_BASICCANCELPATTERN']._serialized_start=5514 + _globals['_BASICCANCELPATTERN']._serialized_end=5584 + _globals['_BASICCANCELOK']._serialized_start=5586 + _globals['_BASICCANCELOK']._serialized_end=5623 + _globals['_BASICCANCELOKPATTERN']._serialized_start=5625 + _globals['_BASICCANCELOKPATTERN']._serialized_end=5691 + _globals['_BASICPUBLISH']._serialized_start=5694 + _globals['_BASICPUBLISH']._serialized_end=5884 + _globals['_BASICPUBLISHPATTERN']._serialized_start=5886 + _globals['_BASICPUBLISHPATTERN']._serialized_end=5913 + _globals['_BASICRETURN']._serialized_start=5916 + _globals['_BASICRETURN']._serialized_end=6107 + _globals['_BASICRETURNPATTERN']._serialized_start=6110 + _globals['_BASICRETURNPATTERN']._serialized_end=6266 + _globals['_BASICACK']._serialized_start=6268 + _globals['_BASICACK']._serialized_end=6318 + _globals['_BASICACKPATTERN']._serialized_start=6320 + _globals['_BASICACKPATTERN']._serialized_end=6387 + _globals['_BASICNACK']._serialized_start=6389 + _globals['_BASICNACK']._serialized_end=6457 + _globals['_BASICNACKPATTERN']._serialized_start=6459 + _globals['_BASICNACKPATTERN']._serialized_end=6533 + _globals['_BASICREJECT']._serialized_start=6535 + _globals['_BASICREJECT']._serialized_end=6587 + _globals['_BASICREJECTPATTERN']._serialized_start=6589 + _globals['_BASICREJECTPATTERN']._serialized_end=6659 + _globals['_BASICDELIVER']._serialized_start=6662 + _globals['_BASICDELIVER']._serialized_end=6879 + _globals['_BASICDELIVERPATTERN']._serialized_start=6881 + _globals['_BASICDELIVERPATTERN']._serialized_end=6996 + _globals['_BASICGET']._serialized_start=6998 + _globals['_BASICGET']._serialized_end=7039 + _globals['_BASICGETPATTERN']._serialized_start=7041 + _globals['_BASICGETPATTERN']._serialized_end=7070 + _globals['_BASICGETOK']._serialized_start=7073 + _globals['_BASICGETOK']._serialized_end=7289 + _globals['_BASICGETOKPATTERN']._serialized_start=7291 + _globals['_BASICGETOKPATTERN']._serialized_end=7360 + _globals['_BASICGETEMPTY']._serialized_start=7362 + _globals['_BASICGETEMPTY']._serialized_end=7377 + _globals['_BASICGETEMPTYPATTERN']._serialized_start=7379 + _globals['_BASICGETEMPTYPATTERN']._serialized_end=7401 + _globals['_METHOD']._serialized_start=7404 + _globals['_METHOD']._serialized_end=9889 + _globals['_PATTERN']._serialized_start=9892 + _globals['_PATTERN']._serialized_end=12639 + _globals['_WEIGHTED']._serialized_start=12641 + _globals['_WEIGHTED']._serialized_end=12735 + _globals['_SCRIPTED']._serialized_start=12738 + _globals['_SCRIPTED']._serialized_end=12916 + _globals['_SESSIONCONFIG']._serialized_start=12919 + _globals['_SESSIONCONFIG']._serialized_end=13451 + _globals['_TRANSCRIPTLINE']._serialized_start=13454 + _globals['_TRANSCRIPTLINE']._serialized_end=13683 + _globals['_STATUSREPLY']._serialized_start=13686 + _globals['_STATUSREPLY']._serialized_end=13864 + _globals['_CONFIGUREREQUEST']._serialized_start=13866 + _globals['_CONFIGUREREQUEST']._serialized_end=13941 + _globals['_STATUSREQUEST']._serialized_start=13943 + _globals['_STATUSREQUEST']._serialized_end=13958 + _globals['_RESETREQUEST']._serialized_start=13960 + _globals['_RESETREQUEST']._serialized_end=13974 + _globals['_TRANSCRIPTREQUEST']._serialized_start=13976 + _globals['_TRANSCRIPTREQUEST']._serialized_end=13995 + _globals['_IMPOSTER']._serialized_start=14519 + _globals['_IMPOSTER']._serialized_end=14906 +# @@protoc_insertion_point(module_scope) diff --git a/src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2.pyi b/src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2.pyi new file mode 100644 index 00000000..367f1a5d --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2.pyi @@ -0,0 +1,2727 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +Copyright 2026 Bloomberg Finance L.P. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from collections import abc as _abc +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import timestamp_pb2 as _timestamp_pb2 +from google.protobuf.internal import containers as _containers +from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper +import builtins as _builtins +import sys +import typing as _typing + +if sys.version_info >= (3, 11): + from typing import TypeAlias as _TypeAlias, Never as _Never +else: + from typing_extensions import TypeAlias as _TypeAlias, Never as _Never + +DESCRIPTOR: _descriptor.FileDescriptor + +class _BaseWeight: + ValueType = _typing.NewType("ValueType", _builtins.int) + V: _TypeAlias = ValueType # noqa: Y015 + +class _BaseWeightEnumTypeWrapper(_enum_type_wrapper._EnumTypeWrapper[_BaseWeight.ValueType], _builtins.type): + DESCRIPTOR: _descriptor.EnumDescriptor + BASE_WEIGHT_UNSPECIFIED: _BaseWeight.ValueType # 0 + BASE_WEIGHT: _BaseWeight.ValueType # 100 + +class BaseWeight(_BaseWeight, metaclass=_BaseWeightEnumTypeWrapper): ... + +BASE_WEIGHT_UNSPECIFIED: BaseWeight.ValueType # 0 +BASE_WEIGHT: BaseWeight.ValueType # 100 +Global___BaseWeight: _TypeAlias = BaseWeight # noqa: Y015 + +class _TranscriptEvent: + ValueType = _typing.NewType("ValueType", _builtins.int) + V: _TypeAlias = ValueType # noqa: Y015 + +class _TranscriptEventEnumTypeWrapper(_enum_type_wrapper._EnumTypeWrapper[_TranscriptEvent.ValueType], _builtins.type): + DESCRIPTOR: _descriptor.EnumDescriptor + TRANSCRIPT_EVENT_UNSPECIFIED: _TranscriptEvent.ValueType # 0 + TRANSCRIPT_EVENT_SESSION_STARTED: _TranscriptEvent.ValueType # 1 + TRANSCRIPT_EVENT_TCP_CONNECTED: _TranscriptEvent.ValueType # 2 + TRANSCRIPT_EVENT_TCP_REJECTED: _TranscriptEvent.ValueType # 3 + TRANSCRIPT_EVENT_TCP_DOWN: _TranscriptEvent.ValueType # 4 + TRANSCRIPT_EVENT_IDLE_TIMEOUT: _TranscriptEvent.ValueType # 5 + TRANSCRIPT_EVENT_PROTOCOL_HEADER: _TranscriptEvent.ValueType # 6 + TRANSCRIPT_EVENT_FRAME_MALFORMED: _TranscriptEvent.ValueType # 7 + TRANSCRIPT_EVENT_FRAME_HEARTBEAT: _TranscriptEvent.ValueType # 8 + TRANSCRIPT_EVENT_METHOD_INGRESS: _TranscriptEvent.ValueType # 9 + TRANSCRIPT_EVENT_METHOD_EGRESS: _TranscriptEvent.ValueType # 10 + TRANSCRIPT_EVENT_METHOD_UNREPRESENTABLE: _TranscriptEvent.ValueType # 11 + +class TranscriptEvent(_TranscriptEvent, metaclass=_TranscriptEventEnumTypeWrapper): ... + +TRANSCRIPT_EVENT_UNSPECIFIED: TranscriptEvent.ValueType # 0 +TRANSCRIPT_EVENT_SESSION_STARTED: TranscriptEvent.ValueType # 1 +TRANSCRIPT_EVENT_TCP_CONNECTED: TranscriptEvent.ValueType # 2 +TRANSCRIPT_EVENT_TCP_REJECTED: TranscriptEvent.ValueType # 3 +TRANSCRIPT_EVENT_TCP_DOWN: TranscriptEvent.ValueType # 4 +TRANSCRIPT_EVENT_IDLE_TIMEOUT: TranscriptEvent.ValueType # 5 +TRANSCRIPT_EVENT_PROTOCOL_HEADER: TranscriptEvent.ValueType # 6 +TRANSCRIPT_EVENT_FRAME_MALFORMED: TranscriptEvent.ValueType # 7 +TRANSCRIPT_EVENT_FRAME_HEARTBEAT: TranscriptEvent.ValueType # 8 +TRANSCRIPT_EVENT_METHOD_INGRESS: TranscriptEvent.ValueType # 9 +TRANSCRIPT_EVENT_METHOD_EGRESS: TranscriptEvent.ValueType # 10 +TRANSCRIPT_EVENT_METHOD_UNREPRESENTABLE: TranscriptEvent.ValueType # 11 +Global___TranscriptEvent: _TypeAlias = TranscriptEvent # noqa: Y015 + +@_typing.final +class Void(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___Void: _TypeAlias = Void # noqa: Y015 + +@_typing.final +class StringList(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + ITEMS_FIELD_NUMBER: _builtins.int + @_builtins.property + def items(self) -> _containers.RepeatedScalarFieldContainer[_builtins.str]: ... + def __init__( + self, + *, + items: _abc.Iterable[_builtins.str] | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["items", b"items"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___StringList: _TypeAlias = StringList # noqa: Y015 + +@_typing.final +class AmqpValue(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + BOOLEAN_FIELD_NUMBER: _builtins.int + LONG_FIELD_NUMBER: _builtins.int + STRING_FIELD_NUMBER: _builtins.int + BYTES_FIELD_NUMBER: _builtins.int + DECIMAL_FIELD_NUMBER: _builtins.int + TIMESTAMP_FIELD_NUMBER: _builtins.int + VOID_FIELD_NUMBER: _builtins.int + TABLE_FIELD_NUMBER: _builtins.int + ARRAY_FIELD_NUMBER: _builtins.int + boolean: _builtins.bool + long: _builtins.int + string: _builtins.str + bytes: _builtins.bytes + decimal: _builtins.str + @_builtins.property + def timestamp(self) -> _timestamp_pb2.Timestamp: ... + @_builtins.property + def void(self) -> Global___Void: ... + @_builtins.property + def table(self) -> Global___AmqpTable: ... + @_builtins.property + def array(self) -> Global___AmqpArray: ... + def __init__( + self, + *, + boolean: _builtins.bool = ..., + long: _builtins.int = ..., + string: _builtins.str = ..., + bytes: _builtins.bytes = ..., + decimal: _builtins.str = ..., + timestamp: _timestamp_pb2.Timestamp | None = ..., + void: Global___Void | None = ..., + table: Global___AmqpTable | None = ..., + array: Global___AmqpArray | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["array", b"array", "boolean", b"boolean", "bytes", b"bytes", "decimal", b"decimal", "long", b"long", "string", b"string", "table", b"table", "timestamp", b"timestamp", "value", b"value", "void", b"void"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["array", b"array", "boolean", b"boolean", "bytes", b"bytes", "decimal", b"decimal", "long", b"long", "string", b"string", "table", b"table", "timestamp", b"timestamp", "value", b"value", "void", b"void"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType_value: _TypeAlias = _typing.Literal["boolean", "long", "string", "bytes", "decimal", "timestamp", "void", "table", "array"] # noqa: Y015 + _WhichOneofArgType_value: _TypeAlias = _typing.Literal["value", b"value"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType_value) -> _WhichOneofReturnType_value | None: ... + +Global___AmqpValue: _TypeAlias = AmqpValue # noqa: Y015 + +@_typing.final +class AmqpTable(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + @_typing.final + class EntriesEntry(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + KEY_FIELD_NUMBER: _builtins.int + VALUE_FIELD_NUMBER: _builtins.int + key: _builtins.str + @_builtins.property + def value(self) -> Global___AmqpValue: ... + def __init__( + self, + *, + key: _builtins.str = ..., + value: Global___AmqpValue | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["value", b"value"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["key", b"key", "value", b"value"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + + ENTRIES_FIELD_NUMBER: _builtins.int + @_builtins.property + def entries(self) -> _containers.MessageMap[_builtins.str, Global___AmqpValue]: ... + def __init__( + self, + *, + entries: _abc.Mapping[_builtins.str, Global___AmqpValue] | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["entries", b"entries"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___AmqpTable: _TypeAlias = AmqpTable # noqa: Y015 + +@_typing.final +class AmqpArray(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + ITEMS_FIELD_NUMBER: _builtins.int + @_builtins.property + def items(self) -> _containers.RepeatedCompositeFieldContainer[Global___AmqpValue]: ... + def __init__( + self, + *, + items: _abc.Iterable[Global___AmqpValue] | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["items", b"items"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___AmqpArray: _TypeAlias = AmqpArray # noqa: Y015 + +@_typing.final +class MessageProperties(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CONTENT_TYPE_FIELD_NUMBER: _builtins.int + CONTENT_ENCODING_FIELD_NUMBER: _builtins.int + HEADERS_FIELD_NUMBER: _builtins.int + DELIVERY_MODE_FIELD_NUMBER: _builtins.int + PRIORITY_FIELD_NUMBER: _builtins.int + CORRELATION_ID_FIELD_NUMBER: _builtins.int + REPLY_TO_FIELD_NUMBER: _builtins.int + EXPIRATION_FIELD_NUMBER: _builtins.int + MESSAGE_ID_FIELD_NUMBER: _builtins.int + TIMESTAMP_FIELD_NUMBER: _builtins.int + TYPE_FIELD_NUMBER: _builtins.int + USER_ID_FIELD_NUMBER: _builtins.int + APP_ID_FIELD_NUMBER: _builtins.int + content_type: _builtins.str + content_encoding: _builtins.str + delivery_mode: _builtins.int + priority: _builtins.int + correlation_id: _builtins.str + reply_to: _builtins.str + expiration: _builtins.str + message_id: _builtins.str + timestamp: _builtins.int + type: _builtins.str + user_id: _builtins.str + app_id: _builtins.str + @_builtins.property + def headers(self) -> Global___AmqpTable: ... + def __init__( + self, + *, + content_type: _builtins.str | None = ..., + content_encoding: _builtins.str | None = ..., + headers: Global___AmqpTable | None = ..., + delivery_mode: _builtins.int | None = ..., + priority: _builtins.int | None = ..., + correlation_id: _builtins.str | None = ..., + reply_to: _builtins.str | None = ..., + expiration: _builtins.str | None = ..., + message_id: _builtins.str | None = ..., + timestamp: _builtins.int | None = ..., + type: _builtins.str | None = ..., + user_id: _builtins.str | None = ..., + app_id: _builtins.str | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_app_id", b"_app_id", "_content_encoding", b"_content_encoding", "_content_type", b"_content_type", "_correlation_id", b"_correlation_id", "_delivery_mode", b"_delivery_mode", "_expiration", b"_expiration", "_headers", b"_headers", "_message_id", b"_message_id", "_priority", b"_priority", "_reply_to", b"_reply_to", "_timestamp", b"_timestamp", "_type", b"_type", "_user_id", b"_user_id", "app_id", b"app_id", "content_encoding", b"content_encoding", "content_type", b"content_type", "correlation_id", b"correlation_id", "delivery_mode", b"delivery_mode", "expiration", b"expiration", "headers", b"headers", "message_id", b"message_id", "priority", b"priority", "reply_to", b"reply_to", "timestamp", b"timestamp", "type", b"type", "user_id", b"user_id"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_app_id", b"_app_id", "_content_encoding", b"_content_encoding", "_content_type", b"_content_type", "_correlation_id", b"_correlation_id", "_delivery_mode", b"_delivery_mode", "_expiration", b"_expiration", "_headers", b"_headers", "_message_id", b"_message_id", "_priority", b"_priority", "_reply_to", b"_reply_to", "_timestamp", b"_timestamp", "_type", b"_type", "_user_id", b"_user_id", "app_id", b"app_id", "content_encoding", b"content_encoding", "content_type", b"content_type", "correlation_id", b"correlation_id", "delivery_mode", b"delivery_mode", "expiration", b"expiration", "headers", b"headers", "message_id", b"message_id", "priority", b"priority", "reply_to", b"reply_to", "timestamp", b"timestamp", "type", b"type", "user_id", b"user_id"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__app_id: _TypeAlias = _typing.Literal["app_id"] # noqa: Y015 + _WhichOneofArgType__app_id: _TypeAlias = _typing.Literal["_app_id", b"_app_id"] # noqa: Y015 + _WhichOneofReturnType__content_encoding: _TypeAlias = _typing.Literal["content_encoding"] # noqa: Y015 + _WhichOneofArgType__content_encoding: _TypeAlias = _typing.Literal["_content_encoding", b"_content_encoding"] # noqa: Y015 + _WhichOneofReturnType__content_type: _TypeAlias = _typing.Literal["content_type"] # noqa: Y015 + _WhichOneofArgType__content_type: _TypeAlias = _typing.Literal["_content_type", b"_content_type"] # noqa: Y015 + _WhichOneofReturnType__correlation_id: _TypeAlias = _typing.Literal["correlation_id"] # noqa: Y015 + _WhichOneofArgType__correlation_id: _TypeAlias = _typing.Literal["_correlation_id", b"_correlation_id"] # noqa: Y015 + _WhichOneofReturnType__delivery_mode: _TypeAlias = _typing.Literal["delivery_mode"] # noqa: Y015 + _WhichOneofArgType__delivery_mode: _TypeAlias = _typing.Literal["_delivery_mode", b"_delivery_mode"] # noqa: Y015 + _WhichOneofReturnType__expiration: _TypeAlias = _typing.Literal["expiration"] # noqa: Y015 + _WhichOneofArgType__expiration: _TypeAlias = _typing.Literal["_expiration", b"_expiration"] # noqa: Y015 + _WhichOneofReturnType__headers: _TypeAlias = _typing.Literal["headers"] # noqa: Y015 + _WhichOneofArgType__headers: _TypeAlias = _typing.Literal["_headers", b"_headers"] # noqa: Y015 + _WhichOneofReturnType__message_id: _TypeAlias = _typing.Literal["message_id"] # noqa: Y015 + _WhichOneofArgType__message_id: _TypeAlias = _typing.Literal["_message_id", b"_message_id"] # noqa: Y015 + _WhichOneofReturnType__priority: _TypeAlias = _typing.Literal["priority"] # noqa: Y015 + _WhichOneofArgType__priority: _TypeAlias = _typing.Literal["_priority", b"_priority"] # noqa: Y015 + _WhichOneofReturnType__reply_to: _TypeAlias = _typing.Literal["reply_to"] # noqa: Y015 + _WhichOneofArgType__reply_to: _TypeAlias = _typing.Literal["_reply_to", b"_reply_to"] # noqa: Y015 + _WhichOneofReturnType__timestamp: _TypeAlias = _typing.Literal["timestamp"] # noqa: Y015 + _WhichOneofArgType__timestamp: _TypeAlias = _typing.Literal["_timestamp", b"_timestamp"] # noqa: Y015 + _WhichOneofReturnType__type: _TypeAlias = _typing.Literal["type"] # noqa: Y015 + _WhichOneofArgType__type: _TypeAlias = _typing.Literal["_type", b"_type"] # noqa: Y015 + _WhichOneofReturnType__user_id: _TypeAlias = _typing.Literal["user_id"] # noqa: Y015 + _WhichOneofArgType__user_id: _TypeAlias = _typing.Literal["_user_id", b"_user_id"] # noqa: Y015 + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__app_id) -> _WhichOneofReturnType__app_id | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__content_encoding) -> _WhichOneofReturnType__content_encoding | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__content_type) -> _WhichOneofReturnType__content_type | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__correlation_id) -> _WhichOneofReturnType__correlation_id | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__delivery_mode) -> _WhichOneofReturnType__delivery_mode | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__expiration) -> _WhichOneofReturnType__expiration | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__headers) -> _WhichOneofReturnType__headers | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__message_id) -> _WhichOneofReturnType__message_id | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__priority) -> _WhichOneofReturnType__priority | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__reply_to) -> _WhichOneofReturnType__reply_to | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__timestamp) -> _WhichOneofReturnType__timestamp | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__type) -> _WhichOneofReturnType__type | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__user_id) -> _WhichOneofReturnType__user_id | None: ... + +Global___MessageProperties: _TypeAlias = MessageProperties # noqa: Y015 + +@_typing.final +class NegotiationOptions(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + MECHANISMS_FIELD_NUMBER: _builtins.int + LOCALES_FIELD_NUMBER: _builtins.int + PUBLISHER_CONFIRMS_FIELD_NUMBER: _builtins.int + BASIC_NACK_FIELD_NUMBER: _builtins.int + CONSUMER_CANCEL_NOTIFY_FIELD_NUMBER: _builtins.int + CHANNEL_MAX_FIELD_NUMBER: _builtins.int + FRAME_MAX_FIELD_NUMBER: _builtins.int + HEARTBEAT_FIELD_NUMBER: _builtins.int + publisher_confirms: _builtins.bool + basic_nack: _builtins.bool + consumer_cancel_notify: _builtins.bool + channel_max: _builtins.int + frame_max: _builtins.int + heartbeat: _builtins.int + @_builtins.property + def mechanisms(self) -> Global___StringList: ... + @_builtins.property + def locales(self) -> Global___StringList: ... + def __init__( + self, + *, + mechanisms: Global___StringList | None = ..., + locales: Global___StringList | None = ..., + publisher_confirms: _builtins.bool | None = ..., + basic_nack: _builtins.bool | None = ..., + consumer_cancel_notify: _builtins.bool | None = ..., + channel_max: _builtins.int | None = ..., + frame_max: _builtins.int | None = ..., + heartbeat: _builtins.int | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_basic_nack", b"_basic_nack", "_channel_max", b"_channel_max", "_consumer_cancel_notify", b"_consumer_cancel_notify", "_frame_max", b"_frame_max", "_heartbeat", b"_heartbeat", "_locales", b"_locales", "_mechanisms", b"_mechanisms", "_publisher_confirms", b"_publisher_confirms", "basic_nack", b"basic_nack", "channel_max", b"channel_max", "consumer_cancel_notify", b"consumer_cancel_notify", "frame_max", b"frame_max", "heartbeat", b"heartbeat", "locales", b"locales", "mechanisms", b"mechanisms", "publisher_confirms", b"publisher_confirms"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_basic_nack", b"_basic_nack", "_channel_max", b"_channel_max", "_consumer_cancel_notify", b"_consumer_cancel_notify", "_frame_max", b"_frame_max", "_heartbeat", b"_heartbeat", "_locales", b"_locales", "_mechanisms", b"_mechanisms", "_publisher_confirms", b"_publisher_confirms", "basic_nack", b"basic_nack", "channel_max", b"channel_max", "consumer_cancel_notify", b"consumer_cancel_notify", "frame_max", b"frame_max", "heartbeat", b"heartbeat", "locales", b"locales", "mechanisms", b"mechanisms", "publisher_confirms", b"publisher_confirms"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__basic_nack: _TypeAlias = _typing.Literal["basic_nack"] # noqa: Y015 + _WhichOneofArgType__basic_nack: _TypeAlias = _typing.Literal["_basic_nack", b"_basic_nack"] # noqa: Y015 + _WhichOneofReturnType__channel_max: _TypeAlias = _typing.Literal["channel_max"] # noqa: Y015 + _WhichOneofArgType__channel_max: _TypeAlias = _typing.Literal["_channel_max", b"_channel_max"] # noqa: Y015 + _WhichOneofReturnType__consumer_cancel_notify: _TypeAlias = _typing.Literal["consumer_cancel_notify"] # noqa: Y015 + _WhichOneofArgType__consumer_cancel_notify: _TypeAlias = _typing.Literal["_consumer_cancel_notify", b"_consumer_cancel_notify"] # noqa: Y015 + _WhichOneofReturnType__frame_max: _TypeAlias = _typing.Literal["frame_max"] # noqa: Y015 + _WhichOneofArgType__frame_max: _TypeAlias = _typing.Literal["_frame_max", b"_frame_max"] # noqa: Y015 + _WhichOneofReturnType__heartbeat: _TypeAlias = _typing.Literal["heartbeat"] # noqa: Y015 + _WhichOneofArgType__heartbeat: _TypeAlias = _typing.Literal["_heartbeat", b"_heartbeat"] # noqa: Y015 + _WhichOneofReturnType__locales: _TypeAlias = _typing.Literal["locales"] # noqa: Y015 + _WhichOneofArgType__locales: _TypeAlias = _typing.Literal["_locales", b"_locales"] # noqa: Y015 + _WhichOneofReturnType__mechanisms: _TypeAlias = _typing.Literal["mechanisms"] # noqa: Y015 + _WhichOneofArgType__mechanisms: _TypeAlias = _typing.Literal["_mechanisms", b"_mechanisms"] # noqa: Y015 + _WhichOneofReturnType__publisher_confirms: _TypeAlias = _typing.Literal["publisher_confirms"] # noqa: Y015 + _WhichOneofArgType__publisher_confirms: _TypeAlias = _typing.Literal["_publisher_confirms", b"_publisher_confirms"] # noqa: Y015 + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__basic_nack) -> _WhichOneofReturnType__basic_nack | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__channel_max) -> _WhichOneofReturnType__channel_max | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__consumer_cancel_notify) -> _WhichOneofReturnType__consumer_cancel_notify | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__frame_max) -> _WhichOneofReturnType__frame_max | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__heartbeat) -> _WhichOneofReturnType__heartbeat | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__locales) -> _WhichOneofReturnType__locales | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__mechanisms) -> _WhichOneofReturnType__mechanisms | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__publisher_confirms) -> _WhichOneofReturnType__publisher_confirms | None: ... + +Global___NegotiationOptions: _TypeAlias = NegotiationOptions # noqa: Y015 + +@_typing.final +class ConnectionStart(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + VERSION_MAJOR_FIELD_NUMBER: _builtins.int + VERSION_MINOR_FIELD_NUMBER: _builtins.int + MECHANISMS_FIELD_NUMBER: _builtins.int + LOCALES_FIELD_NUMBER: _builtins.int + PUBLISHER_CONFIRMS_FIELD_NUMBER: _builtins.int + BASIC_NACK_FIELD_NUMBER: _builtins.int + CONSUMER_CANCEL_NOTIFY_FIELD_NUMBER: _builtins.int + version_major: _builtins.int + version_minor: _builtins.int + publisher_confirms: _builtins.bool + basic_nack: _builtins.bool + consumer_cancel_notify: _builtins.bool + @_builtins.property + def mechanisms(self) -> _containers.RepeatedScalarFieldContainer[_builtins.str]: ... + @_builtins.property + def locales(self) -> _containers.RepeatedScalarFieldContainer[_builtins.str]: ... + def __init__( + self, + *, + version_major: _builtins.int = ..., + version_minor: _builtins.int = ..., + mechanisms: _abc.Iterable[_builtins.str] | None = ..., + locales: _abc.Iterable[_builtins.str] | None = ..., + publisher_confirms: _builtins.bool = ..., + basic_nack: _builtins.bool = ..., + consumer_cancel_notify: _builtins.bool = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["basic_nack", b"basic_nack", "consumer_cancel_notify", b"consumer_cancel_notify", "locales", b"locales", "mechanisms", b"mechanisms", "publisher_confirms", b"publisher_confirms", "version_major", b"version_major", "version_minor", b"version_minor"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConnectionStart: _TypeAlias = ConnectionStart # noqa: Y015 + +@_typing.final +class ConnectionStartPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + VERSION_MAJOR_FIELD_NUMBER: _builtins.int + VERSION_MINOR_FIELD_NUMBER: _builtins.int + MECHANISMS_FIELD_NUMBER: _builtins.int + LOCALES_FIELD_NUMBER: _builtins.int + PUBLISHER_CONFIRMS_FIELD_NUMBER: _builtins.int + BASIC_NACK_FIELD_NUMBER: _builtins.int + CONSUMER_CANCEL_NOTIFY_FIELD_NUMBER: _builtins.int + version_major: _builtins.int + version_minor: _builtins.int + publisher_confirms: _builtins.bool + basic_nack: _builtins.bool + consumer_cancel_notify: _builtins.bool + @_builtins.property + def mechanisms(self) -> Global___StringList: ... + @_builtins.property + def locales(self) -> Global___StringList: ... + def __init__( + self, + *, + version_major: _builtins.int | None = ..., + version_minor: _builtins.int | None = ..., + mechanisms: Global___StringList | None = ..., + locales: Global___StringList | None = ..., + publisher_confirms: _builtins.bool | None = ..., + basic_nack: _builtins.bool | None = ..., + consumer_cancel_notify: _builtins.bool | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_basic_nack", b"_basic_nack", "_consumer_cancel_notify", b"_consumer_cancel_notify", "_locales", b"_locales", "_mechanisms", b"_mechanisms", "_publisher_confirms", b"_publisher_confirms", "_version_major", b"_version_major", "_version_minor", b"_version_minor", "basic_nack", b"basic_nack", "consumer_cancel_notify", b"consumer_cancel_notify", "locales", b"locales", "mechanisms", b"mechanisms", "publisher_confirms", b"publisher_confirms", "version_major", b"version_major", "version_minor", b"version_minor"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_basic_nack", b"_basic_nack", "_consumer_cancel_notify", b"_consumer_cancel_notify", "_locales", b"_locales", "_mechanisms", b"_mechanisms", "_publisher_confirms", b"_publisher_confirms", "_version_major", b"_version_major", "_version_minor", b"_version_minor", "basic_nack", b"basic_nack", "consumer_cancel_notify", b"consumer_cancel_notify", "locales", b"locales", "mechanisms", b"mechanisms", "publisher_confirms", b"publisher_confirms", "version_major", b"version_major", "version_minor", b"version_minor"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__basic_nack: _TypeAlias = _typing.Literal["basic_nack"] # noqa: Y015 + _WhichOneofArgType__basic_nack: _TypeAlias = _typing.Literal["_basic_nack", b"_basic_nack"] # noqa: Y015 + _WhichOneofReturnType__consumer_cancel_notify: _TypeAlias = _typing.Literal["consumer_cancel_notify"] # noqa: Y015 + _WhichOneofArgType__consumer_cancel_notify: _TypeAlias = _typing.Literal["_consumer_cancel_notify", b"_consumer_cancel_notify"] # noqa: Y015 + _WhichOneofReturnType__locales: _TypeAlias = _typing.Literal["locales"] # noqa: Y015 + _WhichOneofArgType__locales: _TypeAlias = _typing.Literal["_locales", b"_locales"] # noqa: Y015 + _WhichOneofReturnType__mechanisms: _TypeAlias = _typing.Literal["mechanisms"] # noqa: Y015 + _WhichOneofArgType__mechanisms: _TypeAlias = _typing.Literal["_mechanisms", b"_mechanisms"] # noqa: Y015 + _WhichOneofReturnType__publisher_confirms: _TypeAlias = _typing.Literal["publisher_confirms"] # noqa: Y015 + _WhichOneofArgType__publisher_confirms: _TypeAlias = _typing.Literal["_publisher_confirms", b"_publisher_confirms"] # noqa: Y015 + _WhichOneofReturnType__version_major: _TypeAlias = _typing.Literal["version_major"] # noqa: Y015 + _WhichOneofArgType__version_major: _TypeAlias = _typing.Literal["_version_major", b"_version_major"] # noqa: Y015 + _WhichOneofReturnType__version_minor: _TypeAlias = _typing.Literal["version_minor"] # noqa: Y015 + _WhichOneofArgType__version_minor: _TypeAlias = _typing.Literal["_version_minor", b"_version_minor"] # noqa: Y015 + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__basic_nack) -> _WhichOneofReturnType__basic_nack | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__consumer_cancel_notify) -> _WhichOneofReturnType__consumer_cancel_notify | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__locales) -> _WhichOneofReturnType__locales | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__mechanisms) -> _WhichOneofReturnType__mechanisms | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__publisher_confirms) -> _WhichOneofReturnType__publisher_confirms | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__version_major) -> _WhichOneofReturnType__version_major | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__version_minor) -> _WhichOneofReturnType__version_minor | None: ... + +Global___ConnectionStartPattern: _TypeAlias = ConnectionStartPattern # noqa: Y015 + +@_typing.final +class ConnectionStartOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + MECHANISM_FIELD_NUMBER: _builtins.int + LOCALE_FIELD_NUMBER: _builtins.int + RESPONSE_FIELD_NUMBER: _builtins.int + mechanism: _builtins.str + locale: _builtins.str + response: _builtins.str + def __init__( + self, + *, + mechanism: _builtins.str = ..., + locale: _builtins.str = ..., + response: _builtins.str = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["locale", b"locale", "mechanism", b"mechanism", "response", b"response"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConnectionStartOk: _TypeAlias = ConnectionStartOk # noqa: Y015 + +@_typing.final +class ConnectionStartOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + MECHANISM_FIELD_NUMBER: _builtins.int + LOCALE_FIELD_NUMBER: _builtins.int + mechanism: _builtins.str + locale: _builtins.str + def __init__( + self, + *, + mechanism: _builtins.str | None = ..., + locale: _builtins.str | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_locale", b"_locale", "_mechanism", b"_mechanism", "locale", b"locale", "mechanism", b"mechanism"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_locale", b"_locale", "_mechanism", b"_mechanism", "locale", b"locale", "mechanism", b"mechanism"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__locale: _TypeAlias = _typing.Literal["locale"] # noqa: Y015 + _WhichOneofArgType__locale: _TypeAlias = _typing.Literal["_locale", b"_locale"] # noqa: Y015 + _WhichOneofReturnType__mechanism: _TypeAlias = _typing.Literal["mechanism"] # noqa: Y015 + _WhichOneofArgType__mechanism: _TypeAlias = _typing.Literal["_mechanism", b"_mechanism"] # noqa: Y015 + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__locale) -> _WhichOneofReturnType__locale | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__mechanism) -> _WhichOneofReturnType__mechanism | None: ... + +Global___ConnectionStartOkPattern: _TypeAlias = ConnectionStartOkPattern # noqa: Y015 + +@_typing.final +class ConnectionTune(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CHANNEL_MAX_FIELD_NUMBER: _builtins.int + FRAME_MAX_FIELD_NUMBER: _builtins.int + HEARTBEAT_FIELD_NUMBER: _builtins.int + channel_max: _builtins.int + frame_max: _builtins.int + heartbeat: _builtins.int + def __init__( + self, + *, + channel_max: _builtins.int = ..., + frame_max: _builtins.int = ..., + heartbeat: _builtins.int = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["channel_max", b"channel_max", "frame_max", b"frame_max", "heartbeat", b"heartbeat"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConnectionTune: _TypeAlias = ConnectionTune # noqa: Y015 + +@_typing.final +class ConnectionTunePattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CHANNEL_MAX_FIELD_NUMBER: _builtins.int + FRAME_MAX_FIELD_NUMBER: _builtins.int + HEARTBEAT_FIELD_NUMBER: _builtins.int + channel_max: _builtins.int + frame_max: _builtins.int + heartbeat: _builtins.int + def __init__( + self, + *, + channel_max: _builtins.int | None = ..., + frame_max: _builtins.int | None = ..., + heartbeat: _builtins.int | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_channel_max", b"_channel_max", "_frame_max", b"_frame_max", "_heartbeat", b"_heartbeat", "channel_max", b"channel_max", "frame_max", b"frame_max", "heartbeat", b"heartbeat"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_channel_max", b"_channel_max", "_frame_max", b"_frame_max", "_heartbeat", b"_heartbeat", "channel_max", b"channel_max", "frame_max", b"frame_max", "heartbeat", b"heartbeat"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__channel_max: _TypeAlias = _typing.Literal["channel_max"] # noqa: Y015 + _WhichOneofArgType__channel_max: _TypeAlias = _typing.Literal["_channel_max", b"_channel_max"] # noqa: Y015 + _WhichOneofReturnType__frame_max: _TypeAlias = _typing.Literal["frame_max"] # noqa: Y015 + _WhichOneofArgType__frame_max: _TypeAlias = _typing.Literal["_frame_max", b"_frame_max"] # noqa: Y015 + _WhichOneofReturnType__heartbeat: _TypeAlias = _typing.Literal["heartbeat"] # noqa: Y015 + _WhichOneofArgType__heartbeat: _TypeAlias = _typing.Literal["_heartbeat", b"_heartbeat"] # noqa: Y015 + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__channel_max) -> _WhichOneofReturnType__channel_max | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__frame_max) -> _WhichOneofReturnType__frame_max | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__heartbeat) -> _WhichOneofReturnType__heartbeat | None: ... + +Global___ConnectionTunePattern: _TypeAlias = ConnectionTunePattern # noqa: Y015 + +@_typing.final +class ConnectionTuneOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CHANNEL_MAX_FIELD_NUMBER: _builtins.int + FRAME_MAX_FIELD_NUMBER: _builtins.int + HEARTBEAT_FIELD_NUMBER: _builtins.int + channel_max: _builtins.int + frame_max: _builtins.int + heartbeat: _builtins.int + def __init__( + self, + *, + channel_max: _builtins.int = ..., + frame_max: _builtins.int = ..., + heartbeat: _builtins.int = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["channel_max", b"channel_max", "frame_max", b"frame_max", "heartbeat", b"heartbeat"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConnectionTuneOk: _TypeAlias = ConnectionTuneOk # noqa: Y015 + +@_typing.final +class ConnectionTuneOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CHANNEL_MAX_FIELD_NUMBER: _builtins.int + FRAME_MAX_FIELD_NUMBER: _builtins.int + HEARTBEAT_FIELD_NUMBER: _builtins.int + channel_max: _builtins.int + frame_max: _builtins.int + heartbeat: _builtins.int + def __init__( + self, + *, + channel_max: _builtins.int | None = ..., + frame_max: _builtins.int | None = ..., + heartbeat: _builtins.int | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_channel_max", b"_channel_max", "_frame_max", b"_frame_max", "_heartbeat", b"_heartbeat", "channel_max", b"channel_max", "frame_max", b"frame_max", "heartbeat", b"heartbeat"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_channel_max", b"_channel_max", "_frame_max", b"_frame_max", "_heartbeat", b"_heartbeat", "channel_max", b"channel_max", "frame_max", b"frame_max", "heartbeat", b"heartbeat"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__channel_max: _TypeAlias = _typing.Literal["channel_max"] # noqa: Y015 + _WhichOneofArgType__channel_max: _TypeAlias = _typing.Literal["_channel_max", b"_channel_max"] # noqa: Y015 + _WhichOneofReturnType__frame_max: _TypeAlias = _typing.Literal["frame_max"] # noqa: Y015 + _WhichOneofArgType__frame_max: _TypeAlias = _typing.Literal["_frame_max", b"_frame_max"] # noqa: Y015 + _WhichOneofReturnType__heartbeat: _TypeAlias = _typing.Literal["heartbeat"] # noqa: Y015 + _WhichOneofArgType__heartbeat: _TypeAlias = _typing.Literal["_heartbeat", b"_heartbeat"] # noqa: Y015 + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__channel_max) -> _WhichOneofReturnType__channel_max | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__frame_max) -> _WhichOneofReturnType__frame_max | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__heartbeat) -> _WhichOneofReturnType__heartbeat | None: ... + +Global___ConnectionTuneOkPattern: _TypeAlias = ConnectionTuneOkPattern # noqa: Y015 + +@_typing.final +class ConnectionOpen(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + VHOST_FIELD_NUMBER: _builtins.int + vhost: _builtins.str + def __init__( + self, + *, + vhost: _builtins.str = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["vhost", b"vhost"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConnectionOpen: _TypeAlias = ConnectionOpen # noqa: Y015 + +@_typing.final +class ConnectionOpenPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConnectionOpenPattern: _TypeAlias = ConnectionOpenPattern # noqa: Y015 + +@_typing.final +class ConnectionOpenOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConnectionOpenOk: _TypeAlias = ConnectionOpenOk # noqa: Y015 + +@_typing.final +class ConnectionOpenOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConnectionOpenOkPattern: _TypeAlias = ConnectionOpenOkPattern # noqa: Y015 + +@_typing.final +class ConnectionClose(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + REPLY_CODE_FIELD_NUMBER: _builtins.int + REPLY_TEXT_FIELD_NUMBER: _builtins.int + CLASS_ID_FIELD_NUMBER: _builtins.int + METHOD_ID_FIELD_NUMBER: _builtins.int + reply_code: _builtins.int + reply_text: _builtins.str + class_id: _builtins.int + method_id: _builtins.int + def __init__( + self, + *, + reply_code: _builtins.int = ..., + reply_text: _builtins.str = ..., + class_id: _builtins.int = ..., + method_id: _builtins.int = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["class_id", b"class_id", "method_id", b"method_id", "reply_code", b"reply_code", "reply_text", b"reply_text"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConnectionClose: _TypeAlias = ConnectionClose # noqa: Y015 + +@_typing.final +class ConnectionClosePattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + REPLY_CODE_FIELD_NUMBER: _builtins.int + REPLY_TEXT_FIELD_NUMBER: _builtins.int + CLASS_ID_FIELD_NUMBER: _builtins.int + METHOD_ID_FIELD_NUMBER: _builtins.int + reply_code: _builtins.int + reply_text: _builtins.str + class_id: _builtins.int + method_id: _builtins.int + def __init__( + self, + *, + reply_code: _builtins.int | None = ..., + reply_text: _builtins.str | None = ..., + class_id: _builtins.int | None = ..., + method_id: _builtins.int | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_class_id", b"_class_id", "_method_id", b"_method_id", "_reply_code", b"_reply_code", "_reply_text", b"_reply_text", "class_id", b"class_id", "method_id", b"method_id", "reply_code", b"reply_code", "reply_text", b"reply_text"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_class_id", b"_class_id", "_method_id", b"_method_id", "_reply_code", b"_reply_code", "_reply_text", b"_reply_text", "class_id", b"class_id", "method_id", b"method_id", "reply_code", b"reply_code", "reply_text", b"reply_text"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__class_id: _TypeAlias = _typing.Literal["class_id"] # noqa: Y015 + _WhichOneofArgType__class_id: _TypeAlias = _typing.Literal["_class_id", b"_class_id"] # noqa: Y015 + _WhichOneofReturnType__method_id: _TypeAlias = _typing.Literal["method_id"] # noqa: Y015 + _WhichOneofArgType__method_id: _TypeAlias = _typing.Literal["_method_id", b"_method_id"] # noqa: Y015 + _WhichOneofReturnType__reply_code: _TypeAlias = _typing.Literal["reply_code"] # noqa: Y015 + _WhichOneofArgType__reply_code: _TypeAlias = _typing.Literal["_reply_code", b"_reply_code"] # noqa: Y015 + _WhichOneofReturnType__reply_text: _TypeAlias = _typing.Literal["reply_text"] # noqa: Y015 + _WhichOneofArgType__reply_text: _TypeAlias = _typing.Literal["_reply_text", b"_reply_text"] # noqa: Y015 + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__class_id) -> _WhichOneofReturnType__class_id | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__method_id) -> _WhichOneofReturnType__method_id | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__reply_code) -> _WhichOneofReturnType__reply_code | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__reply_text) -> _WhichOneofReturnType__reply_text | None: ... + +Global___ConnectionClosePattern: _TypeAlias = ConnectionClosePattern # noqa: Y015 + +@_typing.final +class ConnectionCloseOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConnectionCloseOk: _TypeAlias = ConnectionCloseOk # noqa: Y015 + +@_typing.final +class ConnectionCloseOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConnectionCloseOkPattern: _TypeAlias = ConnectionCloseOkPattern # noqa: Y015 + +@_typing.final +class ChannelOpen(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ChannelOpen: _TypeAlias = ChannelOpen # noqa: Y015 + +@_typing.final +class ChannelOpenPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ChannelOpenPattern: _TypeAlias = ChannelOpenPattern # noqa: Y015 + +@_typing.final +class ChannelOpenOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ChannelOpenOk: _TypeAlias = ChannelOpenOk # noqa: Y015 + +@_typing.final +class ChannelOpenOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ChannelOpenOkPattern: _TypeAlias = ChannelOpenOkPattern # noqa: Y015 + +@_typing.final +class ChannelClose(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + REPLY_CODE_FIELD_NUMBER: _builtins.int + REPLY_TEXT_FIELD_NUMBER: _builtins.int + CLASS_ID_FIELD_NUMBER: _builtins.int + METHOD_ID_FIELD_NUMBER: _builtins.int + reply_code: _builtins.int + reply_text: _builtins.str + class_id: _builtins.int + method_id: _builtins.int + def __init__( + self, + *, + reply_code: _builtins.int = ..., + reply_text: _builtins.str = ..., + class_id: _builtins.int = ..., + method_id: _builtins.int = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["class_id", b"class_id", "method_id", b"method_id", "reply_code", b"reply_code", "reply_text", b"reply_text"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ChannelClose: _TypeAlias = ChannelClose # noqa: Y015 + +@_typing.final +class ChannelClosePattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + REPLY_CODE_FIELD_NUMBER: _builtins.int + REPLY_TEXT_FIELD_NUMBER: _builtins.int + CLASS_ID_FIELD_NUMBER: _builtins.int + METHOD_ID_FIELD_NUMBER: _builtins.int + reply_code: _builtins.int + reply_text: _builtins.str + class_id: _builtins.int + method_id: _builtins.int + def __init__( + self, + *, + reply_code: _builtins.int | None = ..., + reply_text: _builtins.str | None = ..., + class_id: _builtins.int | None = ..., + method_id: _builtins.int | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_class_id", b"_class_id", "_method_id", b"_method_id", "_reply_code", b"_reply_code", "_reply_text", b"_reply_text", "class_id", b"class_id", "method_id", b"method_id", "reply_code", b"reply_code", "reply_text", b"reply_text"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_class_id", b"_class_id", "_method_id", b"_method_id", "_reply_code", b"_reply_code", "_reply_text", b"_reply_text", "class_id", b"class_id", "method_id", b"method_id", "reply_code", b"reply_code", "reply_text", b"reply_text"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__class_id: _TypeAlias = _typing.Literal["class_id"] # noqa: Y015 + _WhichOneofArgType__class_id: _TypeAlias = _typing.Literal["_class_id", b"_class_id"] # noqa: Y015 + _WhichOneofReturnType__method_id: _TypeAlias = _typing.Literal["method_id"] # noqa: Y015 + _WhichOneofArgType__method_id: _TypeAlias = _typing.Literal["_method_id", b"_method_id"] # noqa: Y015 + _WhichOneofReturnType__reply_code: _TypeAlias = _typing.Literal["reply_code"] # noqa: Y015 + _WhichOneofArgType__reply_code: _TypeAlias = _typing.Literal["_reply_code", b"_reply_code"] # noqa: Y015 + _WhichOneofReturnType__reply_text: _TypeAlias = _typing.Literal["reply_text"] # noqa: Y015 + _WhichOneofArgType__reply_text: _TypeAlias = _typing.Literal["_reply_text", b"_reply_text"] # noqa: Y015 + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__class_id) -> _WhichOneofReturnType__class_id | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__method_id) -> _WhichOneofReturnType__method_id | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__reply_code) -> _WhichOneofReturnType__reply_code | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__reply_text) -> _WhichOneofReturnType__reply_text | None: ... + +Global___ChannelClosePattern: _TypeAlias = ChannelClosePattern # noqa: Y015 + +@_typing.final +class ChannelCloseOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ChannelCloseOk: _TypeAlias = ChannelCloseOk # noqa: Y015 + +@_typing.final +class ChannelCloseOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ChannelCloseOkPattern: _TypeAlias = ChannelCloseOkPattern # noqa: Y015 + +@_typing.final +class ConfirmSelect(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + NO_WAIT_FIELD_NUMBER: _builtins.int + no_wait: _builtins.bool + def __init__( + self, + *, + no_wait: _builtins.bool = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["no_wait", b"no_wait"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConfirmSelect: _TypeAlias = ConfirmSelect # noqa: Y015 + +@_typing.final +class ConfirmSelectPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConfirmSelectPattern: _TypeAlias = ConfirmSelectPattern # noqa: Y015 + +@_typing.final +class ConfirmSelectOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConfirmSelectOk: _TypeAlias = ConfirmSelectOk # noqa: Y015 + +@_typing.final +class ConfirmSelectOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConfirmSelectOkPattern: _TypeAlias = ConfirmSelectOkPattern # noqa: Y015 + +@_typing.final +class ExchangeDeclare(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + EXCHANGE_FIELD_NUMBER: _builtins.int + EXCHANGE_TYPE_FIELD_NUMBER: _builtins.int + PASSIVE_FIELD_NUMBER: _builtins.int + DURABLE_FIELD_NUMBER: _builtins.int + AUTO_DELETE_FIELD_NUMBER: _builtins.int + INTERNAL_FIELD_NUMBER: _builtins.int + NO_WAIT_FIELD_NUMBER: _builtins.int + ARGUMENTS_FIELD_NUMBER: _builtins.int + exchange: _builtins.str + exchange_type: _builtins.str + passive: _builtins.bool + durable: _builtins.bool + auto_delete: _builtins.bool + internal: _builtins.bool + no_wait: _builtins.bool + @_builtins.property + def arguments(self) -> Global___AmqpTable: ... + def __init__( + self, + *, + exchange: _builtins.str = ..., + exchange_type: _builtins.str = ..., + passive: _builtins.bool = ..., + durable: _builtins.bool = ..., + auto_delete: _builtins.bool = ..., + internal: _builtins.bool = ..., + no_wait: _builtins.bool = ..., + arguments: Global___AmqpTable | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_arguments", b"_arguments", "arguments", b"arguments"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_arguments", b"_arguments", "arguments", b"arguments", "auto_delete", b"auto_delete", "durable", b"durable", "exchange", b"exchange", "exchange_type", b"exchange_type", "internal", b"internal", "no_wait", b"no_wait", "passive", b"passive"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__arguments: _TypeAlias = _typing.Literal["arguments"] # noqa: Y015 + _WhichOneofArgType__arguments: _TypeAlias = _typing.Literal["_arguments", b"_arguments"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__arguments) -> _WhichOneofReturnType__arguments | None: ... + +Global___ExchangeDeclare: _TypeAlias = ExchangeDeclare # noqa: Y015 + +@_typing.final +class ExchangeDeclarePattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + EXCHANGE_FIELD_NUMBER: _builtins.int + exchange: _builtins.str + def __init__( + self, + *, + exchange: _builtins.str | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_exchange", b"_exchange", "exchange", b"exchange"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_exchange", b"_exchange", "exchange", b"exchange"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__exchange: _TypeAlias = _typing.Literal["exchange"] # noqa: Y015 + _WhichOneofArgType__exchange: _TypeAlias = _typing.Literal["_exchange", b"_exchange"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__exchange) -> _WhichOneofReturnType__exchange | None: ... + +Global___ExchangeDeclarePattern: _TypeAlias = ExchangeDeclarePattern # noqa: Y015 + +@_typing.final +class ExchangeDeclareOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ExchangeDeclareOk: _TypeAlias = ExchangeDeclareOk # noqa: Y015 + +@_typing.final +class ExchangeDeclareOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ExchangeDeclareOkPattern: _TypeAlias = ExchangeDeclareOkPattern # noqa: Y015 + +@_typing.final +class ExchangeBind(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + DESTINATION_FIELD_NUMBER: _builtins.int + SOURCE_FIELD_NUMBER: _builtins.int + ROUTING_KEY_FIELD_NUMBER: _builtins.int + NO_WAIT_FIELD_NUMBER: _builtins.int + ARGUMENTS_FIELD_NUMBER: _builtins.int + destination: _builtins.str + source: _builtins.str + routing_key: _builtins.str + no_wait: _builtins.bool + @_builtins.property + def arguments(self) -> Global___AmqpTable: ... + def __init__( + self, + *, + destination: _builtins.str = ..., + source: _builtins.str = ..., + routing_key: _builtins.str = ..., + no_wait: _builtins.bool = ..., + arguments: Global___AmqpTable | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_arguments", b"_arguments", "arguments", b"arguments"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_arguments", b"_arguments", "arguments", b"arguments", "destination", b"destination", "no_wait", b"no_wait", "routing_key", b"routing_key", "source", b"source"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__arguments: _TypeAlias = _typing.Literal["arguments"] # noqa: Y015 + _WhichOneofArgType__arguments: _TypeAlias = _typing.Literal["_arguments", b"_arguments"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__arguments) -> _WhichOneofReturnType__arguments | None: ... + +Global___ExchangeBind: _TypeAlias = ExchangeBind # noqa: Y015 + +@_typing.final +class ExchangeBindPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ExchangeBindPattern: _TypeAlias = ExchangeBindPattern # noqa: Y015 + +@_typing.final +class ExchangeBindOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ExchangeBindOk: _TypeAlias = ExchangeBindOk # noqa: Y015 + +@_typing.final +class ExchangeBindOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ExchangeBindOkPattern: _TypeAlias = ExchangeBindOkPattern # noqa: Y015 + +@_typing.final +class QueueDeclare(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + QUEUE_FIELD_NUMBER: _builtins.int + PASSIVE_FIELD_NUMBER: _builtins.int + DURABLE_FIELD_NUMBER: _builtins.int + EXCLUSIVE_FIELD_NUMBER: _builtins.int + AUTO_DELETE_FIELD_NUMBER: _builtins.int + NO_WAIT_FIELD_NUMBER: _builtins.int + ARGUMENTS_FIELD_NUMBER: _builtins.int + queue: _builtins.str + passive: _builtins.bool + durable: _builtins.bool + exclusive: _builtins.bool + auto_delete: _builtins.bool + no_wait: _builtins.bool + @_builtins.property + def arguments(self) -> Global___AmqpTable: ... + def __init__( + self, + *, + queue: _builtins.str = ..., + passive: _builtins.bool = ..., + durable: _builtins.bool = ..., + exclusive: _builtins.bool = ..., + auto_delete: _builtins.bool = ..., + no_wait: _builtins.bool = ..., + arguments: Global___AmqpTable | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_arguments", b"_arguments", "arguments", b"arguments"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_arguments", b"_arguments", "arguments", b"arguments", "auto_delete", b"auto_delete", "durable", b"durable", "exclusive", b"exclusive", "no_wait", b"no_wait", "passive", b"passive", "queue", b"queue"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__arguments: _TypeAlias = _typing.Literal["arguments"] # noqa: Y015 + _WhichOneofArgType__arguments: _TypeAlias = _typing.Literal["_arguments", b"_arguments"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__arguments) -> _WhichOneofReturnType__arguments | None: ... + +Global___QueueDeclare: _TypeAlias = QueueDeclare # noqa: Y015 + +@_typing.final +class QueueDeclarePattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___QueueDeclarePattern: _TypeAlias = QueueDeclarePattern # noqa: Y015 + +@_typing.final +class QueueDeclareOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + QUEUE_FIELD_NUMBER: _builtins.int + MESSAGE_COUNT_FIELD_NUMBER: _builtins.int + CONSUMER_COUNT_FIELD_NUMBER: _builtins.int + queue: _builtins.str + message_count: _builtins.int + consumer_count: _builtins.int + def __init__( + self, + *, + queue: _builtins.str = ..., + message_count: _builtins.int = ..., + consumer_count: _builtins.int = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["consumer_count", b"consumer_count", "message_count", b"message_count", "queue", b"queue"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___QueueDeclareOk: _TypeAlias = QueueDeclareOk # noqa: Y015 + +@_typing.final +class QueueDeclareOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___QueueDeclareOkPattern: _TypeAlias = QueueDeclareOkPattern # noqa: Y015 + +@_typing.final +class QueueBind(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + QUEUE_FIELD_NUMBER: _builtins.int + EXCHANGE_FIELD_NUMBER: _builtins.int + ROUTING_KEY_FIELD_NUMBER: _builtins.int + NO_WAIT_FIELD_NUMBER: _builtins.int + ARGUMENTS_FIELD_NUMBER: _builtins.int + queue: _builtins.str + exchange: _builtins.str + routing_key: _builtins.str + no_wait: _builtins.bool + @_builtins.property + def arguments(self) -> Global___AmqpTable: ... + def __init__( + self, + *, + queue: _builtins.str = ..., + exchange: _builtins.str = ..., + routing_key: _builtins.str = ..., + no_wait: _builtins.bool = ..., + arguments: Global___AmqpTable | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_arguments", b"_arguments", "arguments", b"arguments"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_arguments", b"_arguments", "arguments", b"arguments", "exchange", b"exchange", "no_wait", b"no_wait", "queue", b"queue", "routing_key", b"routing_key"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__arguments: _TypeAlias = _typing.Literal["arguments"] # noqa: Y015 + _WhichOneofArgType__arguments: _TypeAlias = _typing.Literal["_arguments", b"_arguments"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__arguments) -> _WhichOneofReturnType__arguments | None: ... + +Global___QueueBind: _TypeAlias = QueueBind # noqa: Y015 + +@_typing.final +class QueueBindPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___QueueBindPattern: _TypeAlias = QueueBindPattern # noqa: Y015 + +@_typing.final +class QueueBindOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___QueueBindOk: _TypeAlias = QueueBindOk # noqa: Y015 + +@_typing.final +class QueueBindOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___QueueBindOkPattern: _TypeAlias = QueueBindOkPattern # noqa: Y015 + +@_typing.final +class BasicQos(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + PREFETCH_COUNT_FIELD_NUMBER: _builtins.int + PREFETCH_SIZE_FIELD_NUMBER: _builtins.int + GLOBAL_QOS_FIELD_NUMBER: _builtins.int + prefetch_count: _builtins.int + prefetch_size: _builtins.int + global_qos: _builtins.bool + def __init__( + self, + *, + prefetch_count: _builtins.int = ..., + prefetch_size: _builtins.int = ..., + global_qos: _builtins.bool = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["global_qos", b"global_qos", "prefetch_count", b"prefetch_count", "prefetch_size", b"prefetch_size"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicQos: _TypeAlias = BasicQos # noqa: Y015 + +@_typing.final +class BasicQosPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicQosPattern: _TypeAlias = BasicQosPattern # noqa: Y015 + +@_typing.final +class BasicQosOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicQosOk: _TypeAlias = BasicQosOk # noqa: Y015 + +@_typing.final +class BasicQosOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicQosOkPattern: _TypeAlias = BasicQosOkPattern # noqa: Y015 + +@_typing.final +class BasicConsume(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + QUEUE_FIELD_NUMBER: _builtins.int + CONSUMER_TAG_FIELD_NUMBER: _builtins.int + NO_LOCAL_FIELD_NUMBER: _builtins.int + NO_ACK_FIELD_NUMBER: _builtins.int + NO_WAIT_FIELD_NUMBER: _builtins.int + EXCLUSIVE_FIELD_NUMBER: _builtins.int + ARGUMENTS_FIELD_NUMBER: _builtins.int + queue: _builtins.str + consumer_tag: _builtins.str + no_local: _builtins.bool + no_ack: _builtins.bool + no_wait: _builtins.bool + exclusive: _builtins.bool + @_builtins.property + def arguments(self) -> Global___AmqpTable: ... + def __init__( + self, + *, + queue: _builtins.str = ..., + consumer_tag: _builtins.str = ..., + no_local: _builtins.bool = ..., + no_ack: _builtins.bool = ..., + no_wait: _builtins.bool = ..., + exclusive: _builtins.bool = ..., + arguments: Global___AmqpTable | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_arguments", b"_arguments", "arguments", b"arguments"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_arguments", b"_arguments", "arguments", b"arguments", "consumer_tag", b"consumer_tag", "exclusive", b"exclusive", "no_ack", b"no_ack", "no_local", b"no_local", "no_wait", b"no_wait", "queue", b"queue"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__arguments: _TypeAlias = _typing.Literal["arguments"] # noqa: Y015 + _WhichOneofArgType__arguments: _TypeAlias = _typing.Literal["_arguments", b"_arguments"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__arguments) -> _WhichOneofReturnType__arguments | None: ... + +Global___BasicConsume: _TypeAlias = BasicConsume # noqa: Y015 + +@_typing.final +class BasicConsumePattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicConsumePattern: _TypeAlias = BasicConsumePattern # noqa: Y015 + +@_typing.final +class BasicConsumeOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CONSUMER_TAG_FIELD_NUMBER: _builtins.int + consumer_tag: _builtins.str + def __init__( + self, + *, + consumer_tag: _builtins.str = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["consumer_tag", b"consumer_tag"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicConsumeOk: _TypeAlias = BasicConsumeOk # noqa: Y015 + +@_typing.final +class BasicConsumeOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicConsumeOkPattern: _TypeAlias = BasicConsumeOkPattern # noqa: Y015 + +@_typing.final +class BasicCancel(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CONSUMER_TAG_FIELD_NUMBER: _builtins.int + NO_WAIT_FIELD_NUMBER: _builtins.int + consumer_tag: _builtins.str + no_wait: _builtins.bool + def __init__( + self, + *, + consumer_tag: _builtins.str = ..., + no_wait: _builtins.bool = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["consumer_tag", b"consumer_tag", "no_wait", b"no_wait"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicCancel: _TypeAlias = BasicCancel # noqa: Y015 + +@_typing.final +class BasicCancelPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CONSUMER_TAG_FIELD_NUMBER: _builtins.int + consumer_tag: _builtins.str + def __init__( + self, + *, + consumer_tag: _builtins.str | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_consumer_tag", b"_consumer_tag", "consumer_tag", b"consumer_tag"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_consumer_tag", b"_consumer_tag", "consumer_tag", b"consumer_tag"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__consumer_tag: _TypeAlias = _typing.Literal["consumer_tag"] # noqa: Y015 + _WhichOneofArgType__consumer_tag: _TypeAlias = _typing.Literal["_consumer_tag", b"_consumer_tag"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__consumer_tag) -> _WhichOneofReturnType__consumer_tag | None: ... + +Global___BasicCancelPattern: _TypeAlias = BasicCancelPattern # noqa: Y015 + +@_typing.final +class BasicCancelOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CONSUMER_TAG_FIELD_NUMBER: _builtins.int + consumer_tag: _builtins.str + def __init__( + self, + *, + consumer_tag: _builtins.str = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["consumer_tag", b"consumer_tag"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicCancelOk: _TypeAlias = BasicCancelOk # noqa: Y015 + +@_typing.final +class BasicCancelOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CONSUMER_TAG_FIELD_NUMBER: _builtins.int + consumer_tag: _builtins.str + def __init__( + self, + *, + consumer_tag: _builtins.str | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_consumer_tag", b"_consumer_tag", "consumer_tag", b"consumer_tag"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_consumer_tag", b"_consumer_tag", "consumer_tag", b"consumer_tag"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__consumer_tag: _TypeAlias = _typing.Literal["consumer_tag"] # noqa: Y015 + _WhichOneofArgType__consumer_tag: _TypeAlias = _typing.Literal["_consumer_tag", b"_consumer_tag"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__consumer_tag) -> _WhichOneofReturnType__consumer_tag | None: ... + +Global___BasicCancelOkPattern: _TypeAlias = BasicCancelOkPattern # noqa: Y015 + +@_typing.final +class BasicPublish(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + EXCHANGE_FIELD_NUMBER: _builtins.int + ROUTING_KEY_FIELD_NUMBER: _builtins.int + MANDATORY_FIELD_NUMBER: _builtins.int + IMMEDIATE_FIELD_NUMBER: _builtins.int + PROPERTIES_FIELD_NUMBER: _builtins.int + BODY_FIELD_NUMBER: _builtins.int + exchange: _builtins.str + routing_key: _builtins.str + mandatory: _builtins.bool + immediate: _builtins.bool + body: _builtins.bytes + @_builtins.property + def properties(self) -> Global___MessageProperties: ... + def __init__( + self, + *, + exchange: _builtins.str = ..., + routing_key: _builtins.str = ..., + mandatory: _builtins.bool = ..., + immediate: _builtins.bool = ..., + properties: Global___MessageProperties | None = ..., + body: _builtins.bytes = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_properties", b"_properties", "properties", b"properties"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_properties", b"_properties", "body", b"body", "exchange", b"exchange", "immediate", b"immediate", "mandatory", b"mandatory", "properties", b"properties", "routing_key", b"routing_key"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__properties: _TypeAlias = _typing.Literal["properties"] # noqa: Y015 + _WhichOneofArgType__properties: _TypeAlias = _typing.Literal["_properties", b"_properties"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__properties) -> _WhichOneofReturnType__properties | None: ... + +Global___BasicPublish: _TypeAlias = BasicPublish # noqa: Y015 + +@_typing.final +class BasicPublishPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicPublishPattern: _TypeAlias = BasicPublishPattern # noqa: Y015 + +@_typing.final +class BasicReturn(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + REPLY_CODE_FIELD_NUMBER: _builtins.int + REPLY_TEXT_FIELD_NUMBER: _builtins.int + EXCHANGE_FIELD_NUMBER: _builtins.int + ROUTING_KEY_FIELD_NUMBER: _builtins.int + PROPERTIES_FIELD_NUMBER: _builtins.int + BODY_FIELD_NUMBER: _builtins.int + reply_code: _builtins.int + reply_text: _builtins.str + exchange: _builtins.str + routing_key: _builtins.str + body: _builtins.bytes + @_builtins.property + def properties(self) -> Global___MessageProperties: ... + def __init__( + self, + *, + reply_code: _builtins.int = ..., + reply_text: _builtins.str = ..., + exchange: _builtins.str = ..., + routing_key: _builtins.str = ..., + properties: Global___MessageProperties | None = ..., + body: _builtins.bytes = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_properties", b"_properties", "properties", b"properties"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_properties", b"_properties", "body", b"body", "exchange", b"exchange", "properties", b"properties", "reply_code", b"reply_code", "reply_text", b"reply_text", "routing_key", b"routing_key"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__properties: _TypeAlias = _typing.Literal["properties"] # noqa: Y015 + _WhichOneofArgType__properties: _TypeAlias = _typing.Literal["_properties", b"_properties"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__properties) -> _WhichOneofReturnType__properties | None: ... + +Global___BasicReturn: _TypeAlias = BasicReturn # noqa: Y015 + +@_typing.final +class BasicReturnPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + REPLY_CODE_FIELD_NUMBER: _builtins.int + EXCHANGE_FIELD_NUMBER: _builtins.int + ROUTING_KEY_FIELD_NUMBER: _builtins.int + reply_code: _builtins.int + exchange: _builtins.str + routing_key: _builtins.str + def __init__( + self, + *, + reply_code: _builtins.int | None = ..., + exchange: _builtins.str | None = ..., + routing_key: _builtins.str | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_exchange", b"_exchange", "_reply_code", b"_reply_code", "_routing_key", b"_routing_key", "exchange", b"exchange", "reply_code", b"reply_code", "routing_key", b"routing_key"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_exchange", b"_exchange", "_reply_code", b"_reply_code", "_routing_key", b"_routing_key", "exchange", b"exchange", "reply_code", b"reply_code", "routing_key", b"routing_key"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__exchange: _TypeAlias = _typing.Literal["exchange"] # noqa: Y015 + _WhichOneofArgType__exchange: _TypeAlias = _typing.Literal["_exchange", b"_exchange"] # noqa: Y015 + _WhichOneofReturnType__reply_code: _TypeAlias = _typing.Literal["reply_code"] # noqa: Y015 + _WhichOneofArgType__reply_code: _TypeAlias = _typing.Literal["_reply_code", b"_reply_code"] # noqa: Y015 + _WhichOneofReturnType__routing_key: _TypeAlias = _typing.Literal["routing_key"] # noqa: Y015 + _WhichOneofArgType__routing_key: _TypeAlias = _typing.Literal["_routing_key", b"_routing_key"] # noqa: Y015 + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__exchange) -> _WhichOneofReturnType__exchange | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__reply_code) -> _WhichOneofReturnType__reply_code | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__routing_key) -> _WhichOneofReturnType__routing_key | None: ... + +Global___BasicReturnPattern: _TypeAlias = BasicReturnPattern # noqa: Y015 + +@_typing.final +class BasicAck(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + DELIVERY_TAG_FIELD_NUMBER: _builtins.int + MULTIPLE_FIELD_NUMBER: _builtins.int + delivery_tag: _builtins.int + multiple: _builtins.bool + def __init__( + self, + *, + delivery_tag: _builtins.int = ..., + multiple: _builtins.bool = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["delivery_tag", b"delivery_tag", "multiple", b"multiple"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicAck: _TypeAlias = BasicAck # noqa: Y015 + +@_typing.final +class BasicAckPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + DELIVERY_TAG_FIELD_NUMBER: _builtins.int + delivery_tag: _builtins.int + def __init__( + self, + *, + delivery_tag: _builtins.int | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_delivery_tag", b"_delivery_tag", "delivery_tag", b"delivery_tag"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_delivery_tag", b"_delivery_tag", "delivery_tag", b"delivery_tag"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__delivery_tag: _TypeAlias = _typing.Literal["delivery_tag"] # noqa: Y015 + _WhichOneofArgType__delivery_tag: _TypeAlias = _typing.Literal["_delivery_tag", b"_delivery_tag"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__delivery_tag) -> _WhichOneofReturnType__delivery_tag | None: ... + +Global___BasicAckPattern: _TypeAlias = BasicAckPattern # noqa: Y015 + +@_typing.final +class BasicNack(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + DELIVERY_TAG_FIELD_NUMBER: _builtins.int + MULTIPLE_FIELD_NUMBER: _builtins.int + REQUEUE_FIELD_NUMBER: _builtins.int + delivery_tag: _builtins.int + multiple: _builtins.bool + requeue: _builtins.bool + def __init__( + self, + *, + delivery_tag: _builtins.int = ..., + multiple: _builtins.bool = ..., + requeue: _builtins.bool = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["delivery_tag", b"delivery_tag", "multiple", b"multiple", "requeue", b"requeue"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicNack: _TypeAlias = BasicNack # noqa: Y015 + +@_typing.final +class BasicNackPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + DELIVERY_TAG_FIELD_NUMBER: _builtins.int + delivery_tag: _builtins.int + def __init__( + self, + *, + delivery_tag: _builtins.int | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_delivery_tag", b"_delivery_tag", "delivery_tag", b"delivery_tag"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_delivery_tag", b"_delivery_tag", "delivery_tag", b"delivery_tag"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__delivery_tag: _TypeAlias = _typing.Literal["delivery_tag"] # noqa: Y015 + _WhichOneofArgType__delivery_tag: _TypeAlias = _typing.Literal["_delivery_tag", b"_delivery_tag"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__delivery_tag) -> _WhichOneofReturnType__delivery_tag | None: ... + +Global___BasicNackPattern: _TypeAlias = BasicNackPattern # noqa: Y015 + +@_typing.final +class BasicReject(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + DELIVERY_TAG_FIELD_NUMBER: _builtins.int + REQUEUE_FIELD_NUMBER: _builtins.int + delivery_tag: _builtins.int + requeue: _builtins.bool + def __init__( + self, + *, + delivery_tag: _builtins.int = ..., + requeue: _builtins.bool = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["delivery_tag", b"delivery_tag", "requeue", b"requeue"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicReject: _TypeAlias = BasicReject # noqa: Y015 + +@_typing.final +class BasicRejectPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + DELIVERY_TAG_FIELD_NUMBER: _builtins.int + delivery_tag: _builtins.int + def __init__( + self, + *, + delivery_tag: _builtins.int | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_delivery_tag", b"_delivery_tag", "delivery_tag", b"delivery_tag"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_delivery_tag", b"_delivery_tag", "delivery_tag", b"delivery_tag"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__delivery_tag: _TypeAlias = _typing.Literal["delivery_tag"] # noqa: Y015 + _WhichOneofArgType__delivery_tag: _TypeAlias = _typing.Literal["_delivery_tag", b"_delivery_tag"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__delivery_tag) -> _WhichOneofReturnType__delivery_tag | None: ... + +Global___BasicRejectPattern: _TypeAlias = BasicRejectPattern # noqa: Y015 + +@_typing.final +class BasicDeliver(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CONSUMER_TAG_FIELD_NUMBER: _builtins.int + DELIVERY_TAG_FIELD_NUMBER: _builtins.int + REDELIVERED_FIELD_NUMBER: _builtins.int + EXCHANGE_FIELD_NUMBER: _builtins.int + ROUTING_KEY_FIELD_NUMBER: _builtins.int + PROPERTIES_FIELD_NUMBER: _builtins.int + BODY_FIELD_NUMBER: _builtins.int + consumer_tag: _builtins.str + delivery_tag: _builtins.int + redelivered: _builtins.bool + exchange: _builtins.str + routing_key: _builtins.str + body: _builtins.bytes + @_builtins.property + def properties(self) -> Global___MessageProperties: ... + def __init__( + self, + *, + consumer_tag: _builtins.str = ..., + delivery_tag: _builtins.int = ..., + redelivered: _builtins.bool = ..., + exchange: _builtins.str = ..., + routing_key: _builtins.str = ..., + properties: Global___MessageProperties | None = ..., + body: _builtins.bytes = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_properties", b"_properties", "properties", b"properties"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_properties", b"_properties", "body", b"body", "consumer_tag", b"consumer_tag", "delivery_tag", b"delivery_tag", "exchange", b"exchange", "properties", b"properties", "redelivered", b"redelivered", "routing_key", b"routing_key"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__properties: _TypeAlias = _typing.Literal["properties"] # noqa: Y015 + _WhichOneofArgType__properties: _TypeAlias = _typing.Literal["_properties", b"_properties"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__properties) -> _WhichOneofReturnType__properties | None: ... + +Global___BasicDeliver: _TypeAlias = BasicDeliver # noqa: Y015 + +@_typing.final +class BasicDeliverPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CONSUMER_TAG_FIELD_NUMBER: _builtins.int + DELIVERY_TAG_FIELD_NUMBER: _builtins.int + consumer_tag: _builtins.str + delivery_tag: _builtins.int + def __init__( + self, + *, + consumer_tag: _builtins.str | None = ..., + delivery_tag: _builtins.int | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_consumer_tag", b"_consumer_tag", "_delivery_tag", b"_delivery_tag", "consumer_tag", b"consumer_tag", "delivery_tag", b"delivery_tag"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_consumer_tag", b"_consumer_tag", "_delivery_tag", b"_delivery_tag", "consumer_tag", b"consumer_tag", "delivery_tag", b"delivery_tag"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__consumer_tag: _TypeAlias = _typing.Literal["consumer_tag"] # noqa: Y015 + _WhichOneofArgType__consumer_tag: _TypeAlias = _typing.Literal["_consumer_tag", b"_consumer_tag"] # noqa: Y015 + _WhichOneofReturnType__delivery_tag: _TypeAlias = _typing.Literal["delivery_tag"] # noqa: Y015 + _WhichOneofArgType__delivery_tag: _TypeAlias = _typing.Literal["_delivery_tag", b"_delivery_tag"] # noqa: Y015 + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__consumer_tag) -> _WhichOneofReturnType__consumer_tag | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__delivery_tag) -> _WhichOneofReturnType__delivery_tag | None: ... + +Global___BasicDeliverPattern: _TypeAlias = BasicDeliverPattern # noqa: Y015 + +@_typing.final +class BasicGet(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + QUEUE_FIELD_NUMBER: _builtins.int + NO_ACK_FIELD_NUMBER: _builtins.int + queue: _builtins.str + no_ack: _builtins.bool + def __init__( + self, + *, + queue: _builtins.str = ..., + no_ack: _builtins.bool = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["no_ack", b"no_ack", "queue", b"queue"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicGet: _TypeAlias = BasicGet # noqa: Y015 + +@_typing.final +class BasicGetPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicGetPattern: _TypeAlias = BasicGetPattern # noqa: Y015 + +@_typing.final +class BasicGetOk(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + DELIVERY_TAG_FIELD_NUMBER: _builtins.int + REDELIVERED_FIELD_NUMBER: _builtins.int + EXCHANGE_FIELD_NUMBER: _builtins.int + ROUTING_KEY_FIELD_NUMBER: _builtins.int + MESSAGE_COUNT_FIELD_NUMBER: _builtins.int + PROPERTIES_FIELD_NUMBER: _builtins.int + BODY_FIELD_NUMBER: _builtins.int + delivery_tag: _builtins.int + redelivered: _builtins.bool + exchange: _builtins.str + routing_key: _builtins.str + message_count: _builtins.int + body: _builtins.bytes + @_builtins.property + def properties(self) -> Global___MessageProperties: ... + def __init__( + self, + *, + delivery_tag: _builtins.int = ..., + redelivered: _builtins.bool = ..., + exchange: _builtins.str = ..., + routing_key: _builtins.str = ..., + message_count: _builtins.int = ..., + properties: Global___MessageProperties | None = ..., + body: _builtins.bytes = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_properties", b"_properties", "properties", b"properties"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_properties", b"_properties", "body", b"body", "delivery_tag", b"delivery_tag", "exchange", b"exchange", "message_count", b"message_count", "properties", b"properties", "redelivered", b"redelivered", "routing_key", b"routing_key"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__properties: _TypeAlias = _typing.Literal["properties"] # noqa: Y015 + _WhichOneofArgType__properties: _TypeAlias = _typing.Literal["_properties", b"_properties"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__properties) -> _WhichOneofReturnType__properties | None: ... + +Global___BasicGetOk: _TypeAlias = BasicGetOk # noqa: Y015 + +@_typing.final +class BasicGetOkPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + DELIVERY_TAG_FIELD_NUMBER: _builtins.int + delivery_tag: _builtins.int + def __init__( + self, + *, + delivery_tag: _builtins.int | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_delivery_tag", b"_delivery_tag", "delivery_tag", b"delivery_tag"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_delivery_tag", b"_delivery_tag", "delivery_tag", b"delivery_tag"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__delivery_tag: _TypeAlias = _typing.Literal["delivery_tag"] # noqa: Y015 + _WhichOneofArgType__delivery_tag: _TypeAlias = _typing.Literal["_delivery_tag", b"_delivery_tag"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__delivery_tag) -> _WhichOneofReturnType__delivery_tag | None: ... + +Global___BasicGetOkPattern: _TypeAlias = BasicGetOkPattern # noqa: Y015 + +@_typing.final +class BasicGetEmpty(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicGetEmpty: _TypeAlias = BasicGetEmpty # noqa: Y015 + +@_typing.final +class BasicGetEmptyPattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___BasicGetEmptyPattern: _TypeAlias = BasicGetEmptyPattern # noqa: Y015 + +@_typing.final +class Method(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CONNECTION_START_FIELD_NUMBER: _builtins.int + CONNECTION_START_OK_FIELD_NUMBER: _builtins.int + CONNECTION_TUNE_FIELD_NUMBER: _builtins.int + CONNECTION_TUNE_OK_FIELD_NUMBER: _builtins.int + CONNECTION_OPEN_FIELD_NUMBER: _builtins.int + CONNECTION_OPEN_OK_FIELD_NUMBER: _builtins.int + CONNECTION_CLOSE_FIELD_NUMBER: _builtins.int + CONNECTION_CLOSE_OK_FIELD_NUMBER: _builtins.int + CHANNEL_OPEN_FIELD_NUMBER: _builtins.int + CHANNEL_OPEN_OK_FIELD_NUMBER: _builtins.int + CHANNEL_CLOSE_FIELD_NUMBER: _builtins.int + CHANNEL_CLOSE_OK_FIELD_NUMBER: _builtins.int + CONFIRM_SELECT_FIELD_NUMBER: _builtins.int + CONFIRM_SELECT_OK_FIELD_NUMBER: _builtins.int + EXCHANGE_DECLARE_FIELD_NUMBER: _builtins.int + EXCHANGE_DECLARE_OK_FIELD_NUMBER: _builtins.int + QUEUE_DECLARE_FIELD_NUMBER: _builtins.int + QUEUE_DECLARE_OK_FIELD_NUMBER: _builtins.int + QUEUE_BIND_FIELD_NUMBER: _builtins.int + QUEUE_BIND_OK_FIELD_NUMBER: _builtins.int + BASIC_QOS_FIELD_NUMBER: _builtins.int + BASIC_QOS_OK_FIELD_NUMBER: _builtins.int + BASIC_CONSUME_FIELD_NUMBER: _builtins.int + BASIC_CONSUME_OK_FIELD_NUMBER: _builtins.int + BASIC_CANCEL_FIELD_NUMBER: _builtins.int + BASIC_CANCEL_OK_FIELD_NUMBER: _builtins.int + BASIC_PUBLISH_FIELD_NUMBER: _builtins.int + BASIC_RETURN_FIELD_NUMBER: _builtins.int + BASIC_ACK_FIELD_NUMBER: _builtins.int + BASIC_NACK_FIELD_NUMBER: _builtins.int + BASIC_REJECT_FIELD_NUMBER: _builtins.int + BASIC_DELIVER_FIELD_NUMBER: _builtins.int + BASIC_GET_FIELD_NUMBER: _builtins.int + BASIC_GET_OK_FIELD_NUMBER: _builtins.int + BASIC_GET_EMPTY_FIELD_NUMBER: _builtins.int + EXCHANGE_BIND_FIELD_NUMBER: _builtins.int + EXCHANGE_BIND_OK_FIELD_NUMBER: _builtins.int + @_builtins.property + def connection_start(self) -> Global___ConnectionStart: ... + @_builtins.property + def connection_start_ok(self) -> Global___ConnectionStartOk: ... + @_builtins.property + def connection_tune(self) -> Global___ConnectionTune: ... + @_builtins.property + def connection_tune_ok(self) -> Global___ConnectionTuneOk: ... + @_builtins.property + def connection_open(self) -> Global___ConnectionOpen: ... + @_builtins.property + def connection_open_ok(self) -> Global___ConnectionOpenOk: ... + @_builtins.property + def connection_close(self) -> Global___ConnectionClose: ... + @_builtins.property + def connection_close_ok(self) -> Global___ConnectionCloseOk: ... + @_builtins.property + def channel_open(self) -> Global___ChannelOpen: ... + @_builtins.property + def channel_open_ok(self) -> Global___ChannelOpenOk: ... + @_builtins.property + def channel_close(self) -> Global___ChannelClose: ... + @_builtins.property + def channel_close_ok(self) -> Global___ChannelCloseOk: ... + @_builtins.property + def confirm_select(self) -> Global___ConfirmSelect: ... + @_builtins.property + def confirm_select_ok(self) -> Global___ConfirmSelectOk: ... + @_builtins.property + def exchange_declare(self) -> Global___ExchangeDeclare: ... + @_builtins.property + def exchange_declare_ok(self) -> Global___ExchangeDeclareOk: ... + @_builtins.property + def queue_declare(self) -> Global___QueueDeclare: ... + @_builtins.property + def queue_declare_ok(self) -> Global___QueueDeclareOk: ... + @_builtins.property + def queue_bind(self) -> Global___QueueBind: ... + @_builtins.property + def queue_bind_ok(self) -> Global___QueueBindOk: ... + @_builtins.property + def basic_qos(self) -> Global___BasicQos: ... + @_builtins.property + def basic_qos_ok(self) -> Global___BasicQosOk: ... + @_builtins.property + def basic_consume(self) -> Global___BasicConsume: ... + @_builtins.property + def basic_consume_ok(self) -> Global___BasicConsumeOk: ... + @_builtins.property + def basic_cancel(self) -> Global___BasicCancel: ... + @_builtins.property + def basic_cancel_ok(self) -> Global___BasicCancelOk: ... + @_builtins.property + def basic_publish(self) -> Global___BasicPublish: ... + @_builtins.property + def basic_return(self) -> Global___BasicReturn: ... + @_builtins.property + def basic_ack(self) -> Global___BasicAck: ... + @_builtins.property + def basic_nack(self) -> Global___BasicNack: ... + @_builtins.property + def basic_reject(self) -> Global___BasicReject: ... + @_builtins.property + def basic_deliver(self) -> Global___BasicDeliver: ... + @_builtins.property + def basic_get(self) -> Global___BasicGet: ... + @_builtins.property + def basic_get_ok(self) -> Global___BasicGetOk: ... + @_builtins.property + def basic_get_empty(self) -> Global___BasicGetEmpty: ... + @_builtins.property + def exchange_bind(self) -> Global___ExchangeBind: ... + @_builtins.property + def exchange_bind_ok(self) -> Global___ExchangeBindOk: ... + def __init__( + self, + *, + connection_start: Global___ConnectionStart | None = ..., + connection_start_ok: Global___ConnectionStartOk | None = ..., + connection_tune: Global___ConnectionTune | None = ..., + connection_tune_ok: Global___ConnectionTuneOk | None = ..., + connection_open: Global___ConnectionOpen | None = ..., + connection_open_ok: Global___ConnectionOpenOk | None = ..., + connection_close: Global___ConnectionClose | None = ..., + connection_close_ok: Global___ConnectionCloseOk | None = ..., + channel_open: Global___ChannelOpen | None = ..., + channel_open_ok: Global___ChannelOpenOk | None = ..., + channel_close: Global___ChannelClose | None = ..., + channel_close_ok: Global___ChannelCloseOk | None = ..., + confirm_select: Global___ConfirmSelect | None = ..., + confirm_select_ok: Global___ConfirmSelectOk | None = ..., + exchange_declare: Global___ExchangeDeclare | None = ..., + exchange_declare_ok: Global___ExchangeDeclareOk | None = ..., + queue_declare: Global___QueueDeclare | None = ..., + queue_declare_ok: Global___QueueDeclareOk | None = ..., + queue_bind: Global___QueueBind | None = ..., + queue_bind_ok: Global___QueueBindOk | None = ..., + basic_qos: Global___BasicQos | None = ..., + basic_qos_ok: Global___BasicQosOk | None = ..., + basic_consume: Global___BasicConsume | None = ..., + basic_consume_ok: Global___BasicConsumeOk | None = ..., + basic_cancel: Global___BasicCancel | None = ..., + basic_cancel_ok: Global___BasicCancelOk | None = ..., + basic_publish: Global___BasicPublish | None = ..., + basic_return: Global___BasicReturn | None = ..., + basic_ack: Global___BasicAck | None = ..., + basic_nack: Global___BasicNack | None = ..., + basic_reject: Global___BasicReject | None = ..., + basic_deliver: Global___BasicDeliver | None = ..., + basic_get: Global___BasicGet | None = ..., + basic_get_ok: Global___BasicGetOk | None = ..., + basic_get_empty: Global___BasicGetEmpty | None = ..., + exchange_bind: Global___ExchangeBind | None = ..., + exchange_bind_ok: Global___ExchangeBindOk | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["basic_ack", b"basic_ack", "basic_cancel", b"basic_cancel", "basic_cancel_ok", b"basic_cancel_ok", "basic_consume", b"basic_consume", "basic_consume_ok", b"basic_consume_ok", "basic_deliver", b"basic_deliver", "basic_get", b"basic_get", "basic_get_empty", b"basic_get_empty", "basic_get_ok", b"basic_get_ok", "basic_nack", b"basic_nack", "basic_publish", b"basic_publish", "basic_qos", b"basic_qos", "basic_qos_ok", b"basic_qos_ok", "basic_reject", b"basic_reject", "basic_return", b"basic_return", "channel_close", b"channel_close", "channel_close_ok", b"channel_close_ok", "channel_open", b"channel_open", "channel_open_ok", b"channel_open_ok", "confirm_select", b"confirm_select", "confirm_select_ok", b"confirm_select_ok", "connection_close", b"connection_close", "connection_close_ok", b"connection_close_ok", "connection_open", b"connection_open", "connection_open_ok", b"connection_open_ok", "connection_start", b"connection_start", "connection_start_ok", b"connection_start_ok", "connection_tune", b"connection_tune", "connection_tune_ok", b"connection_tune_ok", "exchange_bind", b"exchange_bind", "exchange_bind_ok", b"exchange_bind_ok", "exchange_declare", b"exchange_declare", "exchange_declare_ok", b"exchange_declare_ok", "kind", b"kind", "queue_bind", b"queue_bind", "queue_bind_ok", b"queue_bind_ok", "queue_declare", b"queue_declare", "queue_declare_ok", b"queue_declare_ok"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["basic_ack", b"basic_ack", "basic_cancel", b"basic_cancel", "basic_cancel_ok", b"basic_cancel_ok", "basic_consume", b"basic_consume", "basic_consume_ok", b"basic_consume_ok", "basic_deliver", b"basic_deliver", "basic_get", b"basic_get", "basic_get_empty", b"basic_get_empty", "basic_get_ok", b"basic_get_ok", "basic_nack", b"basic_nack", "basic_publish", b"basic_publish", "basic_qos", b"basic_qos", "basic_qos_ok", b"basic_qos_ok", "basic_reject", b"basic_reject", "basic_return", b"basic_return", "channel_close", b"channel_close", "channel_close_ok", b"channel_close_ok", "channel_open", b"channel_open", "channel_open_ok", b"channel_open_ok", "confirm_select", b"confirm_select", "confirm_select_ok", b"confirm_select_ok", "connection_close", b"connection_close", "connection_close_ok", b"connection_close_ok", "connection_open", b"connection_open", "connection_open_ok", b"connection_open_ok", "connection_start", b"connection_start", "connection_start_ok", b"connection_start_ok", "connection_tune", b"connection_tune", "connection_tune_ok", b"connection_tune_ok", "exchange_bind", b"exchange_bind", "exchange_bind_ok", b"exchange_bind_ok", "exchange_declare", b"exchange_declare", "exchange_declare_ok", b"exchange_declare_ok", "kind", b"kind", "queue_bind", b"queue_bind", "queue_bind_ok", b"queue_bind_ok", "queue_declare", b"queue_declare", "queue_declare_ok", b"queue_declare_ok"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType_kind: _TypeAlias = _typing.Literal["connection_start", "connection_start_ok", "connection_tune", "connection_tune_ok", "connection_open", "connection_open_ok", "connection_close", "connection_close_ok", "channel_open", "channel_open_ok", "channel_close", "channel_close_ok", "confirm_select", "confirm_select_ok", "exchange_declare", "exchange_declare_ok", "queue_declare", "queue_declare_ok", "queue_bind", "queue_bind_ok", "basic_qos", "basic_qos_ok", "basic_consume", "basic_consume_ok", "basic_cancel", "basic_cancel_ok", "basic_publish", "basic_return", "basic_ack", "basic_nack", "basic_reject", "basic_deliver", "basic_get", "basic_get_ok", "basic_get_empty", "exchange_bind", "exchange_bind_ok"] # noqa: Y015 + _WhichOneofArgType_kind: _TypeAlias = _typing.Literal["kind", b"kind"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType_kind) -> _WhichOneofReturnType_kind | None: ... + +Global___Method: _TypeAlias = Method # noqa: Y015 + +@_typing.final +class Pattern(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CONNECTION_START_FIELD_NUMBER: _builtins.int + CONNECTION_START_OK_FIELD_NUMBER: _builtins.int + CONNECTION_TUNE_FIELD_NUMBER: _builtins.int + CONNECTION_TUNE_OK_FIELD_NUMBER: _builtins.int + CONNECTION_OPEN_FIELD_NUMBER: _builtins.int + CONNECTION_OPEN_OK_FIELD_NUMBER: _builtins.int + CONNECTION_CLOSE_FIELD_NUMBER: _builtins.int + CONNECTION_CLOSE_OK_FIELD_NUMBER: _builtins.int + CHANNEL_OPEN_FIELD_NUMBER: _builtins.int + CHANNEL_OPEN_OK_FIELD_NUMBER: _builtins.int + CHANNEL_CLOSE_FIELD_NUMBER: _builtins.int + CHANNEL_CLOSE_OK_FIELD_NUMBER: _builtins.int + CONFIRM_SELECT_FIELD_NUMBER: _builtins.int + CONFIRM_SELECT_OK_FIELD_NUMBER: _builtins.int + EXCHANGE_DECLARE_FIELD_NUMBER: _builtins.int + EXCHANGE_DECLARE_OK_FIELD_NUMBER: _builtins.int + EXCHANGE_BIND_FIELD_NUMBER: _builtins.int + EXCHANGE_BIND_OK_FIELD_NUMBER: _builtins.int + QUEUE_DECLARE_FIELD_NUMBER: _builtins.int + QUEUE_DECLARE_OK_FIELD_NUMBER: _builtins.int + QUEUE_BIND_FIELD_NUMBER: _builtins.int + QUEUE_BIND_OK_FIELD_NUMBER: _builtins.int + BASIC_QOS_FIELD_NUMBER: _builtins.int + BASIC_QOS_OK_FIELD_NUMBER: _builtins.int + BASIC_CONSUME_FIELD_NUMBER: _builtins.int + BASIC_CONSUME_OK_FIELD_NUMBER: _builtins.int + BASIC_CANCEL_FIELD_NUMBER: _builtins.int + BASIC_CANCEL_OK_FIELD_NUMBER: _builtins.int + BASIC_PUBLISH_FIELD_NUMBER: _builtins.int + BASIC_RETURN_FIELD_NUMBER: _builtins.int + BASIC_ACK_FIELD_NUMBER: _builtins.int + BASIC_NACK_FIELD_NUMBER: _builtins.int + BASIC_REJECT_FIELD_NUMBER: _builtins.int + BASIC_DELIVER_FIELD_NUMBER: _builtins.int + BASIC_GET_FIELD_NUMBER: _builtins.int + BASIC_GET_OK_FIELD_NUMBER: _builtins.int + BASIC_GET_EMPTY_FIELD_NUMBER: _builtins.int + @_builtins.property + def connection_start(self) -> Global___ConnectionStartPattern: ... + @_builtins.property + def connection_start_ok(self) -> Global___ConnectionStartOkPattern: ... + @_builtins.property + def connection_tune(self) -> Global___ConnectionTunePattern: ... + @_builtins.property + def connection_tune_ok(self) -> Global___ConnectionTuneOkPattern: ... + @_builtins.property + def connection_open(self) -> Global___ConnectionOpenPattern: ... + @_builtins.property + def connection_open_ok(self) -> Global___ConnectionOpenOkPattern: ... + @_builtins.property + def connection_close(self) -> Global___ConnectionClosePattern: ... + @_builtins.property + def connection_close_ok(self) -> Global___ConnectionCloseOkPattern: ... + @_builtins.property + def channel_open(self) -> Global___ChannelOpenPattern: ... + @_builtins.property + def channel_open_ok(self) -> Global___ChannelOpenOkPattern: ... + @_builtins.property + def channel_close(self) -> Global___ChannelClosePattern: ... + @_builtins.property + def channel_close_ok(self) -> Global___ChannelCloseOkPattern: ... + @_builtins.property + def confirm_select(self) -> Global___ConfirmSelectPattern: ... + @_builtins.property + def confirm_select_ok(self) -> Global___ConfirmSelectOkPattern: ... + @_builtins.property + def exchange_declare(self) -> Global___ExchangeDeclarePattern: ... + @_builtins.property + def exchange_declare_ok(self) -> Global___ExchangeDeclareOkPattern: ... + @_builtins.property + def exchange_bind(self) -> Global___ExchangeBindPattern: ... + @_builtins.property + def exchange_bind_ok(self) -> Global___ExchangeBindOkPattern: ... + @_builtins.property + def queue_declare(self) -> Global___QueueDeclarePattern: ... + @_builtins.property + def queue_declare_ok(self) -> Global___QueueDeclareOkPattern: ... + @_builtins.property + def queue_bind(self) -> Global___QueueBindPattern: ... + @_builtins.property + def queue_bind_ok(self) -> Global___QueueBindOkPattern: ... + @_builtins.property + def basic_qos(self) -> Global___BasicQosPattern: ... + @_builtins.property + def basic_qos_ok(self) -> Global___BasicQosOkPattern: ... + @_builtins.property + def basic_consume(self) -> Global___BasicConsumePattern: ... + @_builtins.property + def basic_consume_ok(self) -> Global___BasicConsumeOkPattern: ... + @_builtins.property + def basic_cancel(self) -> Global___BasicCancelPattern: ... + @_builtins.property + def basic_cancel_ok(self) -> Global___BasicCancelOkPattern: ... + @_builtins.property + def basic_publish(self) -> Global___BasicPublishPattern: ... + @_builtins.property + def basic_return(self) -> Global___BasicReturnPattern: ... + @_builtins.property + def basic_ack(self) -> Global___BasicAckPattern: ... + @_builtins.property + def basic_nack(self) -> Global___BasicNackPattern: ... + @_builtins.property + def basic_reject(self) -> Global___BasicRejectPattern: ... + @_builtins.property + def basic_deliver(self) -> Global___BasicDeliverPattern: ... + @_builtins.property + def basic_get(self) -> Global___BasicGetPattern: ... + @_builtins.property + def basic_get_ok(self) -> Global___BasicGetOkPattern: ... + @_builtins.property + def basic_get_empty(self) -> Global___BasicGetEmptyPattern: ... + def __init__( + self, + *, + connection_start: Global___ConnectionStartPattern | None = ..., + connection_start_ok: Global___ConnectionStartOkPattern | None = ..., + connection_tune: Global___ConnectionTunePattern | None = ..., + connection_tune_ok: Global___ConnectionTuneOkPattern | None = ..., + connection_open: Global___ConnectionOpenPattern | None = ..., + connection_open_ok: Global___ConnectionOpenOkPattern | None = ..., + connection_close: Global___ConnectionClosePattern | None = ..., + connection_close_ok: Global___ConnectionCloseOkPattern | None = ..., + channel_open: Global___ChannelOpenPattern | None = ..., + channel_open_ok: Global___ChannelOpenOkPattern | None = ..., + channel_close: Global___ChannelClosePattern | None = ..., + channel_close_ok: Global___ChannelCloseOkPattern | None = ..., + confirm_select: Global___ConfirmSelectPattern | None = ..., + confirm_select_ok: Global___ConfirmSelectOkPattern | None = ..., + exchange_declare: Global___ExchangeDeclarePattern | None = ..., + exchange_declare_ok: Global___ExchangeDeclareOkPattern | None = ..., + exchange_bind: Global___ExchangeBindPattern | None = ..., + exchange_bind_ok: Global___ExchangeBindOkPattern | None = ..., + queue_declare: Global___QueueDeclarePattern | None = ..., + queue_declare_ok: Global___QueueDeclareOkPattern | None = ..., + queue_bind: Global___QueueBindPattern | None = ..., + queue_bind_ok: Global___QueueBindOkPattern | None = ..., + basic_qos: Global___BasicQosPattern | None = ..., + basic_qos_ok: Global___BasicQosOkPattern | None = ..., + basic_consume: Global___BasicConsumePattern | None = ..., + basic_consume_ok: Global___BasicConsumeOkPattern | None = ..., + basic_cancel: Global___BasicCancelPattern | None = ..., + basic_cancel_ok: Global___BasicCancelOkPattern | None = ..., + basic_publish: Global___BasicPublishPattern | None = ..., + basic_return: Global___BasicReturnPattern | None = ..., + basic_ack: Global___BasicAckPattern | None = ..., + basic_nack: Global___BasicNackPattern | None = ..., + basic_reject: Global___BasicRejectPattern | None = ..., + basic_deliver: Global___BasicDeliverPattern | None = ..., + basic_get: Global___BasicGetPattern | None = ..., + basic_get_ok: Global___BasicGetOkPattern | None = ..., + basic_get_empty: Global___BasicGetEmptyPattern | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["basic_ack", b"basic_ack", "basic_cancel", b"basic_cancel", "basic_cancel_ok", b"basic_cancel_ok", "basic_consume", b"basic_consume", "basic_consume_ok", b"basic_consume_ok", "basic_deliver", b"basic_deliver", "basic_get", b"basic_get", "basic_get_empty", b"basic_get_empty", "basic_get_ok", b"basic_get_ok", "basic_nack", b"basic_nack", "basic_publish", b"basic_publish", "basic_qos", b"basic_qos", "basic_qos_ok", b"basic_qos_ok", "basic_reject", b"basic_reject", "basic_return", b"basic_return", "channel_close", b"channel_close", "channel_close_ok", b"channel_close_ok", "channel_open", b"channel_open", "channel_open_ok", b"channel_open_ok", "confirm_select", b"confirm_select", "confirm_select_ok", b"confirm_select_ok", "connection_close", b"connection_close", "connection_close_ok", b"connection_close_ok", "connection_open", b"connection_open", "connection_open_ok", b"connection_open_ok", "connection_start", b"connection_start", "connection_start_ok", b"connection_start_ok", "connection_tune", b"connection_tune", "connection_tune_ok", b"connection_tune_ok", "exchange_bind", b"exchange_bind", "exchange_bind_ok", b"exchange_bind_ok", "exchange_declare", b"exchange_declare", "exchange_declare_ok", b"exchange_declare_ok", "method", b"method", "queue_bind", b"queue_bind", "queue_bind_ok", b"queue_bind_ok", "queue_declare", b"queue_declare", "queue_declare_ok", b"queue_declare_ok"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["basic_ack", b"basic_ack", "basic_cancel", b"basic_cancel", "basic_cancel_ok", b"basic_cancel_ok", "basic_consume", b"basic_consume", "basic_consume_ok", b"basic_consume_ok", "basic_deliver", b"basic_deliver", "basic_get", b"basic_get", "basic_get_empty", b"basic_get_empty", "basic_get_ok", b"basic_get_ok", "basic_nack", b"basic_nack", "basic_publish", b"basic_publish", "basic_qos", b"basic_qos", "basic_qos_ok", b"basic_qos_ok", "basic_reject", b"basic_reject", "basic_return", b"basic_return", "channel_close", b"channel_close", "channel_close_ok", b"channel_close_ok", "channel_open", b"channel_open", "channel_open_ok", b"channel_open_ok", "confirm_select", b"confirm_select", "confirm_select_ok", b"confirm_select_ok", "connection_close", b"connection_close", "connection_close_ok", b"connection_close_ok", "connection_open", b"connection_open", "connection_open_ok", b"connection_open_ok", "connection_start", b"connection_start", "connection_start_ok", b"connection_start_ok", "connection_tune", b"connection_tune", "connection_tune_ok", b"connection_tune_ok", "exchange_bind", b"exchange_bind", "exchange_bind_ok", b"exchange_bind_ok", "exchange_declare", b"exchange_declare", "exchange_declare_ok", b"exchange_declare_ok", "method", b"method", "queue_bind", b"queue_bind", "queue_bind_ok", b"queue_bind_ok", "queue_declare", b"queue_declare", "queue_declare_ok", b"queue_declare_ok"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType_method: _TypeAlias = _typing.Literal["connection_start", "connection_start_ok", "connection_tune", "connection_tune_ok", "connection_open", "connection_open_ok", "connection_close", "connection_close_ok", "channel_open", "channel_open_ok", "channel_close", "channel_close_ok", "confirm_select", "confirm_select_ok", "exchange_declare", "exchange_declare_ok", "exchange_bind", "exchange_bind_ok", "queue_declare", "queue_declare_ok", "queue_bind", "queue_bind_ok", "basic_qos", "basic_qos_ok", "basic_consume", "basic_consume_ok", "basic_cancel", "basic_cancel_ok", "basic_publish", "basic_return", "basic_ack", "basic_nack", "basic_reject", "basic_deliver", "basic_get", "basic_get_ok", "basic_get_empty"] # noqa: Y015 + _WhichOneofArgType_method: _TypeAlias = _typing.Literal["method", b"method"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType_method) -> _WhichOneofReturnType_method | None: ... + +Global___Pattern: _TypeAlias = Pattern # noqa: Y015 + +@_typing.final +class Weighted(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + PATTERN_FIELD_NUMBER: _builtins.int + WEIGHT_FIELD_NUMBER: _builtins.int + weight: _builtins.int + @_builtins.property + def pattern(self) -> Global___Pattern: ... + def __init__( + self, + *, + pattern: Global___Pattern | None = ..., + weight: _builtins.int | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_weight", b"_weight", "pattern", b"pattern", "weight", b"weight"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_weight", b"_weight", "pattern", b"pattern", "weight", b"weight"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__weight: _TypeAlias = _typing.Literal["weight"] # noqa: Y015 + _WhichOneofArgType__weight: _TypeAlias = _typing.Literal["_weight", b"_weight"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__weight) -> _WhichOneofReturnType__weight | None: ... + +Global___Weighted: _TypeAlias = Weighted # noqa: Y015 + +@_typing.final +class Scripted(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + RECEIVED_FIELD_NUMBER: _builtins.int + SENT_FIELD_NUMBER: _builtins.int + THEN_SEND_FIELD_NUMBER: _builtins.int + @_builtins.property + def received(self) -> Global___Pattern: ... + @_builtins.property + def sent(self) -> Global___Pattern: ... + @_builtins.property + def then_send(self) -> Global___Pattern: ... + def __init__( + self, + *, + received: Global___Pattern | None = ..., + sent: Global___Pattern | None = ..., + then_send: Global___Pattern | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["received", b"received", "sent", b"sent", "then_send", b"then_send", "when", b"when"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["received", b"received", "sent", b"sent", "then_send", b"then_send", "when", b"when"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType_when: _TypeAlias = _typing.Literal["received", "sent"] # noqa: Y015 + _WhichOneofArgType_when: _TypeAlias = _typing.Literal["when", b"when"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType_when) -> _WhichOneofReturnType_when | None: ... + +Global___Scripted: _TypeAlias = Scripted # noqa: Y015 + +@_typing.final +class SessionConfig(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + SEED_FIELD_NUMBER: _builtins.int + NEGOTIATION_OPTIONS_FIELD_NUMBER: _builtins.int + WEIGHTS_FIELD_NUMBER: _builtins.int + IDLE_WEIGHTS_FIELD_NUMBER: _builtins.int + SCRIPT_FIELD_NUMBER: _builtins.int + STOP_WEIGHT_FIELD_NUMBER: _builtins.int + MAX_SENDS_FIELD_NUMBER: _builtins.int + IDLE_MILLIS_FIELD_NUMBER: _builtins.int + MAX_BUFFER_FIELD_NUMBER: _builtins.int + MAX_TRANSCRIPT_LINES_FIELD_NUMBER: _builtins.int + seed: _builtins.int + stop_weight: _builtins.int + max_sends: _builtins.int + idle_millis: _builtins.int + max_buffer: _builtins.int + max_transcript_lines: _builtins.int + @_builtins.property + def negotiation_options(self) -> Global___NegotiationOptions: ... + @_builtins.property + def weights(self) -> _containers.RepeatedCompositeFieldContainer[Global___Weighted]: ... + @_builtins.property + def idle_weights(self) -> _containers.RepeatedCompositeFieldContainer[Global___Weighted]: ... + @_builtins.property + def script(self) -> _containers.RepeatedCompositeFieldContainer[Global___Scripted]: ... + def __init__( + self, + *, + seed: _builtins.int | None = ..., + negotiation_options: Global___NegotiationOptions | None = ..., + weights: _abc.Iterable[Global___Weighted] | None = ..., + idle_weights: _abc.Iterable[Global___Weighted] | None = ..., + script: _abc.Iterable[Global___Scripted] | None = ..., + stop_weight: _builtins.int | None = ..., + max_sends: _builtins.int | None = ..., + idle_millis: _builtins.int | None = ..., + max_buffer: _builtins.int | None = ..., + max_transcript_lines: _builtins.int | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_idle_millis", b"_idle_millis", "_max_buffer", b"_max_buffer", "_max_sends", b"_max_sends", "_max_transcript_lines", b"_max_transcript_lines", "_negotiation_options", b"_negotiation_options", "_seed", b"_seed", "_stop_weight", b"_stop_weight", "idle_millis", b"idle_millis", "max_buffer", b"max_buffer", "max_sends", b"max_sends", "max_transcript_lines", b"max_transcript_lines", "negotiation_options", b"negotiation_options", "seed", b"seed", "stop_weight", b"stop_weight"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_idle_millis", b"_idle_millis", "_max_buffer", b"_max_buffer", "_max_sends", b"_max_sends", "_max_transcript_lines", b"_max_transcript_lines", "_negotiation_options", b"_negotiation_options", "_seed", b"_seed", "_stop_weight", b"_stop_weight", "idle_millis", b"idle_millis", "idle_weights", b"idle_weights", "max_buffer", b"max_buffer", "max_sends", b"max_sends", "max_transcript_lines", b"max_transcript_lines", "negotiation_options", b"negotiation_options", "script", b"script", "seed", b"seed", "stop_weight", b"stop_weight", "weights", b"weights"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__idle_millis: _TypeAlias = _typing.Literal["idle_millis"] # noqa: Y015 + _WhichOneofArgType__idle_millis: _TypeAlias = _typing.Literal["_idle_millis", b"_idle_millis"] # noqa: Y015 + _WhichOneofReturnType__max_buffer: _TypeAlias = _typing.Literal["max_buffer"] # noqa: Y015 + _WhichOneofArgType__max_buffer: _TypeAlias = _typing.Literal["_max_buffer", b"_max_buffer"] # noqa: Y015 + _WhichOneofReturnType__max_sends: _TypeAlias = _typing.Literal["max_sends"] # noqa: Y015 + _WhichOneofArgType__max_sends: _TypeAlias = _typing.Literal["_max_sends", b"_max_sends"] # noqa: Y015 + _WhichOneofReturnType__max_transcript_lines: _TypeAlias = _typing.Literal["max_transcript_lines"] # noqa: Y015 + _WhichOneofArgType__max_transcript_lines: _TypeAlias = _typing.Literal["_max_transcript_lines", b"_max_transcript_lines"] # noqa: Y015 + _WhichOneofReturnType__negotiation_options: _TypeAlias = _typing.Literal["negotiation_options"] # noqa: Y015 + _WhichOneofArgType__negotiation_options: _TypeAlias = _typing.Literal["_negotiation_options", b"_negotiation_options"] # noqa: Y015 + _WhichOneofReturnType__seed: _TypeAlias = _typing.Literal["seed"] # noqa: Y015 + _WhichOneofArgType__seed: _TypeAlias = _typing.Literal["_seed", b"_seed"] # noqa: Y015 + _WhichOneofReturnType__stop_weight: _TypeAlias = _typing.Literal["stop_weight"] # noqa: Y015 + _WhichOneofArgType__stop_weight: _TypeAlias = _typing.Literal["_stop_weight", b"_stop_weight"] # noqa: Y015 + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__idle_millis) -> _WhichOneofReturnType__idle_millis | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__max_buffer) -> _WhichOneofReturnType__max_buffer | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__max_sends) -> _WhichOneofReturnType__max_sends | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__max_transcript_lines) -> _WhichOneofReturnType__max_transcript_lines | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__negotiation_options) -> _WhichOneofReturnType__negotiation_options | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__seed) -> _WhichOneofReturnType__seed | None: ... + @_typing.overload + def WhichOneof(self, oneof_group: _WhichOneofArgType__stop_weight) -> _WhichOneofReturnType__stop_weight | None: ... + +Global___SessionConfig: _TypeAlias = SessionConfig # noqa: Y015 + +@_typing.final +class TranscriptLine(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + INDEX_FIELD_NUMBER: _builtins.int + MICROS_FIELD_NUMBER: _builtins.int + EPOCH_MICROS_FIELD_NUMBER: _builtins.int + EVENT_FIELD_NUMBER: _builtins.int + METHOD_FIELD_NUMBER: _builtins.int + DETAIL_FIELD_NUMBER: _builtins.int + WIRE_BYTES_FIELD_NUMBER: _builtins.int + index: _builtins.int + micros: _builtins.int + epoch_micros: _builtins.int + event: Global___TranscriptEvent.ValueType + detail: _builtins.str + wire_bytes: _builtins.bytes + @_builtins.property + def method(self) -> Global___Method: ... + def __init__( + self, + *, + index: _builtins.int = ..., + micros: _builtins.int = ..., + epoch_micros: _builtins.int = ..., + event: Global___TranscriptEvent.ValueType = ..., + method: Global___Method | None = ..., + detail: _builtins.str = ..., + wire_bytes: _builtins.bytes = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_method", b"_method", "method", b"method"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_method", b"_method", "detail", b"detail", "epoch_micros", b"epoch_micros", "event", b"event", "index", b"index", "method", b"method", "micros", b"micros", "wire_bytes", b"wire_bytes"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__method: _TypeAlias = _typing.Literal["method"] # noqa: Y015 + _WhichOneofArgType__method: _TypeAlias = _typing.Literal["_method", b"_method"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__method) -> _WhichOneofReturnType__method | None: ... + +Global___TranscriptLine: _TypeAlias = TranscriptLine # noqa: Y015 + +@_typing.final +class StatusReply(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + AMQP_PORT_FIELD_NUMBER: _builtins.int + AMQP_GREETED_FIELD_NUMBER: _builtins.int + TRANSCRIPT_LINES_FIELD_NUMBER: _builtins.int + EPOCH_MICROS_FIELD_NUMBER: _builtins.int + CONFIG_FIELD_NUMBER: _builtins.int + amqp_port: _builtins.int + amqp_greeted: _builtins.bool + transcript_lines: _builtins.int + epoch_micros: _builtins.int + @_builtins.property + def config(self) -> Global___SessionConfig: ... + def __init__( + self, + *, + amqp_port: _builtins.int | None = ..., + amqp_greeted: _builtins.bool = ..., + transcript_lines: _builtins.int = ..., + epoch_micros: _builtins.int = ..., + config: Global___SessionConfig | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["_amqp_port", b"_amqp_port", "amqp_port", b"amqp_port", "config", b"config"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["_amqp_port", b"_amqp_port", "amqp_greeted", b"amqp_greeted", "amqp_port", b"amqp_port", "config", b"config", "epoch_micros", b"epoch_micros", "transcript_lines", b"transcript_lines"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + _WhichOneofReturnType__amqp_port: _TypeAlias = _typing.Literal["amqp_port"] # noqa: Y015 + _WhichOneofArgType__amqp_port: _TypeAlias = _typing.Literal["_amqp_port", b"_amqp_port"] # noqa: Y015 + def WhichOneof(self, oneof_group: _WhichOneofArgType__amqp_port) -> _WhichOneofReturnType__amqp_port | None: ... + +Global___StatusReply: _TypeAlias = StatusReply # noqa: Y015 + +@_typing.final +class ConfigureRequest(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + CONFIG_FIELD_NUMBER: _builtins.int + @_builtins.property + def config(self) -> Global___SessionConfig: ... + def __init__( + self, + *, + config: Global___SessionConfig | None = ..., + ) -> None: ... + _HasFieldArgType: _TypeAlias = _typing.Literal["config", b"config"] # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _typing.Literal["config", b"config"] # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ConfigureRequest: _TypeAlias = ConfigureRequest # noqa: Y015 + +@_typing.final +class StatusRequest(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___StatusRequest: _TypeAlias = StatusRequest # noqa: Y015 + +@_typing.final +class ResetRequest(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___ResetRequest: _TypeAlias = ResetRequest # noqa: Y015 + +@_typing.final +class TranscriptRequest(_message.Message): + DESCRIPTOR: _descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + _HasFieldArgType: _TypeAlias = _Never # noqa: Y015 + def HasField(self, field_name: _HasFieldArgType) -> _builtins.bool: ... + _ClearFieldArgType: _TypeAlias = _Never # noqa: Y015 + def ClearField(self, field_name: _ClearFieldArgType) -> None: ... + def WhichOneof(self, oneof_group: _Never) -> None: ... + +Global___TranscriptRequest: _TypeAlias = TranscriptRequest # noqa: Y015 diff --git a/src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2_grpc.py b/src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2_grpc.py new file mode 100644 index 00000000..a1283503 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2_grpc.py @@ -0,0 +1,226 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +from amqp_imposter.control.v1 import imposter_pb2 as amqp__imposter_dot_control_dot_v1_dot_imposter__pb2 + +GRPC_GENERATED_VERSION = '1.84.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 amqp_imposter/control/v1/imposter_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 ImposterStub: + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Configure = channel.unary_unary( + '/amqp_imposter.control.v1.Imposter/Configure', + request_serializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.ConfigureRequest.SerializeToString, + response_deserializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.StatusReply.FromString, + _registered_method=True) + self.Status = channel.unary_unary( + '/amqp_imposter.control.v1.Imposter/Status', + request_serializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.StatusRequest.SerializeToString, + response_deserializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.StatusReply.FromString, + _registered_method=True) + self.Reset = channel.unary_unary( + '/amqp_imposter.control.v1.Imposter/Reset', + request_serializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.ResetRequest.SerializeToString, + response_deserializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.StatusReply.FromString, + _registered_method=True) + self.Transcript = channel.unary_stream( + '/amqp_imposter.control.v1.Imposter/Transcript', + request_serializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.TranscriptRequest.SerializeToString, + response_deserializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.TranscriptLine.FromString, + _registered_method=True) + + +class ImposterServicer: + """Missing associated documentation comment in .proto file.""" + + def Configure(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 Status(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 Reset(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 Transcript(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_ImposterServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Configure': grpc.unary_unary_rpc_method_handler( + servicer.Configure, + request_deserializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.ConfigureRequest.FromString, + response_serializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.StatusReply.SerializeToString, + ), + 'Status': grpc.unary_unary_rpc_method_handler( + servicer.Status, + request_deserializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.StatusRequest.FromString, + response_serializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.StatusReply.SerializeToString, + ), + 'Reset': grpc.unary_unary_rpc_method_handler( + servicer.Reset, + request_deserializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.ResetRequest.FromString, + response_serializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.StatusReply.SerializeToString, + ), + 'Transcript': grpc.unary_stream_rpc_method_handler( + servicer.Transcript, + request_deserializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.TranscriptRequest.FromString, + response_serializer=amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.TranscriptLine.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'amqp_imposter.control.v1.Imposter', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('amqp_imposter.control.v1.Imposter', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class Imposter: + """Missing associated documentation comment in .proto file.""" + + @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, + '/amqp_imposter.control.v1.Imposter/Configure', + amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.ConfigureRequest.SerializeToString, + amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.StatusReply.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def Status(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, + '/amqp_imposter.control.v1.Imposter/Status', + amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.StatusRequest.SerializeToString, + amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.StatusReply.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def Reset(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, + '/amqp_imposter.control.v1.Imposter/Reset', + amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.ResetRequest.SerializeToString, + amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.StatusReply.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def Transcript(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_stream( + request, + target, + '/amqp_imposter.control.v1.Imposter/Transcript', + amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.TranscriptRequest.SerializeToString, + amqp__imposter_dot_control_dot_v1_dot_imposter__pb2.TranscriptLine.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2_grpc.pyi b/src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2_grpc.pyi new file mode 100644 index 00000000..3d99ca07 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/control/v1/imposter_pb2_grpc.pyi @@ -0,0 +1,90 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +Copyright 2026 Bloomberg Finance L.P. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +from amqp_imposter.control.v1 import imposter_pb2 as _imposter_pb2 +from collections import abc as _abc +from grpc import aio as _aio +import abc as _abc_1 +import grpc as _grpc +import sys +import typing as _typing + +if sys.version_info >= (3, 11): + from typing import Self as _Self +else: + from typing_extensions import Self as _Self + +_T = _typing.TypeVar("_T") + +class _MaybeAsyncIterator(_abc.AsyncIterator[_T], _abc.Iterator[_T], metaclass=_abc_1.ABCMeta): ... + +class _ServicerContext(_grpc.ServicerContext, _aio.ServicerContext): # type: ignore[misc, type-arg] + ... + +GRPC_GENERATED_VERSION: str +GRPC_VERSION: str + +class ImposterStub: + @_typing.overload + def __new__(cls, channel: _grpc.Channel) -> _Self: ... + @_typing.overload + def __new__(cls, channel: _aio.Channel) -> ImposterAsyncStub: ... + Configure: _grpc.UnaryUnaryMultiCallable[_imposter_pb2.ConfigureRequest, _imposter_pb2.StatusReply] + Status: _grpc.UnaryUnaryMultiCallable[_imposter_pb2.StatusRequest, _imposter_pb2.StatusReply] + Reset: _grpc.UnaryUnaryMultiCallable[_imposter_pb2.ResetRequest, _imposter_pb2.StatusReply] + Transcript: _grpc.UnaryStreamMultiCallable[_imposter_pb2.TranscriptRequest, _imposter_pb2.TranscriptLine] + +@_typing.type_check_only +class ImposterAsyncStub(ImposterStub): + def __init__(self, channel: _aio.Channel) -> None: ... + Configure: _aio.UnaryUnaryMultiCallable[_imposter_pb2.ConfigureRequest, _imposter_pb2.StatusReply] # type: ignore[assignment] + Status: _aio.UnaryUnaryMultiCallable[_imposter_pb2.StatusRequest, _imposter_pb2.StatusReply] # type: ignore[assignment] + Reset: _aio.UnaryUnaryMultiCallable[_imposter_pb2.ResetRequest, _imposter_pb2.StatusReply] # type: ignore[assignment] + Transcript: _aio.UnaryStreamMultiCallable[_imposter_pb2.TranscriptRequest, _imposter_pb2.TranscriptLine] # type: ignore[assignment] + +class ImposterServicer(metaclass=_abc_1.ABCMeta): + @_abc_1.abstractmethod + def Configure( + self, + request: _imposter_pb2.ConfigureRequest, + context: _ServicerContext, + ) -> _typing.Union[_imposter_pb2.StatusReply, _abc.Awaitable[_imposter_pb2.StatusReply]]: ... + + @_abc_1.abstractmethod + def Status( + self, + request: _imposter_pb2.StatusRequest, + context: _ServicerContext, + ) -> _typing.Union[_imposter_pb2.StatusReply, _abc.Awaitable[_imposter_pb2.StatusReply]]: ... + + @_abc_1.abstractmethod + def Reset( + self, + request: _imposter_pb2.ResetRequest, + context: _ServicerContext, + ) -> _typing.Union[_imposter_pb2.StatusReply, _abc.Awaitable[_imposter_pb2.StatusReply]]: ... + + @_abc_1.abstractmethod + def Transcript( + self, + request: _imposter_pb2.TranscriptRequest, + context: _ServicerContext, + ) -> _typing.Union[_abc.Iterator[_imposter_pb2.TranscriptLine], _abc.AsyncIterator[_imposter_pb2.TranscriptLine]]: ... + +def add_ImposterServicer_to_server(servicer: ImposterServicer, server: _typing.Union[_grpc.Server, _aio.Server]) -> None: ... diff --git a/src/tests/amqp_imposter/amqp_imposter/helpers/nonempty_tuple.py b/src/tests/amqp_imposter/amqp_imposter/helpers/nonempty_tuple.py new file mode 100644 index 00000000..3fe507c2 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/helpers/nonempty_tuple.py @@ -0,0 +1,16 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +type NonEmptyTuple[T] = tuple[T, *tuple[T, ...]] diff --git a/src/tests/amqp_imposter/amqp_imposter/imposter.py b/src/tests/amqp_imposter/amqp_imposter/imposter.py new file mode 100644 index 00000000..20811eca --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/imposter.py @@ -0,0 +1,29 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass + +from .amqp.server import AmqpServer +from .amqp.session import Session + + +class ImposterError(ValueError): + pass + + +@dataclass(slots=True) +class Imposter: + session: Session + amqp_server: AmqpServer diff --git a/src/tests/amqp_imposter/amqp_imposter/model/__init__.py b/src/tests/amqp_imposter/amqp_imposter/model/__init__.py new file mode 100644 index 00000000..3e199781 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/__init__.py @@ -0,0 +1,75 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .generators import ( + DEFAULT_GEN_STATE, + Gen, + GenState, +) +from .method_fields import ( + HOLE, + Arbitrary, + Exact, + Hole, + MessageProperties, + Table, + TableValue, + assert_concrete, + is_concrete, +) +from .method_sampling import DEFAULT_MAX_SENDS +from .method_scripts import Received, ScriptRule, Sent +from .method_weights import ( + BASE_WEIGHT, + DEFAULT_WEIGHTS, + STOP_WEIGHT, + WeightedPattern, +) +from .methods import METHOD_KINDS, Method +from .model_server import ModelServer +from .model_state import Unrepresentable +from .negotiation_options import ( + DEFAULT_NEGOTIATION_OPTIONS, + NegotiationOptions, +) + +__all__ = [ + "BASE_WEIGHT", + "DEFAULT_GEN_STATE", + "DEFAULT_MAX_SENDS", + "DEFAULT_NEGOTIATION_OPTIONS", + "DEFAULT_WEIGHTS", + "HOLE", + "METHOD_KINDS", + "STOP_WEIGHT", + "Arbitrary", + "Exact", + "Gen", + "GenState", + "Hole", + "MessageProperties", + "Method", + "ModelServer", + "NegotiationOptions", + "Received", + "ScriptRule", + "Sent", + "Table", + "TableValue", + "Unrepresentable", + "WeightedPattern", + "assert_concrete", + "is_concrete", +] diff --git a/src/tests/amqp_imposter/amqp_imposter/model/constants.py b/src/tests/amqp_imposter/amqp_imposter/model/constants.py new file mode 100644 index 00000000..60e61b97 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/constants.py @@ -0,0 +1,54 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +OCTET_MAX = 2**8 - 1 +SHORT_MAX = 2**16 - 1 +LONG_MAX = 2**32 - 1 +LONGLONG_MAX = 2**64 - 1 + +PROTOCOL_MAJOR = 0 +PROTOCOL_MINOR = 9 +PROTOCOL_REVISION = 1 + +FRAME_END = 206 +FRAME_OVERHEAD = 8 +FRAME_MIN_FLOOR = 4096 +CHANNEL_MAX_CEIL = SHORT_MAX +FRAME_MAX_CEIL = LONG_MAX +HEARTBEAT_MAX = SHORT_MAX + +NAME_MAX = 127 +MESSAGE_BODY_MAX = FRAME_MIN_FLOOR - FRAME_OVERHEAD +SHORTSTR_MAX = OCTET_MAX + +PRIORITY_MIN = 0 +PRIORITY_MAX = 9 +DELIVERY_MODES = (1, 2) + +DEFAULT_OVERFLOWS = ("drop-head", "reject-publish") +DEFAULT_QUEUE_TYPES = ("classic", "quorum") +DEFAULT_EXCHANGE_TYPES = ("direct", "fanout", "topic", "headers") +DEFAULT_CONTENT_TYPES = ("text/plain", "application/xml", "application/json") +DEFAULT_CONTENT_ENCODINGS = ("gzip", "zstd", "identity") + +DEFAULT_MECHANISMS = ("PLAIN",) +DEFAULT_LOCALES = ("en_US",) +DEFAULT_PUBLISHER_CONFIRMS = True +DEFAULT_BASIC_NACK = True +DEFAULT_CONSUMER_CANCEL_NOTIFY = True +DEFAULT_CHANNEL_MAX = 2047 +DEFAULT_FRAME_MAX = 131072 +DEFAULT_HEARTBEAT = 0 +DEFAULT_VHOST = "/" diff --git a/src/tests/amqp_imposter/amqp_imposter/model/docs/ERRORS.md b/src/tests/amqp_imposter/amqp_imposter/model/docs/ERRORS.md new file mode 100644 index 00000000..ed410702 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/docs/ERRORS.md @@ -0,0 +1,55 @@ +Some errors need certain circumstances to arise in order to be sendable; +e.g. for a method to be received with some bad fields (think double `channel.open`). +Let's call these "situational". + +Other errors are "always-on"; think an operator decides to close a connection via management UI. + +Here are the forced situational errors we've specified: + +- PRECONDITION_FAILED / `channel.close` (406) + - Acknowledgement for delivery_tag = 0 with multiple = False + - Acknowledgement when no-ack=Tru + - Acknowledgement with unknown delivery tag + - Acknowledgement for an already-acknowledged delivery tag +- CHANNEL_ERROR / `connection.close` (504) + - `channel.open` for an already-open channel + - Any other method on a closed channel +- NOT_IMPLEMENTED / `connection.close` (540) + - `basic.publish` with immediate + - `basic.qos` with prefetch_size + +The class_id and method_id fields for situational errors are fixed based on what method caused the error. + +There are also situational errors that aren't forced by a client action, but an alternative to the Ok. +For instance, a non-passive `queue.declare` could lead to PRECONDITION_FAILED. +Exhaustively: + +- `connection.open`: INVALID_PATH(402), NOT_ALLOWED(530) +- `channel.open`: RESOURCE_ERROR(506) +- `exchange.declare`: NOT_FOUND(404), ACCESS_REFUSED(403) if passive; otherwise ACCESS_REFUSED(403), PRECONDITION_FAILED(406), NOT_ALLOWED(530), RESOURCE_ERROR(506) +- `exchange.bind`: NOT_FOUND(404), ACCESS_REFUSED(403), RESOURCE_ERROR(506) +- `queue.declare`: NOT_FOUND(404), ACCESS_REFUSED(403), RESOURCE_LOCKED(405) if passive; otherwise ACCESS_REFUSED(403), RESOURCE_LOCKED(405), PRECONDITION_FAILED(406), RESOURCE_ERROR(506) +- `queue.bind`, `basic.consume`: NOT_FOUND(404), ACCESS_REFUSED(403), RESOURCE_LOCKED(405), RESOURCE_ERROR(506) +- `basic.get`: NOT_FOUND(404), ACCESS_REFUSED(403), RESOURCE_LOCKED(405) +- `basic.publish`: NOT_FOUND(404) unless to the default exchange, ACCESS_REFUSED(403), CONTENT_TOO_LARGE(311) + +An error is available until the model sends any method that would imply it has been accepted. + +Forced errors carry the reply text "FORCED" and unforced ones "NOT_FORCED". +That means they can be pattern-matched by the input weights. + +The always-on errors are always "NOT_FORCED": +- CONNECTION_FORCED / `connection.close` (320) +- INTERNAL_ERROR / `connection.close` (541) + +The class_id and method_id fields for always-on errors are both fixed to 0. + +There are also certain conditions on which the model will short-circuit for now (produce Unrepresentable): +- Protocol header is sent twice +- The client's negotiation contradicts the prodived NegotiationOptions +- A second simultaneous connection is opened +- `basic.qos` with global +- A second simultaneous consumer, whether or not it is pipelined behind the first + +Known gaps: +- Passive declares with no-wait still offer errors, although the spec says they have no effect diff --git a/src/tests/amqp_imposter/amqp_imposter/model/docs/GENERATORS.md b/src/tests/amqp_imposter/amqp_imposter/model/docs/GENERATORS.md new file mode 100644 index 00000000..27651d22 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/docs/GENERATORS.md @@ -0,0 +1,24 @@ +The fields of a method in the bag are in one of two states: + +- Exact (an instantiation of the method is incomplete without a concrete value here; value is determined by the model state) +- Arbitrary (functionally equivalent instantiations of a method could hold different values; value is always compatible with the model state) + +For an arbitrary slot, the caller may bring their own value and we generate if they don't. +The types below are inspired by the QuickCheck white paper. + +``` + type Gen[T] = (GenState) -> (T, GenState) + type Exact[T] = T | Hole # the hole should be filled with concrete; until then, the method makes no sense + type Arbitrary[T] = T | Gen[T] + + BasicDeliver + consumer_tag: Exact[str] + delivery_tag: Exact[int] + redelivered: Arbitrary[bool] # you can get deliveries that look like they were previously delivered to another consumer + exchange: Arbitrary[str] # N.b. should be bound when it's bagged if it's a requeue! + routing_key: Arbitrary[str] + properties: Arbitrary[MessageProperties | None] + body: Arbitrary[bytes] +``` + +For example, send(method) tries to unify any arbitrary fields in the bag with those that are already concrete in the input method, and generates the ones that are still Gen. diff --git a/src/tests/amqp_imposter/amqp_imposter/model/docs/INTRO.md b/src/tests/amqp_imposter/amqp_imposter/model/docs/INTRO.md new file mode 100644 index 00000000..9f41c008 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/docs/INTRO.md @@ -0,0 +1,22 @@ +The aim of this model is to generate method sequences for plausible AMQP client-server interactions. + +Always keep the logic in this model decoupled from the imposter harness. +This is a pure and reusable AMQP state machine. + +To that end server tracks a "bag of methods" that it can send or be controlled to send, updating it on every send/receive event. +If you ask the server to send a method that's illegal in that part of the sequence, it raises Unrepresentable. +If you ask the server to receive a method that's not yet representable by the model, it raises Unrepresentable. +Everything in the bag is what a conforming server is permitted to send. + +A good mental model is that the bag is underpinned by a multiset S plus a queue Q, and it contains S ∪ { Q.head }. +In effect: +- S contains any always-on-offer methods (e.g. deliveries) +- Q contains methods that must be ordered (e.g. if we received `queue.declare` -> `queue.bind`, `queue.declare-ok` -> `queue.bind-ok` must also be ordered). + +We make some simplifications while also adhering to the spec: + +- There's one vhost, one connection and one channel at a time. +- Every method in send/receive is assumed to belong this channel. +- Topology is volatile: declaring and binding never affect routability, and message counts are arbitrary. +- Messages are produced arbitrarily. Exchange, routing key, properties and body are generated (but of course requeues and returns do match the originals). +- The server is constructed with the exact capabilities and limits it'll accept in the handshake. diff --git a/src/tests/amqp_imposter/amqp_imposter/model/docs/PIPELINING.md b/src/tests/amqp_imposter/amqp_imposter/model/docs/PIPELINING.md new file mode 100644 index 00000000..81724bf5 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/docs/PIPELINING.md @@ -0,0 +1,5 @@ +Some invariants in the pipelining model: + +- Replies are ordered by receipt irrespective of class (e.g. `channel.open` -> `queue.declare` gives `channel.open-ok` -> `queue.declare-ok`) +- Effects are applied on receipt +- no-wait just means that we owe no reply, but an error response should be made available until the state advances past it (e.g. if we confirm a message that came later) diff --git a/src/tests/amqp_imposter/amqp_imposter/model/docs/REVEAL.md b/src/tests/amqp_imposter/amqp_imposter/model/docs/REVEAL.md new file mode 100644 index 00000000..3c1fe64d --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/docs/REVEAL.md @@ -0,0 +1,43 @@ +The `RevealingLine` data structure is used to enforce ordering constraints, in particular: + +- When a pending method becomes observable/sendable +- When an observed or superseded method can be dismissed + +Say a client sends a `queue.declare` and then a publish on a confirming channel. +The broker replies in order, so it can't confirm the publish before sending the `queue.declare-ok`. +Thus the `queue.declare-ok` is Opaque, hiding the publish behind it. + +One thing that's tricky to model is that a nowait method needs no reply, but the imposter should be able to respond with an error! +Thus we owe an optional reply that's Transparent, which doesn't hide anything behind it. + +``` + RevealingLine[T]: + Revealed[T] = Transparent[T] | Opaque[T] + + push(Revealed[T] revealed) -> RevealingLine[T] # append to back + observe() -> tuple[T, ...] # observe up to and including the first opaque item + + split(n) -> (RevealingLine[T], RevealingLine[T]) # split into first n items, rest; observable, unobservable by default + split_while(p) -> (RevealingLine[T], RevealingLine[T]) +``` + +Used in the model as follows. + +``` + KnownReplyState = { expected_methods: tuple[Method], causing_method: Method | None } + DeferredReplyState = { causing_method: BasicGet } # expected_methods are implicit and decided at reveal-time + DeferredPublishState = { publish_state: PublishState } + + ReplyState = KnownReplyState | DeferredReplyState + + line: RevealingLine[DeferredPublishState | ReplyState] + + - Replies are Opaque, except optional replies (to a nowait or a publish), which are Transparent. + - When a reply is sent, that pops it along with all the items in front. + + - Publishes are Transparent. + - Publishes are never at the front. (They are always popped/flushed whenever they reach the front of the line.) + - Publishes that are observable are added to standby. + - Publishes that are confirmed, returned or nacked on standby cause a pop that takes with it all the items in front. + (A publish is only popped in this situation or if it's at the front of the line.) +``` diff --git a/src/tests/amqp_imposter/amqp_imposter/model/docs/WEIGHTS.md b/src/tests/amqp_imposter/amqp_imposter/model/docs/WEIGHTS.md new file mode 100644 index 00000000..96eef38c --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/docs/WEIGHTS.md @@ -0,0 +1,152 @@ +I'd like the server to automatically draw from the enumerated sendables. +We could supply it with weights, e.g.: + +``` + basic.ack = 8 + basic.cancel = 1 + channel.close = 2 + connection.close = 1 + ... +``` + +And any omissions are 100 by default, except for server-initiated teardown (`connection.close`/`channel.close`/`basic.cancel`), +which need to be 0; otherwise the model would not progress and using send_gen to autopilot some basic things would become annoying. +100 is chosen because it allows some granularity +(e.g. if we used one but I wanted to override a server-side Close to be a _bit_ likely, I could set it to e.g. 5, and the rest can stay at the default 100). + +But this exclusion brings trouble: if we do not somehow exclude the Closes that are genuine method replies, how will the model state progress? +We need some kind of exclusion. + + +Then say the enumerated sendables are these: + +``` + basic.ack(X), basic.ack(Y), channel.close(X), connection.close(X) +``` + +Considering just the method kinds available, the draw must satisfy: + +``` + P(any basic.ack) / P(any channel.close) = 8 / 2 + P(any channel.close) / P(any connection.close) = 2 / 1 + + P(any basic.ack) + P(any channel.close) + P(any connection.close) = 1 + + -> + + P(any basic.ack) = 8 / 11 + P(any channel.close) = 2 / 11 + P(any connection.close) = 1 / 11 +``` + +Which gives us the probabilities for the actual draw: + +``` + P(basic.ack(X)) + P(basic.ack(Y)) = P(any basic.ack) + P(channel.close(X)) = P(any channel.close) + P(connection.close(X)) = P(any connection.close) + + P(basic.ack(X)) = P(basic.ack(Y)) + + -> + + P(basic.ack(X)) = 4 / 11 + P(basic.ack(Y)) = 4 / 11 + P(channel.close(X)) = 2 / 11 + P(connection.close(X)) = 1 / 11 +``` + +Something else to think about: +- Should weight 0 actually be allowed? +- Should there also be a max weight? + +---------------------------------------------- + +What if we extended the patterns from names to fields too? + +This would also allow us to specify zero default weight to `connection.close`/`channel.close` (class-id=0, method-id=0) for no broker-initated teardown. +For another example, the following would never confirm delivery tag 2 and keep cancelling a particular consumer (more useful once we have multiple channels). + +``` + basic.ack(2) = 0 + basic.ack() = 1 + basic.cancel("im-a-consumer") = 8 +``` + +- Question: What is the resolution order? + Example: say we have specified different weights on + `basic.return(exchange="ex")` + `basic.return(routing_key="rk")` + then which one matches: + `P(basic.return(exchange="ex", routing_key="rk"))` + Answer: use the given order for resolution, this is how many (all?) pattern-matchers solve this problem + +- Question: What happens if you don't specify a value on a specific field? + Answer: unify with what's in the bag. + +- What happens if you specify a value on an arbitrary field? + Answer: discussion below + +---------------------------------------------- + +Let's assume we specify values on an arbitrary field - what will that mean? + +Consider if this statement even makes sense: "this is the weight of `basic.deliver(message='hello')` compared to all other deliveries" -- but there are ~infinitely many deliveries! +Maybe we could somehow bias towards this string? + +Sidestepping that objection for a moment, let's work through an example for a simpler type: bool. + +requeue is an arbitrary field on acknowledgments, so suppose we're given weighted patterns in the following order: + +``` + basic.nack(1) = 5 + basic.nack(requeue=True) = 4 + basic.nack() = 2 +``` + +Say the enumerated sendables are: + +``` + basic.nack(1), basic.nack(2) +``` + +Matching in order while restricting the domain of requeue: + +``` + basic.nack(1) takes basic.nack(1, requeue=True), basic.nack(1, requeue=False) + basic.nack(requeue=True) takes basic.nack(2, requeue=True) + basic.nack() takes basic.nack(2, requeue=False) +``` + +then we should have: + +``` + P(basic.nack(1)) / P(basic.nack(2, requeue=True)) / P(basic.nack(2, requeue=False)) = 5 / 4 / 2 +``` + +Instead, the resolution order + +``` + basic.nack(requeue=True) = 4 + basic.nack(1) = 5 + basic.nack() = 2 +``` + +would've given: + +``` + [P(basic.nack(1, requeue=True)) + P(basic.nack(2, requeue=True))] / P(basic.nack(1, requeue=False)) = 4 / 5 +``` + +We're effectively implementing full pattern-matching with knowledge of the domain of each type, and trying to bias towards some values. +This would feel fiddly to use and be way too complex to implement. + +We could try to simplify: perhaps the first pattern that matches an arbitrary field should fix it to that value. +But then this would be unintuitive: + +``` + basic.nack(requeue=True) = 1 + basic.nack(requeue=False) = 100 # no-op, but you'd think it might weight the False side of basic.nack to 100. +``` + +TL;DR: only exact fields make sense in patterns. diff --git a/src/tests/amqp_imposter/amqp_imposter/model/error_replies.py b/src/tests/amqp_imposter/amqp_imposter/model/error_replies.py new file mode 100644 index 00000000..5b974bee --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/error_replies.py @@ -0,0 +1,107 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import method_fields as mf +from . import methods as m + +type ErrorReply = m.ChannelClose | m.ConnectionClose + + +def error_reply( + reply_code: mf.ReplyCode, reply_text: str, causing_method: m.Method +) -> ErrorReply: + if reply_code in mf.SOFT_ERRORS: + return m.ChannelClose( + reply_code=reply_code, + reply_text=reply_text, + class_id=causing_method.CLASS_ID, + method_id=causing_method.METHOD_ID, + ) + else: + return m.ConnectionClose( + reply_code=reply_code, + reply_text=reply_text, + class_id=causing_method.CLASS_ID, + method_id=causing_method.METHOD_ID, + ) + + +def optional_reply_codes(method: m.Method | None) -> tuple[mf.ReplyCode, ...]: + # reply codes that a received method can enable + # e.g. non-passive QueueDeclare can optionally be answered with PRECONDITION_FAILED + match method: + case m.ConnectionOpen(): + return (mf.ReplyCode.INVALID_PATH, mf.ReplyCode.NOT_ALLOWED) + case m.ChannelOpen(): + return (mf.ReplyCode.RESOURCE_ERROR,) + case m.ExchangeDeclare(passive=True): + return (mf.ReplyCode.NOT_FOUND, mf.ReplyCode.ACCESS_REFUSED) + case m.ExchangeDeclare(): + return ( + mf.ReplyCode.ACCESS_REFUSED, + mf.ReplyCode.PRECONDITION_FAILED, + mf.ReplyCode.NOT_ALLOWED, + mf.ReplyCode.RESOURCE_ERROR, + ) + case m.ExchangeBind(): + return ( + mf.ReplyCode.NOT_FOUND, + mf.ReplyCode.ACCESS_REFUSED, + mf.ReplyCode.RESOURCE_ERROR, + ) + case m.QueueDeclare(passive=True): + return ( + mf.ReplyCode.NOT_FOUND, + mf.ReplyCode.ACCESS_REFUSED, + mf.ReplyCode.RESOURCE_LOCKED, + ) + case m.QueueDeclare(): + return ( + mf.ReplyCode.ACCESS_REFUSED, + mf.ReplyCode.RESOURCE_LOCKED, + mf.ReplyCode.PRECONDITION_FAILED, + mf.ReplyCode.RESOURCE_ERROR, + ) + case m.QueueBind() | m.BasicConsume(): + return ( + mf.ReplyCode.NOT_FOUND, + mf.ReplyCode.ACCESS_REFUSED, + mf.ReplyCode.RESOURCE_LOCKED, + mf.ReplyCode.RESOURCE_ERROR, + ) + case m.BasicGet(): + return ( + mf.ReplyCode.NOT_FOUND, + mf.ReplyCode.ACCESS_REFUSED, + mf.ReplyCode.RESOURCE_LOCKED, + ) + case m.BasicPublish(exchange=""): + return (mf.ReplyCode.ACCESS_REFUSED, mf.ReplyCode.CONTENT_TOO_LARGE) + case m.BasicPublish(): + return ( + mf.ReplyCode.NOT_FOUND, + mf.ReplyCode.ACCESS_REFUSED, + mf.ReplyCode.CONTENT_TOO_LARGE, + ) + case _: + return () + + +def optional_error_replies(method: m.Method | None) -> tuple[ErrorReply, ...]: + return tuple( + error_reply(reply_code, mf.NOT_FORCED_REPLY_TEXT, method) + for reply_code in optional_reply_codes(method) + if method is not None + ) diff --git a/src/tests/amqp_imposter/amqp_imposter/model/generators.py b/src/tests/amqp_imposter/amqp_imposter/model/generators.py new file mode 100644 index 00000000..3be29e6a --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/generators.py @@ -0,0 +1,127 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from abc import ABC, abstractmethod +from collections.abc import Callable +from dataclasses import dataclass +from random import Random + +from ..helpers.nonempty_tuple import NonEmptyTuple + +SEED_BITS = 64 + + +class BadBounds(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class GenState: + seed: int + + def advance(self) -> GenState: + seed_randomness = Random(self.seed) + return GenState(seed_randomness.getrandbits(SEED_BITS)) + + def generate[T](self, from_randomness: Callable[[Random], T]) -> tuple[T, GenState]: + randomness = Random(self.seed) + return from_randomness(randomness), GenState(randomness.getrandbits(SEED_BITS)) + + +DEFAULT_GEN_STATE = GenState(0) + + +class Gen[T](ABC): + __slots__ = () + + @abstractmethod + def from_randomness(self, randomness: Random) -> T: ... + + def generate(self, gen_state: GenState) -> tuple[T, GenState]: + return gen_state.generate(self.from_randomness) + + +@dataclass(frozen=True, slots=True) +class Constant[T](Gen[T]): + value: T + + def from_randomness(self, randomness: Random) -> T: + return self.value + + def generate(self, gen_state: GenState) -> tuple[T, GenState]: + return self.value, gen_state + + +@dataclass(frozen=True, slots=True) +class Choice[T](Gen[T]): + choices: NonEmptyTuple[T] + + def from_randomness(self, randomness: Random) -> T: + return randomness.choice(self.choices) + + +@dataclass(frozen=True, slots=True) +class RangedInt(Gen[int]): + min_value: int + max_value: int + + def __post_init__(self) -> None: + if self.min_value > self.max_value: + raise BadBounds(f"bad range: [{self.min_value}, {self.max_value}]") + + def from_randomness(self, randomness: Random) -> int: + return randomness.randint(self.min_value, self.max_value) + + +@dataclass(frozen=True, slots=True) +class NonzeroChance(Gen[bool]): + chance: float + + def __post_init__(self) -> None: + if not 0 < self.chance <= 1: + raise BadBounds(f"chance outside (0, 1]: {self.chance}") + + def from_randomness(self, randomness: Random) -> bool: + return randomness.random() < self.chance + + +@dataclass(frozen=True, slots=True) +class RandomBytes(Gen[bytes]): + min_length: int + max_length: int + + def __post_init__(self) -> None: + if self.min_length < 0 or self.min_length > self.max_length: + raise BadBounds(f"bad range: [{self.min_length}, {self.max_length}]") + + def from_randomness(self, randomness: Random) -> bytes: + return randomness.randbytes( + randomness.randint(self.min_length, self.max_length) + ) + + +@dataclass(frozen=True, slots=True) +class RandomText(Gen[str]): + min_length: int + max_length: int + + def __post_init__(self) -> None: + if self.min_length < 0 or self.min_length > self.max_length: + raise BadBounds(f"bad range: [{self.min_length}, {self.max_length}]") + + def from_randomness(self, randomness: Random) -> str: + length = randomness.randint(self.min_length, self.max_length) + blob = randomness.randbytes(length).hex() + return blob[:length] diff --git a/src/tests/amqp_imposter/amqp_imposter/model/helpers/revealing_line.py b/src/tests/amqp_imposter/amqp_imposter/model/helpers/revealing_line.py new file mode 100644 index 00000000..d9bd4ee1 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/helpers/revealing_line.py @@ -0,0 +1,89 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from typing import TypeIs + + +@dataclass(frozen=True, slots=True) +class Transparent[T]: + item: T + + +@dataclass(frozen=True, slots=True) +class Opaque[T]: + item: T + + +type Revealed[T] = Transparent[T] | Opaque[T] + + +def is_transparent[T](revealed: Revealed[T]) -> TypeIs[Transparent[T]]: + return isinstance(revealed, Transparent) + + +def is_opaque[T](revealed: Revealed[T]) -> TypeIs[Opaque[T]]: + return isinstance(revealed, Opaque) + + +@dataclass(frozen=True, slots=True) +class RevealingLine[T]: + line: tuple[Revealed[T], ...] = () + + def push(self, revealed: Revealed[T]) -> RevealingLine[T]: + return RevealingLine((*self.line, revealed)) + + def split(self, n: int | None = None) -> tuple[RevealingLine[T], RevealingLine[T]]: + if n is not None: + before = self.line[:n] + after = self.line[n:] + return RevealingLine(before), RevealingLine(after) + else: + before_opaque, rest = self.split_while(is_transparent) + opaque, after_opaque = rest.split(1) + return RevealingLine((*before_opaque, *opaque)), after_opaque + + def split_while( + self, predicate: Callable[[Revealed[T]], bool] + ) -> tuple[RevealingLine[T], RevealingLine[T]]: + before: list[Revealed[T]] = [] + after: list[Revealed[T]] = [] + + splitting = True + for item in self.line: + if splitting and predicate(item): + before.append(item) + else: + splitting = False + after.append(item) + + return RevealingLine(tuple(before)), RevealingLine(tuple(after)) + + def last(self) -> Revealed[T] | None: + return self.line[-1] if self.line else None + + def items(self) -> tuple[T, ...]: + return tuple(revealed.item for revealed in self.line) + + def observe(self) -> tuple[T, ...]: + observable, _ = self.split() + return observable.items() + + def __iter__(self) -> Iterator[Revealed[T]]: + return iter(self.line) + + def __len__(self) -> int: + return len(self.line) diff --git a/src/tests/amqp_imposter/amqp_imposter/model/method_bag.py b/src/tests/amqp_imposter/amqp_imposter/model/method_bag.py new file mode 100644 index 00000000..7303e3df --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/method_bag.py @@ -0,0 +1,254 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import error_replies as er +from . import method_fields as mf +from . import methods as m +from . import model_state as ms + + +def sendables(state: ms.ModelState) -> tuple[m.Method, ...]: + match connection_state := state.connection_state: + case ms.ClosedConnectionState(): + return () + case ms.OpenConnectionState(): + return sendable_ordered_methods(state) + sendable_standby_methods( + state, connection_state + ) + case _: + return sendable_ordered_methods(state) + + +def sendable_ordered_methods(state: ms.ModelState) -> tuple[m.Method, ...]: + return tuple( + method + for _, reply in ms.observable_replies(state) + for method in expected_methods(state, reply) + ) + + +def expected_methods( + state: ms.ModelState, reply: ms.ReplyState +) -> tuple[m.Method, ...]: + match reply: + case ms.KnownReplyState(causing_method=m.BasicConsume(no_wait=True) as consume): + reply_methods = reply.expected_methods + expected_consumer_methods( + state, consume + ) + case ms.KnownReplyState(): + reply_methods = reply.expected_methods + case ms.DeferredReplyState(causing_method=m.BasicGet()): + reply_methods = expected_get_methods(state) + + return reply_methods + er.optional_error_replies(reply.causing_method) + + +def expected_consumer_methods( + state: ms.ModelState, consume: m.BasicConsume +) -> tuple[m.Method, ...]: + match state.connection_state: + case ms.OpenConnectionState( + channel_state=ms.OpenChannelState( + consumer_state=ms.ActiveConsumerState(consumer_tag=consumer_tag) + ) as channel_state + ) if consumer_tag == consume.consumer_tag: + return sendable_consumer_methods(state, channel_state) + case _: + return () + + +def expected_get_methods(state: ms.ModelState) -> tuple[m.Method, ...]: + match state.connection_state: + case ms.OpenConnectionState( + channel_state=ms.OpenChannelState() as channel_state + ): + return (*sendable_get_oks(state, channel_state), m.BasicGetEmpty()) + case _: + return () + + +def sendable_get_oks( + state: ms.ModelState, channel_state: ms.OpenChannelState +) -> tuple[m.BasicGetOk, ...]: + delivery_tag = channel_state.next_delivery_tag + + fresh_get_ok = m.BasicGetOk(delivery_tag=delivery_tag) + requeued_get_oks = tuple( + m.BasicGetOk( + delivery_tag=delivery_tag, + redelivered=True, + exchange=message.exchange, + routing_key=message.routing_key, + properties=message.properties, + body=message.body, + ) + for message in state.requeued_message_states + ) + return (fresh_get_ok, *requeued_get_oks) + + +def sendable_standby_methods( + state: ms.ModelState, connection_state: ms.OpenConnectionState +) -> tuple[m.Method, ...]: + match channel_state := connection_state.channel_state: + case ms.OpenChannelState(): + return sendable_unprompted_closes() + sendable_standby_messages( + state, channel_state + ) + case _: + return sendable_unprompted_closes() + + +def sendable_unprompted_closes() -> tuple[m.Method, ...]: + return tuple( + m.ConnectionClose( + reply_code=reply_code, + reply_text=mf.NOT_FORCED_REPLY_TEXT, + class_id=mf.UNPROMPTED_CLASS_ID, + method_id=mf.UNPROMPTED_METHOD_ID, + ) + for reply_code in sorted(mf.UNPROMPTED_ERRORS) + ) + + +def sendable_standby_messages( + state: ms.ModelState, channel_state: ms.OpenChannelState +) -> tuple[m.Method, ...]: + standby_publishes = channel_state.unconfirmed_states + ms.observable_publishes( + state + ) + return ( + sendable_standby_consumer_methods(state, channel_state) + + sendable_returns_for(standby_publishes) + + sendable_confirms_for(standby_publishes) + ) + + +def sendable_standby_consumer_methods( + state: ms.ModelState, channel_state: ms.OpenChannelState +) -> tuple[m.Method, ...]: + if ms.has_pending_consume(state): + return () + + return sendable_consumer_methods(state, channel_state) + + +def sendable_consumer_methods( + state: ms.ModelState, channel_state: ms.OpenChannelState +) -> tuple[m.Method, ...]: + match consumer_state := channel_state.consumer_state: + case ms.ActiveConsumerState(): + return sendable_consumer_cancels( + state, consumer_state + ) + sendable_consumer_deliveries(state, channel_state, consumer_state) + case ms.CancelledConsumerState() | ms.NoConsumerState(): + return () + + +def sendable_consumer_cancels( + state: ms.ModelState, consumer_state: ms.ActiveConsumerState +) -> tuple[m.Method, ...]: + if not state.negotiation_options.consumer_cancel_notify: + return () + + return (m.BasicCancel(consumer_tag=consumer_state.consumer_tag, no_wait=True),) + + +def sendable_consumer_deliveries( + state: ms.ModelState, + channel_state: ms.OpenChannelState, + consumer_state: ms.ActiveConsumerState, +) -> tuple[m.Method, ...]: + if not ms.has_prefetch_space(channel_state, consumer_state): + return () + + delivery_tag = channel_state.next_delivery_tag + + # a fresh delivery with arbitrary fields + fresh_delivery = m.BasicDeliver( + consumer_tag=consumer_state.consumer_tag, + delivery_tag=delivery_tag, + ) + requeued_deliveries = tuple( + m.BasicDeliver( + consumer_tag=consumer_state.consumer_tag, + delivery_tag=delivery_tag, + redelivered=True, + exchange=message.exchange, + routing_key=message.routing_key, + properties=message.properties, + body=message.body, + ) + for message in state.requeued_message_states + ) + return (fresh_delivery, *requeued_deliveries) + + +def sendable_returns_for( + publish_states: tuple[ms.PublishState, ...], +) -> tuple[m.Method, ...]: + return tuple( + m.BasicReturn( + reply_code=mf.ReplyCode.UNROUTABLE, + reply_text=mf.GEN_NONEMPTY_SHORTSTR, + exchange=publish_state.message_state.exchange, + routing_key=publish_state.message_state.routing_key, + properties=publish_state.message_state.properties, + body=publish_state.message_state.body, + ) + for publish_state in publish_states + if ms.is_returnable(publish_state) + ) + + +def sendable_confirms_for( + publishes: tuple[ms.PublishState, ...], +) -> tuple[m.Method, ...]: + return sendable_ack_confirms_for(publishes) + sendable_nack_confirms_for(publishes) + + +def sendable_ack_confirms_for( + publishes: tuple[ms.PublishState, ...], +) -> tuple[m.Method, ...]: + # we can ack any unconfirmed publish regardless of whether it has been returned + return tuple( + m.BasicAck( + delivery_tag=confirm_seq, # confirms reuse the delivery-tag field + multiple=multiple, + ) + for multiple in (False, True) + for confirm_seq in ms.confirm_seqs(publishes) + ) + + +def sendable_nack_confirms_for( + publishes: tuple[ms.PublishState, ...], +) -> tuple[m.Method, ...]: + returned = ms.returned_confirm_seqs(publishes) + min_returned = min(returned, default=None) + + # we can single-nack any unconfirmed publish that has not been returned + # and multiple-nack any unconfirmed publish below min_returned + return tuple( + m.BasicNack( + delivery_tag=confirm_seq, + multiple=multiple, + requeue=mf.GEN_BIT, # the requeue field is arbitrary when the server nacks + ) + for multiple in (False, True) + for confirm_seq in ms.confirm_seqs(publishes) + if confirm_seq not in returned + and (not multiple or min_returned is None or confirm_seq < min_returned) + ) diff --git a/src/tests/amqp_imposter/amqp_imposter/model/method_fields.py b/src/tests/amqp_imposter/amqp_imposter/model/method_fields.py new file mode 100644 index 00000000..a33eba17 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/method_fields.py @@ -0,0 +1,268 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal +from enum import IntEnum +from random import Random +from typing import Any, ClassVar, TypeIs + +from amqp_imposter.model.constants import ( + DEFAULT_CONTENT_ENCODINGS, + DEFAULT_CONTENT_TYPES, + DEFAULT_EXCHANGE_TYPES, + DEFAULT_OVERFLOWS, + DEFAULT_QUEUE_TYPES, + DEFAULT_VHOST, + DELIVERY_MODES, + LONG_MAX, + MESSAGE_BODY_MAX, + NAME_MAX, + OCTET_MAX, + PRIORITY_MAX, + PRIORITY_MIN, + SHORT_MAX, + SHORTSTR_MAX, +) + +from .generators import ( + Choice, + Constant, + Gen, + NonzeroChance, + RandomBytes, + RandomText, + RangedInt, +) + + +@dataclass(frozen=True, slots=True) +class Hole: + pass + + +HOLE = Hole() + +type Exact[T] = T | Hole +type Arbitrary[T] = T | Gen[T] + + +def is_concrete[T](value: Exact[T] | Arbitrary[T]) -> TypeIs[T]: + return not isinstance(value, Gen | Hole) + + +def assert_concrete[T](value: Exact[T] | Arbitrary[T]) -> T: + assert is_concrete(value), value + return value + + +@dataclass(frozen=True, slots=True) +class Void: + pass # this becomes b'V' on the wire, different to the field being absent + + +type TableValue = ( + bool + | int + | str + | bytes + | Decimal + | datetime + | Void + | Table + | tuple[TableValue, ...] +) +Table = frozendict[str, TableValue] + + +class ClassId(IntEnum): + CONNECTION = 10 + CHANNEL = 20 + EXCHANGE = 40 + QUEUE = 50 + BASIC = 60 + CONFIRM = 85 + + +class ReplyCode(IntEnum): + SUCCESS = 200 + CONTENT_TOO_LARGE = 311 + UNROUTABLE = 312 + NO_CONSUMERS = 313 + CONNECTION_FORCED = 320 + INVALID_PATH = 402 + ACCESS_REFUSED = 403 + NOT_FOUND = 404 + RESOURCE_LOCKED = 405 + PRECONDITION_FAILED = 406 + FRAME_ERROR = 501 + SYNTAX_ERROR = 502 + COMMAND_INVALID = 503 + CHANNEL_ERROR = 504 + UNEXPECTED_FRAME = 505 + RESOURCE_ERROR = 506 + NOT_ALLOWED = 530 + NOT_IMPLEMENTED = 540 + INTERNAL_ERROR = 541 + + +SOFT_ERRORS = frozenset( + { + ReplyCode.CONTENT_TOO_LARGE, + ReplyCode.UNROUTABLE, + ReplyCode.NO_CONSUMERS, + ReplyCode.ACCESS_REFUSED, + ReplyCode.NOT_FOUND, + ReplyCode.RESOURCE_LOCKED, + ReplyCode.PRECONDITION_FAILED, + } +) + +HARD_ERRORS = frozenset( + { + ReplyCode.CONNECTION_FORCED, + ReplyCode.INVALID_PATH, + ReplyCode.FRAME_ERROR, + ReplyCode.SYNTAX_ERROR, + ReplyCode.COMMAND_INVALID, + ReplyCode.CHANNEL_ERROR, + ReplyCode.UNEXPECTED_FRAME, + ReplyCode.RESOURCE_ERROR, + ReplyCode.NOT_ALLOWED, + ReplyCode.NOT_IMPLEMENTED, + ReplyCode.INTERNAL_ERROR, + } +) + +FORCED_REPLY_TEXT = "FORCED" +NOT_FORCED_REPLY_TEXT = "NOT_FORCED" + +UNPROMPTED_ERRORS = frozenset( + { + ReplyCode.CONNECTION_FORCED, + ReplyCode.INTERNAL_ERROR, + } +) +UNPROMPTED_CLASS_ID = 0 +UNPROMPTED_METHOD_ID = 0 + + +@dataclass(frozen=True, slots=True) +class MessageProperties: + content_type: str | None = None + content_encoding: str | None = None + headers: Table | None = None + delivery_mode: int | None = None + priority: int | None = None + correlation_id: str | None = None + reply_to: str | None = None + expiration: str | None = None + message_id: str | None = None + timestamp: int | None = None + type: str | None = None + user_id: str | None = None + app_id: str | None = None + cluster_id: Void = Void() + + +@dataclass(frozen=True, slots=True) +class RandomTable(Gen[Table]): + eligible_key_gens: tuple[tuple[str, Gen[TableValue]], ...] + + def from_randomness(self, randomness: Random) -> Table: + key_gens = randomness.sample( + self.eligible_key_gens, + randomness.randint(0, len(self.eligible_key_gens)), + ) + return Table((key, gen.from_randomness(randomness)) for key, gen in key_gens) + + +GEN_BIT = NonzeroChance(0.5) +GEN_OCTET = RangedInt(0, OCTET_MAX) +GEN_SHORT = RangedInt(0, SHORT_MAX) +GEN_LONG = RangedInt(0, LONG_MAX) +GEN_ZERO = Constant(0) +GEN_VHOST = Constant(DEFAULT_VHOST) +GEN_FALSE = Constant(False) +GEN_TRUE = Constant(True) +GEN_NONE = Constant(None) +GEN_EMPTY_STR = Constant("") +GEN_EMPTY_BYTES = Constant(b"") +GEN_SHORTSTR = RandomText(0, SHORTSTR_MAX) +GEN_NONEMPTY_SHORTSTR = RandomText(1, SHORTSTR_MAX) +GEN_NAME = RandomText(0, NAME_MAX) +GEN_NONEMPTY_NAME = RandomText(1, NAME_MAX) +GEN_MESSAGE_BODY = RandomBytes(0, MESSAGE_BODY_MAX) +GEN_PRIORITY = RangedInt(PRIORITY_MIN, PRIORITY_MAX) +GEN_DELIVERY_MODE = Choice(DELIVERY_MODES) +GEN_OVERFLOW = Choice(DEFAULT_OVERFLOWS) +GEN_QUEUE_TYPE = Choice(DEFAULT_QUEUE_TYPES) +GEN_EXCHANGE_TYPE = Choice(DEFAULT_EXCHANGE_TYPES) +GEN_CONTENT_TYPE = Choice(DEFAULT_CONTENT_TYPES) +GEN_CONTENT_ENCODING = Choice(DEFAULT_CONTENT_ENCODINGS) +GEN_QUEUE_ARGUMENTS = RandomTable( + ( + ("x-message-ttl", GEN_LONG), + ("x-expires", GEN_LONG), + ("x-max-length", GEN_LONG), + ("x-max-length-bytes", GEN_LONG), + ("x-overflow", GEN_OVERFLOW), + ("x-dead-letter-exchange", GEN_NAME), + ("x-dead-letter-routing-key", GEN_SHORTSTR), + ("x-queue-type", GEN_QUEUE_TYPE), + ("x-single-active-consumer", GEN_BIT), + ("x-delivery-limit", GEN_LONG), + ) +) +GEN_MESSAGE_HEADERS = RandomTable( + ( + ("x-machina", GEN_LONG), + ("x-first-death-queue", GEN_NAME), + ("x-first-death-exchange", GEN_NAME), + ) +) + + +@dataclass(frozen=True, slots=True) +class RandomMessageProperties(Gen[MessageProperties | None]): + ELIGIBLE_KEY_GENS: ClassVar[tuple[tuple[str, Gen[TableValue]], ...]] = ( + ("content_type", GEN_CONTENT_TYPE), + ("content_encoding", GEN_CONTENT_ENCODING), + ("headers", GEN_MESSAGE_HEADERS), + ("delivery_mode", GEN_DELIVERY_MODE), + ("priority", GEN_PRIORITY), + ("correlation_id", GEN_NONEMPTY_SHORTSTR), + ("reply_to", GEN_NONEMPTY_NAME), + ("expiration", GEN_NONEMPTY_SHORTSTR), + ("message_id", GEN_NONEMPTY_SHORTSTR), + ("timestamp", GEN_LONG), + ("type", GEN_NONEMPTY_SHORTSTR), + ("user_id", GEN_NONEMPTY_NAME), + ("app_id", GEN_NONEMPTY_NAME), + ) + + def from_randomness(self, randomness: Random) -> MessageProperties | None: + key_gens = randomness.sample( + self.ELIGIBLE_KEY_GENS, + randomness.randint(0, len(self.ELIGIBLE_KEY_GENS)), + ) + values: dict[str, Any] = { + key: gen.from_randomness(randomness) for key, gen in key_gens + } + return MessageProperties(**values) + + +GEN_MESSAGE_PROPERTIES = RandomMessageProperties() diff --git a/src/tests/amqp_imposter/amqp_imposter/model/method_sampling.py b/src/tests/amqp_imposter/amqp_imposter/model/method_sampling.py new file mode 100644 index 00000000..faf45241 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/method_sampling.py @@ -0,0 +1,93 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass +from random import Random + +from . import method_unification as mu +from . import method_weights as mw +from . import methods as m +from .generators import Gen + +type PatternMethods = tuple[mw.WeightedPattern, tuple[m.Method, ...]] + +DEFAULT_MAX_SENDS = 1000 + + +@dataclass(frozen=True, slots=True) +class Stop: + pass + + +@dataclass(frozen=True, slots=True) +class MethodSampler(Gen[m.Method | Stop]): + unified_by_weighted_pattern: tuple[PatternMethods, ...] + stop_weight: int + + def __post_init__(self) -> None: + if self.stop_weight <= 0: + raise mw.BadWeight(f"stop needs a positive weight: {self.stop_weight}") + + def from_randomness(self, randomness: Random) -> m.Method | Stop: + groups: tuple[tuple[m.Method | Stop, ...], ...] = ( + *(unified for _, unified in self.unified_by_weighted_pattern), + (Stop(),), + ) + weights = ( + *(wp.weight for wp, _ in self.unified_by_weighted_pattern), + self.stop_weight, + ) + + [group] = randomness.choices(groups, weights=weights) + return randomness.choice(group) + + +def methods_by_weighted_pattern( + methods: tuple[m.Method, ...], weighted_patterns: tuple[mw.WeightedPattern, ...] +) -> tuple[PatternMethods, ...]: + unified_by_pattern: list[PatternMethods] = [] + + remaining = methods + + for wp in weighted_patterns: + # take all the methods we can match by unifying with the pattern + pairs = tuple((method, mu.unify(wp.pattern, method)) for method in remaining) + + # partition to those that could unify and those that couldn't + unified = tuple(u for _, u in pairs if u is not None) + remaining = tuple(method for method, u in pairs if u is None) + + unified_by_pattern.append((wp, unified)) + + return tuple(unified_by_pattern) + + +def make_sampler( + methods: tuple[m.Method, ...], + weighted_patterns: tuple[mw.WeightedPattern, ...], + stop_weight: int, +) -> MethodSampler: + effective_wps = ( + *weighted_patterns, + *mw.implicit_weighted_patterns(m.distinct_kinds(methods)), + ) + return MethodSampler( + tuple( + (wp, unified) + for wp, unified in methods_by_weighted_pattern(methods, effective_wps) + if wp.weight > 0 and unified + ), + stop_weight, + ) diff --git a/src/tests/amqp_imposter/amqp_imposter/model/method_scripts.py b/src/tests/amqp_imposter/amqp_imposter/model/method_scripts.py new file mode 100644 index 00000000..0c1738f7 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/method_scripts.py @@ -0,0 +1,50 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass + +from . import method_unification as mu +from . import methods as m + + +@dataclass(frozen=True, slots=True) +class Received: + method: m.Method + + +@dataclass(frozen=True, slots=True) +class Sent: + method: m.Method + + +@dataclass(frozen=True, slots=True) +class ScriptRule: + when: Received | Sent + then_send: m.Method + + def __post_init__(self) -> None: + m.validate_pattern(self.when.method) + m.validate_pattern(self.then_send) + + +def match_rules( + script: tuple[ScriptRule, ...], when: Received | Sent +) -> tuple[ScriptRule, ...]: + return tuple( + rule + for rule in script + if type(rule.when) is type(when) + and mu.unify(rule.when.method, when.method) is not None + ) diff --git a/src/tests/amqp_imposter/amqp_imposter/model/method_unification.py b/src/tests/amqp_imposter/amqp_imposter/model/method_unification.py new file mode 100644 index 00000000..765057ad --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/method_unification.py @@ -0,0 +1,78 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections.abc import Iterable +from typing import Any + +from . import generators as g +from . import method_fields as mf +from . import methods as m + + +def unify[M: m.Method](method: M, other: m.Method) -> M | None: + """ + The unification of two methods, or None if it does not exist. + If both are arbitrary, the other value is taken. + """ + if not m.is_same_kind(method, other): + return None + + unified_fields: dict[str, Any] = {} + + method_fields = m.get_fields(method) + other_fields = m.get_fields(other) + + for name, value in method_fields.items(): + other_value = other_fields[name] + + if not mf.is_concrete(value): + unified_fields[name] = other_value + elif not mf.is_concrete(other_value) or value == other_value: + unified_fields[name] = value + else: + return None + + return m.set_fields(method, unified_fields) + + +def unify_any[M: m.Method](method: M, others: Iterable[m.Method]) -> M | None: + """ + The unification with the first other method that unifies, or None if none does. + """ + for other in others: + if (unified := unify(method, other)) is not None: + return unified + + return None + + +def concretise[M: m.Method](method: M, gen_state: g.GenState) -> tuple[M, g.GenState]: + """ + Generates missing fields for a method so that is fully specified and ready to encode. + """ + assert not m.is_method_pattern(method), method + + concrete_values: dict[str, Any] = {} + + for name, value in m.get_fields(method).items(): + gen_state = gen_state.advance() + + match value: + case g.Gen(): + concrete_values[name] = value.generate(gen_state)[0] + case _: + concrete_values[name] = mf.assert_concrete(value) + + return (m.set_fields(method, concrete_values), gen_state) diff --git a/src/tests/amqp_imposter/amqp_imposter/model/method_weights.py b/src/tests/amqp_imposter/amqp_imposter/model/method_weights.py new file mode 100644 index 00000000..69bcfde3 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/method_weights.py @@ -0,0 +1,67 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections.abc import Iterable +from dataclasses import dataclass + +from . import method_fields as mf +from . import methods as m + +BASE_WEIGHT = 100 +STOP_WEIGHT = BASE_WEIGHT +UNPROMPTED_TEARDOWN_WEIGHT = 0 +NOT_FORCED_WEIGHT = 0 + + +class BadWeight(ValueError): + pass + + +@dataclass(frozen=True, slots=True) +class WeightedPattern: + pattern: m.Method + weight: int = BASE_WEIGHT + + def __post_init__(self) -> None: + if self.weight < 0: + raise BadWeight(f"negative weight: {self.weight}") + + m.validate_pattern(self.pattern) + + +UNPROMPTED_CONNECTION_CLOSE = m.ConnectionClose( + class_id=mf.UNPROMPTED_CLASS_ID, method_id=mf.UNPROMPTED_METHOD_ID +) +UNPROMPTED_CHANNEL_CLOSE = m.ChannelClose( + class_id=mf.UNPROMPTED_CLASS_ID, method_id=mf.UNPROMPTED_METHOD_ID +) +UNPROMPTED_CANCEL = m.BasicCancel() +NOT_FORCED_CONNECTION_CLOSE = m.ConnectionClose(reply_text=mf.NOT_FORCED_REPLY_TEXT) +NOT_FORCED_CHANNEL_CLOSE = m.ChannelClose(reply_text=mf.NOT_FORCED_REPLY_TEXT) + +DEFAULT_WEIGHTS: tuple[WeightedPattern, ...] = () + + +def implicit_weighted_patterns( + kinds: Iterable[type[m.Method]], +) -> tuple[WeightedPattern, ...]: + return ( + WeightedPattern(UNPROMPTED_CONNECTION_CLOSE, UNPROMPTED_TEARDOWN_WEIGHT), + WeightedPattern(UNPROMPTED_CANCEL, UNPROMPTED_TEARDOWN_WEIGHT), + WeightedPattern(UNPROMPTED_CHANNEL_CLOSE, UNPROMPTED_TEARDOWN_WEIGHT), + WeightedPattern(NOT_FORCED_CONNECTION_CLOSE, NOT_FORCED_WEIGHT), + WeightedPattern(NOT_FORCED_CHANNEL_CLOSE, NOT_FORCED_WEIGHT), + *(WeightedPattern(kind()) for kind in kinds), + ) diff --git a/src/tests/amqp_imposter/amqp_imposter/model/methods.py b/src/tests/amqp_imposter/amqp_imposter/model/methods.py new file mode 100644 index 00000000..ed477e52 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/methods.py @@ -0,0 +1,483 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass, fields +from typing import Any, ClassVar, TypeIs, get_args + +from .method_fields import ( + GEN_BIT, + GEN_EMPTY_BYTES, + GEN_EMPTY_STR, + GEN_EXCHANGE_TYPE, + GEN_FALSE, + GEN_LONG, + GEN_MESSAGE_BODY, + GEN_MESSAGE_PROPERTIES, + GEN_NAME, + GEN_NONE, + GEN_NONEMPTY_NAME, + GEN_NONEMPTY_SHORTSTR, + GEN_SHORTSTR, + GEN_VHOST, + GEN_ZERO, + HOLE, + Arbitrary, + ClassId, + Exact, + MessageProperties, + Table, + is_concrete, +) + + +@dataclass(frozen=True, slots=True) +class ConnectionStart: + CLASS_ID: ClassVar[int] = ClassId.CONNECTION + METHOD_ID: ClassVar[int] = 10 + + version_major: Exact[int] = HOLE + version_minor: Exact[int] = HOLE + mechanisms: Exact[tuple[str, ...]] = HOLE + locales: Exact[tuple[str, ...]] = HOLE + publisher_confirms: Exact[bool] = HOLE + basic_nack: Exact[bool] = HOLE + consumer_cancel_notify: Exact[bool] = HOLE + + +@dataclass(frozen=True, slots=True) +class ConnectionStartOk: + CLASS_ID: ClassVar[int] = ClassId.CONNECTION + METHOD_ID: ClassVar[int] = 11 + + mechanism: Exact[str] = HOLE + locale: Exact[str] = HOLE + response: Arbitrary[str] = GEN_EMPTY_STR + + +@dataclass(frozen=True, slots=True) +class ConnectionTune: + CLASS_ID: ClassVar[int] = ClassId.CONNECTION + METHOD_ID: ClassVar[int] = 30 + + channel_max: Exact[int] = HOLE + frame_max: Exact[int] = HOLE + heartbeat: Exact[int] = HOLE + + +@dataclass(frozen=True, slots=True) +class ConnectionTuneOk: + CLASS_ID: ClassVar[int] = ClassId.CONNECTION + METHOD_ID: ClassVar[int] = 31 + + channel_max: Exact[int] = HOLE + frame_max: Exact[int] = HOLE + heartbeat: Exact[int] = HOLE + + +@dataclass(frozen=True, slots=True) +class ConnectionOpen: + CLASS_ID: ClassVar[int] = ClassId.CONNECTION + METHOD_ID: ClassVar[int] = 40 + + vhost: Arbitrary[str] = GEN_VHOST + + +@dataclass(frozen=True, slots=True) +class ConnectionOpenOk: + CLASS_ID: ClassVar[int] = ClassId.CONNECTION + METHOD_ID: ClassVar[int] = 41 + + +@dataclass(frozen=True, slots=True) +class ConnectionClose: + CLASS_ID: ClassVar[int] = ClassId.CONNECTION + METHOD_ID: ClassVar[int] = 50 + + reply_code: Exact[int] = HOLE + reply_text: Exact[str] = HOLE + class_id: Exact[int] = HOLE + method_id: Exact[int] = HOLE + + +@dataclass(frozen=True, slots=True) +class ConnectionCloseOk: + CLASS_ID: ClassVar[int] = ClassId.CONNECTION + METHOD_ID: ClassVar[int] = 51 + + +@dataclass(frozen=True, slots=True) +class ChannelOpen: + CLASS_ID: ClassVar[int] = ClassId.CHANNEL + METHOD_ID: ClassVar[int] = 10 + + +@dataclass(frozen=True, slots=True) +class ChannelOpenOk: + CLASS_ID: ClassVar[int] = ClassId.CHANNEL + METHOD_ID: ClassVar[int] = 11 + + +@dataclass(frozen=True, slots=True) +class ChannelClose: + CLASS_ID: ClassVar[int] = ClassId.CHANNEL + METHOD_ID: ClassVar[int] = 40 + + reply_code: Exact[int] = HOLE + reply_text: Exact[str] = HOLE + class_id: Exact[int] = HOLE + method_id: Exact[int] = HOLE + + +@dataclass(frozen=True, slots=True) +class ChannelCloseOk: + CLASS_ID: ClassVar[int] = ClassId.CHANNEL + METHOD_ID: ClassVar[int] = 41 + + +@dataclass(frozen=True, slots=True) +class ConfirmSelect: + CLASS_ID: ClassVar[int] = ClassId.CONFIRM + METHOD_ID: ClassVar[int] = 10 + + no_wait: Arbitrary[bool] = ( + GEN_FALSE # instantiations with this True need to be hand-crafted + ) + + +@dataclass(frozen=True, slots=True) +class ConfirmSelectOk: + CLASS_ID: ClassVar[int] = ClassId.CONFIRM + METHOD_ID: ClassVar[int] = 11 + + +@dataclass(frozen=True, slots=True) +class ExchangeDeclare: + CLASS_ID: ClassVar[int] = ClassId.EXCHANGE + METHOD_ID: ClassVar[int] = 10 + + exchange: Exact[str] = HOLE + exchange_type: Arbitrary[str] = GEN_EXCHANGE_TYPE + passive: Arbitrary[bool] = GEN_FALSE + durable: Arbitrary[bool] = GEN_FALSE + auto_delete: Arbitrary[bool] = GEN_FALSE + internal: Arbitrary[bool] = GEN_FALSE + no_wait: Arbitrary[bool] = GEN_FALSE + arguments: Arbitrary[Table | None] = GEN_NONE + + +@dataclass(frozen=True, slots=True) +class ExchangeDeclareOk: + CLASS_ID: ClassVar[int] = ClassId.EXCHANGE + METHOD_ID: ClassVar[int] = 11 + + +@dataclass(frozen=True, slots=True) +class ExchangeBind: + CLASS_ID: ClassVar[int] = ClassId.EXCHANGE + METHOD_ID: ClassVar[int] = 30 + + destination: Arbitrary[str] = GEN_NONEMPTY_NAME + source: Arbitrary[str] = GEN_NONEMPTY_NAME + routing_key: Arbitrary[str] = GEN_SHORTSTR + no_wait: Arbitrary[bool] = GEN_FALSE + arguments: Arbitrary[Table | None] = GEN_NONE + + +@dataclass(frozen=True, slots=True) +class ExchangeBindOk: + CLASS_ID: ClassVar[int] = ClassId.EXCHANGE + METHOD_ID: ClassVar[int] = 31 + + +@dataclass(frozen=True, slots=True) +class QueueDeclare: + CLASS_ID: ClassVar[int] = ClassId.QUEUE + METHOD_ID: ClassVar[int] = 10 + + queue: Arbitrary[str] = GEN_NONEMPTY_NAME + passive: Arbitrary[bool] = GEN_FALSE + durable: Arbitrary[bool] = GEN_FALSE + exclusive: Arbitrary[bool] = GEN_FALSE + auto_delete: Arbitrary[bool] = GEN_FALSE + no_wait: Arbitrary[bool] = GEN_FALSE + arguments: Arbitrary[Table | None] = GEN_NONE + + +@dataclass(frozen=True, slots=True) +class QueueDeclareOk: + CLASS_ID: ClassVar[int] = ClassId.QUEUE + METHOD_ID: ClassVar[int] = 11 + + queue: Arbitrary[str] = GEN_NONEMPTY_NAME # can be anonymous + message_count: Arbitrary[int] = GEN_LONG + consumer_count: Arbitrary[int] = GEN_LONG + + +@dataclass(frozen=True, slots=True) +class QueueBind: + CLASS_ID: ClassVar[int] = ClassId.QUEUE + METHOD_ID: ClassVar[int] = 20 + + queue: Arbitrary[str] = GEN_NONEMPTY_NAME + exchange: Arbitrary[str] = GEN_NONEMPTY_NAME + routing_key: Arbitrary[str] = GEN_SHORTSTR + no_wait: Arbitrary[bool] = GEN_FALSE + arguments: Arbitrary[Table | None] = GEN_NONE + + +@dataclass(frozen=True, slots=True) +class QueueBindOk: + CLASS_ID: ClassVar[int] = ClassId.QUEUE + METHOD_ID: ClassVar[int] = 21 + + +@dataclass(frozen=True, slots=True) +class BasicQos: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 10 + + prefetch_count: Arbitrary[int] = GEN_ZERO + prefetch_size: Arbitrary[int] = GEN_ZERO # nonzero produces "not implemented" + global_qos: Arbitrary[bool] = GEN_FALSE + + +@dataclass(frozen=True, slots=True) +class BasicQosOk: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 11 + + +@dataclass(frozen=True, slots=True) +class BasicConsume: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 20 + + queue: Arbitrary[str] = GEN_NONEMPTY_NAME + consumer_tag: Arbitrary[str] = GEN_EMPTY_STR + no_local: Arbitrary[bool] = GEN_FALSE + no_ack: Arbitrary[bool] = GEN_FALSE + no_wait: Arbitrary[bool] = GEN_FALSE + exclusive: Arbitrary[bool] = GEN_FALSE + arguments: Arbitrary[Table | None] = GEN_NONE + + +@dataclass(frozen=True, slots=True) +class BasicConsumeOk: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 21 + + consumer_tag: Arbitrary[str] = GEN_NONEMPTY_SHORTSTR + + +@dataclass(frozen=True, slots=True) +class BasicCancel: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 30 + + consumer_tag: Exact[str] = HOLE + no_wait: Arbitrary[bool] = GEN_FALSE + + +@dataclass(frozen=True, slots=True) +class BasicCancelOk: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 31 + + consumer_tag: Exact[str] = HOLE + + +@dataclass(frozen=True, slots=True) +class BasicPublish: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 40 + + exchange: Arbitrary[str] = GEN_NAME + routing_key: Arbitrary[str] = GEN_SHORTSTR + mandatory: Arbitrary[bool] = GEN_FALSE + immediate: Arbitrary[bool] = GEN_FALSE # true produces "not implemented" + properties: Arbitrary[MessageProperties | None] = GEN_NONE + body: Arbitrary[bytes] = GEN_MESSAGE_BODY + + +@dataclass(frozen=True, slots=True) +class BasicReturn: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 50 + + reply_code: Exact[int] = HOLE + reply_text: Arbitrary[str] = GEN_NONEMPTY_SHORTSTR + exchange: Exact[str] = HOLE + routing_key: Exact[str] = HOLE + properties: Arbitrary[MessageProperties | None] = GEN_NONE + body: Arbitrary[bytes] = GEN_EMPTY_BYTES + + +@dataclass(frozen=True, slots=True) +class BasicAck: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 80 + + delivery_tag: Exact[int] = HOLE + multiple: Arbitrary[bool] = GEN_FALSE + + +@dataclass(frozen=True, slots=True) +class BasicNack: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 120 + + delivery_tag: Exact[int] = HOLE + multiple: Arbitrary[bool] = GEN_FALSE + requeue: Arbitrary[bool] = GEN_FALSE + + +@dataclass(frozen=True, slots=True) +class BasicReject: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 90 + + delivery_tag: Exact[int] = HOLE + requeue: Arbitrary[bool] = GEN_FALSE + + +@dataclass(frozen=True, slots=True) +class BasicDeliver: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 60 + + consumer_tag: Exact[str] = HOLE + delivery_tag: Exact[int] = HOLE + redelivered: Arbitrary[bool] = GEN_BIT + exchange: Arbitrary[str] = GEN_NONEMPTY_NAME + routing_key: Arbitrary[str] = GEN_SHORTSTR + properties: Arbitrary[MessageProperties | None] = GEN_MESSAGE_PROPERTIES + body: Arbitrary[bytes] = GEN_MESSAGE_BODY + + +@dataclass(frozen=True, slots=True) +class BasicGet: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 70 + + queue: Arbitrary[str] = GEN_NONEMPTY_NAME + no_ack: Arbitrary[bool] = GEN_FALSE + + +@dataclass(frozen=True, slots=True) +class BasicGetOk: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 71 + + delivery_tag: Exact[int] = HOLE + redelivered: Arbitrary[bool] = GEN_BIT + exchange: Arbitrary[str] = GEN_NONEMPTY_NAME + routing_key: Arbitrary[str] = GEN_SHORTSTR + message_count: Arbitrary[int] = GEN_LONG + properties: Arbitrary[MessageProperties | None] = GEN_MESSAGE_PROPERTIES + body: Arbitrary[bytes] = GEN_MESSAGE_BODY + + +@dataclass(frozen=True, slots=True) +class BasicGetEmpty: + CLASS_ID: ClassVar[int] = ClassId.BASIC + METHOD_ID: ClassVar[int] = 72 + + +type Method = ( + ConnectionStart + | ConnectionStartOk + | ConnectionTune + | ConnectionTuneOk + | ConnectionOpen + | ConnectionOpenOk + | ConnectionClose + | ConnectionCloseOk + | ChannelOpen + | ChannelOpenOk + | ChannelClose + | ChannelCloseOk + | ConfirmSelect + | ConfirmSelectOk + | ExchangeDeclare + | ExchangeDeclareOk + | ExchangeBind + | ExchangeBindOk + | QueueDeclare + | QueueDeclareOk + | QueueBind + | QueueBindOk + | BasicQos + | BasicQosOk + | BasicConsume + | BasicConsumeOk + | BasicCancel + | BasicCancelOk + | BasicPublish + | BasicReturn + | BasicAck + | BasicNack + | BasicReject + | BasicDeliver + | BasicGet + | BasicGetOk + | BasicGetEmpty +) + + +METHOD_KINDS: tuple[type[Method], ...] = get_args(Method.__value__) + + +def is_same_kind[M: Method](other: Method, method: M) -> TypeIs[M]: + if (other.CLASS_ID, other.METHOD_ID) == (method.CLASS_ID, method.METHOD_ID): + assert type(other) is type(method) + return True + return False + + +def distinct_kinds(methods: tuple[Method, ...]) -> tuple[type[Method], ...]: + return tuple(dict.fromkeys(type(method) for method in methods)) + + +def exact_fields(kind: type[Method]) -> tuple[str, ...]: + return tuple(field.name for field in fields(kind) if field.default is HOLE) + + +def arbitrary_fields(method: Method) -> tuple[str, ...]: + return tuple( + field.name + for field in fields(method) + if field.default is not HOLE and is_concrete(getattr(method, field.name)) + ) + + +class BadPattern(ValueError): + pass + + +def validate_pattern(pattern: Method) -> None: + if names := arbitrary_fields(pattern): + raise BadPattern(f"{type(pattern).__name__} cannot be matched on {names}") + + +def set_fields[M: Method](method: M, fields: dict[str, Any]) -> M: + return type(method)(**fields) + + +def get_fields(method: Method) -> dict[str, Any]: + return {field.name: getattr(method, field.name) for field in fields(method)} + + +def is_method_pattern(method: Method) -> bool: + return any(getattr(method, field.name) is HOLE for field in fields(method)) diff --git a/src/tests/amqp_imposter/amqp_imposter/model/model_server.py b/src/tests/amqp_imposter/amqp_imposter/model/model_server.py new file mode 100644 index 00000000..ed3f3fd7 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/model_server.py @@ -0,0 +1,145 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import generators as g +from . import method_bag as mb +from . import method_sampling as msa +from . import method_scripts as scr +from . import method_unification as mu +from . import method_weights as mw +from . import methods as m +from . import model_state as ms +from . import negotiation_options as no +from . import transition_receive, transition_send + + +class ModelServer: + """ + Models a one-channel-at-a-time AMQP 0-9-1 server state machine. + """ + + def __init__( + self, + negotiation_options: no.NegotiationOptions = no.DEFAULT_NEGOTIATION_OPTIONS, + gen_state: g.GenState = g.DEFAULT_GEN_STATE, + ) -> None: + self.model_state = ms.ModelState(negotiation_options=negotiation_options) + self.gen_state = gen_state + + self._remember_when: scr.Received | scr.Sent | None = None + + def receive_protocol_header(self) -> ms.Unrepresentable | None: + match transition_receive.apply_protocol_header(self.model_state): + case ms.Unrepresentable() as unrepr: + return unrepr + + case ms.ModelState() as model_state: + self.model_state = model_state + return None + + def receive(self, method: m.Method) -> m.Method | ms.Unrepresentable: + if m.is_method_pattern(method): + return ms.Unrepresentable(method) + + receivable, gen_state = mu.concretise(method, self.gen_state) + + match transition_receive.apply(self.model_state, receivable): + case ms.Unrepresentable() as unrepr: + return unrepr + + case ms.ModelState() as model_state: + self.model_state = model_state + self.gen_state = gen_state.advance() + self._remember_when = scr.Received(receivable) + return receivable + + def send(self, method: m.Method) -> m.Method | ms.Unrepresentable: + sendable = mu.unify_any(method, self.sendables()) + + if sendable is None: + return ms.Unrepresentable(method) + + sendable, gen_state = mu.concretise(sendable, self.gen_state) + + match transition_send.apply(self.model_state, sendable): + case ms.Unrepresentable() as unrepr: + return unrepr + + case ms.ModelState() as model_state: + self.model_state = model_state + self.gen_state = gen_state.advance() + self._remember_when = scr.Sent(sendable) + return sendable + + def send_gen( + self, + script: tuple[scr.ScriptRule, ...] = (), + weighted_patterns: tuple[mw.WeightedPattern, ...] = mw.DEFAULT_WEIGHTS, + stop_weight: int = mw.STOP_WEIGHT, + ) -> m.Method | msa.Stop: + rules = ( + scr.match_rules(script, self._remember_when) + if self._remember_when is not None + else () + ) + + for rule in rules: + thens = [ + unified + for sendable in self.sendables() + if (unified := mu.unify(rule.then_send, sendable)) is not None + ] + match thens: + case [t, *ts]: + chosen, self.gen_state = g.Choice((t, *ts)).generate(self.gen_state) + concrete = self.send(chosen) + + assert not isinstance(concrete, ms.Unrepresentable), concrete + return concrete + + sampler = msa.make_sampler(self.sendables(), weighted_patterns, stop_weight) + sampled, self.gen_state = sampler.generate(self.gen_state) + + match sampled: + case msa.Stop(): + return sampled + + case method: + concrete = self.send(method) + assert not isinstance(concrete, ms.Unrepresentable), concrete + return concrete + + def send_gens( + self, + script: tuple[scr.ScriptRule, ...] = (), + weighted_patterns: tuple[mw.WeightedPattern, ...] = mw.DEFAULT_WEIGHTS, + stop_weight: int = mw.STOP_WEIGHT, + max_sends: int = msa.DEFAULT_MAX_SENDS, + ) -> list[m.Method]: + methods: list[m.Method] = [] + + while len(methods) < max_sends: + match self.send_gen(script, weighted_patterns, stop_weight): + case msa.Stop(): + break + + case method: + methods.append(method) + + self._remember_when = None + return methods + + def sendables(self) -> tuple[m.Method, ...]: + return mb.sendables(self.model_state) diff --git a/src/tests/amqp_imposter/amqp_imposter/model/model_state.py b/src/tests/amqp_imposter/amqp_imposter/model/model_state.py new file mode 100644 index 00000000..4afffcad --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/model_state.py @@ -0,0 +1,831 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections.abc import Callable, Iterator +from dataclasses import dataclass, replace + +from . import error_replies as er +from . import method_fields as mf +from . import methods as m +from .helpers.revealing_line import ( + Opaque, + Revealed, + RevealingLine, + Transparent, + is_opaque, +) +from .negotiation_options import DEFAULT_NEGOTIATION_OPTIONS, NegotiationOptions + + +@dataclass(frozen=True, slots=True) +class Unrepresentable: + method: m.Method | None = None + + +@dataclass(frozen=True, slots=True) +class MessageState: + exchange: str + routing_key: str + properties: mf.MessageProperties | None + body: bytes + + +@dataclass(frozen=True, slots=True) +class UnreturnableRoutingState: + pass + + +@dataclass(frozen=True, slots=True) +class ReturnableRoutingState: + pass + + +@dataclass(frozen=True, slots=True) +class ReturnedRoutingState: + pass + + +type RoutingState = ( + UnreturnableRoutingState | ReturnableRoutingState | ReturnedRoutingState +) + + +@dataclass(frozen=True, slots=True) +class PublishState: + confirm_seq: int | None # if not confirming + routing_state: RoutingState + message_state: MessageState + + +@dataclass(frozen=True, slots=True) +class DeliveryState: + delivery_tag: int + message_state: MessageState + + +@dataclass(frozen=True, slots=True) +class ActiveConsumerState: + consumer_tag: str + no_ack: bool + + +@dataclass(frozen=True, slots=True) +class CancelledConsumerState: + consumer_tag: str + + +@dataclass(frozen=True, slots=True) +class NoConsumerState: + pass + + +type ConsumerState = ActiveConsumerState | CancelledConsumerState | NoConsumerState + + +@dataclass(frozen=True, slots=True) +class NotConfirmingState: + pass + + +@dataclass(frozen=True, slots=True) +class ConfirmingState: + next_confirm_seq: int = 1 + + +type ConfirmState = NotConfirmingState | ConfirmingState + + +@dataclass(frozen=True, slots=True) +class ClosedChannelState: + pass + + +@dataclass(frozen=True, slots=True) +class OpenChannelState: + confirm_state: ConfirmState = NotConfirmingState() + consumer_state: ConsumerState = NoConsumerState() + + unacked_states: tuple[DeliveryState, ...] = () + unconfirmed_states: tuple[PublishState, ...] = () # confirmables and returnables + + prefetch_count: int = 0 + + next_delivery_tag: int = 1 + + +@dataclass(frozen=True, slots=True) +class ClosingChannelState: + pass + + +type ChannelState = OpenChannelState | ClosedChannelState | ClosingChannelState + + +@dataclass(frozen=True, slots=True) +class ClosedConnectionState: + pass + + +@dataclass(frozen=True, slots=True) +class GreetedConnectionState: + pass + + +@dataclass(frozen=True, slots=True) +class StartedConnectionState: + pass + + +@dataclass(frozen=True, slots=True) +class TunedConnectionState: + pass + + +@dataclass(frozen=True, slots=True) +class OpenConnectionState: + channel_state: ChannelState = ClosedChannelState() + + +@dataclass(frozen=True, slots=True) +class ClosingConnectionState: + pass + + +type ConnectionState = ( + ClosedConnectionState + | GreetedConnectionState + | StartedConnectionState + | TunedConnectionState + | OpenConnectionState + | ClosingConnectionState +) + +INITIAL_CONNECTION_STATE = ClosedConnectionState() + + +@dataclass(frozen=True, slots=True) +class KnownReplyState: + expected_methods: tuple[m.Method, ...] + causing_method: m.Method | None = None + + +@dataclass(frozen=True, slots=True) +class DeferredPublishState: + publish_state: PublishState + + +@dataclass(frozen=True, slots=True) +class DeferredReplyState: + causing_method: m.BasicGet + + +type ReplyState = KnownReplyState | DeferredReplyState +type RevealedState = ReplyState | DeferredPublishState + +EMPTY_LINE: RevealingLine[RevealedState] = RevealingLine() + + +@dataclass(frozen=True, slots=True) +class ModelState: + connection_state: ConnectionState = INITIAL_CONNECTION_STATE + + # a line of RevealedStates; only what can be observed from the head is sendable + line: RevealingLine[RevealedState] = EMPTY_LINE + + # requeued messages on standby; deliveries are always sendable + requeued_message_states: tuple[MessageState, ...] = () + + negotiation_options: NegotiationOptions = DEFAULT_NEGOTIATION_OPTIONS + + +def message_state( + method: m.BasicDeliver | m.BasicGetOk | m.BasicPublish | m.BasicReturn, +) -> MessageState: + exchange = mf.assert_concrete(method.exchange) + routing_key = mf.assert_concrete(method.routing_key) + properties = mf.assert_concrete(method.properties) + body = mf.assert_concrete(method.body) + + return MessageState( + exchange=exchange, routing_key=routing_key, properties=properties, body=body + ) + + +### ========= +### Helpers +### ========= + + +def observable_replies(state: ModelState) -> tuple[tuple[int, ReplyState], ...]: + observable, _ = state.line.split() + return tuple( + (i, revealed.item) + for i, revealed in enumerate(observable) + if isinstance(revealed.item, (KnownReplyState, DeferredReplyState)) + ) + + +def observable_publishes(state: ModelState) -> tuple[PublishState, ...]: + observable, _ = state.line.split() + return filter_deferred_publishes(observable) + + +def awaits_consume_ok(state: ModelState) -> bool: + return any( + is_opaque(revealed) + and isinstance(revealed.item, KnownReplyState) + and isinstance(revealed.item.causing_method, m.BasicConsume) + for revealed in state.line + ) + + +def has_pending_consume(state: ModelState) -> bool: + return any( + isinstance(revealed.item, KnownReplyState) + and isinstance(revealed.item.causing_method, m.BasicConsume) + for revealed in state.line + ) + + +def is_deferred_publish(revealed: Revealed[RevealedState]) -> bool: + return isinstance(revealed.item, DeferredPublishState) + + +def filter_deferred_publishes( + line: RevealingLine[RevealedState], +) -> tuple[PublishState, ...]: + return tuple( + item.publish_state + for item in line.items() + if isinstance(item, DeferredPublishState) + ) + + +def has_terminal_error(state: ModelState) -> bool: + # check if there's a forced close anywhere in line + # in our case it's both connection and channel errors (there's no multiplexing yet) + return any( + is_opaque(revealed) + and isinstance(revealed.item, KnownReplyState) + and all( + isinstance(method, (m.ChannelClose, m.ConnectionClose)) + for method in revealed.item.expected_methods + ) + for revealed in state.line + ) + + +def is_returnable(publish_state: PublishState) -> bool: + return isinstance(publish_state.routing_state, ReturnableRoutingState) + + +def is_returned(publish_state: PublishState) -> bool: + return isinstance(publish_state.routing_state, ReturnedRoutingState) + + +def is_confirmable(publish_state: PublishState) -> bool: + return publish_state.confirm_seq is not None + + +def is_confirmed_by( + publish_state: PublishState, confirm_seq: int, multiple: bool +) -> bool: + if publish_state.confirm_seq is None: + # no-confirm + return False + + if multiple: + return publish_state.confirm_seq <= confirm_seq + + return publish_state.confirm_seq == confirm_seq + + +def is_acknowledgeable( + channel_state: OpenChannelState, delivery_tag: int, multiple: bool +) -> bool: + if delivery_tag == 0: + return multiple # do not permit delivery-tag=0 with multiple=False + + return any( + delivery.delivery_tag == delivery_tag + for delivery in channel_state.unacked_states + ) + + +def has_prefetch_space( + channel_state: OpenChannelState, consumer_state: ActiveConsumerState +) -> bool: + return ( + consumer_state.no_ack + or channel_state.prefetch_count == 0 + or len(channel_state.unacked_states) < channel_state.prefetch_count + ) + + +def maybe_make_confirm_seq(channel_state: OpenChannelState) -> int | None: + match channel_state.confirm_state: + case ConfirmingState(next_confirm_seq): + return next_confirm_seq + case NotConfirmingState(): + return None + + +def advanced_confirm_state(confirm_state: ConfirmState) -> ConfirmState: + match confirm_state: + case ConfirmingState(next_confirm_seq): + return ConfirmingState(next_confirm_seq + 1) + case NotConfirmingState(): + return confirm_state + + +def confirm_seqs(publish_states: tuple[PublishState, ...]) -> tuple[int, ...]: + return tuple( + publish_state.confirm_seq + for publish_state in publish_states + if publish_state.confirm_seq is not None + ) + + +def returned_confirm_seqs(publish_states: tuple[PublishState, ...]) -> frozenset[int]: + return frozenset( + publish_state.confirm_seq + for publish_state in publish_states + if publish_state.confirm_seq is not None and is_returned(publish_state) + ) + + +def owe_reply( + state: ModelState, + reply_method: m.Method, + causing_method: m.Method | None = None, +) -> ModelState: + reply_state = KnownReplyState((reply_method,), causing_method) + line = state.line.push(Opaque(reply_state)) + state = replace(state, line=line) + return state + + +def owe_optional_reply(state: ModelState, causing_method: m.Method) -> ModelState: + to_schedule: Revealed[RevealedState] = Transparent( + KnownReplyState((), causing_method) + ) + match state.line.last(): + case Transparent(KnownReplyState((), last_causing_method)) if ( + # optimisation + # we can merge identical error replies for different transparent causing methods + # as we ne never need to correlate them back to the cause + er.optional_error_replies(last_causing_method) + == er.optional_error_replies(causing_method) + ): + line, _ = state.line.split(len(state.line) - 1) + return replace(state, line=line.push(to_schedule)) + case _: + return replace(state, line=state.line.push(to_schedule)) + + +def owe_get_reply(state: ModelState, get: m.BasicGet) -> ModelState: + state = replace( + state, line=state.line.push(Opaque(DeferredReplyState(causing_method=get))) + ) + return state + + +def owe_no_replies(state: ModelState) -> ModelState: + state = replace(state, line=EMPTY_LINE) + return state + + +def set_prefetch_count( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, + prefetch_count: int, +) -> ModelState: + channel_state = replace(channel_state, prefetch_count=prefetch_count) + connection_state = replace(connection_state, channel_state=channel_state) + state = replace(state, connection_state=connection_state) + return state + + +def owe_publish( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, + publish_state: PublishState, +) -> ModelState: + channel_state = replace( + channel_state, confirm_state=advanced_confirm_state(channel_state.confirm_state) + ) + connection_state = replace(connection_state, channel_state=channel_state) + + state = replace(state, connection_state=connection_state) + + if is_confirmable(publish_state) or is_returnable(publish_state): + line = state.line.push(Transparent(DeferredPublishState(publish_state))) + + state = replace(state, line=line) + state, _ = advance_line(state, connection_state, channel_state, 0) + return state + else: + return state + + +def add_unconfirmed( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, + publish_states: tuple[PublishState, ...], +) -> tuple[ModelState, OpenChannelState]: + unconfirmed_states = (*channel_state.unconfirmed_states, *publish_states) + + channel_state = replace( + channel_state, + unconfirmed_states=unconfirmed_states, + ) + connection_state = replace(connection_state, channel_state=channel_state) + state = replace(state, connection_state=connection_state) + return state, channel_state + + +### ========================================= +### Publish, delivery and reply bookkeeping +### ========================================= + + +def advance_line( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, + n: int, +) -> tuple[ModelState, OpenChannelState]: + retired, rest = state.line.split(n) + # drain publish_states from head so they can move to unconfirmed + drained, line = rest.split_while(is_deferred_publish) + + state = replace(state, line=line) + return add_unconfirmed( + state, + connection_state, + channel_state, + filter_deferred_publishes(retired) + filter_deferred_publishes(drained), + ) + + +def publish_indices( + line: RevealingLine[RevealedState], predicate: Callable[[PublishState], bool] +) -> Iterator[int]: + return ( + i + for i, revealed in enumerate(line) + if isinstance(revealed.item, DeferredPublishState) + and predicate(revealed.item.publish_state) + ) + + +def retire_reply(state: ModelState, i: int) -> ModelState: + match connection_state := state.connection_state: + case OpenConnectionState(channel_state=OpenChannelState() as channel_state): + state, _ = advance_line(state, connection_state, channel_state, i + 1) + return state + case _: + retired, line = state.line.split(i + 1) + + assert not filter_deferred_publishes(retired), ( + "deferred publishes in non-open channel" + ) + return replace(state, line=line) + + +def maybe_track_delivery( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, + no_ack: bool, + delivery_state: DeliveryState, +) -> ModelState: + next_delivery_tag = channel_state.next_delivery_tag + 1 + unacked_states = ( + channel_state.unacked_states + if no_ack + else (*channel_state.unacked_states, delivery_state) + ) + + channel_state = replace( + channel_state, + next_delivery_tag=next_delivery_tag, + unacked_states=unacked_states, + ) + connection_state = replace(connection_state, channel_state=channel_state) + state = replace(state, connection_state=connection_state) + return state + + +def retire_requeued(state: ModelState, message: MessageState) -> ModelState: + requeued_message_states = state.requeued_message_states + + # find a requeued mesage to retire + for i, requeued in enumerate(requeued_message_states): + if requeued == message: + remaining = requeued_message_states[:i] + requeued_message_states[i + 1 :] + return replace(state, requeued_message_states=remaining) + + return state + + +def return_unconfirmed( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, + message: MessageState, +) -> ModelState: + + def correlate(publish_state: PublishState) -> bool: + return is_returnable(publish_state) and publish_state.message_state == message + + n = ( # find the correlated publish + 0 + if any(map(correlate, channel_state.unconfirmed_states)) + else next((i + 1 for i in publish_indices(state.line, correlate)), 0) + ) + state, channel_state = advance_line(state, connection_state, channel_state, n) + + unconfirmed_states = channel_state.unconfirmed_states + i = next( + ( + i + for i, publish_state in enumerate(unconfirmed_states) + if correlate(publish_state) + ), + None, + ) + if i is None: + return state + + publish_state = unconfirmed_states[i] + + if is_confirmable(publish_state): + unconfirmed_states = ( + *unconfirmed_states[:i], + replace(publish_state, routing_state=ReturnedRoutingState()), + *unconfirmed_states[i + 1 :], + ) + else: + unconfirmed_states = ( + *unconfirmed_states[:i], + *unconfirmed_states[i + 1 :], + ) + + channel_state = replace(channel_state, unconfirmed_states=unconfirmed_states) + connection_state = replace(connection_state, channel_state=channel_state) + state = replace(state, connection_state=connection_state) + return state + + +def retire_unconfirmed( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, + confirm_seq: int, + multiple: bool, +) -> ModelState: + def is_confirmed(publish_state: PublishState) -> bool: + return is_confirmed_by(publish_state, confirm_seq, multiple) + + n = max((i + 1 for i in publish_indices(state.line, is_confirmed)), default=0) + state, channel_state = advance_line(state, connection_state, channel_state, n) + + unconfirmed_states = tuple( + publish_state + for publish_state in channel_state.unconfirmed_states + if not is_confirmed(publish_state) + ) + channel_state = replace(channel_state, unconfirmed_states=unconfirmed_states) + connection_state = replace(connection_state, channel_state=channel_state) + state = replace(state, connection_state=connection_state) + return state + + +def filter_deliveries( + channel_state: OpenChannelState, delivery_tag: int, multiple: bool +) -> tuple[DeliveryState, ...]: + if multiple and delivery_tag == 0: # acknowledge-all + return channel_state.unacked_states + + if multiple: + return tuple( + delivery + for delivery in channel_state.unacked_states + if delivery.delivery_tag <= delivery_tag + ) + else: + return tuple( + delivery + for delivery in channel_state.unacked_states + if delivery.delivery_tag == delivery_tag + ) + + +def retire_deliveries( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, + deliveries: tuple[DeliveryState, ...], +) -> ModelState: + assert all(delivery in channel_state.unacked_states for delivery in deliveries) + + unacked_states = tuple( + unacked_state + for unacked_state in channel_state.unacked_states + if unacked_state not in deliveries + ) + channel_state = replace(channel_state, unacked_states=unacked_states) + connection_state = replace(connection_state, channel_state=channel_state) + state = replace(state, connection_state=connection_state) + return state + + +def requeue_deliveries( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, + deliveries: tuple[DeliveryState, ...], +) -> ModelState: + requeued_message_states = state.requeued_message_states + tuple( + delivery.message_state for delivery in deliveries + ) + state = replace(state, requeued_message_states=requeued_message_states) + state = retire_deliveries(state, connection_state, channel_state, deliveries) + return state + + +### ========================== +### Simple state transitions +### ========================== + + +def to_confirming_state( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, +) -> ModelState: + match channel_state.confirm_state: + case ConfirmingState(): + return state + + case NotConfirmingState(): + channel_state = replace(channel_state, confirm_state=ConfirmingState()) + connection_state = replace(connection_state, channel_state=channel_state) + state = replace(state, connection_state=connection_state) + return state + + +# N.b. unused parameter at runtime gives nice type-checking and documentation +def to_no_consumer_state( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, + consumer_state: ActiveConsumerState | CancelledConsumerState, +) -> ModelState: + channel_state = replace(channel_state, consumer_state=NoConsumerState()) + connection_state = replace(connection_state, channel_state=channel_state) + state = replace(state, connection_state=connection_state) + return state + + +def to_cancelled_consumer_state( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, + consumer_state: ActiveConsumerState, +) -> ModelState: + channel_state = replace( + channel_state, + consumer_state=CancelledConsumerState(consumer_state.consumer_tag), + ) + connection_state = replace(connection_state, channel_state=channel_state) + state = replace(state, connection_state=connection_state) + return state + + +def to_active_consumer_state( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, + consumer_state: CancelledConsumerState | NoConsumerState, + consumer_tag: str, + no_ack: bool, +) -> ModelState: + channel_state = replace( + channel_state, + consumer_state=ActiveConsumerState(consumer_tag=consumer_tag, no_ack=no_ack), + ) + connection_state = replace(connection_state, channel_state=channel_state) + state = replace(state, connection_state=connection_state) + return state + + +def to_open_channel_state( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: ClosedChannelState, +) -> ModelState: + connection_state = replace(connection_state, channel_state=OpenChannelState()) + state = replace(state, connection_state=connection_state) + return state + + +def to_closing_channel_state( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: OpenChannelState, +) -> ModelState: + state = owe_no_replies(state) + state = requeue_deliveries( + state, connection_state, channel_state, channel_state.unacked_states + ) + connection_state = replace(connection_state, channel_state=ClosingChannelState()) + state = replace(state, connection_state=connection_state) + return state + + +def to_closed_channel_state( + state: ModelState, + connection_state: OpenConnectionState, + channel_state: ClosingChannelState, +) -> ModelState: + state = owe_no_replies(state) + connection_state = replace(connection_state, channel_state=ClosedChannelState()) + state = replace(state, connection_state=connection_state) + return state + + +def to_closing_connection_state( + state: ModelState, connection_state: TunedConnectionState | OpenConnectionState +) -> ModelState: + state = owe_no_replies(state) + + match connection_state: + case OpenConnectionState(channel_state=OpenChannelState() as channel_state): + state = requeue_deliveries( + state, connection_state, channel_state, channel_state.unacked_states + ) + case _: + pass + + state = replace(state, connection_state=ClosingConnectionState()) + return state + + +def to_closed_connection_state( + state: ModelState, connection_state: ClosingConnectionState +) -> ModelState: + state = owe_no_replies(state) + state = replace(state, connection_state=ClosedConnectionState()) + return state + + +def to_greeted_connection_state( + state: ModelState, connection_state: ClosedConnectionState +) -> ModelState: + state = owe_no_replies(state) + state = replace(state, connection_state=GreetedConnectionState()) + return state + + +def to_started_connection_state( + state: ModelState, connection_state: GreetedConnectionState +) -> ModelState: + state = owe_no_replies(state) + state = replace(state, connection_state=StartedConnectionState()) + return state + + +def to_tuned_connection_state( + state: ModelState, connection_state: StartedConnectionState +) -> ModelState: + state = owe_no_replies(state) + state = replace(state, connection_state=TunedConnectionState()) + return state + + +def to_open_connection_state( + state: ModelState, connection_state: TunedConnectionState +) -> ModelState: + state = owe_no_replies(state) + state = replace(state, connection_state=OpenConnectionState()) + return state + + +type TransitionOutcome = ModelState | Unrepresentable diff --git a/src/tests/amqp_imposter/amqp_imposter/model/negotiation_options.py b/src/tests/amqp_imposter/amqp_imposter/model/negotiation_options.py new file mode 100644 index 00000000..f78de91b --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/negotiation_options.py @@ -0,0 +1,48 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass + +from ..helpers.nonempty_tuple import NonEmptyTuple +from .constants import ( + DEFAULT_BASIC_NACK, + DEFAULT_CHANNEL_MAX, + DEFAULT_CONSUMER_CANCEL_NOTIFY, + DEFAULT_FRAME_MAX, + DEFAULT_HEARTBEAT, + DEFAULT_LOCALES, + DEFAULT_MECHANISMS, + DEFAULT_PUBLISHER_CONFIRMS, +) + + +@dataclass(frozen=True, slots=True) +class NegotiationOptions: + """ + Configures how the server initiates ConnectionStart and ConnectionTune. + The server will only accept exact matches of these values in the replies. + """ + + mechanisms: NonEmptyTuple[str] = DEFAULT_MECHANISMS + locales: NonEmptyTuple[str] = DEFAULT_LOCALES + publisher_confirms: bool = DEFAULT_PUBLISHER_CONFIRMS + basic_nack: bool = DEFAULT_BASIC_NACK + consumer_cancel_notify: bool = DEFAULT_CONSUMER_CANCEL_NOTIFY + channel_max: int = DEFAULT_CHANNEL_MAX + frame_max: int = DEFAULT_FRAME_MAX + heartbeat: int = DEFAULT_HEARTBEAT + + +DEFAULT_NEGOTIATION_OPTIONS = NegotiationOptions() diff --git a/src/tests/amqp_imposter/amqp_imposter/model/transition_receive.py b/src/tests/amqp_imposter/amqp_imposter/model/transition_receive.py new file mode 100644 index 00000000..27780928 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/transition_receive.py @@ -0,0 +1,530 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import error_replies as er +from . import method_fields as mf +from . import methods as m +from . import model_state as ms +from .constants import ( + DELIVERY_MODES, + PRIORITY_MAX, + PRIORITY_MIN, + PROTOCOL_MAJOR, + PROTOCOL_MINOR, +) +from .method_fields import assert_concrete + + +def apply(state: ms.ModelState, method: m.Method) -> ms.TransitionOutcome: + match connection_state := state.connection_state: + case ms.ClosedConnectionState(): + return ms.Unrepresentable(method) + case ms.GreetedConnectionState(): + return apply_greeted(state, connection_state, method) + case ms.StartedConnectionState(): + return apply_started(state, connection_state, method) + case ms.TunedConnectionState(): + return apply_tuned(state, connection_state, method) + case ms.OpenConnectionState(): + return apply_open(state, connection_state, method) + case ms.ClosingConnectionState(): + return apply_closing(state, connection_state, method) + + +def apply_protocol_header(state: ms.ModelState) -> ms.TransitionOutcome: + match state.connection_state: + case ms.ClosedConnectionState(): + state = ms.to_greeted_connection_state(state, state.connection_state) + state = ms.owe_reply( + state, + m.ConnectionStart( + version_major=PROTOCOL_MAJOR, + version_minor=PROTOCOL_MINOR, + mechanisms=state.negotiation_options.mechanisms, + locales=state.negotiation_options.locales, + publisher_confirms=state.negotiation_options.publisher_confirms, + basic_nack=state.negotiation_options.basic_nack, + consumer_cancel_notify=state.negotiation_options.consumer_cancel_notify, + ), + ) + return state + case _: + return ms.Unrepresentable() + + +def owe_error( + state: ms.ModelState, reply_code: mf.ReplyCode, causing_method: m.Method +) -> ms.ModelState: + return ms.owe_reply( + state, er.error_reply(reply_code, mf.FORCED_REPLY_TEXT, causing_method) + ) + + +def method_queue_declare_ok(method: m.QueueDeclare) -> m.QueueDeclareOk: + queue = assert_concrete(method.queue) + + if queue == "": + # anonymous queue; owe a reply with arbitrary queue name + return m.QueueDeclareOk() + else: + return m.QueueDeclareOk(queue=queue) + + +def method_basic_consume_ok(consumer_tag: str) -> m.BasicConsumeOk: + if consumer_tag == "": + # anonymous consumer; owe a reply with arbitrary consumer tag + return m.BasicConsumeOk() + else: + return m.BasicConsumeOk(consumer_tag=consumer_tag) + + +def apply_greeted( + state: ms.ModelState, connection_state: ms.GreetedConnectionState, method: m.Method +) -> ms.TransitionOutcome: + options = state.negotiation_options + + match method: + case m.ConnectionStartOk() if ( + assert_concrete(method.mechanism) in options.mechanisms + and assert_concrete(method.locale) in options.locales + ): + state = ms.to_started_connection_state(state, connection_state) + state = ms.owe_reply( + state, + m.ConnectionTune( + channel_max=options.channel_max, + frame_max=options.frame_max, + heartbeat=options.heartbeat, + ), + ) + return state + case _: + return ms.Unrepresentable(method) + + +def apply_started( + state: ms.ModelState, + connection_state: ms.StartedConnectionState, + method: m.Method, +) -> ms.TransitionOutcome: + options = state.negotiation_options + + match method: + case m.ConnectionTuneOk() if ( + method.channel_max, + method.frame_max, + method.heartbeat, + ) == (options.channel_max, options.frame_max, options.heartbeat): + state = ms.to_tuned_connection_state(state, connection_state) + return state + case _: + return ms.Unrepresentable(method) + + +def apply_tuned( + state: ms.ModelState, + connection_state: ms.TunedConnectionState, + method: m.Method, +) -> ms.TransitionOutcome: + match method: + case m.ConnectionOpen(): + # not an open connection just yet; cannot be pipelined with e.g. Channel.Open + state = ms.owe_reply(state, m.ConnectionOpenOk(), method) + return state + case _: + return ms.Unrepresentable(method) + + +def apply_closing( + state: ms.ModelState, + connection_state: ms.ClosingConnectionState, + method: m.Method, +) -> ms.TransitionOutcome: + match method: + case m.ConnectionClose(): + state = ms.owe_no_replies(state) + state = ms.owe_reply(state, m.ConnectionCloseOk(), method) + return state + case m.ConnectionCloseOk(): + return ms.to_closed_connection_state(state, connection_state) + case _: + return state + + +def apply_open( + state: ms.ModelState, connection_state: ms.OpenConnectionState, method: m.Method +) -> ms.TransitionOutcome: + match method: + case ( + m.ConnectionStart() + | m.ConnectionStartOk() + | m.ConnectionTune() + | m.ConnectionTuneOk() + | m.ConnectionOpen() + | m.ConnectionOpenOk() + | m.ConnectionCloseOk() + ): + return ms.Unrepresentable(method) + case m.ConnectionClose(): + state = ms.to_closing_connection_state(state, connection_state) + state = ms.owe_reply(state, m.ConnectionCloseOk(), method) + return state + case _: + return apply_channel(state, connection_state, method) + + +def apply_channel( + state: ms.ModelState, connection_state: ms.OpenConnectionState, method: m.Method +) -> ms.TransitionOutcome: + match channel_state := connection_state.channel_state: + case ms.OpenChannelState(): + return apply_open_channel(state, connection_state, channel_state, method) + case ms.ClosingChannelState(): + return apply_closing_channel(state, connection_state, channel_state, method) + case ms.ClosedChannelState(): + return apply_closed_channel(state, connection_state, channel_state, method) + + +def apply_closed_channel( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.ClosedChannelState, + method: m.Method, +) -> ms.TransitionOutcome: + match method: + case m.ChannelOpen(): + state = ms.to_open_channel_state(state, connection_state, channel_state) + state = ms.owe_reply(state, m.ChannelOpenOk(), method) + return state + case m.ChannelCloseOk(): + return ms.Unrepresentable(method) + case _: + state = owe_error(state, mf.ReplyCode.CHANNEL_ERROR, method) + return state + + +def apply_closing_channel( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.ClosingChannelState, + method: m.Method, +) -> ms.TransitionOutcome: + match method: + case m.ChannelClose(): + state = ms.owe_reply(state, m.ChannelCloseOk()) + return state + case m.ChannelCloseOk(): + state = ms.to_closed_channel_state(state, connection_state, channel_state) + return state + case _: + return state + + +def apply_open_channel( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.Method, +) -> ms.TransitionOutcome: + if isinstance(method, m.ChannelCloseOk): + return ms.Unrepresentable(method) + + if ms.has_terminal_error(state): + return state + + match method: + case m.ChannelOpen(): + state = owe_error(state, mf.ReplyCode.CHANNEL_ERROR, method) + return state + case m.ChannelClose(): + state = ms.to_closing_channel_state(state, connection_state, channel_state) + state = ms.owe_reply(state, m.ChannelCloseOk()) + return state + case m.ConfirmSelect(): + return apply_confirm_select(state, connection_state, channel_state, method) + case m.ExchangeDeclare(): + return apply_unless_no_wait(state, method, m.ExchangeDeclareOk()) + case m.ExchangeBind(): + return apply_unless_no_wait(state, method, m.ExchangeBindOk()) + case m.QueueDeclare(): + return apply_unless_no_wait(state, method, method_queue_declare_ok(method)) + case m.QueueBind(): + return apply_unless_no_wait(state, method, m.QueueBindOk()) + case m.BasicQos(): + return apply_basic_qos(state, method) + case m.BasicConsume(): + return apply_basic_consume(state, connection_state, channel_state, method) + case m.BasicCancel(): + return apply_basic_cancel(state, connection_state, channel_state, method) + case m.BasicGet(): + return ms.owe_get_reply(state, method) + case m.BasicPublish(): + return apply_basic_publish(state, connection_state, channel_state, method) + case m.BasicAck(): + return apply_ack(state, connection_state, channel_state, method) + case m.BasicNack(): + return apply_nack(state, connection_state, channel_state, method) + case m.BasicReject(): + return apply_reject(state, connection_state, channel_state, method) + case _: + return ms.Unrepresentable(method) + + +def apply_unless_no_wait( + state: ms.ModelState, + causing_method: m.ExchangeDeclare | m.ExchangeBind | m.QueueDeclare | m.QueueBind, + reply_method: m.Method, +) -> ms.TransitionOutcome: + # a received method must define no-wait + # we should not be here if it doesn't + no_wait = assert_concrete(causing_method.no_wait) + + if no_wait: + state = ms.owe_optional_reply(state, causing_method) + return state + else: + state = ms.owe_reply(state, reply_method, causing_method) + return state + + +def apply_confirm_select( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.ConfirmSelect, +) -> ms.TransitionOutcome: + no_wait = assert_concrete(method.no_wait) + + state = ms.to_confirming_state(state, connection_state, channel_state) + + if no_wait: + return state + else: + state = ms.owe_reply(state, m.ConfirmSelectOk(), method) + return state + + +def apply_basic_qos(state: ms.ModelState, method: m.BasicQos) -> ms.TransitionOutcome: + global_qos = assert_concrete(method.global_qos) + prefetch_size = assert_concrete(method.prefetch_size) + + if global_qos: + return ms.Unrepresentable(method) + + if prefetch_size != 0: + state = owe_error(state, mf.ReplyCode.NOT_IMPLEMENTED, method) + return state + else: + state = ms.owe_reply(state, m.BasicQosOk(), method) + return state + + +def apply_basic_consume( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.BasicConsume, +) -> ms.TransitionOutcome: + consumer_tag = assert_concrete(method.consumer_tag) + no_wait = assert_concrete(method.no_wait) + + match consumer_state := channel_state.consumer_state: + case ms.ActiveConsumerState(): + return ms.Unrepresentable(method) + + case ms.CancelledConsumerState() | ms.NoConsumerState(): + if ms.awaits_consume_ok(state): + return ms.Unrepresentable(method) + + match no_wait, consumer_tag: + case True, "": + return ms.Unrepresentable(method) + case True, _: + no_ack = assert_concrete(method.no_ack) + state = ms.to_active_consumer_state( + state, + connection_state, + channel_state, + consumer_state, + consumer_tag, + no_ack, + ) + return ms.owe_optional_reply(state, method) + case False, _: + state = ms.owe_reply( + state, method_basic_consume_ok(consumer_tag), method + ) + return state + + +def apply_basic_cancel( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.BasicCancel, +) -> ms.TransitionOutcome: + no_wait = assert_concrete(method.no_wait) + consumer_tag = assert_concrete(method.consumer_tag) + + match consumer_state := channel_state.consumer_state: + case ms.ActiveConsumerState() | ms.CancelledConsumerState(): + # possibly reconcile our cancel with the client's + if consumer_state.consumer_tag != consumer_tag: + return ms.Unrepresentable(method) + + if no_wait: + return ms.to_no_consumer_state( + state, connection_state, channel_state, consumer_state + ) + else: + return ms.owe_reply( + state, m.BasicCancelOk(consumer_tag=consumer_tag), method + ) + case ms.NoConsumerState(): + return ms.Unrepresentable(method) + + +def apply_basic_publish( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.BasicPublish, +) -> ms.TransitionOutcome: + immediate = assert_concrete(method.immediate) + properties = assert_concrete(method.properties) + mandatory = assert_concrete(method.mandatory) + routing_key = assert_concrete(method.routing_key) + exchange = assert_concrete(method.exchange) + body = assert_concrete(method.body) + + if immediate: + state = owe_error(state, mf.ReplyCode.NOT_IMPLEMENTED, method) + return state + + if not validate_properties(properties): + # TODO should we send PRECONDITION_FAILED instead + return ms.Unrepresentable(method) + + state = ms.owe_optional_reply(state, method) + + # if not mandatory, we know straight away it won't be returned + routing_state = ( + ms.ReturnableRoutingState() if mandatory else ms.UnreturnableRoutingState() + ) + confirm_seq = ms.maybe_make_confirm_seq(channel_state) + + state = ms.owe_publish( + state, + connection_state, + channel_state, + ms.PublishState( + message_state=ms.MessageState( + exchange=exchange, + routing_key=routing_key, + properties=properties, + body=body, + ), + confirm_seq=confirm_seq, + routing_state=routing_state, + ), + ) + return state + + +def validate_priority(priority: int | None) -> bool: + return priority is None or PRIORITY_MIN <= priority <= PRIORITY_MAX + + +def validate_delivery_mode(delivery_mode: int | None) -> bool: + return delivery_mode is None or delivery_mode in DELIVERY_MODES + + +def validate_properties(properties: mf.MessageProperties | None) -> bool: + if properties is None: + return True + + return validate_priority(properties.priority) and validate_delivery_mode( + properties.delivery_mode + ) + + +def apply_ack( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.BasicAck, +) -> ms.TransitionOutcome: + multiple = assert_concrete(method.multiple) + delivery_tag = assert_concrete(method.delivery_tag) + + if not ms.is_acknowledgeable(channel_state, delivery_tag, multiple): + state = owe_error(state, mf.ReplyCode.PRECONDITION_FAILED, method) + return state + + state = ms.retire_deliveries( + state, + connection_state, + channel_state, + ms.filter_deliveries(channel_state, delivery_tag, multiple), + ) + return state + + +def apply_nack( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.BasicNack, +) -> ms.TransitionOutcome: + multiple = assert_concrete(method.multiple) + requeue = assert_concrete(method.requeue) + delivery_tag = assert_concrete(method.delivery_tag) + + if not ms.is_acknowledgeable(channel_state, delivery_tag, multiple): + state = owe_error(state, mf.ReplyCode.PRECONDITION_FAILED, method) + return state + + filtered = ms.filter_deliveries(channel_state, delivery_tag, multiple) + + if requeue: + state = ms.requeue_deliveries(state, connection_state, channel_state, filtered) + return state + else: + state = ms.retire_deliveries(state, connection_state, channel_state, filtered) + return state + + +def apply_reject( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.BasicReject, +) -> ms.TransitionOutcome: + multiple = False + requeue = assert_concrete(method.requeue) + delivery_tag = assert_concrete(method.delivery_tag) + + if not ms.is_acknowledgeable(channel_state, delivery_tag, multiple): + state = owe_error(state, mf.ReplyCode.PRECONDITION_FAILED, method) + return state + + filtered = ms.filter_deliveries(channel_state, delivery_tag, multiple) + + if requeue: + state = ms.requeue_deliveries(state, connection_state, channel_state, filtered) + return state + else: + state = ms.retire_deliveries(state, connection_state, channel_state, filtered) + return state diff --git a/src/tests/amqp_imposter/amqp_imposter/model/transition_send.py b/src/tests/amqp_imposter/amqp_imposter/model/transition_send.py new file mode 100644 index 00000000..c9a47398 --- /dev/null +++ b/src/tests/amqp_imposter/amqp_imposter/model/transition_send.py @@ -0,0 +1,294 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from . import method_bag as mb +from . import method_unification as mu +from . import methods as m +from . import model_state as ms +from .method_fields import assert_concrete + + +def apply(state: ms.ModelState, method: m.Method) -> ms.TransitionOutcome: + state, causing_method = take_reply(state, method) + state = apply_connection(state, method, causing_method) + return state + + +def take_reply( + state: ms.ModelState, method: m.Method +) -> tuple[ms.ModelState, m.Method | None]: + for i, reply in ms.observable_replies(state): + # check if we can unify with any of the expected methods + expected_method = mu.unify_any(method, mb.expected_methods(state, reply)) + + if expected_method is not None: + state = ms.retire_reply(state, i) + return state, reply.causing_method + + return state, None + + +def apply_connection( + state: ms.ModelState, method: m.Method, causing_method: m.Method | None +) -> ms.ModelState: + connection_state = state.connection_state + + match connection_state, method: + case ms.TunedConnectionState(), m.ConnectionOpenOk(): + state = ms.to_open_connection_state(state, connection_state) + return state + case ms.ClosingConnectionState(), m.ConnectionCloseOk(): + state = ms.to_closed_connection_state(state, connection_state) + return state + case ( + (ms.TunedConnectionState() | ms.OpenConnectionState()), + m.ConnectionClose(), + ): + state = ms.to_closing_connection_state(state, connection_state) + return state + case ms.OpenConnectionState(), _: + return apply_open(state, connection_state, method, causing_method) + case _: + return state + + +def apply_open( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + method: m.Method, + causing_method: m.Method | None, +) -> ms.ModelState: + channel_state = connection_state.channel_state + + match channel_state, method: + case ms.ClosingChannelState(), m.ChannelCloseOk(): + state = ms.to_closed_channel_state(state, connection_state, channel_state) + return state + case ms.OpenChannelState(), _: + state = apply_open_channel( + state, connection_state, channel_state, method, causing_method + ) + return state + case _: + return state + + +def apply_open_channel( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.Method, + causing_method: m.Method | None, +) -> ms.ModelState: + match method: + case m.ChannelClose(): + return ms.to_closing_channel_state(state, connection_state, channel_state) + case m.BasicQosOk(): + return apply_basic_qos_ok( + state, connection_state, channel_state, causing_method + ) + case m.BasicConsumeOk(): + return apply_basic_consume_ok( + state, connection_state, channel_state, method, causing_method + ) + case m.BasicCancel(): + return apply_basic_cancel(state, connection_state, channel_state) + case m.BasicCancelOk(): + return apply_basic_cancel_ok(state, connection_state, channel_state, method) + case m.BasicDeliver(): + return apply_basic_deliver(state, connection_state, channel_state, method) + case m.BasicGetOk(): + assert isinstance(causing_method, m.BasicGet), causing_method + return apply_basic_get_ok( + state, connection_state, channel_state, method, causing_method + ) + case m.BasicReturn(): + return apply_basic_return(state, connection_state, channel_state, method) + case m.BasicAck() | m.BasicNack(): + return apply_acknowledgement(state, connection_state, channel_state, method) + case _: + return state + + +def apply_basic_qos_ok( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + causing_method: m.Method | None, +) -> ms.ModelState: + match causing_method: + case m.BasicQos(): + prefetch_count = assert_concrete(causing_method.prefetch_count) + state = ms.set_prefetch_count( + state, connection_state, channel_state, prefetch_count + ) + return state + case _: + return state + + +def apply_basic_consume_ok( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.BasicConsumeOk, + causing_method: m.Method | None, +) -> ms.ModelState: + consumer_state = channel_state.consumer_state + + match consumer_state, causing_method: + case ((ms.CancelledConsumerState() | ms.NoConsumerState()), m.BasicConsume()): + consumer_tag = assert_concrete(method.consumer_tag) + no_ack = assert_concrete(causing_method.no_ack) + + state = ms.to_active_consumer_state( + state, + connection_state, + channel_state, + consumer_state, + consumer_tag, + no_ack, + ) + return state + case _: + return state + + +def apply_basic_cancel( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, +) -> ms.ModelState: + consumer_state = channel_state.consumer_state + + match consumer_state: + case ms.ActiveConsumerState(): + # the client may not know yet, so remember what we cancelled + state = ms.to_cancelled_consumer_state( + state, connection_state, channel_state, consumer_state + ) + return state + case ms.CancelledConsumerState() | ms.NoConsumerState(): + return state + + +def apply_basic_cancel_ok( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.BasicCancelOk, +) -> ms.ModelState: + consumer_state = channel_state.consumer_state + consumer_tag = assert_concrete(method.consumer_tag) + + match consumer_state: + case ms.ActiveConsumerState() | ms.CancelledConsumerState() if ( + consumer_state.consumer_tag == consumer_tag + ): + state = ms.to_no_consumer_state( + state, connection_state, channel_state, consumer_state + ) + return state + case ms.ActiveConsumerState() | ms.CancelledConsumerState(): + return state + case ms.NoConsumerState(): + return state + + +def apply_basic_deliver( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.BasicDeliver, +) -> ms.ModelState: + consumer_state = channel_state.consumer_state + + match consumer_state: + case ms.ActiveConsumerState(): + return apply_delivery( + state, + connection_state, + channel_state, + method, + consumer_state.no_ack, + ) + case ms.CancelledConsumerState() | ms.NoConsumerState(): + return state + + +def apply_basic_get_ok( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.BasicGetOk, + causing_method: m.BasicGet, +) -> ms.ModelState: + return apply_delivery( + state, + connection_state, + channel_state, + method, + assert_concrete(causing_method.no_ack), + ) + + +def apply_delivery( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.BasicDeliver | m.BasicGetOk, + no_ack: bool, +) -> ms.ModelState: + redelivered = assert_concrete(method.redelivered) + delivery_tag = assert_concrete(method.delivery_tag) + message_state = ms.message_state(method) + delivery_state = ms.DeliveryState( + delivery_tag=delivery_tag, + message_state=message_state, + ) + if redelivered: + state = ms.retire_requeued(state, message_state) + + state = ms.maybe_track_delivery( + state, connection_state, channel_state, no_ack, delivery_state + ) + return state + + +def apply_basic_return( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.BasicReturn, +) -> ms.ModelState: + state = ms.return_unconfirmed( + state, connection_state, channel_state, ms.message_state(method) + ) + return state + + +def apply_acknowledgement( + state: ms.ModelState, + connection_state: ms.OpenConnectionState, + channel_state: ms.OpenChannelState, + method: m.BasicAck | m.BasicNack, +) -> ms.ModelState: + multiple = assert_concrete(method.multiple) + delivery_tag = assert_concrete(method.delivery_tag) + + state = ms.retire_unconfirmed( + state, connection_state, channel_state, delivery_tag, multiple + ) + return state diff --git a/src/tests/amqp_imposter/amqp_imposter/py.typed b/src/tests/amqp_imposter/amqp_imposter/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/proto/amqp_imposter/control/v1/imposter.proto b/src/tests/amqp_imposter/proto/amqp_imposter/control/v1/imposter.proto new file mode 100644 index 00000000..c17f332b --- /dev/null +++ b/src/tests/amqp_imposter/proto/amqp_imposter/control/v1/imposter.proto @@ -0,0 +1,562 @@ +// Copyright 2026 Bloomberg Finance L.P. +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; + +package amqp_imposter.control.v1; + +import "google/protobuf/timestamp.proto"; + +message Void {} + +message StringList { + repeated string items = 1; +} + +message AmqpValue { + oneof value { + bool boolean = 1; + int64 long = 2; + string string = 3; + bytes bytes = 4; + string decimal = 5; + google.protobuf.Timestamp timestamp = 6; + Void void = 7; + AmqpTable table = 8; + AmqpArray array = 9; + } +} + +message AmqpTable { + map entries = 1; +} + +message AmqpArray { + repeated AmqpValue items = 1; +} + +message MessageProperties { + optional string content_type = 1; + optional string content_encoding = 2; + optional AmqpTable headers = 3; + optional uint32 delivery_mode = 4; + optional uint32 priority = 5; + optional string correlation_id = 6; + optional string reply_to = 7; + optional string expiration = 8; + optional string message_id = 9; + optional uint64 timestamp = 10; + optional string type = 11; + optional string user_id = 12; + optional string app_id = 13; +} + +message NegotiationOptions { + optional StringList mechanisms = 1; + optional StringList locales = 2; + optional bool publisher_confirms = 3; + optional bool basic_nack = 4; + optional bool consumer_cancel_notify = 5; + optional uint32 channel_max = 6; + optional uint32 frame_max = 7; + optional uint32 heartbeat = 8; +} + +message ConnectionStart { + uint32 version_major = 1; + uint32 version_minor = 2; + repeated string mechanisms = 3; + repeated string locales = 4; + bool publisher_confirms = 5; + bool basic_nack = 6; + bool consumer_cancel_notify = 7; +} +message ConnectionStartPattern { + optional uint32 version_major = 1; + optional uint32 version_minor = 2; + optional StringList mechanisms = 3; + optional StringList locales = 4; + optional bool publisher_confirms = 5; + optional bool basic_nack = 6; + optional bool consumer_cancel_notify = 7; +} + +message ConnectionStartOk { + string mechanism = 1; + string locale = 2; + string response = 3; +} +message ConnectionStartOkPattern { + optional string mechanism = 1; + optional string locale = 2; + reserved 3; +} + +message ConnectionTune { + uint32 channel_max = 1; + uint32 frame_max = 2; + uint32 heartbeat = 3; +} +message ConnectionTunePattern { + optional uint32 channel_max = 1; + optional uint32 frame_max = 2; + optional uint32 heartbeat = 3; +} + +message ConnectionTuneOk { + uint32 channel_max = 1; + uint32 frame_max = 2; + uint32 heartbeat = 3; +} +message ConnectionTuneOkPattern { + optional uint32 channel_max = 1; + optional uint32 frame_max = 2; + optional uint32 heartbeat = 3; +} + +message ConnectionOpen { + string vhost = 1; +} +message ConnectionOpenPattern { + reserved 1; +} + +message ConnectionOpenOk {} +message ConnectionOpenOkPattern {} + +message ConnectionClose { + uint32 reply_code = 1; + string reply_text = 2; + uint32 class_id = 3; + uint32 method_id = 4; +} +message ConnectionClosePattern { + optional uint32 reply_code = 1; + optional string reply_text = 2; + optional uint32 class_id = 3; + optional uint32 method_id = 4; +} + +message ConnectionCloseOk {} +message ConnectionCloseOkPattern {} + +message ChannelOpen {} +message ChannelOpenPattern {} + +message ChannelOpenOk {} +message ChannelOpenOkPattern {} + +message ChannelClose { + uint32 reply_code = 1; + string reply_text = 2; + uint32 class_id = 3; + uint32 method_id = 4; +} +message ChannelClosePattern { + optional uint32 reply_code = 1; + optional string reply_text = 2; + optional uint32 class_id = 3; + optional uint32 method_id = 4; +} + +message ChannelCloseOk {} +message ChannelCloseOkPattern {} + +message ConfirmSelect { + bool no_wait = 1; +} +message ConfirmSelectPattern { + reserved 1; +} + +message ConfirmSelectOk {} +message ConfirmSelectOkPattern {} + +message ExchangeDeclare { + string exchange = 1; + string exchange_type = 2; + bool passive = 3; + bool durable = 4; + bool auto_delete = 5; + bool internal = 6; + bool no_wait = 7; + optional AmqpTable arguments = 8; +} +message ExchangeDeclarePattern { + optional string exchange = 1; + reserved 2 to 8; +} + +message ExchangeDeclareOk {} +message ExchangeDeclareOkPattern {} + +message ExchangeBind { + string destination = 1; + string source = 2; + string routing_key = 3; + bool no_wait = 4; + optional AmqpTable arguments = 5; +} +message ExchangeBindPattern { + reserved 1 to 5; +} + +message ExchangeBindOk {} +message ExchangeBindOkPattern {} + +message QueueDeclare { + string queue = 1; + bool passive = 2; + bool durable = 3; + bool exclusive = 4; + bool auto_delete = 5; + bool no_wait = 6; + optional AmqpTable arguments = 7; +} +message QueueDeclarePattern { + reserved 1 to 7; +} + +message QueueDeclareOk { + string queue = 1; + uint32 message_count = 2; + uint32 consumer_count = 3; +} +message QueueDeclareOkPattern { + reserved 1 to 3; +} + +message QueueBind { + string queue = 1; + string exchange = 2; + string routing_key = 3; + bool no_wait = 4; + optional AmqpTable arguments = 5; +} +message QueueBindPattern { + reserved 1 to 5; +} + +message QueueBindOk {} +message QueueBindOkPattern {} + +message BasicQos { + uint32 prefetch_count = 1; + uint32 prefetch_size = 2; + bool global_qos = 3; +} +message BasicQosPattern { + reserved 1 to 3; +} + +message BasicQosOk {} +message BasicQosOkPattern {} + +message BasicConsume { + string queue = 1; + string consumer_tag = 2; + bool no_local = 3; + bool no_ack = 4; + bool no_wait = 5; + bool exclusive = 6; + optional AmqpTable arguments = 7; +} +message BasicConsumePattern { + reserved 1 to 7; +} + +message BasicConsumeOk { + string consumer_tag = 1; +} +message BasicConsumeOkPattern { + reserved 1; +} + +message BasicCancel { + string consumer_tag = 1; + bool no_wait = 2; +} +message BasicCancelPattern { + optional string consumer_tag = 1; + reserved 2; +} + +message BasicCancelOk { + string consumer_tag = 1; +} +message BasicCancelOkPattern { + optional string consumer_tag = 1; +} + +message BasicPublish { + string exchange = 1; + string routing_key = 2; + bool mandatory = 3; + bool immediate = 4; + optional MessageProperties properties = 5; + bytes body = 6; +} +message BasicPublishPattern { + reserved 1 to 6; +} + +message BasicReturn { + uint32 reply_code = 1; + string reply_text = 2; + string exchange = 3; + string routing_key = 4; + optional MessageProperties properties = 5; + bytes body = 6; +} +message BasicReturnPattern { + optional uint32 reply_code = 1; + reserved 2; + optional string exchange = 3; + optional string routing_key = 4; + reserved 5, 6; +} + +message BasicAck { + uint64 delivery_tag = 1; + bool multiple = 2; +} +message BasicAckPattern { + optional uint64 delivery_tag = 1; + reserved 2; +} + +message BasicNack { + uint64 delivery_tag = 1; + bool multiple = 2; + bool requeue = 3; +} +message BasicNackPattern { + optional uint64 delivery_tag = 1; + reserved 2, 3; +} + +message BasicReject { + uint64 delivery_tag = 1; + bool requeue = 2; +} +message BasicRejectPattern { + optional uint64 delivery_tag = 1; + reserved 2; +} + +message BasicDeliver { + string consumer_tag = 1; + uint64 delivery_tag = 2; + bool redelivered = 3; + string exchange = 4; + string routing_key = 5; + optional MessageProperties properties = 6; + bytes body = 7; +} +message BasicDeliverPattern { + optional string consumer_tag = 1; + optional uint64 delivery_tag = 2; + reserved 3 to 7; +} + +message BasicGet { + string queue = 1; + bool no_ack = 2; +} +message BasicGetPattern { + reserved 1, 2; +} + +message BasicGetOk { + uint64 delivery_tag = 1; + bool redelivered = 2; + string exchange = 3; + string routing_key = 4; + uint32 message_count = 5; + optional MessageProperties properties = 6; + bytes body = 7; +} +message BasicGetOkPattern { + optional uint64 delivery_tag = 1; + reserved 2 to 7; +} + +message BasicGetEmpty {} +message BasicGetEmptyPattern {} + +message Method { + oneof kind { + ConnectionStart connection_start = 1; + ConnectionStartOk connection_start_ok = 2; + ConnectionTune connection_tune = 3; + ConnectionTuneOk connection_tune_ok = 4; + ConnectionOpen connection_open = 5; + ConnectionOpenOk connection_open_ok = 6; + ConnectionClose connection_close = 7; + ConnectionCloseOk connection_close_ok = 8; + ChannelOpen channel_open = 9; + ChannelOpenOk channel_open_ok = 10; + ChannelClose channel_close = 11; + ChannelCloseOk channel_close_ok = 12; + ConfirmSelect confirm_select = 13; + ConfirmSelectOk confirm_select_ok = 14; + ExchangeDeclare exchange_declare = 15; + ExchangeDeclareOk exchange_declare_ok = 16; + QueueDeclare queue_declare = 17; + QueueDeclareOk queue_declare_ok = 18; + QueueBind queue_bind = 19; + QueueBindOk queue_bind_ok = 20; + BasicQos basic_qos = 21; + BasicQosOk basic_qos_ok = 22; + BasicConsume basic_consume = 23; + BasicConsumeOk basic_consume_ok = 24; + BasicCancel basic_cancel = 25; + BasicCancelOk basic_cancel_ok = 26; + BasicPublish basic_publish = 27; + BasicReturn basic_return = 28; + BasicAck basic_ack = 29; + BasicNack basic_nack = 30; + BasicReject basic_reject = 31; + BasicDeliver basic_deliver = 32; + BasicGet basic_get = 33; + BasicGetOk basic_get_ok = 34; + BasicGetEmpty basic_get_empty = 35; + ExchangeBind exchange_bind = 36; + ExchangeBindOk exchange_bind_ok = 37; + } +} + +enum BaseWeight { + BASE_WEIGHT_UNSPECIFIED = 0; + BASE_WEIGHT = 100; +} + +message Pattern { + oneof method { + ConnectionStartPattern connection_start = 1; + ConnectionStartOkPattern connection_start_ok = 2; + ConnectionTunePattern connection_tune = 3; + ConnectionTuneOkPattern connection_tune_ok = 4; + ConnectionOpenPattern connection_open = 5; + ConnectionOpenOkPattern connection_open_ok = 6; + ConnectionClosePattern connection_close = 7; + ConnectionCloseOkPattern connection_close_ok = 8; + ChannelOpenPattern channel_open = 9; + ChannelOpenOkPattern channel_open_ok = 10; + ChannelClosePattern channel_close = 11; + ChannelCloseOkPattern channel_close_ok = 12; + ConfirmSelectPattern confirm_select = 13; + ConfirmSelectOkPattern confirm_select_ok = 14; + ExchangeDeclarePattern exchange_declare = 15; + ExchangeDeclareOkPattern exchange_declare_ok = 16; + ExchangeBindPattern exchange_bind = 17; + ExchangeBindOkPattern exchange_bind_ok = 18; + QueueDeclarePattern queue_declare = 19; + QueueDeclareOkPattern queue_declare_ok = 20; + QueueBindPattern queue_bind = 21; + QueueBindOkPattern queue_bind_ok = 22; + BasicQosPattern basic_qos = 23; + BasicQosOkPattern basic_qos_ok = 24; + BasicConsumePattern basic_consume = 25; + BasicConsumeOkPattern basic_consume_ok = 26; + BasicCancelPattern basic_cancel = 27; + BasicCancelOkPattern basic_cancel_ok = 28; + BasicPublishPattern basic_publish = 29; + BasicReturnPattern basic_return = 30; + BasicAckPattern basic_ack = 31; + BasicNackPattern basic_nack = 32; + BasicRejectPattern basic_reject = 33; + BasicDeliverPattern basic_deliver = 34; + BasicGetPattern basic_get = 35; + BasicGetOkPattern basic_get_ok = 36; + BasicGetEmptyPattern basic_get_empty = 37; + } +} + +message Weighted { + Pattern pattern = 1; + optional uint32 weight = 2; +} + +message Scripted { + oneof when { + Pattern received = 1; + Pattern sent = 2; + } + Pattern then_send = 3; +} + +message SessionConfig { + optional uint64 seed = 1; + optional NegotiationOptions negotiation_options = 2; + repeated Weighted weights = 3; + repeated Weighted idle_weights = 4; + repeated Scripted script = 5; + optional uint32 stop_weight = 6; + optional uint32 max_sends = 7; + optional uint32 idle_millis = 8; + optional uint32 max_buffer = 9; + optional uint32 max_transcript_lines = 10; +} + +enum TranscriptEvent { + TRANSCRIPT_EVENT_UNSPECIFIED = 0; + TRANSCRIPT_EVENT_SESSION_STARTED = 1; + TRANSCRIPT_EVENT_TCP_CONNECTED = 2; + TRANSCRIPT_EVENT_TCP_REJECTED = 3; + TRANSCRIPT_EVENT_TCP_DOWN = 4; + TRANSCRIPT_EVENT_IDLE_TIMEOUT = 5; + TRANSCRIPT_EVENT_PROTOCOL_HEADER = 6; + TRANSCRIPT_EVENT_FRAME_MALFORMED = 7; + TRANSCRIPT_EVENT_FRAME_HEARTBEAT = 8; + TRANSCRIPT_EVENT_METHOD_INGRESS = 9; + TRANSCRIPT_EVENT_METHOD_EGRESS = 10; + TRANSCRIPT_EVENT_METHOD_UNREPRESENTABLE = 11; +} + +message TranscriptLine { + uint64 index = 1; + int64 micros = 2; + int64 epoch_micros = 3; + TranscriptEvent event = 4; + optional Method method = 5; + string detail = 6; + bytes wire_bytes = 7; +} + +message StatusReply { + optional uint32 amqp_port = 1; + bool amqp_greeted = 2; + uint64 transcript_lines = 3; + int64 epoch_micros = 4; + SessionConfig config = 5; +} + +message ConfigureRequest { + SessionConfig config = 1; +} + +message StatusRequest {} + +message ResetRequest {} + +message TranscriptRequest {} + +service Imposter { + rpc Configure(ConfigureRequest) returns (StatusReply); + rpc Status(StatusRequest) returns (StatusReply); + rpc Reset(ResetRequest) returns (StatusReply); + rpc Transcript(TranscriptRequest) returns (stream TranscriptLine); +} diff --git a/src/tests/amqp_imposter/pyproject.toml b/src/tests/amqp_imposter/pyproject.toml new file mode 100644 index 00000000..415f1dd4 --- /dev/null +++ b/src/tests/amqp_imposter/pyproject.toml @@ -0,0 +1,111 @@ +[build-system] +requires = ["setuptools >= 77"] +build-backend = "setuptools.build_meta" + +[project] +name = "amqp-imposter" +version = "0.10.0" +requires-python = ">=3.15" +dependencies = [ + "pika >= 1.4", + "grpcio == 1.84.0", + "grpcio-reflection == 1.84.0", + "protobuf == 7.36.1", +] + +[project.scripts] +amqp-imposter = "amqp_imposter.control.cli:main" + +[tool.setuptools.packages.find] +include = ["amqp_imposter*"] + +[tool.setuptools.package-data] +"*" = ["py.typed", "*.pyi", "*.md"] + +[tool.pytest] +addopts = ["-ra"] +testpaths = ["tests"] +consider_namespace_packages = true +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + +[tool.tox] +legacy_tox_ini = """ +[tox] +envlist = py{315}-{test,mypy},lint + +[testenv] +description = Run test under {base_python} +deps = + -rrequirements-dev.txt +commands = + python -m pytest -n auto --junitxml=junit-{envname}.xml --cov=amqp_imposter --cov-report=xml:coverage-{envname}.xml {posargs} + +[testenv:lint] +description = Run version-agnostic lint tools +commands = + ruff check {env:RUFF_EXTRA_ARGS} + ruff format --check + +[testenv:proto] +description = Regenerate protobuf and gRPC code from proto/ +commands = + python -m grpc_tools.protoc -I{toxinidir}/proto --plugin=protoc-gen-mypy={envbindir}/protoc-gen-mypy --plugin=protoc-gen-mypy_grpc={envbindir}/protoc-gen-mypy_grpc --python_out={toxinidir} --mypy_out={toxinidir} --grpc_python_out={toxinidir} --mypy_grpc_out={toxinidir} amqp_imposter/control/v1/imposter.proto +[testenv:py{315}-mypy] +description = Run mypy under {base_python} +commands = + mypy +""" + +[tool.mypy] +explicit_package_bases = true +strict = true +packages = ["amqp_imposter", "tests"] + +[[tool.mypy.overrides]] +module = ["pika.*"] +ignore_missing_imports = true + +[tool.ruff] +target-version = "py315" +extend-exclude = ["*_pb2.py", "*_pb2.pyi", "*_pb2_grpc.py", "*_pb2_grpc.pyi"] + +[tool.ruff.lint] +# https://docs.astral.sh/ruff/rules/ +# In addition to the default set of Ruff rules, also check: +extend-select = [ + 'A', # flake8-builtins + 'ASYNC', # flake8-async + 'B', # flake8-bugbear + 'BLE', # flake8-blind-except + 'C4', # flake8-comprehensions + 'DTZ', # flake8-datetimez + 'FAST', # FastAPI + 'I', # isort + 'INT', # flake8-gettext + 'ISC', # flake8-implicit-str-concat + 'LOG', # flake8-logging + 'NPY', # NumPy-specific rules + 'PGH', # pygrep-hooks + 'PIE', # flake8-pie + 'PLC', # Pylint Convention + 'PLE', # Pylint Error + 'PLW', # Pylint Warning + 'PT', # flake8-pytest-style + 'RUF', # Ruff-specific rules + 'SLF', # flake8-self + 'T10', # flake8-debugger + 'T20', # flake8-print + 'UP', # pyupgrade + 'W', # pycodestyle Warning + 'YTT', # flake8-2020 +] +ignore = [ +] + +[tool.ruff.lint.isort] +known-first-party = ["amqp_imposter"] + +[tool.ruff.lint.flake8-pytest-style] +parametrize-values-type = "tuple" + diff --git a/src/tests/amqp_imposter/requirements-dev.txt b/src/tests/amqp_imposter/requirements-dev.txt new file mode 100644 index 00000000..78c974a6 --- /dev/null +++ b/src/tests/amqp_imposter/requirements-dev.txt @@ -0,0 +1,12 @@ +mypy >= 0.990 +pytest >= 9.0.0 +pytest-cov +ruff +tox >= 4.21 +pytest-asyncio +pytest-timeout +pytest-xdist +grpcio-tools == 1.84.0 +mypy-protobuf >= 5.1.0 +grpc-stubs >= 1.53 +types-protobuf >= 7.35 diff --git a/src/tests/amqp_imposter/tests/amqp/__init__.py b/src/tests/amqp_imposter/tests/amqp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/tests/amqp/helpers.py b/src/tests/amqp_imposter/tests/amqp/helpers.py new file mode 100644 index 00000000..16fe48df --- /dev/null +++ b/src/tests/amqp_imposter/tests/amqp/helpers.py @@ -0,0 +1,170 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from collections.abc import Callable +from dataclasses import replace + +from amqp_imposter.amqp import method_framing as fr +from amqp_imposter.amqp import session as se +from amqp_imposter.amqp.transcript import ( + TranscriptEvent, + TranscriptLine, +) +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m +from amqp_imposter.model.method_scripts import Received, ScriptRule +from amqp_imposter.model.method_weights import ( + BASE_WEIGHT, + WeightedPattern, +) + +FRAME_MAX = 131072 +GLOBAL_CHANNEL = 0 +CHANNEL = 1 +OTHER_CHANNEL = 2 +SEED = 0 +STOP_WEIGHT_DRAIN = 1 +STOP_WEIGHT_LIKELY = 2 * BASE_WEIGHT + +CONNECTION_CLOSE = m.ConnectionClose( + mf.ReplyCode.SUCCESS, + "Normal shutdown", + mf.UNPROMPTED_CLASS_ID, + mf.UNPROMPTED_METHOD_ID, +) +CONNECTION_START = m.ConnectionStart( + version_major=0, + version_minor=9, + mechanisms=("PLAIN",), + locales=("en_US",), + publisher_confirms=True, + basic_nack=True, + consumer_cancel_notify=True, +) +CONNECTION_START_OK = m.ConnectionStartOk(mechanism="PLAIN", locale="en_US") +CONNECTION_TUNE_OK = m.ConnectionTuneOk(channel_max=2047, frame_max=131072, heartbeat=0) +CHANNEL_CLOSE = m.ChannelClose( + reply_code=mf.ReplyCode.SUCCESS, + reply_text="Normal shutdown", + class_id=mf.UNPROMPTED_CLASS_ID, + method_id=mf.UNPROMPTED_METHOD_ID, +) +BASIC_PUBLISH = m.BasicPublish(exchange="ex", routing_key="rk", body=b"x") +BASIC_CONSUME = m.BasicConsume( + queue="q", + consumer_tag="ctag", + no_local=False, + no_ack=False, + no_wait=False, + exclusive=False, + arguments=None, +) + +B_BAD_FRAME = b"\x63\x00\x01\x00\x00\x00\x01\x00\xce" +B_OVERLONG_FRAME = b"\x01\x00\x01" + (10_000_000).to_bytes(4, "big") + b"\x00" * 200 + + +def encoded_method(method: m.Method, channel: int = GLOBAL_CHANNEL) -> bytes: + return fr.encode(fr.MethodOnChannel(channel, method), FRAME_MAX) + + +def filter_action_methods( + acts: tuple[se.SocketAction, ...], +) -> tuple[fr.MethodOnChannel, ...]: + sent: list[fr.MethodOnChannel] = [] + + for act in acts: + match act: + case se.Write(): + items, _ = fr.decode(act.payload, FRAME_MAX) + sent.extend( + item for item in items if isinstance(item, fr.MethodOnChannel) + ) + case _: + continue + + return tuple(sent) + + +def filter_action_method_kinds( + acts: tuple[se.SocketAction, ...], +) -> list[type[m.Method]]: + return [type(act.method) for act in filter_action_methods(acts)] + + +def has_drop_action(acts: tuple[se.SocketAction, ...]) -> bool: + return any(isinstance(act, se.Drop) for act in acts) + + +def transcript_events(lines: tuple[TranscriptLine, ...]) -> tuple[TranscriptEvent, ...]: + return tuple(line.event for line in lines) + + +def transcript_time_normalised( + lines: tuple[TranscriptLine, ...], +) -> tuple[TranscriptLine, ...]: + return tuple(replace(line, micros=0, epoch_micros=0) for line in lines) + + +def transcript_method_lines( + lines: tuple[TranscriptLine, ...], +) -> tuple[TranscriptLine, ...]: + return tuple(line for line in lines if line.method is not None) + + +def handshake(session: se.Session) -> tuple[se.SocketAction, ...]: + acts = list(session.on_ingress(fr.PROTOCOL_HEADER_091)) + acts.extend(session.on_ingress(encoded_method(CONNECTION_START_OK))) + acts.extend(session.on_ingress(encoded_method(CONNECTION_TUNE_OK))) + acts.extend(session.on_ingress(encoded_method(m.ConnectionOpen()))) + return tuple(acts) + + +# avoid drawing the stop pattern here +GREETING_WEIGHTS = (WeightedPattern(m.ConnectionStart(), 10**9),) +SCRIPTED_REPLIES = ( + ScriptRule(Received(m.ConnectionStartOk()), then_send=m.ConnectionTune()), + ScriptRule(Received(m.ConnectionOpen()), then_send=m.ConnectionOpenOk()), + ScriptRule(Received(m.ConnectionClose()), then_send=m.ConnectionCloseOk()), + ScriptRule(Received(m.ChannelOpen()), then_send=m.ChannelOpenOk()), + ScriptRule( + Received(m.ChannelOpen()), + then_send=m.ConnectionClose(reply_code=mf.ReplyCode.CHANNEL_ERROR), + ), + ScriptRule(Received(m.ChannelClose()), then_send=m.ChannelCloseOk()), + ScriptRule(Received(m.BasicConsume()), then_send=m.BasicConsumeOk()), +) + + +def session_connected(stop_weight: int) -> se.Session: + session = se.Session( + se.SessionConfig( + seed=SEED, + stop_weight=stop_weight, + weights=GREETING_WEIGHTS, + script=SCRIPTED_REPLIES, + ) + ) + session.on_connected() + return session + + +async def poll_until( + predicate: Callable[[], bool], timeout_s: float = 2.0, interval_s: float = 0.005 +) -> None: + async with asyncio.timeout(timeout_s): + while not predicate(): # noqa: ASYNC110 + await asyncio.sleep(interval_s) diff --git a/src/tests/amqp_imposter/tests/amqp/test_method_framing.py b/src/tests/amqp_imposter/tests/amqp/test_method_framing.py new file mode 100644 index 00000000..3eee901a --- /dev/null +++ b/src/tests/amqp_imposter/tests/amqp/test_method_framing.py @@ -0,0 +1,236 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import replace +from typing import Any + +import pika.spec as ps +import pytest + +from amqp_imposter.amqp import method_framing as fr +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m +from tests.amqp.helpers import CONNECTION_START + +from ..helpers import concrete + +SEED = 7 +FRAME_MAX = 131072 +FRAME_MIN = 4096 + +B_HEARTBEAT = b"\x08\x00\x00\x00\x00\x00\x00\xce" + +ACK = fr.MethodOnChannel(1, m.BasicAck(delivery_tag=1, multiple=False)) +B_ACK = b"\x01\x00\x01\x00\x00\x00\r\x00<\x00P\x00\x00\x00\x00\x00\x00\x00\x01\x00\xce" + +PUBLISH = fr.MethodOnChannel( + 1, + m.BasicPublish( + exchange="ex", + routing_key="rk", + mandatory=False, + immediate=False, + properties=mf.MessageProperties(content_type="text/plain", delivery_mode=2), + body=b"hello", + ), +) +B_PUBLISH = ( + b"\x01\x00\x01\x00\x00\x00\r\x00<\x00(\x00\x00\x02ex\x02rk\x00\xce" + b"\x02\x00\x01\x00\x00\x00\x1a\x00<\x00\x00\x00\x00\x00\x00\x00\x00\x00\x05\x90\x00\ntext/plain\x02\xce" + b"\x03\x00\x01\x00\x00\x00\x05hello\xce" +) + + +def test_decode_protocol_header() -> None: + assert fr.decode(fr.PROTOCOL_HEADER_091, FRAME_MAX) == ( + (fr.ProtocolHeader(fr.PROTOCOL_HEADER_091),), + b"", + ) + + +def test_heartbeat_compose_decode_encode() -> None: + assert fr.encode(fr.Heartbeat(), FRAME_MAX) == B_HEARTBEAT + assert fr.decode(B_HEARTBEAT, FRAME_MAX) == ((fr.Heartbeat(),), b"") + + +@pytest.mark.parametrize( + ("egress", "encoded"), + ( + (ACK, B_ACK), + (PUBLISH, B_PUBLISH), + ), + ids=("ack", "publish"), +) +def test_encodings(egress: fr.MethodOnChannel, encoded: bytes) -> None: + assert fr.encode(egress, FRAME_MAX) == encoded + assert fr.decode(encoded, FRAME_MAX) == ((egress,), b"") + + +# fmt: off +@pytest.mark.parametrize( + "egress", + ( + fr.MethodOnChannel(1, m.BasicAck(delivery_tag=7, multiple=True)), + fr.MethodOnChannel(1, m.ChannelOpenOk()), + fr.MethodOnChannel(0, m.ConnectionStart(version_major=0, version_minor=9, mechanisms=("PLAIN", "AMQPLAIN"), locales=("en_US",), publisher_confirms=True, basic_nack=True, consumer_cancel_notify=True,)), + fr.MethodOnChannel(1, m.QueueDeclare(queue="q", passive=False, durable=True, exclusive=False, auto_delete=False, no_wait=False, arguments=mf.Table({"x-max-length": 10}))), + fr.MethodOnChannel(1, m.BasicDeliver(consumer_tag="ct", delivery_tag=1, redelivered=False, exchange="ex", routing_key="rk", properties=mf.MessageProperties(content_type="text/plain"), body=b"hi")), + ), +) +def test_compose_decode_encode(egress: fr.MethodOnChannel) -> None: + assert fr.decode(fr.encode(egress, FRAME_MAX), FRAME_MAX) == ((egress,), b"") + +# fmt: on + + +def test_decode_in_increments() -> None: + other = fr.MethodOnChannel(1, m.BasicAck(delivery_tag=2, multiple=False)) + buffer = fr.encode(ACK, FRAME_MAX) + fr.encode(other, FRAME_MAX) + + initial_items, initial_carry = fr.decode(buffer, FRAME_MAX) + assert initial_items == (ACK, other) + assert initial_carry == b"" + + seen_items: list[fr.Ingress] = [] + carry = b"" + for byte in buffer: + items, carry = fr.decode(carry + bytes([byte]), FRAME_MAX) + seen_items.extend(items) + + assert tuple(seen_items) == initial_items + assert carry == b"" + + +def test_frame_remainder() -> None: + encoded = fr.encode(PUBLISH, FRAME_MAX) + i_cut = len(encoded) - 3 + + items, rest = fr.decode(encoded[:i_cut], FRAME_MAX) + assert items == () + assert rest == encoded[:i_cut] + + assert fr.decode(rest + encoded[i_cut:], FRAME_MAX) == ((PUBLISH,), b"") + + +def test_frame_body_remainder() -> None: + big = fr.MethodOnChannel( + 1, + m.BasicDeliver( + consumer_tag="c", + delivery_tag=1, + redelivered=False, + exchange="e", + routing_key="r", + properties=mf.MessageProperties(), + body=b"x" * 5000, + ), + ) + assert fr.decode(fr.encode(big, FRAME_MIN), FRAME_MAX) == ((big,), b"") + + +@pytest.mark.parametrize( + "bad", + ( + pytest.param(B_ACK[:-1] + b"\x00", id="bad_frame_end"), + pytest.param(b"\x63\x00\x01\x00\x00\x00\x01\x00\xce", id="bad_frame_type"), + pytest.param( + b"\x01\x00\x01\x00\x00\x00\x04\x00\x3c\x03\xe7\xce", + id="unknown_method", + ), + pytest.param( + B_PUBLISH.replace(b"text/plain\x02\xce", b"text/plain\x02\x00"), + id="bad_header_frame_end", + ), + ), +) +def test_bad_frames(bad: bytes) -> None: + items, rest = fr.decode(bad, FRAME_MAX) + assert [type(one) for one in items] == [fr.Undecodable] + assert rest == b"" + + +def test_buffer_maxed() -> None: + huge = b"\x01\x00\x01" + (10_000_000).to_bytes(4, "big") + b"\x00" * 200 + + items, rest = fr.decode(huge, 100) + assert [type(one) for one in items] == [fr.Undecodable] + assert rest == b"" + + +EMPTY_ON_THE_WIRE: dict[str, Any] = { + "arguments": mf.Table(), + "properties": mf.MessageProperties(), +} + + +def as_received(method: m.Method) -> m.Method: + return m.set_fields( + method, + { + name: EMPTY_ON_THE_WIRE[name] + if value is None and name in EMPTY_ON_THE_WIRE + else value + for name, value in m.get_fields(method).items() + }, + ) + + +@pytest.mark.parametrize("kind", m.METHOD_KINDS, ids=lambda k: k.__name__) +def test_every_kind_compose_decode_encode(kind: type[m.Method]) -> None: + egress = fr.MethodOnChannel(1, concrete(kind, SEED)) + received = fr.MethodOnChannel(egress.channel, as_received(egress.method)) + + assert fr.decode(fr.encode(egress, FRAME_MAX), FRAME_MAX) == ((received,), b"") + + +CONNECTION_START_CAPABILITIES_OFF = replace( + CONNECTION_START, + publisher_confirms=False, + basic_nack=False, + consumer_cancel_notify=False, +) + + +def start_spec(capabilities: Any) -> Any: + spec = ps.Connection.Start(server_properties={fr.CAPABILITIES: capabilities}) + spec.mechanisms, spec.locales = "PLAIN", "en_US" + + return spec + + +@pytest.mark.parametrize("capabilities", ("nonsense", 7, [], None), ids=repr) +def test_unusable_capabilities_are_absent(capabilities: Any) -> None: + assert fr.from_pika(start_spec(capabilities)) == CONNECTION_START_CAPABILITIES_OFF + + +def test_capabilities_are_read() -> None: + method = fr.from_pika(start_spec({fr.BASIC_NACK: True})) + + assert method == replace(CONNECTION_START_CAPABILITIES_OFF, basic_nack=True) + + +def test_absent_longstr_is_empty() -> None: + assert fr.from_pika(ps.Connection.StartOk()) == m.ConnectionStartOk( + mechanism="PLAIN", locale="en_US", response="" + ) + + +def test_absent_delimited_longstr_is_empty() -> None: + spec = ps.Connection.Start() + spec.mechanisms, spec.locales = "", "" + + assert fr.from_pika(spec) == replace( + CONNECTION_START_CAPABILITIES_OFF, mechanisms=(), locales=() + ) diff --git a/src/tests/amqp_imposter/tests/amqp/test_server.py b/src/tests/amqp_imposter/tests/amqp/test_server.py new file mode 100644 index 00000000..2118fc3f --- /dev/null +++ b/src/tests/amqp_imposter/tests/amqp/test_server.py @@ -0,0 +1,427 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import socket +import struct +from collections.abc import Callable +from contextlib import suppress +from dataclasses import replace + +import pytest + +from amqp_imposter.amqp import method_framing as fr +from amqp_imposter.amqp import session as se +from amqp_imposter.amqp.server import HOST, AmqpServer +from amqp_imposter.amqp.transcript import TranscriptEvent as ev +from amqp_imposter.model import methods as m +from amqp_imposter.model.method_weights import ( + UNPROMPTED_CONNECTION_CLOSE, + WeightedPattern, +) + +from .helpers import ( + CHANNEL, + CONNECTION_CLOSE, + CONNECTION_START_OK, + CONNECTION_TUNE_OK, + FRAME_MAX, + GREETING_WEIGHTS, + SCRIPTED_REPLIES, + SEED, + STOP_WEIGHT_DRAIN, + encoded_method, + poll_until, + transcript_events, +) + +ACTION_DELAY_S = 0.02 +UNREAD_BYTES = 64 * 1024 * 1024 + + +def make_config(idle_millis: int = se.DEFAULT_IDLE_MILLIS) -> se.SessionConfig: + return se.SessionConfig( + seed=SEED, + stop_weight=STOP_WEIGHT_DRAIN, + idle_millis=idle_millis, + weights=GREETING_WEIGHTS, + script=SCRIPTED_REPLIES, + ) + + +def start_only_on_idle(idle_millis: int) -> se.SessionConfig: + return replace( + make_config(idle_millis), + weights=(WeightedPattern(m.ConnectionStart(), 0),), + idle_weights=(WeightedPattern(m.ConnectionStart()),), + ) + + +def teardown_on_idle(idle_millis: int) -> se.SessionConfig: + return replace( + make_config(idle_millis), + idle_weights=(WeightedPattern(UNPROMPTED_CONNECTION_CLOSE),), + ) + + +def make_amqp_server(idle_millis: int = se.DEFAULT_IDLE_MILLIS) -> AmqpServer: + return AmqpServer(se.Session(make_config(idle_millis))) + + +class SlowAmqpServer(AmqpServer): + async def do_actions( + self, actions: tuple[se.SocketAction, ...], writer: asyncio.StreamWriter + ) -> bool: + await asyncio.sleep(ACTION_DELAY_S) + return await super().do_actions(actions, writer) + + +async def make_amqp_client( + port: int, +) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + return await asyncio.wait_for(asyncio.open_connection(HOST, port), 2.0) + + +async def close_writer(writer: asyncio.StreamWriter) -> None: + writer.close() + await writer.wait_closed() + + +async def egress_of(reader: asyncio.StreamReader) -> tuple[fr.Ingress, ...]: + buffer = b"" + + async with asyncio.timeout(2.0): + while True: + buffer += await reader.read(4096) + items, buffer = fr.decode(buffer, FRAME_MAX) + if items: + return items + + +def make_event_seen_cb(server: AmqpServer, event: ev) -> Callable[[], bool]: + return lambda: event in transcript_events(server.session.transcript()) + + +async def amqp_handshake(writer: asyncio.StreamWriter) -> None: + writer.write(fr.PROTOCOL_HEADER_091) + writer.write(encoded_method(CONNECTION_START_OK)) + writer.write(encoded_method(CONNECTION_TUNE_OK)) + writer.write(encoded_method(m.ConnectionOpen())) + await writer.drain() + + +async def decode_until_eof(reader: asyncio.StreamReader) -> tuple[fr.Ingress, ...]: + buffer = b"" + + async with asyncio.timeout(2.0): + while chunk := await reader.read(4096): + buffer += chunk + + items, _ = fr.decode(buffer, FRAME_MAX) + return items + + +async def test_start_ephemeral_port() -> None: + server = make_amqp_server() + + port = await server.start() + + assert port > 0 + assert server.port == port + + await server.stop() + + +async def test_start_requested_port() -> None: + first = make_amqp_server() + port = await first.start() + await first.stop() + server = make_amqp_server() + + assert await server.start(port=port) == port + + await server.stop() + + +async def test_refuse_client_after_stop() -> None: + server = make_amqp_server() + port = await server.start() + + await server.stop() + + with pytest.raises(ConnectionRefusedError): + await make_amqp_client(port) + + +async def test_drop_client_on_stop() -> None: + server = make_amqp_server() + port = await server.start() + reader, _ = await make_amqp_client(port) + await poll_until(make_event_seen_cb(server, ev.TCP_CONNECTED)) + + await server.stop() + + assert await asyncio.wait_for(reader.read(), 2.0) == b"" + + +async def test_stop_while_client_reconnects() -> None: + server = make_amqp_server() + port = await server.start() + writers: list[asyncio.StreamWriter] = [] + + async def keep_connecting() -> None: + while True: + with suppress(OSError): + _, writer = await asyncio.open_connection(HOST, port) + writers.append(writer) + await asyncio.sleep(0) + + connecting = asyncio.create_task(keep_connecting()) + await poll_until(lambda: server.writer is not None) + + await asyncio.wait_for(server.stop(), 2.0) + + connecting.cancel() + for writer in writers: + writer.close() + + +async def test_disconnect_hung_client() -> None: + server = make_amqp_server() + port = await server.start() + _, writer = await make_amqp_client(port) + await poll_until(lambda: server.writer is not None) + assert server.writer is not None + server.writer.write(bytes(UNREAD_BYTES)) + + await asyncio.wait_for(server.disconnect(), 2.0) + + assert server.writer is None + + await close_writer(writer) + await server.stop() + + +async def test_rejecting_connections() -> None: + server = make_amqp_server() + port = await server.start() + + async with server.rejecting_connections(): + reader, refused = await make_amqp_client(port) + + assert await asyncio.wait_for(reader.read(), 2.0) == b"" + assert ev.TCP_CONNECTED not in transcript_events(server.session.transcript()) + + _, accepted = await make_amqp_client(port) + await poll_until(make_event_seen_cb(server, ev.TCP_CONNECTED)) + + await close_writer(refused) + await close_writer(accepted) + await server.stop() + + +async def test_connect() -> None: + server = make_amqp_server() + port = await server.start() + + _, writer = await make_amqp_client(port) + await poll_until(make_event_seen_cb(server, ev.TCP_CONNECTED)) + + await close_writer(writer) + await server.stop() + + +async def test_client_close() -> None: + server = make_amqp_server() + port = await server.start() + _, writer = await make_amqp_client(port) + await poll_until(make_event_seen_cb(server, ev.TCP_CONNECTED)) + + await close_writer(writer) + await poll_until(make_event_seen_cb(server, ev.TCP_DOWN)) + + await server.stop() + + +async def test_greeting() -> None: + server = make_amqp_server() + port = await server.start() + reader, writer = await make_amqp_client(port) + + writer.write(fr.PROTOCOL_HEADER_091) + await writer.drain() + + assert [type(one) for one in await egress_of(reader)] == [fr.MethodOnChannel] + + await close_writer(writer) + await server.stop() + + +async def test_second_client_rejected() -> None: + server = make_amqp_server() + port = await server.start() + _, first = await make_amqp_client(port) + await poll_until(make_event_seen_cb(server, ev.TCP_CONNECTED)) + + reader, writer = await make_amqp_client(port) + await poll_until(make_event_seen_cb(server, ev.TCP_REJECTED)) + + assert await asyncio.wait_for(reader.read(), 2.0) == b"" + + await close_writer(writer) + await close_writer(first) + await server.stop() + + +async def test_drop_closes_connection() -> None: + server = make_amqp_server() + port = await server.start() + reader, writer = await make_amqp_client(port) + + writer.write(encoded_method(CONNECTION_START_OK)) + await writer.drain() + + assert await asyncio.wait_for(reader.read(), 2.0) == b"" + + await server.stop() + + +async def test_client_reset() -> None: + loop = asyncio.get_running_loop() + unhandled: list[dict[str, object]] = [] + loop.set_exception_handler(lambda _, context: unhandled.append(context)) + server = make_amqp_server() + port = await server.start() + _, writer = await make_amqp_client(port) + await poll_until(make_event_seen_cb(server, ev.TCP_CONNECTED)) + + writer.get_extra_info("socket").setsockopt( + socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0) + ) + writer.transport.abort() + await poll_until(make_event_seen_cb(server, ev.TCP_DOWN)) + await asyncio.sleep(0) + + assert unhandled == [] + + await server.stop() + + +async def test_reconnect_after_close() -> None: + server = make_amqp_server() + port = await server.start() + _, first = await make_amqp_client(port) + await poll_until(make_event_seen_cb(server, ev.TCP_CONNECTED)) + await close_writer(first) + await poll_until(make_event_seen_cb(server, ev.TCP_DOWN)) + + _, writer = await make_amqp_client(port) + await poll_until(lambda: server.writer is not None) + + assert ev.TCP_REJECTED not in transcript_events(server.session.transcript()) + + await close_writer(writer) + await server.stop() + + +async def test_idling() -> None: + server = AmqpServer(se.Session(start_only_on_idle(idle_millis=10))) + port = await server.start() + _, writer = await make_amqp_client(port) + writer.write(fr.PROTOCOL_HEADER_091) + await writer.drain() + + await poll_until(make_event_seen_cb(server, ev.IDLE_TIMEOUT)) + + await close_writer(writer) + await server.stop() + + +async def test_method_ingress_resets_idle_timer() -> None: + server = AmqpServer(se.Session(teardown_on_idle(idle_millis=500))) + port = await server.start() + + _, writer = await make_amqp_client(port) + await amqp_handshake(writer) + writer.write(encoded_method(m.ChannelOpen(), CHANNEL)) + await writer.drain() + + for _ in range(18): + writer.write(encoded_method(m.BasicQos(), CHANNEL)) + await writer.drain() + await asyncio.sleep(0.01) + + assert ev.IDLE_TIMEOUT not in transcript_events(server.session.transcript()) + await poll_until(make_event_seen_cb(server, ev.IDLE_TIMEOUT)) + + await close_writer(writer) + await server.stop() + + +async def test_heartbeat_does_not_reset_idle_timer() -> None: + server = AmqpServer(se.Session(teardown_on_idle(idle_millis=60))) + port = await server.start() + + _, writer = await make_amqp_client(port) + await amqp_handshake(writer) + heartbeat = fr.encode(fr.Heartbeat(), FRAME_MAX) + + for _ in range(20): + writer.write(heartbeat) + await writer.drain() + await asyncio.sleep(0.01) + + assert ev.IDLE_TIMEOUT in transcript_events(server.session.transcript()) + + await close_writer(writer) + await server.stop() + + +async def test_slow_egress_does_not_starve_ingress() -> None: + server = SlowAmqpServer(se.Session(start_only_on_idle(idle_millis=1))) + port = await server.start() + _, writer = await make_amqp_client(port) + writer.write(fr.PROTOCOL_HEADER_091) + await writer.drain() + await poll_until(make_event_seen_cb(server, ev.IDLE_TIMEOUT)) + + writer.write(encoded_method(CONNECTION_START_OK)) + await writer.drain() + + await poll_until(make_event_seen_cb(server, ev.METHOD_INGRESS)) + + await close_writer(writer) + await server.stop() + + +async def test_flush_on_drop() -> None: + server = make_amqp_server() + port = await server.start() + reader, writer = await make_amqp_client(port) + + await amqp_handshake(writer) + writer.write(encoded_method(CONNECTION_CLOSE)) + await writer.drain() + + methods = [ + type(item.method) + for item in await decode_until_eof(reader) + if isinstance(item, fr.MethodOnChannel) + ] + + assert m.ConnectionCloseOk in methods + + await close_writer(writer) + await server.stop() diff --git a/src/tests/amqp_imposter/tests/amqp/test_session.py b/src/tests/amqp_imposter/tests/amqp/test_session.py new file mode 100644 index 00000000..9cf70842 --- /dev/null +++ b/src/tests/amqp_imposter/tests/amqp/test_session.py @@ -0,0 +1,436 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.amqp import method_framing as fr +from amqp_imposter.amqp import session as se +from amqp_imposter.amqp.transcript import TranscriptEvent as ev +from amqp_imposter.model import methods as m +from amqp_imposter.model.method_scripts import Received, ScriptRule +from amqp_imposter.model.method_weights import WeightedPattern + +from .helpers import ( + B_BAD_FRAME, + B_OVERLONG_FRAME, + BASIC_CONSUME, + BASIC_PUBLISH, + CHANNEL, + CHANNEL_CLOSE, + CONNECTION_START, + CONNECTION_START_OK, + CONNECTION_TUNE_OK, + GLOBAL_CHANNEL, + OTHER_CHANNEL, + SEED, + STOP_WEIGHT_DRAIN, + STOP_WEIGHT_LIKELY, + encoded_method, + filter_action_method_kinds, + filter_action_methods, + handshake, + has_drop_action, + session_connected, + transcript_events, + transcript_method_lines, + transcript_time_normalised, +) + +IDLES = 20 +MANY_IDLES = 200 + + +def test_session_starts() -> None: + session = se.Session(se.SessionConfig(seed=SEED, stop_weight=STOP_WEIGHT_DRAIN)) + + assert transcript_events(session.transcript()) == (ev.SESSION_STARTED,) + assert session.transcript()[0].index == 0 + + +def test_connect_then_disconnect() -> None: + session = se.Session(se.SessionConfig(seed=SEED, stop_weight=STOP_WEIGHT_DRAIN)) + + assert session.on_connected() == () + assert session.on_disconnected() == () + assert transcript_events(session.transcript()) == ( + ev.SESSION_STARTED, + ev.TCP_CONNECTED, + ev.TCP_DOWN, + ) + + +def test_rejected() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + + acts = session.on_rejected() + + assert has_drop_action(acts) + assert transcript_events(session.transcript())[-1] == ev.TCP_REJECTED + + +def test_ingress_protocol_header() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + + acts = session.on_ingress(fr.PROTOCOL_HEADER_091) + + assert filter_action_methods(acts) == ( + fr.MethodOnChannel(GLOBAL_CHANNEL, CONNECTION_START), + ) + assert transcript_events(session.transcript())[-2:] == ( + ev.PROTOCOL_HEADER, + ev.METHOD_EGRESS, + ) + + +def test_handshake() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + + acts = handshake(session) + + assert filter_action_method_kinds(acts) == [ + m.ConnectionStart, + m.ConnectionTune, + m.ConnectionOpenOk, + ] + assert ev.METHOD_INGRESS in transcript_events(session.transcript()) + + +def test_method_before_protocol_header() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + + acts = session.on_ingress(encoded_method(CONNECTION_START_OK)) + + assert has_drop_action(acts) + assert transcript_events(session.transcript())[-1] == ev.METHOD_UNREPRESENTABLE + + +def test_second_protocol_header() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + session.on_ingress(fr.PROTOCOL_HEADER_091) + + acts = session.on_ingress(fr.PROTOCOL_HEADER_091) + + assert has_drop_action(acts) + assert transcript_events(session.transcript())[-1] == ev.METHOD_UNREPRESENTABLE + + +def test_unsupported_protocol_header() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + amqp_0_10 = b"AMQP\x01\x01\x00\x0a" + + acts = session.on_ingress(amqp_0_10) + + assert acts == ( + se.Write(fr.PROTOCOL_HEADER_091), + se.Drop(se.REASON_UNSUPPORTED_PROTOCOL), + ) + assert session.transcript()[-1].event == ev.METHOD_UNREPRESENTABLE + assert session.transcript()[-1].wire_bytes == amqp_0_10 + + +def test_malformed_frame() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + session.on_ingress(fr.PROTOCOL_HEADER_091) + + acts = session.on_ingress(B_BAD_FRAME) + + assert has_drop_action(acts) + assert transcript_events(session.transcript())[-1] == ev.FRAME_MALFORMED + + +@pytest.mark.parametrize( + ("method", "channel"), + ( + (CONNECTION_START_OK, CHANNEL), + (m.ChannelOpen(), GLOBAL_CHANNEL), + ), + ids=("connection_not_on_global", "channel_on_global"), +) +def test_bad_channels(method: m.Method, channel: int) -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + session.on_ingress(fr.PROTOCOL_HEADER_091) + + assert has_drop_action(session.on_ingress(encoded_method(method, channel))) + + +def test_two_channels_at_once() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + handshake(session) + session.on_ingress(encoded_method(m.ChannelOpen(), CHANNEL)) + + acts = session.on_ingress(encoded_method(m.ChannelOpen(), OTHER_CHANNEL)) + + assert has_drop_action(acts) + + +def test_wrong_channel() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + handshake(session) + session.on_ingress(encoded_method(m.ChannelOpen(), CHANNEL)) + + acts = session.on_ingress(encoded_method(BASIC_PUBLISH, OTHER_CHANNEL)) + + assert has_drop_action(acts) + + +def test_duplicate_open() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + handshake(session) + session.on_ingress(encoded_method(m.ChannelOpen(), CHANNEL)) + + acts = session.on_ingress(encoded_method(m.ChannelOpen(), CHANNEL)) + + assert not has_drop_action(acts) + assert filter_action_method_kinds(acts) == [m.ConnectionClose] + + +def test_channel_close() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + handshake(session) + session.on_ingress(encoded_method(m.ChannelOpen(), CHANNEL)) + session.on_ingress(encoded_method(CHANNEL_CLOSE, CHANNEL)) + + acts = session.on_ingress(encoded_method(m.ChannelOpen(), OTHER_CHANNEL)) + + assert not has_drop_action(acts) + assert filter_action_methods(acts) == ( + fr.MethodOnChannel(OTHER_CHANNEL, m.ChannelOpenOk()), + ) + + +def test_reconnect_clean_slate() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + session.on_ingress(fr.PROTOCOL_HEADER_091) + session.on_ingress(encoded_method(CONNECTION_START_OK)) + session.on_disconnected() + + session.on_connected() + acts = session.on_ingress(fr.PROTOCOL_HEADER_091) + + assert not has_drop_action(acts) + assert filter_action_method_kinds(acts) == [m.ConnectionStart] + + +def test_nothing_to_send() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + handshake(session) + + i = len(session.transcript()) + acts: tuple[se.SocketAction, ...] = () + + acts += session.on_idle() + acts += session.on_idle() + acts += session.on_idle() + acts += session.on_idle() + + assert filter_action_methods(acts) == () + assert session.transcript()[i:] == () # no-op timeouts only + + +def test_zero_weight_never_sent() -> None: + session = se.Session( + se.SessionConfig(seed=SEED, weights=(WeightedPattern(m.ConnectionStart(), 0),)) + ) + session.on_connected() + + acts = session.on_ingress(fr.PROTOCOL_HEADER_091) + acts += session.on_idle() + acts += session.on_idle() + acts += session.on_idle() + + assert filter_action_methods(acts) == () + + +def test_idle_weights_iff_idle() -> None: + session = se.Session( + se.SessionConfig( + seed=SEED, + stop_weight=STOP_WEIGHT_DRAIN, + weights=(WeightedPattern(m.ConnectionStart(), 0),), + idle_weights=(WeightedPattern(m.ConnectionStart()),), + ) + ) + session.on_connected() + + assert filter_action_methods(session.on_ingress(fr.PROTOCOL_HEADER_091)) == () + + idle_acts = tuple(act for _ in range(IDLES) for act in session.on_idle()) + + assert filter_action_method_kinds(idle_acts) == [m.ConnectionStart] + + +def test_script_method_on_receive() -> None: + session = se.Session( + se.SessionConfig( + seed=SEED, + stop_weight=STOP_WEIGHT_DRAIN, + weights=(WeightedPattern(m.ConnectionTune(), 0),), + script=( + ScriptRule( + Received(m.ConnectionStartOk()), then_send=m.ConnectionTune() + ), + ), + ) + ) + session.on_connected() + session.on_ingress(fr.PROTOCOL_HEADER_091) + + acts = session.on_ingress(encoded_method(CONNECTION_START_OK)) + + assert filter_action_method_kinds(acts) == [m.ConnectionTune] + + +def test_deterministic() -> None: + first, second = ( + session_connected(STOP_WEIGHT_DRAIN), + session_connected(STOP_WEIGHT_DRAIN), + ) + + handshake(first) + handshake(second) + + assert transcript_time_normalised(first.transcript()) == transcript_time_normalised( + second.transcript() + ) + + +def test_reset_seed() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + handshake(session) + before = transcript_time_normalised(session.transcript()) + + session.reset() + session.on_connected() + handshake(session) + + assert transcript_time_normalised(session.transcript()) == before + + +def test_reset_transcript() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + handshake(session) + + session.reset() + + assert transcript_events(session.transcript()) == (ev.SESSION_STARTED,) + + +def test_reset_config() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + replacement = se.SessionConfig(seed=99, idle_millis=7) + + session.reset(replacement) + + assert session.config == replacement + + +def test_transcript_time_order() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + handshake(session) + lines = session.transcript() + + assert [line.index for line in lines] == list(range(len(lines))) + assert [line.micros for line in lines] == sorted(line.micros for line in lines) + + +def test_greeting() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + assert session.is_greeted() is False + + session.on_ingress(fr.PROTOCOL_HEADER_091) + assert session.is_greeted() is True + + session.on_disconnected() + assert session.is_greeted() is False + + +def test_reset_greeting() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + session.on_ingress(fr.PROTOCOL_HEADER_091) + + session.reset() + + assert session.is_greeted() is False + + +def test_reset_not_total_ingress_count() -> None: + session = session_connected(STOP_WEIGHT_DRAIN) + handshake(session) + before = session.total_ingress_methods + assert before > 0 + + session.reset() + + assert session.total_ingress_methods == before + + +def test_ingress_buffer_maxed() -> None: + session = se.Session( + se.SessionConfig(seed=SEED, stop_weight=STOP_WEIGHT_DRAIN, max_buffer=100) + ) + session.on_connected() + session.on_ingress(fr.PROTOCOL_HEADER_091) + + acts = session.on_ingress(B_OVERLONG_FRAME) + + assert has_drop_action(acts) + assert transcript_events(session.transcript())[-1] == ev.FRAME_MALFORMED + + +def test_transcript_capped() -> None: + session = se.Session( + se.SessionConfig( + seed=SEED, stop_weight=STOP_WEIGHT_DRAIN, max_transcript_lines=2 + ) + ) + session.on_connected() + session.on_disconnected() + + lines = session.transcript() + + assert len(lines) == 2 + assert lines[0].index == 1 + + +def test_transcribe_consumer() -> None: + session = session_connected(STOP_WEIGHT_LIKELY) + + for setup_part in ( + fr.PROTOCOL_HEADER_091, + encoded_method(CONNECTION_START_OK), + encoded_method(CONNECTION_TUNE_OK), + encoded_method(m.ConnectionOpen()), + encoded_method(m.ChannelOpen(), CHANNEL), + encoded_method(BASIC_CONSUME, CHANNEL), + ): + i = len(session.transcript()) + session.on_ingress(setup_part) + for _ in range(IDLES): + session.on_idle() + assert all( + line.event in (ev.METHOD_INGRESS, ev.METHOD_EGRESS) + for line in transcript_method_lines(session.transcript()[i:]) + ) + + i = len(session.transcript()) + for _ in range(MANY_IDLES): + session.on_idle() + + lines = session.transcript()[i:] # cut the transcript to the idle part + assert lines[0].event == ev.IDLE_TIMEOUT + + method_lines = transcript_method_lines(lines) + assert method_lines + assert all(line.event == ev.METHOD_EGRESS for line in method_lines) diff --git a/src/tests/amqp_imposter/tests/amqp/test_transcript.py b/src/tests/amqp_imposter/tests/amqp/test_transcript.py new file mode 100644 index 00000000..c72304f4 --- /dev/null +++ b/src/tests/amqp_imposter/tests/amqp/test_transcript.py @@ -0,0 +1,63 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.amqp import transcript as tr + + +def make_transcript(max_lines: int, count: int) -> tr.Transcript: + transcript = tr.Transcript(max_lines=max_lines) + + for _ in range(count): + transcript.add( + micros=0, + epoch_micros=0, + event=tr.TranscriptEvent.TCP_CONNECTED, + ) + return transcript + + +def test_indices() -> None: + transcript = make_transcript(max_lines=tr.DEFAULT_MAX_TRANSCRIPT_LINES, count=3) + + assert [line.index for line in transcript.lines] == [0, 1, 2] + + +def test_recorded() -> None: + transcript = make_transcript(max_lines=2, count=5) + + assert transcript.recorded == 5 + + +def test_drop_oldest() -> None: + transcript = make_transcript(max_lines=2, count=5) + + assert [line.index for line in transcript.lines] == [3, 4] + + +def test_under_cap() -> None: + transcript = make_transcript(max_lines=tr.DEFAULT_MAX_TRANSCRIPT_LINES, count=50) + + assert len(transcript.lines) == 50 + + +@pytest.mark.parametrize("max_lines", (1, 3, 10, tr.DEFAULT_MAX_TRANSCRIPT_LINES)) +def test_first_index(max_lines: int) -> None: + transcript = make_transcript(max_lines=max_lines, count=10) + + first = transcript.lines[0].index + + assert first == transcript.recorded - len(transcript.lines) diff --git a/src/tests/amqp_imposter/tests/control/__init__.py b/src/tests/amqp_imposter/tests/control/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/tests/control/conftest.py b/src/tests/amqp_imposter/tests/control/conftest.py new file mode 100644 index 00000000..f7133390 --- /dev/null +++ b/src/tests/amqp_imposter/tests/control/conftest.py @@ -0,0 +1,30 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import shutil +import tempfile +from collections.abc import Iterator +from pathlib import Path + +import pytest + +from .helpers import SHORT_TEMP_DIR + + +@pytest.fixture +def socket_path() -> Iterator[Path]: + base_path = tempfile.mkdtemp(dir=SHORT_TEMP_DIR) + yield Path(base_path) / "control.sock" + shutil.rmtree(base_path, ignore_errors=True) diff --git a/src/tests/amqp_imposter/tests/control/helpers.py b/src/tests/amqp_imposter/tests/control/helpers.py new file mode 100644 index 00000000..bec288f0 --- /dev/null +++ b/src/tests/amqp_imposter/tests/control/helpers.py @@ -0,0 +1,35 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + +from grpc import aio + +from amqp_imposter.amqp.server import AmqpServer +from amqp_imposter.amqp.session import Session +from amqp_imposter.control import transport as tp +from amqp_imposter.imposter import Imposter + +SHORT_TEMP_DIR = "/tmp" + + +def make_imposter() -> Imposter: + session = Session() + + return Imposter(session=session, amqp_server=AmqpServer(session)) + + +def channel_for(path: Path) -> aio.Channel: + return aio.insecure_channel(tp.uds_target(path), options=tp.GRPC_CHANNEL_OPTS) diff --git a/src/tests/amqp_imposter/tests/control/test_cli.py b/src/tests/amqp_imposter/tests/control/test_cli.py new file mode 100644 index 00000000..332da6e1 --- /dev/null +++ b/src/tests/amqp_imposter/tests/control/test_cli.py @@ -0,0 +1,252 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import json +import socket +import stat +import sys +from pathlib import Path +from typing import Any + +import pytest + +from amqp_imposter.amqp.server import EPHEMERAL_PORT, HOST +from amqp_imposter.control import cli +from amqp_imposter.control import transport as tp +from amqp_imposter.control.v1 import imposter_pb2 as pb +from amqp_imposter.control.v1 import imposter_pb2_grpc as pb_grpc + +from .helpers import channel_for + +STARTUP_TIMEOUT = 5.0 +SOCK_PATH_SPECIFIED = "/tmp/given.sock" + + +async def cli_start_imposter(*arguments: str) -> asyncio.subprocess.Process: + return await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "amqp_imposter.control", + *arguments, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + +async def read_announcement(process: asyncio.subprocess.Process) -> dict[str, Any]: + assert process.stdout is not None + + line = await asyncio.wait_for(process.stdout.readline(), STARTUP_TIMEOUT) + assert line, "not ready" + + document: dict[str, Any] = json.loads(line) + return document + + +async def terminate(process: asyncio.subprocess.Process) -> None: + process.terminate() + await asyncio.wait_for(process.wait(), STARTUP_TIMEOUT) + + +def test_sock_flag() -> None: + assert cli.parse_args(["--socket", SOCK_PATH_SPECIFIED]).socket == Path( + SOCK_PATH_SPECIFIED + ) + + +def test_sock_default() -> None: + assert cli.parse_args([]).socket is None + + +def test_amqp_port_flag() -> None: + assert cli.parse_args(["--amqp-port", "5672"]).amqp_port == 5672 + + +def test_amqp_port_default() -> None: + assert cli.parse_args([]).amqp_port == EPHEMERAL_PORT + + +@pytest.mark.parametrize( + "argv", + ( + ["bogus"], + ["--amqp-port", "nope"], + ["--amqp-port"], + ["--socket"], + ["--nope"], + ["serve"], + ), +) +def test_usage_errors(argv: list[str]) -> None: + with pytest.raises(SystemExit) as e: + cli.parse_args(argv) + + assert e.value.code == cli.ReturnCodes.USAGE_ERROR + + +async def test_readiness_announcement(socket_path: Path) -> None: + process = await cli_start_imposter("--socket", str(socket_path)) + + announcement = await read_announcement(process) + + assert set(announcement) == {"socket", "amqp_port"} + assert announcement["socket"] == str(socket_path) + assert isinstance(announcement["amqp_port"], int) + + await terminate(process) + + +async def test_usable_when_ready(socket_path: Path) -> None: + process = await cli_start_imposter("--socket", str(socket_path)) + await read_announcement(process) + + channel = channel_for(socket_path) + reply = await pb_grpc.ImposterStub(channel).Status(pb.StatusRequest()) + + assert reply.transcript_lines > 0 + + await channel.close(None) + await terminate(process) + + +async def test_announced_port_is_bound(socket_path: Path) -> None: + process = await cli_start_imposter("--socket", str(socket_path), "--amqp-port", "0") + announcement = await read_announcement(process) + + channel = channel_for(socket_path) + reply = await pb_grpc.ImposterStub(channel).Status(pb.StatusRequest()) + + assert reply.amqp_port == announcement["amqp_port"] + + await channel.close(None) + await terminate(process) + + +async def test_sock_removed_on_signal(socket_path: Path) -> None: + process = await cli_start_imposter("--socket", str(socket_path)) + await read_announcement(process) + + await terminate(process) + + assert not await asyncio.to_thread(socket_path.exists) + + +async def test_sock_announce() -> None: + process = await cli_start_imposter() + announcement = await read_announcement(process) + + socket_path = Path(announcement["socket"]) + + assert socket_path.name == tp.CONTROL_SOCK_NAME + assert socket_path.parent.parent == tp.CONTROL_SOCK_DIR_PARENT + assert socket_path.parent.name.startswith("amqp-imposter-") + + await terminate(process) + + +async def test_private_sock_dir() -> None: + process = await cli_start_imposter() + announcement = await read_announcement(process) + directory = Path(announcement["socket"]).parent + + mode = await asyncio.to_thread(lambda: directory.stat().st_mode) + + assert stat.S_IMODE(mode) == 0o700 + + await terminate(process) + + +async def test_generated_sock_cleaned_up() -> None: + process = await cli_start_imposter() + announcement = await read_announcement(process) + directory = Path(announcement["socket"]).parent + + await terminate(process) + + assert not await asyncio.to_thread(directory.exists) + + +async def test_specified_sock_not_cleaned_up(socket_path: Path) -> None: + process = await cli_start_imposter("--socket", str(socket_path)) + await read_announcement(process) + + await terminate(process) + + assert await asyncio.to_thread(socket_path.parent.exists) + + +async def test_sock_taken(socket_path: Path) -> None: + process = await cli_start_imposter("--socket", str(socket_path)) + await read_announcement(process) + + intruder = await cli_start_imposter("--socket", str(socket_path)) + code = await asyncio.wait_for(intruder.wait(), STARTUP_TIMEOUT) + + assert code == cli.ReturnCodes.RUNTIME_ERROR + assert await asyncio.to_thread(socket_path.exists) + + await terminate(process) + + +def bind_stale_sock(sock_path: Path) -> None: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.bind(str(sock_path)) + + +async def test_stale_sock_replaced(socket_path: Path) -> None: + await asyncio.to_thread(bind_stale_sock, socket_path) + + process = await cli_start_imposter("--socket", str(socket_path)) + announcement = await read_announcement(process) + + assert announcement["socket"] == str(socket_path) + + await terminate(process) + + +async def test_keep_regular_file_at_sock_path(socket_path: Path) -> None: + await asyncio.to_thread(socket_path.write_text, "not a socket") + + process = await cli_start_imposter("--socket", str(socket_path)) + code = await asyncio.wait_for(process.wait(), STARTUP_TIMEOUT) + + assert code == cli.ReturnCodes.RUNTIME_ERROR + assert await asyncio.to_thread(socket_path.read_text) == "not a socket" + + +async def test_keep_directory_at_sock_path(socket_path: Path) -> None: + await asyncio.to_thread(socket_path.mkdir) + + process = await cli_start_imposter("--socket", str(socket_path)) + code = await asyncio.wait_for(process.wait(), STARTUP_TIMEOUT) + + assert code == cli.ReturnCodes.RUNTIME_ERROR + assert await asyncio.to_thread(socket_path.is_dir) + + +async def test_amqp_port_taken(socket_path: Path) -> None: + with socket.socket() as taken: + taken.bind((HOST, 0)) + taken.listen() + port = str(taken.getsockname()[1]) + + intruder = await cli_start_imposter( + "--socket", str(socket_path), "--amqp-port", port + ) + code = await asyncio.wait_for(intruder.wait(), STARTUP_TIMEOUT) + + assert code == cli.ReturnCodes.RUNTIME_ERROR + assert not await asyncio.to_thread(socket_path.exists) diff --git a/src/tests/amqp_imposter/tests/control/test_config_proto.py b/src/tests/amqp_imposter/tests/control/test_config_proto.py new file mode 100644 index 00000000..55d4d5af --- /dev/null +++ b/src/tests/amqp_imposter/tests/control/test_config_proto.py @@ -0,0 +1,256 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import replace + +import pytest + +from amqp_imposter.amqp import session as se +from amqp_imposter.amqp import transcript as tr +from amqp_imposter.control import config_proto as cp +from amqp_imposter.control.v1 import imposter_pb2 as pb +from amqp_imposter.imposter import ImposterError +from amqp_imposter.model import constants as c +from amqp_imposter.model import method_scripts as scr +from amqp_imposter.model import method_weights as mw +from amqp_imposter.model import methods as m +from amqp_imposter.model import negotiation_options as no + +from ..helpers import filled + +CONFIGS = ( + se.DEFAULT_SESSION_CONFIG, + replace(se.DEFAULT_SESSION_CONFIG, seed=7, stop_weight=1, idle_millis=1), + replace(se.DEFAULT_SESSION_CONFIG, max_buffer=4096, max_transcript_lines=1), + replace( + se.DEFAULT_SESSION_CONFIG, + negotiation_options=no.NegotiationOptions( + mechanisms=("PLAIN", "AMQPLAIN"), + locales=("en_US", "en_GB"), + publisher_confirms=False, + basic_nack=False, + consumer_cancel_notify=False, + channel_max=7, + frame_max=8192, + heartbeat=60, + ), + ), + replace( + se.DEFAULT_SESSION_CONFIG, + weights=( + mw.WeightedPattern(m.BasicAck(delivery_tag=2), 0), + mw.WeightedPattern(m.BasicNack(), 8), + ), + ), + replace( + se.DEFAULT_SESSION_CONFIG, + idle_weights=( + mw.WeightedPattern(mw.UNPROMPTED_CONNECTION_CLOSE, 0), + mw.WeightedPattern(m.BasicDeliver(), 8), + ), + ), + replace( + se.DEFAULT_SESSION_CONFIG, + script=( + scr.ScriptRule(scr.Received(m.BasicPublish()), then_send=m.BasicAck()), + scr.ScriptRule( + scr.Sent(m.BasicAck(delivery_tag=2)), then_send=m.BasicDeliver() + ), + ), + max_sends=3, + ), +) + + +@pytest.mark.parametrize("config", CONFIGS, ids=range(len(CONFIGS))) +def test_compose_from_to(config: se.SessionConfig) -> None: + assert cp.from_proto(cp.to_proto(config)) == config + + +@pytest.mark.parametrize("config", CONFIGS, ids=range(len(CONFIGS))) +def test_survives_the_wire(config: se.SessionConfig) -> None: + encoded = cp.to_proto(config).SerializeToString() + + assert cp.from_proto(pb.SessionConfig.FromString(encoded)) == config + + +def test_all_fields_missing() -> None: + assert cp.from_proto(pb.SessionConfig()) == se.DEFAULT_SESSION_CONFIG + + +def test_some_fields_missing() -> None: + assert cp.from_proto(pb.SessionConfig(seed=7)) == replace( + se.DEFAULT_SESSION_CONFIG, seed=7 + ) + + +def test_negotiation_partial() -> None: + message = pb.SessionConfig(negotiation_options=pb.NegotiationOptions(heartbeat=60)) + + assert cp.from_proto(message).negotiation_options == replace( + no.DEFAULT_NEGOTIATION_OPTIONS, heartbeat=60 + ) + + +def test_encoded_config_is_total() -> None: + message = cp.to_proto(se.DEFAULT_SESSION_CONFIG) + + assert {field.name for field, _ in message.ListFields()} == { + "seed", + "negotiation_options", + "stop_weight", + "idle_millis", + "max_buffer", + "max_transcript_lines", + "max_sends", + } + + +def test_encoded_negotiation_is_total() -> None: + message = cp.negotiation_to_proto(no.DEFAULT_NEGOTIATION_OPTIONS) + + assert {field.name for field, _ in message.ListFields()} == { + field.name for field in pb.NegotiationOptions.DESCRIPTOR.fields + } + + +@pytest.mark.parametrize("kind", m.METHOD_KINDS, ids=lambda k: k.__name__) +def test_pattern_message_covers_exact_fields(kind: type[m.Method]) -> None: + arms = pb.Pattern.DESCRIPTOR.oneofs_by_name["method"].fields + [message] = [ + one.message_type + for one in arms + if one.message_type is not None + and one.message_type.name == kind.__name__ + "Pattern" + ] + + assert {one.name for one in message.fields} == set(m.exact_fields(kind)) + + +@pytest.mark.parametrize("kind", m.METHOD_KINDS, ids=lambda k: k.__name__) +def test_empty_pattern_compose_from_to(kind: type[m.Method]) -> None: + assert cp.pattern_from_proto(cp.pattern_to_proto(kind())) == kind() + + +@pytest.mark.parametrize( + "pattern", + ( + m.BasicAck(delivery_tag=2), + m.BasicNack(delivery_tag=0), + m.BasicCancel(consumer_tag="ctag"), + m.BasicDeliver(consumer_tag="ctag", delivery_tag=1), + m.ConnectionClose(reply_code=320, class_id=0, method_id=0), + m.BasicReturn(reply_code=312, exchange="ex", routing_key="rk"), + m.ConnectionStartOk(mechanism="PLAIN", locale="en_US"), + m.ConnectionStart(mechanisms=("PLAIN", "AMQPLAIN"), locales=("en_US", "en_GB")), + ), + ids=lambda p: type(p).__name__, +) +def test_pattern_compose_from_to(pattern: m.Method) -> None: + assert cp.pattern_from_proto(cp.pattern_to_proto(pattern)) == pattern + + +@pytest.mark.parametrize("kind", m.METHOD_KINDS, ids=lambda k: k.__name__) +def test_filled_pattern_compose_from_to(kind: type[m.Method]) -> None: + pattern = filled(kind) + + assert cp.pattern_from_proto(cp.pattern_to_proto(pattern)) == pattern + + +def test_pattern_keeps_holes() -> None: + pattern = cp.pattern_from_proto(cp.pattern_to_proto(m.BasicAck())) + + assert m.is_method_pattern(pattern) + + +def test_pattern_fills_holes() -> None: + pattern = cp.pattern_from_proto(cp.pattern_to_proto(m.BasicAck(delivery_tag=2))) + + assert not m.is_method_pattern(pattern) + + +def test_weighted_compose_from_to() -> None: + weighted = mw.WeightedPattern(m.BasicNack(delivery_tag=2), 8) + + assert cp.weighted_from_proto(cp.weighted_to_proto(weighted)) == weighted + + +def test_schema_base_weight_matches_the_model() -> None: + assert pb.BASE_WEIGHT == mw.BASE_WEIGHT + + +def test_missing_weight_is_base() -> None: + message = pb.Weighted(pattern=pb.Pattern(basic_ack=pb.BasicAckPattern())) + + assert cp.weighted_from_proto(message).weight == mw.BASE_WEIGHT + + +def test_zero_weight_is_kept() -> None: + message = pb.Weighted(pattern=pb.Pattern(basic_ack=pb.BasicAckPattern()), weight=0) + + assert cp.weighted_from_proto(message).weight == 0 + + +def test_empty_pattern_is_bad_value() -> None: + with pytest.raises(ImposterError): + cp.pattern_from_proto(pb.Pattern()) + + +@pytest.mark.parametrize( + "rule", + ( + scr.ScriptRule(scr.Received(m.BasicPublish()), then_send=m.BasicNack()), + scr.ScriptRule( + scr.Sent(m.BasicCancel(consumer_tag="ctag")), + then_send=m.ConnectionClose(), + ), + ), + ids=lambda rule: type(rule.when).__name__, +) +def test_script_compose_from_to(rule: scr.ScriptRule) -> None: + assert cp.script_rule_from_proto(cp.script_rule_to_proto(rule)) == rule + + +# fmt: off +BAD_CONFIGS: tuple[pb.SessionConfig, ...] = ( + pb.SessionConfig(stop_weight=0), + pb.SessionConfig(max_sends=0), + pb.SessionConfig(weights=(pb.Weighted(weight=1),)), + pb.SessionConfig(weights=(pb.Weighted(pattern=pb.Pattern(), weight=1),)), + pb.SessionConfig(idle_weights=(pb.Weighted(weight=1),)), + pb.SessionConfig(idle_weights=(pb.Weighted(pattern=pb.Pattern(), weight=1),)), + pb.SessionConfig(script=(pb.Scripted(then_send=pb.Pattern(basic_ack=pb.BasicAckPattern())),)), + pb.SessionConfig(script=(pb.Scripted(sent=pb.Pattern(basic_ack=pb.BasicAckPattern())),)), + pb.SessionConfig(script=(pb.Scripted(sent=pb.Pattern(basic_ack=pb.BasicAckPattern()), then_send=pb.Pattern()),)), + pb.SessionConfig(script=(pb.Scripted(sent=pb.Pattern(), then_send=pb.Pattern(basic_ack=pb.BasicAckPattern())),)), + pb.SessionConfig(script=(pb.Scripted(received=pb.Pattern(), then_send=pb.Pattern(basic_ack=pb.BasicAckPattern())),)), + pb.SessionConfig(idle_millis=0), + pb.SessionConfig(max_buffer=0), + pb.SessionConfig(max_transcript_lines=0), + pb.SessionConfig(max_transcript_lines=tr.HARD_MAX_TRANSCRIPT_LINES + 1), + pb.SessionConfig(negotiation_options=pb.NegotiationOptions(mechanisms=pb.StringList())), + pb.SessionConfig(negotiation_options=pb.NegotiationOptions(locales=pb.StringList())), + pb.SessionConfig(negotiation_options=pb.NegotiationOptions(frame_max=100)), + pb.SessionConfig(negotiation_options=pb.NegotiationOptions(frame_max=c.FRAME_MIN_FLOOR - 1)), + pb.SessionConfig(negotiation_options=pb.NegotiationOptions(heartbeat=c.HEARTBEAT_MAX + 1)), + pb.SessionConfig(negotiation_options=pb.NegotiationOptions(channel_max=c.CHANNEL_MAX_CEIL + 1)), +) +# fmt: on +@pytest.mark.parametrize("message", BAD_CONFIGS, ids=range(len(BAD_CONFIGS))) +def test_errors(message: pb.SessionConfig) -> None: + with pytest.raises(ImposterError) as e: + cp.from_proto(message) + + assert str(e.value) diff --git a/src/tests/amqp_imposter/tests/control/test_method_proto.py b/src/tests/amqp_imposter/tests/control/test_method_proto.py new file mode 100644 index 00000000..e5182cb2 --- /dev/null +++ b/src/tests/amqp_imposter/tests/control/test_method_proto.py @@ -0,0 +1,200 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import fields +from datetime import UTC, datetime +from decimal import Decimal + +import pytest + +from amqp_imposter.amqp import transcript as tr +from amqp_imposter.control import method_proto as mp +from amqp_imposter.control.v1 import imposter_pb2 as pb +from amqp_imposter.imposter import ImposterError +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import concrete + +SAMPLES = 10 + + +NESTED_TABLE = mf.Table({"inner": 1, "deeper": mf.Table({"leaf": "x"})}) + +TABLE_VALUES: tuple[mf.TableValue, ...] = ( + True, + False, + 0, + -1, + 2**40, + "", + "text", + b"", + b"\xde\xad\xbe\xef", + Decimal("1.25"), + Decimal("-0.001"), + datetime(2026, 9, 6, 12, 30, 45, 123456, tzinfo=UTC), + mf.Void(), + NESTED_TABLE, + (1, "two", False), + (mf.Void(), NESTED_TABLE), + (), +) + +PROPERTIES: tuple[mf.MessageProperties, ...] = ( + mf.MessageProperties(), + mf.MessageProperties(content_type="text/plain"), + mf.MessageProperties(priority=0, delivery_mode=2, timestamp=0), + mf.MessageProperties(headers=mf.Table()), + mf.MessageProperties(headers=NESTED_TABLE, app_id="", user_id="u"), +) + + +@pytest.mark.parametrize("kind", m.METHOD_KINDS, ids=lambda k: k.__name__) +def test_all_methods_total(kind: type[m.Method]) -> None: + [descriptor] = [ + field.message_type + for field in pb.Method.DESCRIPTOR.fields + if field.message_type and field.message_type.name == kind.__name__ + ] + + proto_fields = {field.name for field in descriptor.fields} + real_fields = {field.name for field in fields(kind)} + + assert proto_fields == real_fields + + +@pytest.mark.parametrize("seed", range(SAMPLES)) +@pytest.mark.parametrize("kind", m.METHOD_KINDS, ids=lambda k: k.__name__) +def test_concrete_compose_from_to(kind: type[m.Method], seed: int) -> None: + method = concrete(kind, seed) + + assert mp.from_proto(mp.to_proto(method)) == method + + +@pytest.mark.parametrize("seed", range(SAMPLES)) +@pytest.mark.parametrize("kind", m.METHOD_KINDS, ids=lambda k: k.__name__) +def test_concrete_compose_from_serialise_to(seed: int, kind: type[m.Method]) -> None: + method = concrete(kind, seed) + encoded = mp.to_proto(method).SerializeToString() + + assert mp.from_proto(pb.Method.FromString(encoded)) == method + + +def test_empty_method() -> None: + with pytest.raises(ImposterError): + mp.from_proto(pb.Method()) + + +def test_empty_value() -> None: + with pytest.raises(ImposterError): + mp.value_from_proto(pb.AmqpValue()) + + +@pytest.mark.parametrize("value", TABLE_VALUES, ids=repr) +def test_table_values_compose_from_To(value: mf.TableValue) -> None: + assert mp.value_from_proto(mp.value_to_proto(value)) == value + + +@pytest.mark.parametrize("value", TABLE_VALUES, ids=repr) +def test_table_value_types_compose_from_to(value: mf.TableValue) -> None: + decoded = mp.value_from_proto(mp.value_to_proto(value)) + + assert type(decoded) is type(value) + + +def test_table_compose_from_to() -> None: + table = mf.Table( + { + "bool": True, + "long": 7, + "string": "s", + "bytes": b"\x00\xff", + "decimal": Decimal("3.5"), + "timestamp": datetime(2026, 1, 1, tzinfo=UTC), + "void": mf.Void(), + "table": NESTED_TABLE, + "array": (1, mf.Void()), + } + ) + assert mp.table_from_proto(mp.table_to_proto(table)) == table + + +def test_empty_table_compose_from_to() -> None: + assert mp.table_from_proto(mp.table_to_proto(mf.Table())) == mf.Table() + + +@pytest.mark.parametrize("properties", PROPERTIES, ids=range(len(PROPERTIES))) +def test_properties_compose_from_to(properties: mf.MessageProperties) -> None: + assert mp.properties_from_proto(mp.properties_to_proto(properties)) == properties + + +def test_empty_properties_vs_absent() -> None: + empty = m.BasicDeliver( + consumer_tag="c", delivery_tag=1, properties=mf.MessageProperties() + ) + absent = m.BasicDeliver(consumer_tag="c", delivery_tag=1, properties=None) + + assert mp.from_proto(mp.to_proto(empty)) != mp.from_proto(mp.to_proto(absent)) + + +def test_empty_arguments_vs_absent() -> None: + empty = m.QueueDeclare(queue="q", arguments=mf.Table()) + absent = m.QueueDeclare(queue="q", arguments=None) + + assert mp.from_proto(mp.to_proto(empty)) != mp.from_proto(mp.to_proto(absent)) + + +def test_line_without_method() -> None: + line = tr.TranscriptLine( + index=3, + micros=7, + epoch_micros=1732, + event=tr.TranscriptEvent.PROTOCOL_HEADER, + wire_bytes=b"AMQP\x00\x00\x09\x01", + ) + message = mp.line_to_proto(line) + + assert not message.HasField("method") + assert (message.index, message.micros, message.epoch_micros) == (3, 7, 1732) + assert message.wire_bytes == b"AMQP\x00\x00\x09\x01" + assert message.detail == "" + + +def test_line_with_method() -> None: + method = m.BasicDeliver(consumer_tag="ct", delivery_tag=1) + line = tr.TranscriptLine( + index=6, + micros=10, + epoch_micros=1735, + event=tr.TranscriptEvent.METHOD_EGRESS, + method=method, + wire_bytes=b"\x01\x02\x03", + ) + message = mp.line_to_proto(line) + + assert message.method == mp.to_proto(method) + + +def test_line_detail() -> None: + line = tr.TranscriptLine( + index=4, + micros=8, + epoch_micros=1733, + event=tr.TranscriptEvent.FRAME_MALFORMED, + detail="short frame", + wire_bytes=b"\xff\xff", + ) + assert mp.line_to_proto(line).detail == "short frame" diff --git a/src/tests/amqp_imposter/tests/control/test_serve.py b/src/tests/amqp_imposter/tests/control/test_serve.py new file mode 100644 index 00000000..be525e58 --- /dev/null +++ b/src/tests/amqp_imposter/tests/control/test_serve.py @@ -0,0 +1,172 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from pathlib import Path + +import grpc +import pytest +from grpc import aio +from grpc_reflection.v1alpha import reflection_pb2, reflection_pb2_grpc + +from amqp_imposter.amqp.session import DEFAULT_SESSION_CONFIG +from amqp_imposter.control import service as sv +from amqp_imposter.control.v1 import imposter_pb2 as pb +from amqp_imposter.control.v1 import imposter_pb2_grpc as pb_grpc +from amqp_imposter.imposter import Imposter + +from .helpers import channel_for, make_imposter + +SEED = 7 +LONG_TRANSCRIPT = 2000 + + +@asynccontextmanager +async def running(imposter: Imposter, path: Path) -> AsyncGenerator[aio.Channel]: + server = await sv.serve(path, imposter) + channel = channel_for(path) + try: + yield channel + finally: + await channel.close(None) + await server.stop(None) + + +async def transcript_lines(channel: aio.Channel) -> tuple[pb.TranscriptLine, ...]: + stub = pb_grpc.ImposterStub(channel) + return tuple([line async for line in stub.Transcript(pb.TranscriptRequest())]) + + +async def test_status_compose_from_to(socket_path: Path) -> None: + async with running(make_imposter(), socket_path) as channel: + reply = await pb_grpc.ImposterStub(channel).Status(pb.StatusRequest()) + + assert reply.transcript_lines == 1 + + +async def test_configure_compose_from_to(socket_path: Path) -> None: + imposter = make_imposter() + + async with running(imposter, socket_path) as channel: + reply = await pb_grpc.ImposterStub(channel).Configure( + pb.ConfigureRequest(config=pb.SessionConfig(seed=SEED)) + ) + + assert reply.config.seed == SEED + assert imposter.session.config.seed == SEED + + +async def test_reset_compose_from_to(socket_path: Path) -> None: + imposter = make_imposter() + + async with running(imposter, socket_path) as channel: + stub = pb_grpc.ImposterStub(channel) + await stub.Configure(pb.ConfigureRequest(config=pb.SessionConfig(seed=SEED))) + await stub.Reset(pb.ResetRequest()) + + assert imposter.session.config == DEFAULT_SESSION_CONFIG + + +async def test_transcript_stream(socket_path: Path) -> None: + imposter = make_imposter() + imposter.session.on_connected() + + async with running(imposter, socket_path) as channel: + lines = await transcript_lines(channel) + + assert [line.index for line in lines] == [ + line.index for line in imposter.session.transcript() + ] + + +async def test_long_transcript_stream(socket_path: Path) -> None: + imposter = make_imposter() + + for _ in range(LONG_TRANSCRIPT): + imposter.session.on_connected() + imposter.session.on_disconnected() + + async with running(imposter, socket_path) as channel: + lines = await transcript_lines(channel) + + assert len(lines) > LONG_TRANSCRIPT + + +async def test_bad_configure_is_invalid_argument(socket_path: Path) -> None: + imposter = make_imposter() + + async with running(imposter, socket_path) as channel: + with pytest.raises(aio.AioRpcError) as e: + await pb_grpc.ImposterStub(channel).Configure( + pb.ConfigureRequest(config=pb.SessionConfig(seed=SEED, stop_weight=0)) + ) + + assert e.value.code() == grpc.StatusCode.INVALID_ARGUMENT + assert imposter.session.config == DEFAULT_SESSION_CONFIG + + +async def test_no_imposter_is_unavailable(socket_path: Path) -> None: + channel = channel_for(socket_path) + + with pytest.raises(aio.AioRpcError) as e: + await pb_grpc.ImposterStub(channel).Status(pb.StatusRequest()) + + assert e.value.code() == grpc.StatusCode.UNAVAILABLE + + await channel.close(None) + + +async def test_concurrent_clients(socket_path: Path) -> None: + async with running(make_imposter(), socket_path) as one: + other = channel_for(socket_path) + + await pb_grpc.ImposterStub(one).Configure( + pb.ConfigureRequest(config=pb.SessionConfig(seed=SEED)) + ) + reply = await pb_grpc.ImposterStub(other).Status(pb.StatusRequest()) + + assert reply.config.seed == SEED + + await other.close(None) + + +async def test_independent_servers(socket_path: Path) -> None: + other_path = socket_path.with_name("other.sock") + + async with ( + running(make_imposter(), socket_path) as one, + running(make_imposter(), other_path) as other, + ): + await pb_grpc.ImposterStub(one).Configure( + pb.ConfigureRequest(config=pb.SessionConfig(seed=SEED)) + ) + reply = await pb_grpc.ImposterStub(other).Status(pb.StatusRequest()) + + assert reply.config.seed != SEED + + +async def test_reflection_lists_the_service(socket_path: Path) -> None: + async with running(make_imposter(), socket_path) as channel: + stub = reflection_pb2_grpc.ServerReflectionStub(channel) + request = reflection_pb2.ServerReflectionRequest(list_services="") + + services = { + service.name + async for response in stub.ServerReflectionInfo(iter([request])) + for service in response.list_services_response.service + } + + assert pb.DESCRIPTOR.services_by_name["Imposter"].full_name in services diff --git a/src/tests/amqp_imposter/tests/control/test_servicer.py b/src/tests/amqp_imposter/tests/control/test_servicer.py new file mode 100644 index 00000000..abc47f8a --- /dev/null +++ b/src/tests/amqp_imposter/tests/control/test_servicer.py @@ -0,0 +1,152 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from dataclasses import replace + +import pytest + +from amqp_imposter.amqp.server import HOST +from amqp_imposter.amqp.session import DEFAULT_SESSION_CONFIG +from amqp_imposter.amqp.transcript import TranscriptEvent as ev +from amqp_imposter.control import config_proto as cp +from amqp_imposter.control import service as sv +from amqp_imposter.control.v1 import imposter_pb2 as pb +from amqp_imposter.imposter import Imposter, ImposterError +from tests.amqp.helpers import poll_until, transcript_events + +from .helpers import make_imposter + +WHEN = 2003 + + +def test_status_output() -> None: + imposter = make_imposter() + + reply = sv.status_reply(imposter, epoch_micros=WHEN) + + assert not reply.HasField("amqp_port") + assert reply.amqp_greeted is False + assert reply.epoch_micros == WHEN + assert reply.transcript_lines == 1 + assert reply.config == cp.to_proto(DEFAULT_SESSION_CONFIG) + + +async def test_status_echoes_config() -> None: + imposter = make_imposter() + await sv.configure(imposter, pb.SessionConfig(seed=7)) + + reply = sv.status_reply(imposter, epoch_micros=WHEN) + + assert cp.from_proto(reply.config) == imposter.session.config + + +async def test_configure_applies() -> None: + imposter = make_imposter() + + await sv.configure(imposter, pb.SessionConfig(seed=9)) + + assert imposter.session.config == replace(DEFAULT_SESSION_CONFIG, seed=9) + + +async def test_configure_resets() -> None: + imposter = make_imposter() + await sv.configure(imposter, pb.SessionConfig(seed=7, idle_millis=7)) + imposter.session.on_connected() + + await sv.configure(imposter, pb.SessionConfig(seed=9)) + + assert imposter.session.config == replace(DEFAULT_SESSION_CONFIG, seed=9) + assert sv.status_reply(imposter, WHEN).transcript_lines == 1 + + +async def test_empty_configure_is_reset() -> None: + imposter = make_imposter() + await sv.configure(imposter, pb.SessionConfig(seed=7)) + + await sv.configure(imposter, pb.SessionConfig()) + + assert imposter.session.config == DEFAULT_SESSION_CONFIG + + +async def test_bad_configure_noop() -> None: + imposter = make_imposter() + await sv.configure(imposter, pb.SessionConfig(seed=7)) + imposter.session.on_connected() + config = imposter.session.config + lines = sv.status_reply(imposter, WHEN).transcript_lines + + with pytest.raises(ImposterError): + await sv.configure(imposter, pb.SessionConfig(seed=9, stop_weight=0)) + + assert imposter.session.config == config + assert sv.status_reply(imposter, WHEN).transcript_lines == lines + + +def test_transcript_line_count() -> None: + imposter = make_imposter() + imposter.session.on_connected() + + reply = sv.status_reply(imposter, WHEN) + + assert reply.transcript_lines == len(imposter.session.transcript()) + + +async def test_line_count_includes_dropped() -> None: + imposter = make_imposter() + await sv.configure(imposter, pb.SessionConfig(max_transcript_lines=2)) + imposter.session.on_connected() + imposter.session.on_disconnected() + + reply = sv.status_reply(imposter, WHEN) + + assert reply.transcript_lines > len(imposter.session.transcript()) + + +async def connect_client( + imposter: Imposter, +) -> tuple[asyncio.StreamReader, asyncio.StreamWriter]: + port = await imposter.amqp_server.start() + client = await asyncio.wait_for(asyncio.open_connection(HOST, port), 2.0) + await poll_until(lambda: imposter.amqp_server.writer is not None) + + return client + + +async def test_configure_drops_connected_client() -> None: + imposter = make_imposter() + reader, writer = await connect_client(imposter) + + await sv.configure(imposter, pb.SessionConfig(seed=9)) + + assert await asyncio.wait_for(reader.read(), 2.0) == b"" + assert transcript_events(imposter.session.transcript()) == (ev.SESSION_STARTED,) + + writer.close() + await imposter.amqp_server.stop() + + +async def test_bad_configure_keeps_connected_client() -> None: + imposter = make_imposter() + _, writer = await connect_client(imposter) + + with pytest.raises(ImposterError): + await sv.configure(imposter, pb.SessionConfig(seed=9, stop_weight=0)) + + assert imposter.amqp_server.writer is not None + assert ev.TCP_DOWN not in transcript_events(imposter.session.transcript()) + + writer.close() + await imposter.amqp_server.stop() diff --git a/src/tests/amqp_imposter/tests/control/test_transport.py b/src/tests/amqp_imposter/tests/control/test_transport.py new file mode 100644 index 00000000..832c35a2 --- /dev/null +++ b/src/tests/amqp_imposter/tests/control/test_transport.py @@ -0,0 +1,35 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from pathlib import Path + +import pytest + +from amqp_imposter.control import transport as tp + +ENDPOINTS = tp.ImposterEndpoints(Path("/tmp/amqp-imposter-x/control.sock"), 5672) + + +def test_endpoints_compose_from_to() -> None: + assert tp.endpoints_from_json_line(ENDPOINTS.to_json_line()) == ENDPOINTS + + +@pytest.mark.parametrize( + "line", + ("", "not json", '{"socket": "/s"}', '{"socket": 1, "amqp_port": 2}'), + ids=("empty", "not_json", "missing_port", "wrong_types"), +) +def test_bad_endpoints_line(line: str) -> None: + assert tp.endpoints_from_json_line(line) is None diff --git a/src/tests/amqp_imposter/tests/e2e/README.md b/src/tests/amqp_imposter/tests/e2e/README.md new file mode 100644 index 00000000..eaff785d --- /dev/null +++ b/src/tests/amqp_imposter/tests/e2e/README.md @@ -0,0 +1,10 @@ +This directory contains a simple runner for the imposter and tests on some simple client apps. + +The goal of the e2e tests is twofold: +1. check that the system works end-to-end +2. check that the imposter is ergonomic to use and identify areas for improvement before a release + - is it easy to configure? + - is it flaky? + - does it have good defaults? + - does it have a sane probability model, reaching edge-cases and avoiding stalls? + - does it perform well enough to use in long or otherwise demanding sessions? diff --git a/src/tests/amqp_imposter/tests/e2e/__init__.py b/src/tests/amqp_imposter/tests/e2e/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/tests/e2e/generators.py b/src/tests/amqp_imposter/tests/e2e/generators.py new file mode 100644 index 00000000..b2b8fd6a --- /dev/null +++ b/src/tests/amqp_imposter/tests/e2e/generators.py @@ -0,0 +1,56 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# reusing generators from the model +from random import Random + +from amqp_imposter.model.generators import GenState, RangedInt +from amqp_imposter.model.method_fields import ( + GEN_BIT, + GEN_NONEMPTY_NAME, + GEN_NONEMPTY_SHORTSTR, + GEN_SHORTSTR, +) + +__all__ = [ + "GEN_BIT", + "GEN_DELIVERIES", + "GEN_MESSAGE_COUNT", + "GEN_NONEMPTY_NAME", + "GEN_NONEMPTY_SHORTSTR", + "GEN_PREFETCH_COUNT", + "GEN_SHORTSTR", + "client_randomness", + "distinct_names", +] + +GEN_PREFETCH_COUNT = RangedInt(1, 30) +GEN_DELIVERIES = RangedInt(1, 50) +GEN_MESSAGE_COUNT = RangedInt(1, 30) + + +def client_randomness(seed: int) -> Random: + # advance so the client doesn't draw the same randomness as the imposter + return Random(GenState(seed).advance().seed) + + +def distinct_names(randomness: Random, count: int) -> tuple[str, ...]: + names: list[str] = [] + while len(names) < count: + name = GEN_NONEMPTY_NAME.from_randomness(randomness) + if name not in names: + names.append(name) + + return tuple(names) diff --git a/src/tests/amqp_imposter/tests/e2e/property.py b/src/tests/amqp_imposter/tests/e2e/property.py new file mode 100644 index 00000000..cf1d366c --- /dev/null +++ b/src/tests/amqp_imposter/tests/e2e/property.py @@ -0,0 +1,60 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections import defaultdict +from collections.abc import Callable, Iterable +from dataclasses import dataclass + +import pytest +from google.protobuf.message import Message + +from amqp_imposter.control.client import ( + Transcript, + format_transcript_around, + pb, + transcript_line_method, +) + + +@dataclass(frozen=True, slots=True) +class Failure: + reason: str + line_index: int | None + + +type Property = Callable[[Transcript], Failure | None] + + +def event_and_method( + line: pb.TranscriptLine, +) -> tuple[pb.TranscriptEvent.ValueType, Message] | None: + if (method := transcript_line_method(line)) is not None: + return line.event, method + return None + + +def assert_properties(transcript: Transcript, properties: Iterable[Property]) -> None: + reasons_by_line_index: defaultdict[int | None, list[str]] = defaultdict(list) + for failure in filter(None, (prop(transcript) for prop in properties)): + reasons_by_line_index[failure.line_index].append(failure.reason) + + if reasons_by_line_index: + pytest.fail( + "\n\n".join( + "\n".join((*reasons, format_transcript_around(transcript, line_index))) + for line_index, reasons in reasons_by_line_index.items() + ), + pytrace=False, + ) diff --git a/src/tests/amqp_imposter/tests/e2e/resilient_consumer/consumer.py b/src/tests/amqp_imposter/tests/e2e/resilient_consumer/consumer.py new file mode 100644 index 00000000..a56877d1 --- /dev/null +++ b/src/tests/amqp_imposter/tests/e2e/resilient_consumer/consumer.py @@ -0,0 +1,109 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections.abc import Callable +from contextlib import suppress +from dataclasses import dataclass, field +from typing import Any + +import pika +from pika.adapters.blocking_connection import BlockingChannel, BlockingConnection +from pika.exceptions import AMQPError, ChannelClosedByBroker, ConsumerCancelled +from pika.frame import Method +from pika.spec import Basic + +HOST = "127.0.0.1" + + +@dataclass(frozen=True, slots=True) +class Delivery: + channel: BlockingChannel + delivery_tag: int + + def ack(self, multiple: bool = False) -> None: + self.channel.basic_ack(self.delivery_tag, multiple=multiple) + + def nack(self, requeue: bool = True, multiple: bool = False) -> None: + self.channel.basic_nack(self.delivery_tag, multiple=multiple, requeue=requeue) + + +type DeliveryCb = Callable[[Delivery], None] +type DeclareTopologyCb = Callable[[BlockingChannel], str] + + +def raise_consumer_cancelled(method_frame: Method[Basic.Cancel]) -> None: + raise ConsumerCancelled(method_frame) + + +@dataclass(slots=True) +class ResilientConsumer: + amqp_port: int + declare_topology: DeclareTopologyCb + consumer_tag: str + prefetch_count: int = 0 + finished: bool = field(default=False, init=False) + + def finish(self) -> None: + self.finished = True + + def start(self, on_delivery: DeliveryCb) -> None: + while not self.finished: + with suppress(AMQPError): + self.hold_connection(on_delivery) + + def hold_connection(self, on_delivery: DeliveryCb) -> None: + connection_params = pika.ConnectionParameters(host=HOST, port=self.amqp_port) + connection = pika.BlockingConnection(connection_params) + try: + while not self.finished: + self.hold_channel(connection, on_delivery) + finally: + with suppress(AMQPError): + if connection.is_open: + connection.close(200, "finished") + + def hold_channel( + self, connection: BlockingConnection, on_delivery: DeliveryCb + ) -> None: + channel = connection.channel() + channel.add_on_cancel_callback(raise_consumer_cancelled) + + with suppress(ChannelClosedByBroker): + channel.basic_qos(prefetch_count=self.prefetch_count) + + while not self.finished: + with suppress(ConsumerCancelled): + queue = self.declare_topology(channel) + self.hold_consume(channel, queue, on_delivery) + + def hold_consume( + self, channel: BlockingChannel, queue: str, on_delivery: DeliveryCb + ) -> None: + def on_message_callback( + channel: BlockingChannel, method: Basic.Deliver, *_: Any + ) -> None: + if not self.finished: + # stop processing so we're not stuck in callbacks forever + on_delivery(Delivery(channel, method.delivery_tag)) + + channel.basic_consume( + queue, + on_message_callback=on_message_callback, + auto_ack=False, + consumer_tag=self.consumer_tag, + ) + + while not self.finished: + channel.connection.process_data_events(time_limit=0.05) diff --git a/src/tests/amqp_imposter/tests/e2e/resilient_consumer/consumer_properties.py b/src/tests/amqp_imposter/tests/e2e/resilient_consumer/consumer_properties.py new file mode 100644 index 00000000..fbf31ac4 --- /dev/null +++ b/src/tests/amqp_imposter/tests/e2e/resilient_consumer/consumer_properties.py @@ -0,0 +1,184 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass +from functools import partial + +from amqp_imposter.control.client import Transcript, pb +from amqp_imposter.control.client.transcript_util import ( + format_line_name, +) + +from ..property import Failure, Property, event_and_method + + +@dataclass(frozen=True, slots=True) +class Exchange: + name: str + + +@dataclass(frozen=True, slots=True) +class Queue: + name: str + + +@dataclass(frozen=True, slots=True) +class Binding: + exchange: str + routing_key: str + + +type Declaration = Exchange | Queue | Binding +type Settlement = pb.BasicAck | pb.BasicNack | pb.BasicReject + + +def settled_delivery_tags( + unsettled: frozenset[int], settlement: Settlement +) -> frozenset[int]: + match settlement: + case pb.BasicAck(multiple=True) | pb.BasicNack(multiple=True): + return frozenset( + t + for t in unsettled + if settlement.delivery_tag == 0 or t <= settlement.delivery_tag + ) + + case _: + return unsettled & {settlement.delivery_tag} + + +def check_deliveries_acknowledged( + transcript: Transcript, prefetch_count: int +) -> Failure | None: + unsettled: frozenset[int] = frozenset() + + for line in transcript: + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.ChannelOpen()): + unsettled = frozenset() + + case (pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.BasicDeliver() as deliver): + unsettled |= {deliver.delivery_tag} + + case ( + pb.TRANSCRIPT_EVENT_METHOD_INGRESS, + pb.BasicAck() | pb.BasicNack() | pb.BasicReject() as settlement, + ): + settled = settled_delivery_tags(unsettled, settlement) + if not settled: + return Failure( + f"Settled delivery tag {settlement.delivery_tag}, " + f"which is not awaiting settlement on this channel", + line.index, + ) + + unsettled -= settled + + if len(unsettled) > prefetch_count: + return Failure( + f"Left {len(unsettled)} deliveries unsettled: expected <={prefetch_count}", + None, + ) + + return None + + +def check_after_teardown_reconsume( + transcript: Transcript, deliveries: int +) -> Failure | None: + teardown: pb.TranscriptLine | None = None + delivered = 0 + + for line in transcript: + if delivered >= deliveries: + break + + match event_and_method(line): + case ( + pb.TRANSCRIPT_EVENT_METHOD_EGRESS, + pb.ConnectionClose() | pb.ChannelClose() | pb.BasicCancel(), + ): + teardown = line + + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicConsume()): + teardown = None + + case (pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.BasicDeliver()): + delivered += 1 + + if teardown is not None: + return Failure( + f"Never consumed again after {format_line_name(teardown)}", + teardown.index, + ) + + return None + + +def check_before_consume_declare( + transcript: Transcript, topology: frozenset[Declaration] +) -> Failure | None: + declared: set[Declaration] = set() + declared_queue_names: set[str] = set() + + for line in transcript: + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.ChannelOpen()) | ( + pb.TRANSCRIPT_EVENT_METHOD_EGRESS, + pb.BasicCancel(), + ): + declared.clear() + declared_queue_names.clear() + + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.ExchangeDeclare() as declare): + declared.add(Exchange(declare.exchange)) + + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.QueueDeclare() as declare): + declared.add(Queue(declare.queue)) + + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.QueueBind() as bind): + declared.add(Binding(bind.exchange, bind.routing_key)) + + case (pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.QueueDeclareOk() as declare_ok): + declared_queue_names.add(declare_ok.queue) + + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicConsume()) if ( + missing := topology - declared + ): + return Failure( + f"Consumed before declaring {', '.join(sorted(map(repr, missing)))}", + line.index, + ) + + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicConsume() as consume) if ( + consume.queue not in declared_queue_names + ): + return Failure( + f"Consumed from {consume.queue!r}, " + f"which was not declared on this channel", + line.index, + ) + + return None + + +def properties( + prefetch_count: int, deliveries: int, topology: frozenset[Declaration] +) -> tuple[Property, ...]: + return ( + partial(check_deliveries_acknowledged, prefetch_count=prefetch_count), + partial(check_after_teardown_reconsume, deliveries=deliveries), + partial(check_before_consume_declare, topology=topology), + ) diff --git a/src/tests/amqp_imposter/tests/e2e/resilient_consumer/consumer_test.py b/src/tests/amqp_imposter/tests/e2e/resilient_consumer/consumer_test.py new file mode 100644 index 00000000..ddb14286 --- /dev/null +++ b/src/tests/amqp_imposter/tests/e2e/resilient_consumer/consumer_test.py @@ -0,0 +1,214 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass + +import pytest +from pika.adapters.blocking_connection import BlockingChannel +from pika.exchange_type import ExchangeType + +from amqp_imposter.control.client import ( + NOT_FORCED_REPLY_TEXT, + AmqpImposter, + amqp_imposter, + pb, + weighted, + when_receive, + when_send, +) + +from ..generators import ( + GEN_DELIVERIES, + GEN_NONEMPTY_NAME, + GEN_NONEMPTY_SHORTSTR, + GEN_PREFETCH_COUNT, + GEN_SHORTSTR, + client_randomness, + distinct_names, +) +from ..property import Property, assert_properties +from .consumer import Delivery, ResilientConsumer +from .consumer_properties import Binding, Declaration, Exchange, Queue, properties + +SEEDS = range(100) +TIMEOUT = 5 # real clients often have backoff so they may not be this fast by default + +ANONYMOUS_QUEUE = "" + + +@dataclass(frozen=True, slots=True) +class ConsumerScenario: + exchange: str + routing_key: str + dl_exchange: str + dl_queue: str + consumer_tag: str + prefetch_count: int + deliveries: int + + def topology(self) -> frozenset[Declaration]: + return frozenset( + ( + Exchange(self.exchange), + Exchange(self.dl_exchange), + Queue(self.dl_queue), + Queue(ANONYMOUS_QUEUE), + Binding(self.dl_exchange, ""), + Binding(self.exchange, self.routing_key), + ) + ) + + def declare_topology(self, channel: BlockingChannel) -> str: + channel.exchange_declare(self.exchange, ExchangeType.topic, durable=True) + channel.exchange_declare(self.dl_exchange, ExchangeType.fanout, durable=True) + channel.queue_declare(self.dl_queue, durable=True) + channel.queue_bind(self.dl_queue, self.dl_exchange, routing_key="") + + queue: str = channel.queue_declare( + ANONYMOUS_QUEUE, + exclusive=True, + arguments={"x-dead-letter-exchange": self.dl_exchange}, + ).method.queue + + channel.queue_bind(queue, self.exchange, routing_key=self.routing_key) + + return queue + + def properties(self) -> tuple[Property, ...]: + return properties(self.prefetch_count, self.deliveries, self.topology()) + + +def consumer_scenario(seed: int) -> ConsumerScenario: + randomness = client_randomness(seed) + exchange, dl_exchange = distinct_names(randomness, 2) + + return ConsumerScenario( + exchange=exchange, + routing_key=GEN_SHORTSTR.from_randomness(randomness), + dl_exchange=dl_exchange, + dl_queue=GEN_NONEMPTY_NAME.from_randomness(randomness), + consumer_tag=GEN_NONEMPTY_SHORTSTR.from_randomness(randomness), + prefetch_count=GEN_PREFETCH_COUNT.from_randomness(randomness), + deliveries=GEN_DELIVERIES.from_randomness(randomness), + ) + + +def consume(imposter: AmqpImposter, scenario: ConsumerScenario) -> None: + consumer = ResilientConsumer( + amqp_port=imposter.amqp_port, + declare_topology=scenario.declare_topology, + consumer_tag=scenario.consumer_tag, + prefetch_count=scenario.prefetch_count, + ) + handled = 0 + + def on_delivery(delivery: Delivery) -> None: + nonlocal handled + + if handled % 2 == 0: + delivery.ack() + else: + delivery.nack() + + handled += 1 + if handled >= scenario.deliveries: + consumer.finish() + + consumer.start(on_delivery) + + +@pytest.mark.timeout(TIMEOUT) +@pytest.mark.parametrize("seed", SEEDS) +def test_stable_connection(seed: int) -> None: + scenario = consumer_scenario(seed) + config = pb.SessionConfig(seed=seed) + + with amqp_imposter(config) as imposter: + consume(imposter, scenario) + + assert_properties(imposter.transcript(), scenario.properties()) + + +@pytest.mark.timeout(TIMEOUT) +@pytest.mark.parametrize("seed", SEEDS) +def test_connection_close_and_cancel(seed: int) -> None: + scenario = consumer_scenario(seed) + config = pb.SessionConfig( + seed=seed, + weights=( + weighted( + pb.ConnectionClosePattern(class_id=0, method_id=0), + pb.BASE_WEIGHT // 50, + ), + weighted(pb.BasicCancelPattern(), pb.BASE_WEIGHT // 10), + ), + ) + + with amqp_imposter(config) as imposter: + consume(imposter, scenario) + + assert_properties(imposter.transcript(), scenario.properties()) + + +@pytest.mark.timeout(TIMEOUT) +@pytest.mark.parametrize("seed", SEEDS) +def test_channel_close_then_connection_close(seed: int) -> None: + scenario = consumer_scenario(seed) + config = pb.SessionConfig( + script=( + when_send( + pb.BasicCancelPattern(), + then_send=pb.ConnectionClosePattern(class_id=0, method_id=0), + ), + when_receive( + pb.ChannelCloseOkPattern(), + then_send=pb.ConnectionClosePattern(class_id=0, method_id=0), + ), + ), + weights=( + weighted( + pb.ChannelClosePattern(reply_text=NOT_FORCED_REPLY_TEXT), + pb.BASE_WEIGHT // 10, + ), + weighted(pb.BasicCancelPattern(), pb.BASE_WEIGHT // 10), + ), + seed=seed, + ) + + with amqp_imposter(config) as imposter: + consume(imposter, scenario) + + assert_properties(imposter.transcript(), scenario.properties()) + + +@pytest.mark.timeout(TIMEOUT) +@pytest.mark.parametrize("seed", SEEDS) +def test_channel_close_and_cancel(seed: int) -> None: + scenario = consumer_scenario(seed) + config = pb.SessionConfig( + weights=( + weighted( + pb.ChannelClosePattern(reply_text=NOT_FORCED_REPLY_TEXT), + pb.BASE_WEIGHT // 10, + ), + weighted(pb.BasicCancelPattern(), pb.BASE_WEIGHT // 50), + ), + seed=seed, + ) + + with amqp_imposter(config) as imposter: + consume(imposter, scenario) + + assert_properties(imposter.transcript(), scenario.properties()) diff --git a/src/tests/amqp_imposter/tests/e2e/resilient_producer/producer.py b/src/tests/amqp_imposter/tests/e2e/resilient_producer/producer.py new file mode 100644 index 00000000..c302b8d2 --- /dev/null +++ b/src/tests/amqp_imposter/tests/e2e/resilient_producer/producer.py @@ -0,0 +1,152 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections.abc import Iterable +from dataclasses import dataclass, field +from functools import partial + +import pika +from pika.adapters.select_connection import SelectConnection +from pika.channel import Channel +from pika.connection import Connection +from pika.frame import Method +from pika.spec import Basic, BasicProperties + +HOST = "127.0.0.1" + + +@dataclass(frozen=True, slots=True) +class Message: + message_id: str + mandatory: bool + + +def close_connection(connection: Connection) -> None: + if connection.is_open: + connection.close() + + +@dataclass(slots=True) +class ChannelPublisher: + channel: Channel + exchange: str + routing_key: str + message_ids_by_delivery_tag: dict[int, str] = field(default_factory=dict) + returned_message_ids: set[str] = field(default_factory=set) + next_delivery_tag: int = 1 + + def publish(self, messages: Iterable[Message]) -> None: + for message in messages: + self.channel.basic_publish( + self.exchange, + self.routing_key, + message.message_id.encode(), + BasicProperties(message_id=message.message_id), + mandatory=message.mandatory, + ) + self.message_ids_by_delivery_tag[self.next_delivery_tag] = ( + message.message_id + ) + self.returned_message_ids.discard(message.message_id) + self.next_delivery_tag += 1 + + def pop_confirmed(self, confirm: Basic.Ack | Basic.Nack) -> tuple[str, ...]: + delivery_tags = ( + tuple( + delivery_tag + for delivery_tag in self.message_ids_by_delivery_tag + if delivery_tag <= confirm.delivery_tag + ) + if confirm.multiple + else (confirm.delivery_tag,) + ) + return tuple( + self.message_ids_by_delivery_tag.pop(delivery_tag) + for delivery_tag in delivery_tags + ) + + def on_return( + self, + channel: Channel, + method: Basic.Return, + properties: BasicProperties, + body: bytes, + ) -> None: + if properties.message_id is not None: + self.returned_message_ids.add(properties.message_id) + + +@dataclass(slots=True) +class ResilientProducer: + amqp_port: int + exchange: str + routing_key: str + messages: tuple[Message, ...] + acked_message_ids: set[str] = field(default_factory=set, init=False) + unroutable_message_ids: set[str] = field(default_factory=set, init=False) + + def outstanding_messages(self) -> tuple[Message, ...]: + return tuple( + m for m in self.messages if m.message_id not in self.acked_message_ids + ) + + def publish_until_acked(self) -> frozenset[str]: + while self.outstanding_messages(): + self.hold_connection() + + return frozenset(self.unroutable_message_ids) + + def hold_connection(self) -> None: + connection = SelectConnection( + pika.ConnectionParameters(host=HOST, port=self.amqp_port), + on_open_callback=lambda connection: connection.channel( + on_open_callback=self.hold_channel + ), + on_open_error_callback=lambda connection, _: connection.ioloop.stop(), + on_close_callback=lambda connection, _: connection.ioloop.stop(), + ) + connection.ioloop.start() + + def hold_channel(self, channel: Channel) -> None: + publisher = ChannelPublisher(channel, self.exchange, self.routing_key) + + channel.add_on_close_callback( + lambda channel, _: close_connection(channel.connection) + ) + channel.add_on_return_callback(publisher.on_return) + channel.confirm_delivery( + partial(self.on_confirm, publisher), + callback=lambda _: publisher.publish(self.outstanding_messages()), + ) + + def on_confirm( + self, publisher: ChannelPublisher, frame: Method[Basic.Ack | Basic.Nack] + ) -> None: + confirm = frame.method + message_ids = publisher.pop_confirmed(confirm) + + match confirm: + case Basic.Ack(): + self.acked_message_ids.update(message_ids) + self.unroutable_message_ids.update( + publisher.returned_message_ids.intersection(message_ids) + ) + case Basic.Nack(): + publisher.publish( + m for m in self.messages if m.message_id in message_ids + ) + + if not self.outstanding_messages(): + close_connection(publisher.channel.connection) diff --git a/src/tests/amqp_imposter/tests/e2e/resilient_producer/producer_properties.py b/src/tests/amqp_imposter/tests/e2e/resilient_producer/producer_properties.py new file mode 100644 index 00000000..80e00071 --- /dev/null +++ b/src/tests/amqp_imposter/tests/e2e/resilient_producer/producer_properties.py @@ -0,0 +1,148 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass, replace +from functools import partial, reduce + +from amqp_imposter.control.client import Transcript, pb + +from ..property import Failure, Property, event_and_method + + +@dataclass(frozen=True, slots=True) +class UnconfirmedPublish: + delivery_tag: int + message_id: str + returned: bool = False + + +@dataclass(frozen=True, slots=True) +class PublishState: + next_delivery_tag: int = 1 + unconfirmed_publishes: tuple[UnconfirmedPublish, ...] = () + acked_message_ids: frozenset[str] = frozenset() + unroutable_message_ids: frozenset[str] = frozenset() + + +def partition_confirmed_unconfirmed( + unconfirmed_publishes: tuple[UnconfirmedPublish, ...], + delivery_tag: int, + multiple: bool, +) -> tuple[tuple[UnconfirmedPublish, ...], tuple[UnconfirmedPublish, ...]]: + + def is_confirmed(publish: UnconfirmedPublish) -> bool: + return publish.delivery_tag == delivery_tag or ( + multiple and publish.delivery_tag < delivery_tag + ) + + return ( + tuple(p for p in unconfirmed_publishes if is_confirmed(p)), + tuple(p for p in unconfirmed_publishes if not is_confirmed(p)), + ) + + +def fold_line_into_publish_state( + state: PublishState, line: pb.TranscriptLine +) -> PublishState: + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.ChannelOpen()): + return PublishState( + acked_message_ids=state.acked_message_ids, + unroutable_message_ids=state.unroutable_message_ids, + ) + + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicPublish() as publish): + unconfirmed = UnconfirmedPublish( + state.next_delivery_tag, publish.properties.message_id + ) + return replace( + state, + next_delivery_tag=state.next_delivery_tag + 1, + unconfirmed_publishes=(*state.unconfirmed_publishes, unconfirmed), + ) + + case (pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.BasicAck() as ack): + acked, unconfirmed_publishes = partition_confirmed_unconfirmed( + state.unconfirmed_publishes, ack.delivery_tag, ack.multiple + ) + return replace( + state, + unconfirmed_publishes=unconfirmed_publishes, + acked_message_ids=state.acked_message_ids + | {p.message_id for p in acked}, + unroutable_message_ids=state.unroutable_message_ids + | {p.message_id for p in acked if p.returned}, + ) + + case (pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.BasicNack() as nack): + _, unconfirmed_publishes = partition_confirmed_unconfirmed( + state.unconfirmed_publishes, nack.delivery_tag, nack.multiple + ) + return replace(state, unconfirmed_publishes=unconfirmed_publishes) + + case (pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.BasicReturn() as basic_return): + return replace( + state, + unconfirmed_publishes=tuple( + replace(p, returned=True) + if p.message_id == basic_return.properties.message_id + else p + for p in state.unconfirmed_publishes + ), + ) + + case _: + return state + + +def check_tracked_acks( + transcript: Transcript, acked_message_ids: frozenset[str] +) -> Failure | None: + state = reduce(fold_line_into_publish_state, transcript, PublishState()) + + if missing := acked_message_ids - state.acked_message_ids: + return Failure(f"Never acked: {', '.join(sorted(missing))}", None) + + return None + + +def check_tracked_unroutable( + transcript: Transcript, unroutable_message_ids: frozenset[str] +) -> Failure | None: + state = reduce(fold_line_into_publish_state, transcript, PublishState()) + + if mismatched := unroutable_message_ids ^ state.unroutable_message_ids: + return Failure( + f"Tracked unroutable differs from observed on {', '.join(sorted(mismatched))}", + None, + ) + + return None + + +def producer_properties( + message_ids: frozenset[str], unroutable_message_ids: frozenset[str] +) -> tuple[Property, ...]: + return ( + partial( + # returned messages are also acked! + check_tracked_acks, + acked_message_ids=message_ids, + ), + partial( + check_tracked_unroutable, + unroutable_message_ids=unroutable_message_ids, + ), + ) diff --git a/src/tests/amqp_imposter/tests/e2e/resilient_producer/producer_test.py b/src/tests/amqp_imposter/tests/e2e/resilient_producer/producer_test.py new file mode 100644 index 00000000..46d13bac --- /dev/null +++ b/src/tests/amqp_imposter/tests/e2e/resilient_producer/producer_test.py @@ -0,0 +1,92 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import dataclass + +import pytest + +from amqp_imposter.control.client import ( + amqp_imposter, + pb, + weighted, +) + +from ..generators import ( + GEN_BIT, + GEN_MESSAGE_COUNT, + GEN_NONEMPTY_NAME, + GEN_SHORTSTR, + client_randomness, +) +from ..property import assert_properties +from .producer import Message, ResilientProducer +from .producer_properties import producer_properties + +SEEDS = range(100) +TIMEOUT = 5 # real clients often have backoff so they may not be this fast by default + + +@dataclass(frozen=True, slots=True) +class ProducerScenario: + exchange: str + routing_key: str + messages: tuple[Message, ...] + + +def producer_scenario(seed: int) -> ProducerScenario: + randomness = client_randomness(seed) + message_count = GEN_MESSAGE_COUNT.from_randomness(randomness) + + return ProducerScenario( + exchange=GEN_NONEMPTY_NAME.from_randomness(randomness), + routing_key=GEN_SHORTSTR.from_randomness(randomness), + messages=tuple( + # some mandatory, some not + Message(f"m{i}", mandatory=GEN_BIT.from_randomness(randomness)) + for i in range(message_count) + ), + ) + + +@pytest.mark.timeout(TIMEOUT) +@pytest.mark.parametrize("seed", SEEDS) +def test_interleave_returns_teardown(seed: int) -> None: + scenario = producer_scenario(seed) + config = pb.SessionConfig( + weights=( + weighted(pb.BasicReturnPattern(), pb.BASE_WEIGHT // 4), + weighted(pb.BasicAckPattern(), pb.BASE_WEIGHT // 2), + weighted( + pb.ConnectionClosePattern(class_id=0, method_id=0), + pb.BASE_WEIGHT // 20, + ), + ), + seed=seed, + ) + + with amqp_imposter(config) as imposter: + producer = ResilientProducer( + amqp_port=imposter.amqp_port, + exchange=scenario.exchange, + routing_key=scenario.routing_key, + messages=scenario.messages, + ) + unroutable_message_ids = producer.publish_until_acked() + + message_ids = frozenset(m.message_id for m in scenario.messages) + assert_properties( + imposter.transcript(), + producer_properties(message_ids, unroutable_message_ids), + ) diff --git a/src/tests/amqp_imposter/tests/helpers.py b/src/tests/amqp_imposter/tests/helpers.py new file mode 100644 index 00000000..788e988e --- /dev/null +++ b/src/tests/amqp_imposter/tests/helpers.py @@ -0,0 +1,53 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any + +from amqp_imposter.model import generators as g +from amqp_imposter.model import method_unification as mu +from amqp_imposter.model import methods as m + +EXACT_VALUES: dict[str, Any] = { + "version_major": 0, + "version_minor": 9, + "mechanisms": ("PLAIN", "AMQPLAIN"), + "locales": ("en_US",), + "publisher_confirms": True, + "basic_nack": True, + "consumer_cancel_notify": True, + "mechanism": "PLAIN", + "locale": "en_US", + "channel_max": 2047, + "frame_max": 131072, + "heartbeat": 60, + "reply_code": 406, + "reply_text": "FORCED", + "class_id": 60, + "method_id": 40, + "consumer_tag": "ctag", + "delivery_tag": 7, + "exchange": "ex", + "routing_key": "rk", +} + + +def filled(kind: type[m.Method]) -> m.Method: + return kind(**{name: EXACT_VALUES[name] for name in m.exact_fields(kind)}) + + +def concrete(kind: type[m.Method], seed: int) -> m.Method: + method, _ = mu.concretise(filled(kind), g.GenState(seed)) + + return method diff --git a/src/tests/amqp_imposter/tests/model/__init__.py b/src/tests/amqp_imposter/tests/model/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/tests/model/class_basic/__init__.py b/src/tests/amqp_imposter/tests/model/class_basic/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_cancel.py b/src/tests/amqp_imposter/tests/model/class_basic/test_cancel.py new file mode 100644 index 00000000..fcd33f52 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_cancel.py @@ -0,0 +1,195 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import Unrepresentable +from amqp_imposter.model import methods as m + +from ..helpers import ( + CONSUMER_TAG, + DO_CANCEL, + OTHER_CONSUMER_TAG, + QUEUE, + DoTeardown, + consume, + consuming, + deliver, + receive_cancel, + receive_unwrap, + send_cancel, + send_unwrap, + subsets_any_sendable, +) + + +def test_no_cancel_ok_before_cancel() -> None: + assert not subsets_any_sendable(consuming(), m.BasicCancelOk) + + +def test_server_cancel_stops_deliveries() -> None: + server = consuming() + + send_cancel(server) + + assert not subsets_any_sendable(server, m.BasicDeliver) + assert not subsets_any_sendable(server, m.BasicCancel) + + +def test_server_cancel_leaves_the_channel_open() -> None: + server = consuming() + + send_cancel(server) + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + + assert subsets_any_sendable(server, m.QueueDeclareOk) + + +@pytest.mark.parametrize("cancel", DO_CANCEL, ids=lambda c: c.__name__) +def test_cancel_requeues_nothing(cancel: DoTeardown) -> None: + server = consuming(prefetch_count=0) + + deliver(server, 1) + deliver(server, 2) + cancel(server) + consume(server) # reconsume + + assert not subsets_any_sendable(server, m.BasicDeliver, redelivered=True) + + +@pytest.mark.parametrize("cancel", DO_CANCEL, ids=lambda c: c.__name__) +def test_ack_after_cancel(cancel: DoTeardown) -> None: + server = consuming(prefetch_count=0) + + deliver(server, 1) + deliver(server, 2) + cancel(server) + receive_unwrap(server, m.BasicAck(delivery_tag=2, multiple=True)) + + assert not subsets_any_sendable(server, m.ChannelClose) + + +def test_client_cancel_during_server_cancel() -> None: + server = consuming() + + send_cancel(server) + receive_unwrap(server, m.BasicCancel(consumer_tag=CONSUMER_TAG)) + + send_unwrap(server, m.BasicCancelOk, consumer_tag=CONSUMER_TAG) + + +def test_reconsume_and_client_cancel_during_server_cancel() -> None: + server = consuming() + + send_cancel(server) + receive_unwrap(server, m.BasicConsume(queue=QUEUE, consumer_tag=OTHER_CONSUMER_TAG)) + receive_unwrap(server, m.BasicCancel(consumer_tag=CONSUMER_TAG)) + send_unwrap(server, m.BasicConsumeOk, consumer_tag=OTHER_CONSUMER_TAG) + send_unwrap(server, m.BasicCancelOk, consumer_tag=CONSUMER_TAG) + + assert subsets_any_sendable(server, m.BasicDeliver, consumer_tag=OTHER_CONSUMER_TAG) + + +@pytest.mark.parametrize("consumer_tag", ("", "other"), ids=("empty", "unknown")) +def test_client_cancel_during_server_cancel_bad_tag(consumer_tag: str) -> None: + server = consuming() + + send_unwrap(server, m.BasicCancel, consumer_tag=CONSUMER_TAG, no_wait=True) + cancelled = server.receive(m.BasicCancel(consumer_tag=consumer_tag)) + + assert isinstance(cancelled, Unrepresentable) + + +def test_double_client_cancel() -> None: + server = consuming() + + receive_cancel(server) + + cancelled = server.receive(m.BasicCancel(consumer_tag=CONSUMER_TAG)) + + assert isinstance(cancelled, Unrepresentable) + + +@pytest.mark.parametrize("consumer_tag", ("", "other"), ids=("empty", "unknown")) +def test_cancel_unknown_tag(consumer_tag: str) -> None: + # RabbitMQ seems to reply CancelOk and cancel nothing + # but this is a client error so let's call it Unrepresentable + server = consuming() + + cancelled = server.receive(m.BasicCancel(consumer_tag=consumer_tag)) + + assert isinstance(cancelled, Unrepresentable) + + +def test_reconsume_after_server_cancel() -> None: + server = consuming() + + send_cancel(server) + receive_unwrap(server, m.BasicConsume(queue=QUEUE, consumer_tag=CONSUMER_TAG)) + send_unwrap(server, m.BasicConsumeOk, consumer_tag=CONSUMER_TAG) + + assert subsets_any_sendable(server, m.BasicDeliver, consumer_tag=CONSUMER_TAG) + + +def test_server_cancel_is_nowait() -> None: + server = consuming() + + assert not subsets_any_sendable(server, m.BasicCancel, no_wait=False) + + send_unwrap(server, m.BasicCancel, consumer_tag=CONSUMER_TAG, no_wait=True) + + +def test_client_cancel_ok() -> None: + server = consuming() + + receive_cancel(server) + + assert not subsets_any_sendable(server, m.BasicCancelOk) + + +def test_no_deliveries_after_cancel_ok() -> None: + server = consuming(prefetch_count=0) + + receive_unwrap(server, m.BasicCancel(consumer_tag=CONSUMER_TAG)) + deliver(server, 1) + send_unwrap(server, m.BasicCancelOk, consumer_tag=CONSUMER_TAG) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + +def test_client_cancel_preempts_the_server_cancel() -> None: + server = consuming() + + receive_cancel(server) + + assert not subsets_any_sendable(server, m.BasicCancel) + + +def test_no_cancel_ok_if_nowait() -> None: + server = consuming() + + receive_unwrap(server, m.BasicCancel(consumer_tag=CONSUMER_TAG, no_wait=True)) + + assert not subsets_any_sendable(server, m.BasicCancelOk) + assert not subsets_any_sendable(server, m.BasicDeliver) + + +def test_client_cancel_leaves_the_channel_open() -> None: + server = consuming() + + receive_cancel(server) + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + + assert subsets_any_sendable(server, m.QueueDeclareOk) diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_consume.py b/src/tests/amqp_imposter/tests/model/class_basic/test_consume.py new file mode 100644 index 00000000..8d011ac3 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_consume.py @@ -0,0 +1,200 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import ( + DEFAULT_GEN_STATE, + Gen, + Unrepresentable, +) +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import method_unification as mu +from amqp_imposter.model import methods as m + +from ..helpers import ( + CONSUMER_TAG, + OTHER_CONSUMER_TAG, + OTHER_QUEUE, + QUEUE, + QUEUE_ARGUMENTS, + channel_errors, + channelled, + connection_errors, + consuming, + error_replies_to, + lookup_unique_sendable, + receive_unwrap, + send_unwrap, + subsets_any_sendable, +) + + +@pytest.mark.parametrize("no_wait", (False, True), ids=("wait", "no_wait")) +def test_consume_error_options(no_wait: bool) -> None: + server = channelled() + + receive_unwrap( + server, m.BasicConsume(queue=QUEUE, consumer_tag=CONSUMER_TAG, no_wait=no_wait) + ) + + assert error_replies_to(server, m.BasicConsume) == channel_errors( + mf.ReplyCode.NOT_FOUND, + mf.ReplyCode.ACCESS_REFUSED, + mf.ReplyCode.RESOURCE_LOCKED, + ) | connection_errors(mf.ReplyCode.RESOURCE_ERROR) + + +def test_no_consume_ok_if_nowait() -> None: + server = channelled() + + receive_unwrap( + server, m.BasicConsume(queue=QUEUE, consumer_tag=CONSUMER_TAG, no_wait=True) + ) + + assert not subsets_any_sendable(server, m.BasicConsumeOk) + + +def test_consume_anonymous_tag() -> None: + server = channelled() + + receive_unwrap(server, m.BasicConsume(queue=QUEUE, consumer_tag="")) + + assert lookup_unique_sendable(server, m.BasicConsumeOk).consumer_tag + + +@pytest.mark.xfail(strict=True, reason="anonymous consumer tag is left to future work") +def test_consume_anonymous_tag_nowait() -> None: + server = channelled() + + receive_unwrap(server, m.BasicConsume(queue=QUEUE, consumer_tag="", no_wait=True)) + + assert lookup_unique_sendable(server, m.BasicDeliver).consumer_tag + + +def test_delivery_has_cluster_id_void() -> None: + delivery, _ = mu.concretise( + lookup_unique_sendable(consuming(), m.BasicDeliver), DEFAULT_GEN_STATE + ) + + assert not isinstance(delivery.properties, Gen) + assert delivery.properties is None or delivery.properties.cluster_id == mf.Void() + + +def test_nowait_consume_orthogonal_to_delivery() -> None: + server = channelled() + + receive_unwrap( + server, m.BasicConsume(queue=QUEUE, consumer_tag=CONSUMER_TAG, no_wait=True) + ) + + send_unwrap(server, m.BasicDeliver, consumer_tag=CONSUMER_TAG) + + +@pytest.mark.parametrize( + "kind", (m.BasicDeliver, m.BasicCancel), ids=lambda k: k.__name__ +) +def test_discard_nowait_consume_error(kind: type[m.Method]) -> None: + server = channelled() + + receive_unwrap( + server, m.BasicConsume(queue=QUEUE, consumer_tag=CONSUMER_TAG, no_wait=True) + ) + send_unwrap(server, kind, consumer_tag=CONSUMER_TAG) + + assert not error_replies_to(server, m.BasicConsume) + + +@pytest.mark.parametrize("declare", (False, True), ids=("adjacent", "declare")) +def test_nowait_reconsume_after_nowait_cancel(declare: bool) -> None: + server = channelled() + + receive_unwrap( + server, m.BasicConsume(queue=QUEUE, consumer_tag=CONSUMER_TAG, no_wait=True) + ) + receive_unwrap(server, m.BasicCancel(consumer_tag=CONSUMER_TAG, no_wait=True)) + if declare: + receive_unwrap(server, m.QueueDeclare(queue=QUEUE, no_wait=True)) + receive_unwrap( + server, + m.BasicConsume(queue=QUEUE, consumer_tag=OTHER_CONSUMER_TAG, no_wait=True), + ) + + assert not subsets_any_sendable(server, m.BasicDeliver, consumer_tag=CONSUMER_TAG) + + send_unwrap(server, m.BasicDeliver, consumer_tag=OTHER_CONSUMER_TAG) + + assert not error_replies_to(server, m.BasicConsume) + assert not error_replies_to(server, m.QueueDeclare) + + +# TODO represent +def test_second_consumer_on_the_same_queue_is_unrepresentable() -> None: + server = consuming() + + assert isinstance( + server.receive(m.BasicConsume(queue=QUEUE, consumer_tag=OTHER_CONSUMER_TAG)), + Unrepresentable, + ) + + +# TODO represent +def test_second_consumer_on_another_queue_is_unrepresentable() -> None: + server = consuming() + + assert isinstance( + server.receive( + m.BasicConsume(queue=OTHER_QUEUE, consumer_tag=OTHER_CONSUMER_TAG) + ), + Unrepresentable, + ) + + +# TODO represent +def test_reused_consumer_tag_is_unrepresentable() -> None: + server = consuming() + + assert isinstance( + server.receive(m.BasicConsume(queue=QUEUE, consumer_tag=CONSUMER_TAG)), + Unrepresentable, + ) + + +# TODO represent +def test_pipelined_second_consumer_is_unrepresentable() -> None: + server = channelled() + + receive_unwrap(server, m.BasicConsume(queue=QUEUE, consumer_tag=CONSUMER_TAG)) + + assert isinstance( + server.receive(m.BasicConsume(queue=QUEUE, consumer_tag=OTHER_CONSUMER_TAG)), + Unrepresentable, + ) + + +def test_consume_with_arguments() -> None: + server = channelled() + + receive_unwrap( + server, + m.BasicConsume( + queue=QUEUE, + consumer_tag=CONSUMER_TAG, + arguments=QUEUE_ARGUMENTS, # the exact keys are not important + ), + ) + send_unwrap(server, m.BasicConsumeOk) + + assert subsets_any_sendable(server, m.BasicDeliver, consumer_tag=CONSUMER_TAG) diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_deliver.py b/src/tests/amqp_imposter/tests/model/class_basic/test_deliver.py new file mode 100644 index 00000000..ace4c599 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_deliver.py @@ -0,0 +1,34 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import Unrepresentable +from amqp_imposter.model import methods as m + +from ..helpers import ( + CONSUMER_TAG, + consuming, + deliver, +) + + +def test_send_delivery_tag_too_high() -> None: + server = consuming(prefetch_count=2) + + deliver(server, 1) + + assert isinstance( + server.send(m.BasicDeliver(consumer_tag=CONSUMER_TAG, delivery_tag=3)), + Unrepresentable, + ) diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_get.py b/src/tests/amqp_imposter/tests/model/class_basic/test_get.py new file mode 100644 index 00000000..27e3dce7 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_get.py @@ -0,0 +1,345 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import DEFAULT_GEN_STATE, Gen +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import method_unification as mu +from amqp_imposter.model import methods as m + +from ..helpers import ( + DO_CHANNEL_CLOSE, + DO_CONNECTION_CLOSE, + DoTeardown, + channel_errors, + channelled, + close_channel_from_client, + consume, + consuming, + deliver, + enable_channel_error, + error_replies_to, + get, + lookup_sendables, + lookup_unique_sendable, + match_fresh_get_ok, + polling, + receive_unwrap, + reconnect, + reopen_channel, + send_get_ok, + send_unwrap, + subsets_any_sendable, +) + + +def test_get_ok() -> None: + server = polling() + + assert subsets_any_sendable(server, m.BasicGetEmpty) + + got = lookup_unique_sendable(server, m.BasicGetOk) + + assert got.delivery_tag == 1 + + server.send(got) + + assert not subsets_any_sendable(server, m.BasicGetOk) + assert not subsets_any_sendable(server, m.BasicGetEmpty) + + receive_unwrap(server, m.BasicAck(delivery_tag=1)) + + assert not subsets_any_sendable(server, m.ChannelClose) + + +def test_get_error_options() -> None: + assert error_replies_to(polling(), m.BasicGet) == channel_errors( + mf.ReplyCode.NOT_FOUND, + mf.ReplyCode.ACCESS_REFUSED, + mf.ReplyCode.RESOURCE_LOCKED, + ) + + +def test_get_empty_spends_no_delivery_tag() -> None: + server = polling() + + send_unwrap(server, m.BasicGetEmpty) + + assert not subsets_any_sendable(server, m.BasicGetOk) + assert not subsets_any_sendable(server, m.BasicGetEmpty) + + get(server) + + assert lookup_unique_sendable(server, m.BasicGetOk).delivery_tag == 1 + + +def test_no_get_reply_unprompted() -> None: + server = channelled() + + assert not subsets_any_sendable(server, m.BasicGetOk) + assert not subsets_any_sendable(server, m.BasicGetEmpty) + + +def test_no_requeued_get_reply_unprompted() -> None: + server = polling() + + send_get_ok(server, body=b"blabla") + receive_unwrap(server, m.BasicReject(delivery_tag=1, requeue=True)) + + assert not subsets_any_sendable(server, m.BasicGetOk) + assert not subsets_any_sendable(server, m.BasicGetEmpty) + + get(server) + + assert subsets_any_sendable(server, m.BasicGetOk, redelivered=True) + + +def test_get_next_delivery_tag() -> None: + server = polling() + + send_unwrap(server, m.BasicGetOk) + get(server) + + assert lookup_unique_sendable(server, m.BasicGetOk).delivery_tag == 2 + + +def test_get_next_delivery_tag_simultaneously_consuming() -> None: + server = consuming(prefetch_count=2) + + send_unwrap(server, m.BasicDeliver, delivery_tag=1) + get(server) + + assert lookup_unique_sendable(server, m.BasicGetOk).delivery_tag == 2 + + +def test_get_ok_has_cluster_id_void() -> None: + got, _ = mu.concretise( + lookup_unique_sendable(polling(), m.BasicGetOk), DEFAULT_GEN_STATE + ) + + assert not isinstance(got.properties, Gen) + assert got.properties is None or got.properties.cluster_id == mf.Void() + + +def test_get_full_prefetch() -> None: + # get fills prefetch, but the prefetch count only stops consumption, not polling + server = consuming(prefetch_count=1) + + send_unwrap(server, m.BasicDeliver, delivery_tag=1) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + get(server) + + assert subsets_any_sendable(server, m.BasicGetOk) + + +def test_pipelined_gets_take_successive_delivery_tags() -> None: + server = polling() + + get(server) + + assert send_unwrap(server, m.BasicGetOk).delivery_tag == 1 + assert lookup_unique_sendable(server, m.BasicGetOk).delivery_tag == 2 + + +def test_pipelined_get_after_empty_spends_no_delivery_tag() -> None: + server = polling() + + get(server) + send_unwrap(server, m.BasicGetEmpty) + + assert lookup_unique_sendable(server, m.BasicGetOk).delivery_tag == 1 + + +def test_only_the_first_pipelined_get_is_answerable() -> None: + server = polling() + + get(server) + + assert len(lookup_sendables(server, m.BasicGetEmpty)) == 1 + + +def test_fresh_get_has_free_body() -> None: + assert isinstance(match_fresh_get_ok(polling()).body, Gen) + + +def test_get_returns_requeued_message() -> None: + server = polling() + + send_get_ok(server, body=b"blabla") + receive_unwrap(server, m.BasicReject(delivery_tag=1, requeue=True)) + get(server) + + again = lookup_unique_sendable(server, m.BasicGetOk, redelivered=True) + + assert again.body == b"blabla" + assert again.delivery_tag == 2 + + +def test_get_retires_requeued_message() -> None: + server = polling() + + send_get_ok(server, body=b"blabla") + receive_unwrap(server, m.BasicReject(delivery_tag=1, requeue=True)) + get(server) + send_unwrap(server, m.BasicGetOk, redelivered=True) + get(server) + + assert not subsets_any_sendable(server, m.BasicGetOk, redelivered=True) + + +def test_get_leaves_other_requeued_messages() -> None: + server = polling() + + send_get_ok(server, body=b"one") + get(server) + send_get_ok(server, body=b"two") + receive_unwrap(server, m.BasicNack(delivery_tag=2, multiple=True, requeue=True)) + get(server) + send_unwrap(server, m.BasicGetOk, body=b"one") + get(server) + + assert lookup_unique_sendable(server, m.BasicGetOk, redelivered=True).body == b"two" + + +def test_consumer_redelivers_message_requeued_from_get() -> None: + server = polling() + + send_get_ok(server, body=b"blabla") + receive_unwrap(server, m.BasicReject(delivery_tag=1, requeue=True)) + consume(server) + + assert lookup_unique_sendable(server, m.BasicDeliver, redelivered=True).body == ( + b"blabla" + ) + + +def test_get_redelivers_message_requeued_from_consumer() -> None: + server = consuming() + + delivered = deliver(server, 1) + receive_unwrap(server, m.BasicReject(delivery_tag=1, requeue=True)) + get(server) + + again = lookup_unique_sendable(server, m.BasicGetOk, redelivered=True) + + assert again.body == delivered.body + + +def test_get_fills_prefetch() -> None: + # this is unclear from the spec, so this test is an interpretative decision + # TODO: if we support multiple consumers on one channel, or global qos, revisit this + server = consuming(prefetch_count=1) + + get(server) + send_unwrap(server, m.BasicGetOk) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + +def test_get_ack_frees_prefetch() -> None: + server = consuming(prefetch_count=1) + + get(server) + got = send_unwrap(server, m.BasicGetOk) + receive_unwrap(server, m.BasicAck(delivery_tag=got.delivery_tag)) + + assert subsets_any_sendable(server, m.BasicDeliver) + + +def test_no_ack_get_leaves_prefetch() -> None: + server = consuming(prefetch_count=1) + + get(server, no_ack=True) + send_unwrap(server, m.BasicGetOk) + + assert subsets_any_sendable(server, m.BasicDeliver) + + +def test_multiple_ack_spans_get_and_delivery() -> None: + server = consuming(prefetch_count=3) + + deliver(server, 1) + get(server) + send_unwrap(server, m.BasicGetOk, delivery_tag=2) + deliver(server, 3) + receive_unwrap(server, m.BasicAck(delivery_tag=3, multiple=True)) + + assert not subsets_any_sendable(server, m.ChannelClose) + assert not subsets_any_sendable(server, m.BasicDeliver, redelivered=True) + + +def test_multiple_nack_requeues_get_and_delivery() -> None: + server = consuming(prefetch_count=3) + + deliver(server, 1) + get(server) + send_unwrap(server, m.BasicGetOk, delivery_tag=2) + deliver(server, 3) + receive_unwrap(server, m.BasicNack(delivery_tag=3, multiple=True, requeue=True)) + + assert len(lookup_sendables(server, m.BasicDeliver, redelivered=True)) == 3 + + +def test_pending_get_precedes_channel_error() -> None: + server = polling() + + enable_channel_error(server) + + assert not error_replies_to(server, m.BasicAck) + + send_unwrap(server, m.BasicGetEmpty) + + assert error_replies_to(server, m.BasicAck) + + +def test_channel_close_discards_pending_get() -> None: + server = polling() + + close_channel_from_client(server) + reopen_channel(server) + + assert not subsets_any_sendable(server, m.BasicGetOk) + assert not subsets_any_sendable(server, m.BasicGetEmpty) + + +@pytest.mark.parametrize("close", DO_CHANNEL_CLOSE, ids=lambda c: c.__name__) +def test_channel_close_requeues_polled_message(close: DoTeardown) -> None: + server = polling() + + send_get_ok(server, body=b"blabla") + close(server) + reopen_channel(server) + consume(server) + + assert lookup_unique_sendable(server, m.BasicDeliver, redelivered=True).body == ( + b"blabla" + ) + + +@pytest.mark.parametrize("close", DO_CONNECTION_CLOSE, ids=lambda c: c.__name__) +def test_connection_close_requeues_polled_message(close: DoTeardown) -> None: + server = polling() + + send_get_ok(server, body=b"blabla") + close(server) + reconnect(server) + consume(server) + + assert lookup_unique_sendable(server, m.BasicDeliver, redelivered=True).body == ( + b"blabla" + ) diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_many_unacked.py b/src/tests/amqp_imposter/tests/model/class_basic/test_many_unacked.py new file mode 100644 index 00000000..8a0ecc83 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_many_unacked.py @@ -0,0 +1,74 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import methods as m + +from ..helpers import ( + consuming, + deliver, + lookup_sendables, + match_fresh_delivery, + prefetch_count_filled, + receive_unwrap, + subsets_any_sendable, +) + +PREFETCH_COUNT = 1000 +REQUEUED = 100 + + +def test_many_unacked_in_bag() -> None: + server = prefetch_count_filled(PREFETCH_COUNT) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + receive_unwrap(server, m.BasicAck(delivery_tag=PREFETCH_COUNT, multiple=True)) + + assert match_fresh_delivery(server).delivery_tag == PREFETCH_COUNT + 1 + + +def test_at_most_one_fresh_delivery_in_bag() -> None: + # only one fresh delivery is ever on offer + server = consuming(prefetch_count=PREFETCH_COUNT) + + for delivery_tag in range(1, PREFETCH_COUNT + 1): + assert len(lookup_sendables(server, m.BasicDeliver)) == 1 + deliver(server, delivery_tag) + + # prefetch is full now + assert not subsets_any_sendable(server, m.BasicDeliver) + + +def test_many_requeue_deliveries_in_bag() -> None: + server = consuming(prefetch_count=PREFETCH_COUNT) + + for delivery_tag in range(1, REQUEUED + 1): + deliver(server, delivery_tag) + + for delivery_tag in range(1, REQUEUED + 1): + receive_unwrap(server, m.BasicNack(delivery_tag=delivery_tag, requeue=True)) + + assert len(lookup_sendables(server, m.BasicDeliver, redelivered=True)) == REQUEUED + + +def test_partial_ack() -> None: + server = prefetch_count_filled(PREFETCH_COUNT) + + receive_unwrap(server, m.BasicAck(delivery_tag=500, multiple=True)) + + for delivery_tag in range(PREFETCH_COUNT + 1, PREFETCH_COUNT + 501): + deliver(server, delivery_tag) + + assert not subsets_any_sendable(server, m.BasicDeliver) diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_many_unconfirmed.py b/src/tests/amqp_imposter/tests/model/class_basic/test_many_unconfirmed.py new file mode 100644 index 00000000..bfa7f0bd --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_many_unconfirmed.py @@ -0,0 +1,135 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import ModelServer +from amqp_imposter.model import methods as m + +from ..helpers import ( + confirming, + delivery_tags, + lookup_sendables, + publish, + send_unwrap, +) + +UNCONFIRMED = 1_000 + + +def body(delivery_tag: int) -> bytes: + return f"m-{delivery_tag}".encode() + + +def confirmable(first: int, last: int) -> set[tuple[int, bool]]: + return { + (delivery_tag, multiple) + for delivery_tag in range(first, last + 1) + for multiple in (False, True) + } + + +def unconfirmed(count: int, mandatory: bool = False) -> ModelServer: + server = confirming() + for delivery_tag in range(1, count + 1): + publish(server, body=body(delivery_tag), mandatory=mandatory) + return server + + +def test_many_unconfirmed_in_bag() -> None: + server = unconfirmed(UNCONFIRMED) + + assert delivery_tags(server, m.BasicAck) == confirmable(1, UNCONFIRMED) + assert delivery_tags(server, m.BasicNack) == confirmable(1, UNCONFIRMED) + + send_unwrap(server, m.BasicAck, delivery_tag=UNCONFIRMED, multiple=True) + + assert delivery_tags(server, m.BasicAck) == set() + assert delivery_tags(server, m.BasicNack) == set() + + +def test_number_of_confirms_in_bag() -> None: + server = unconfirmed(UNCONFIRMED) + + # let's count the acks: + # 1 for each unconfirmed tag alone + # 1 for multiple ack up to each unconfirmed tag + # N.b. that Ack(1, True) and Ack(1, False) are different frames even if they have the same effect! + assert len(lookup_sendables(server, m.BasicAck)) == 2 * UNCONFIRMED + assert len(lookup_sendables(server, m.BasicNack)) == 2 * UNCONFIRMED + + +def test_multiple_ack() -> None: + server = unconfirmed(UNCONFIRMED) + + send_unwrap(server, m.BasicAck, delivery_tag=UNCONFIRMED // 2, multiple=True) + + assert delivery_tags(server, m.BasicAck) == confirmable( + UNCONFIRMED // 2 + 1, UNCONFIRMED + ) + assert delivery_tags(server, m.BasicNack) == confirmable( + UNCONFIRMED // 2 + 1, UNCONFIRMED + ) + + +def test_unordered_single_acks() -> None: + server = unconfirmed(UNCONFIRMED) + + for delivery_tag in range(UNCONFIRMED, UNCONFIRMED // 2, -1): + server.send(m.BasicAck(delivery_tag=delivery_tag)) + + assert delivery_tags(server, m.BasicAck) == confirmable(1, UNCONFIRMED // 2) + + for delivery_tag in range(UNCONFIRMED // 2, 0, -1): + server.send(m.BasicAck(delivery_tag=delivery_tag)) + + assert delivery_tags(server, m.BasicAck) == set() + assert delivery_tags(server, m.BasicNack) == set() + + +def test_next_delivery_tags_after_multiple_ack() -> None: + server = unconfirmed(UNCONFIRMED) + + send_unwrap(server, m.BasicNack, delivery_tag=UNCONFIRMED, multiple=True) + publish(server) + + assert delivery_tags(server, m.BasicAck) == confirmable( + UNCONFIRMED + 1, UNCONFIRMED + 1 + ) + + +def test_publish_confirm_repeat() -> None: + server = confirming() + + for delivery_tag in range(1, UNCONFIRMED + 1): + publish(server) + + assert delivery_tags(server, m.BasicAck) == confirmable( + delivery_tag, delivery_tag + ) + + server.send(m.BasicAck(delivery_tag=delivery_tag)) + + assert delivery_tags(server, m.BasicAck) == set() + + +def test_many_returns_in_bag() -> None: + server = unconfirmed(UNCONFIRMED, mandatory=True) + + returns = lookup_sendables(server, m.BasicReturn) + + assert len(returns) == UNCONFIRMED + assert {returned.body for returned in returns} == { + body(delivery_tag) for delivery_tag in range(1, UNCONFIRMED + 1) + } + assert delivery_tags(server, m.BasicNack) == confirmable(1, UNCONFIRMED) diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_message_properties.py b/src/tests/amqp_imposter/tests/model/class_basic/test_message_properties.py new file mode 100644 index 00000000..7fafe395 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_message_properties.py @@ -0,0 +1,112 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from datetime import UTC, datetime +from decimal import Decimal + +import pytest + +from amqp_imposter.model import Method, Unrepresentable +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m +from amqp_imposter.model.constants import ( + DELIVERY_MODES, + OCTET_MAX, + PRIORITY_MAX, + PRIORITY_MIN, +) + +from ..helpers import ( + EXCHANGE, + ROUTING_KEY, + channelled, + lookup_unique_sendable, + receive_unwrap, +) + +LEGAL_PRIORITIES = tuple(range(PRIORITY_MIN, PRIORITY_MAX + 1)) +ILLEGAL_PRIORITIES = (PRIORITY_MIN - 1, PRIORITY_MAX + 1, OCTET_MAX) +ILLEGAL_DELIVERY_MODES = (min(DELIVERY_MODES) - 1, max(DELIVERY_MODES) + 1) + +EVERY_VALUE = mf.Table( + { + "void": mf.Void(), + "bit": True, + "int": 42, + "string": "text", + "bytes": b"\x00\xff", + "decimal": Decimal("1.25"), + "timestamp": datetime(2026, 8, 22, tzinfo=UTC), + "array": (1, "two", mf.Void()), + "table": mf.Table({"nested": mf.Table({"deep": 1})}), + } +) + + +def test_headers_are_returned_intact() -> None: + server = channelled() + + receive_unwrap( + server, + m.BasicPublish( + exchange=EXCHANGE, + routing_key=ROUTING_KEY, + mandatory=True, + properties=mf.MessageProperties(headers=EVERY_VALUE), + ), + ) + + returned = lookup_unique_sendable(server, m.BasicReturn) + + assert isinstance(returned.properties, mf.MessageProperties) + assert returned.properties.headers == EVERY_VALUE + + +def publish_properties(properties: mf.MessageProperties) -> Method | Unrepresentable: + return channelled().receive( + m.BasicPublish( + exchange=EXCHANGE, routing_key=ROUTING_KEY, properties=properties + ) + ) + + +@pytest.mark.parametrize("priority", ILLEGAL_PRIORITIES) +def test_bad_priority(priority: int) -> None: + assert isinstance( + publish_properties(mf.MessageProperties(priority=priority)), Unrepresentable + ) + + +@pytest.mark.parametrize("priority", LEGAL_PRIORITIES) +def test_good_priority(priority: int) -> None: + assert not isinstance( + publish_properties(mf.MessageProperties(priority=priority)), Unrepresentable + ) + + +@pytest.mark.parametrize("delivery_mode", ILLEGAL_DELIVERY_MODES) +def test_bad_delivery_mode(delivery_mode: int) -> None: + assert isinstance( + publish_properties(mf.MessageProperties(delivery_mode=delivery_mode)), + Unrepresentable, + ) + + +@pytest.mark.parametrize("delivery_mode", DELIVERY_MODES) +def test_good_delivery_mode(delivery_mode: int) -> None: + assert not isinstance( + publish_properties(mf.MessageProperties(delivery_mode=delivery_mode)), + Unrepresentable, + ) diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_multiple_ack.py b/src/tests/amqp_imposter/tests/model/class_basic/test_multiple_ack.py new file mode 100644 index 00000000..23091018 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_multiple_ack.py @@ -0,0 +1,163 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + channel_errors, + consuming, + deliver, + error_replies_to, + lookup_sendables, + match_fresh_delivery, + message_content, + prefetch_count_filled, + receive_unwrap, + subsets_any_sendable, +) + +PREFETCH_COUNT = 3 + + +def test_consume_multiple_ack() -> None: + server = prefetch_count_filled(PREFETCH_COUNT) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + receive_unwrap(server, m.BasicAck(delivery_tag=2, multiple=True)) + + deliver(server, 4) + deliver(server, 5) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + receive_unwrap(server, m.BasicAck(delivery_tag=5, multiple=True)) + + assert match_fresh_delivery(server).delivery_tag == 6 + + +def test_multiple_ack_subrange() -> None: + server = prefetch_count_filled(PREFETCH_COUNT) + + receive_unwrap(server, m.BasicAck(delivery_tag=1, multiple=True)) + + deliver(server, 4) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + +def test_multiple_ack_does_not_requeue() -> None: + server = prefetch_count_filled(PREFETCH_COUNT) + + receive_unwrap(server, m.BasicAck(delivery_tag=3, multiple=True)) + + assert not subsets_any_sendable(server, m.BasicDeliver, redelivered=True) + + +def test_zero_multiple_ack_acknowledges_everything() -> None: + server = prefetch_count_filled(PREFETCH_COUNT) + + receive_unwrap(server, m.BasicAck(delivery_tag=0, multiple=True)) + + for delivery_tag in range(PREFETCH_COUNT + 1, 2 * PREFETCH_COUNT + 1): + deliver(server, delivery_tag) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + +def test_zero_multiple_ack_does_not_requeue() -> None: + server = prefetch_count_filled(PREFETCH_COUNT) + + receive_unwrap(server, m.BasicAck(delivery_tag=0, multiple=True)) + + assert not subsets_any_sendable(server, m.BasicDeliver, redelivered=True) + + +def test_zero_multiple_ack_without_deliveries() -> None: + server = consuming(prefetch_count=PREFETCH_COUNT) + + receive_unwrap(server, m.BasicAck(delivery_tag=0, multiple=True)) + + deliver(server, 1) + + +def test_zero_multiple_nack_requeues_everything() -> None: + server = consuming(prefetch_count=PREFETCH_COUNT) + + delivered = tuple(deliver(server, delivery_tag) for delivery_tag in (1, 2, 3)) + + receive_unwrap(server, m.BasicNack(delivery_tag=0, multiple=True, requeue=True)) + + redeliveries = lookup_sendables(server, m.BasicDeliver, redelivered=True) + + assert {message_content(d) for d in redeliveries} == { + message_content(d) for d in delivered + } + + +@pytest.mark.parametrize( + "acknowledge", + (m.BasicAck, m.BasicNack, m.BasicReject), + ids=lambda k: k.__name__, +) +def test_zero_delivery_tag_alone_closes_channel( + acknowledge: type[m.BasicAck | m.BasicNack | m.BasicReject], +) -> None: + server = prefetch_count_filled(PREFETCH_COUNT) + + receive_unwrap(server, acknowledge(0)) + + assert error_replies_to(server, acknowledge) == channel_errors( + mf.ReplyCode.PRECONDITION_FAILED + ) + + +def test_multiple_nack_requeue() -> None: + server = consuming(prefetch_count=PREFETCH_COUNT) + + first = deliver(server, 1) + second = deliver(server, 2) + deliver(server, 3) + + receive_unwrap(server, m.BasicNack(delivery_tag=2, multiple=True, requeue=True)) + + redeliveries = lookup_sendables(server, m.BasicDeliver, redelivered=True) + + assert {message_content(d) for d in redeliveries} == { + message_content(first), + message_content(second), + } + + +def test_multiple_nack_no_requeue() -> None: + server = prefetch_count_filled(PREFETCH_COUNT) + + receive_unwrap(server, m.BasicNack(delivery_tag=2, multiple=True, requeue=False)) + + assert not subsets_any_sendable(server, m.BasicDeliver, redelivered=True) + + +def test_multiple_nack_destroys_deliveries() -> None: + server = prefetch_count_filled(PREFETCH_COUNT) + + receive_unwrap(server, m.BasicNack(delivery_tag=2, multiple=True, requeue=False)) + + deliver(server, 4) + deliver(server, 5) + + assert not subsets_any_sendable(server, m.BasicDeliver) diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_multiple_confirm.py b/src/tests/amqp_imposter/tests/model/class_basic/test_multiple_confirm.py new file mode 100644 index 00000000..1b68c1ca --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_multiple_confirm.py @@ -0,0 +1,58 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import methods as m + +from ..helpers import confirming, delivery_tags, publish, send_unwrap + + +def test_publish_multiple_ack() -> None: + server = confirming() + + publish(server, body=b"one") + publish(server, body=b"two") + publish(server, body=b"three") + + assert delivery_tags(server, m.BasicAck) == { + (1, False), + (2, False), + (3, False), + (1, True), + (2, True), + (3, True), + } + + send_unwrap(server, m.BasicAck, delivery_tag=2, multiple=True) + + assert delivery_tags(server, m.BasicAck) == {(3, False), (3, True)} + assert delivery_tags(server, m.BasicNack) == {(3, False), (3, True)} + + send_unwrap(server, m.BasicAck, delivery_tag=3, multiple=True) + + assert delivery_tags(server, m.BasicAck) == set() + assert delivery_tags(server, m.BasicNack) == set() + + +def test_publish_multiple_nack() -> None: + server = confirming() + + publish(server, body=b"one") + publish(server, body=b"two") + publish(server, body=b"three") + + send_unwrap(server, m.BasicNack, delivery_tag=2, multiple=True) + + assert delivery_tags(server, m.BasicNack) == {(3, False), (3, True)} + assert delivery_tags(server, m.BasicAck) == {(3, False), (3, True)} diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_no_ack.py b/src/tests/amqp_imposter/tests/model/class_basic/test_no_ack.py new file mode 100644 index 00000000..fe98a180 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_no_ack.py @@ -0,0 +1,161 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + ACKNOWLEDGEMENT_TYPES, + CONSUMER_TAG, + DO_CANCEL, + DO_CHANNEL_CLOSE, + DO_CONNECTION_CLOSE, + QUEUE, + DoTeardown, + channel_errors, + channelled, + consume, + consuming_no_ack, + deliver, + error_replies_to, + get, + lookup_unique_sendable, + receive_unwrap, + reconnect, + reopen_channel, + send_get_ok, + send_unwrap, + subsets_any_sendable, +) + + +def test_no_ack_ignores_prefetch() -> None: + server = consuming_no_ack() + + for delivery_tag in range(1, 100 + 1): + deliver(server, delivery_tag) + + assert subsets_any_sendable(server, m.BasicDeliver) + + +@pytest.mark.parametrize("acknowledge", ACKNOWLEDGEMENT_TYPES, ids=lambda k: k.__name__) +def test_no_ack_acknowledgement_closes_channel( + acknowledge: type[m.BasicAck | m.BasicNack | m.BasicReject], +) -> None: + server = consuming_no_ack() + + deliver(server, 1) + receive_unwrap(server, acknowledge(1)) + + assert error_replies_to(server, acknowledge) == channel_errors( + mf.ReplyCode.PRECONDITION_FAILED + ) + + +def test_no_ack_never_redelivers() -> None: + server = consuming_no_ack() + + deliver(server, 1) + + assert not subsets_any_sendable(server, m.BasicDeliver, redelivered=True) + + +@pytest.mark.parametrize("cancel", DO_CANCEL, ids=lambda c: c.__name__) +def test_no_ack_cancel_requeues_nothing(cancel: DoTeardown) -> None: + server = consuming_no_ack() + + deliver(server, 1) + deliver(server, 2) + cancel(server) + consume(server) + + assert not subsets_any_sendable(server, m.BasicDeliver, redelivered=True) + + +@pytest.mark.parametrize("close", DO_CHANNEL_CLOSE, ids=lambda c: c.__name__) +def test_no_ack_channel_close_requeues_nothing(close: DoTeardown) -> None: + server = consuming_no_ack() + + deliver(server, 1) + deliver(server, 2) + close(server) + reopen_channel(server) + consume(server) + + assert not subsets_any_sendable(server, m.BasicDeliver, redelivered=True) + + +@pytest.mark.parametrize("close", DO_CONNECTION_CLOSE, ids=lambda c: c.__name__) +def test_no_ack_connection_close_requeues_nothing(close: DoTeardown) -> None: + server = consuming_no_ack() + + deliver(server, 1) + deliver(server, 2) + close(server) + reconnect(server) + consume(server) + + assert not subsets_any_sendable(server, m.BasicDeliver, redelivered=True) + + +def test_no_ack_get_ok_ack_closes_channel() -> None: + server = channelled() + + receive_unwrap(server, m.BasicGet(queue=QUEUE, no_ack=True)) + send_unwrap(server, m.BasicGetOk, delivery_tag=1) + receive_unwrap(server, m.BasicAck(delivery_tag=1)) + + assert error_replies_to(server, m.BasicAck) == channel_errors( + mf.ReplyCode.PRECONDITION_FAILED + ) + + +def test_no_ack_consumer_leaves_get_acknowledgeable() -> None: + server = consuming_no_ack() + + deliver(server, 1) + receive_unwrap(server, m.BasicGet(queue=QUEUE)) + + got = lookup_unique_sendable(server, m.BasicGetOk) + + server.send(got) + receive_unwrap(server, m.BasicAck(delivery_tag=got.delivery_tag)) + + assert not subsets_any_sendable(server, m.ChannelClose) + + +@pytest.mark.parametrize("close", DO_CHANNEL_CLOSE, ids=lambda c: c.__name__) +def test_no_ack_get_requeues_nothing(close: DoTeardown) -> None: + server = channelled() + + get(server, no_ack=True) + send_get_ok(server) + close(server) + reopen_channel(server) + consume(server) + + assert not subsets_any_sendable(server, m.BasicDeliver, redelivered=True) + + +def test_no_ack_cancel_ok() -> None: + server = consuming_no_ack() + + deliver(server, 1) + receive_unwrap(server, m.BasicCancel(consumer_tag=CONSUMER_TAG)) + send_unwrap(server, m.BasicCancelOk, consumer_tag=CONSUMER_TAG) + + assert not subsets_any_sendable(server, m.BasicDeliver) diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_no_confirms.py b/src/tests/amqp_imposter/tests/model/class_basic/test_no_confirms.py new file mode 100644 index 00000000..f840a343 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_no_confirms.py @@ -0,0 +1,80 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + channelled, + lookup_sendables, + lookup_unique_sendable, + message_content, + publish, + send_unwrap, + subsets_any_sendable, +) + + +def test_no_confirm_mandatory() -> None: + server = channelled() + + published = publish(server, body=b"limestone", mandatory=True) + + assert not subsets_any_sendable(server, m.BasicAck) + assert not subsets_any_sendable(server, m.BasicNack) + + unroutable = lookup_unique_sendable(server, m.BasicReturn) + + assert unroutable.reply_code == mf.ReplyCode.UNROUTABLE + assert message_content(unroutable) == message_content(published) + + server.send(unroutable) + + assert not subsets_any_sendable(server, m.BasicReturn) + assert not subsets_any_sendable(server, m.BasicAck) + assert not subsets_any_sendable(server, m.BasicNack) + + +def test_no_confirm_no_mandatory() -> None: + server = channelled() + + publish(server) + + assert not subsets_any_sendable(server, m.BasicAck) + assert not subsets_any_sendable(server, m.BasicNack) + assert not subsets_any_sendable(server, m.BasicReturn) + + +def test_no_confirm_after_return_sent() -> None: + server = channelled() + + publish(server, mandatory=True) + send_unwrap(server, m.BasicReturn) + + assert not subsets_any_sendable(server, m.BasicAck) + + +def test_each_mandatory_publish_returns() -> None: + server = channelled() + + publish(server, body=b"one", mandatory=True) + publish(server, body=b"two", mandatory=True) + + assert len(lookup_sendables(server, m.BasicReturn)) == 2 + + server.send(lookup_unique_sendable(server, m.BasicReturn, body=b"one")) + + assert len(lookup_sendables(server, m.BasicReturn)) == 1 + assert lookup_unique_sendable(server, m.BasicReturn, body=b"two") diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_publish.py b/src/tests/amqp_imposter/tests/model/class_basic/test_publish.py new file mode 100644 index 00000000..96049cb8 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_publish.py @@ -0,0 +1,198 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import Unrepresentable +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + EXCHANGE, + QUEUE, + ROUTING_KEY, + ErrorReply, + channel_errors, + channelled, + confirming, + connection_errors, + consuming, + deliver, + delivery_tags, + error_replies_to, + publish, + receive_unwrap, + send_unwrap, + subsets_any_sendable, +) + + +@pytest.mark.parametrize( + ("exchange", "errors"), + ( + ( + EXCHANGE, + channel_errors( + mf.ReplyCode.NOT_FOUND, + mf.ReplyCode.ACCESS_REFUSED, + mf.ReplyCode.CONTENT_TOO_LARGE, + ), + ), + ( + "", + channel_errors(mf.ReplyCode.ACCESS_REFUSED, mf.ReplyCode.CONTENT_TOO_LARGE), + ), + ), + ids=("named", "default"), +) +def test_publish_error_options(exchange: str, errors: set[ErrorReply]) -> None: + server = channelled() + + receive_unwrap(server, m.BasicPublish(exchange=exchange, routing_key=ROUTING_KEY)) + + assert error_replies_to(server, m.BasicPublish) == errors + + +def test_publish_error_closes_channel() -> None: + server = confirming() + + publish(server) + send_unwrap(server, m.ChannelClose, reply_code=mf.ReplyCode.NOT_FOUND) + + assert not subsets_any_sendable(server, m.BasicAck) + assert not isinstance(server.receive(m.ChannelCloseOk()), Unrepresentable) + + +@pytest.mark.parametrize( + "confirm", (m.BasicAck, m.BasicNack), ids=lambda kind: kind.__name__ +) +def test_confirm_discards_publish_error( + confirm: type[m.BasicAck | m.BasicNack], +) -> None: + server = confirming() + + publish(server) + + assert error_replies_to(server, m.BasicPublish) + + send_unwrap(server, confirm, delivery_tag=1, multiple=False) + + assert not error_replies_to(server, m.BasicPublish) + + +def test_later_confirm_discards_publish_error() -> None: + server = confirming() + + publish(server, body=b"one") + publish(server, body=b"two") + + assert error_replies_to(server, m.BasicPublish) + + send_unwrap(server, m.BasicAck, delivery_tag=2, multiple=False) + + assert not error_replies_to(server, m.BasicPublish) + + +def test_earlier_confirm_keeps_publish_error() -> None: + server = confirming() + + publish(server, body=b"one") + publish(server, body=b"two") + send_unwrap(server, m.BasicAck, delivery_tag=1, multiple=False) + + assert error_replies_to(server, m.BasicPublish) + + +def test_return_discards_publish_error() -> None: + server = channelled() + + publish(server, mandatory=True) + + assert error_replies_to(server, m.BasicPublish) + + send_unwrap(server, m.BasicReturn) + + assert not error_replies_to(server, m.BasicPublish) + + +def test_reply_discards_publish_error() -> None: + server = channelled() + + publish(server) + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + + assert error_replies_to(server, m.BasicPublish) + + send_unwrap(server, m.QueueDeclareOk) + + assert not error_replies_to(server, m.BasicPublish) + + +def test_delivery_keeps_publish_error() -> None: + server = consuming() + + publish(server) + deliver(server, 1) + + assert error_replies_to(server, m.BasicPublish) + + +def test_publish_error_ordered() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + publish(server) + + assert not error_replies_to(server, m.BasicPublish) + + send_unwrap(server, m.QueueDeclareOk) + + assert error_replies_to(server, m.BasicPublish) + + +def test_immediate_not_implemented() -> None: + server = channelled() + + receive_unwrap( + server, + m.BasicPublish(exchange=EXCHANGE, routing_key=ROUTING_KEY, immediate=True), + ) + + assert error_replies_to(server, m.BasicPublish) == connection_errors( + mf.ReplyCode.NOT_IMPLEMENTED + ) + + +def test_out_of_order_confirm() -> None: + server = confirming() + + publish(server, body=b"one") + publish(server, body=b"two") + publish(server, body=b"three") + + send_unwrap(server, m.BasicAck, delivery_tag=2, multiple=False) + + assert delivery_tags(server, m.BasicAck) == { + (1, False), + (1, True), + (3, False), + (3, True), + } + assert delivery_tags(server, m.BasicNack) == { + (1, False), + (1, True), + (3, False), + (3, True), + } diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_qos.py b/src/tests/amqp_imposter/tests/model/class_basic/test_qos.py new file mode 100644 index 00000000..fff6c821 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_qos.py @@ -0,0 +1,147 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import Unrepresentable +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + channelled, + connection_errors, + consume, + consuming, + consuming_no_qos, + consuming_zero_prefetch_count, + deliver, + error_replies_to, + prefetch_count_filled, + receive_unwrap, + send_unwrap, + subsets_any_sendable, +) + +HIGH_PREFETCH = 1_000 + + +def test_qos_ok_prompts_nothing() -> None: + server = channelled() + + receive_unwrap(server, m.BasicQos(prefetch_count=1)) + send_unwrap(server, m.BasicQosOk) + + assert not subsets_any_sendable(server, m.BasicQosOk) + assert not subsets_any_sendable(server, m.BasicDeliver) + + +def test_global_qos_is_unrepresentable() -> None: + server = channelled() + + assert isinstance(server.receive(m.BasicQos(global_qos=True)), Unrepresentable) + + +def test_global_qos_precedes_prefetch_size() -> None: + server = channelled() + + assert isinstance( + server.receive(m.BasicQos(prefetch_size=1, global_qos=True)), Unrepresentable + ) + + +def test_channel_qos_bounds_deliveries() -> None: + server = channelled() + + receive_unwrap(server, m.BasicQos(prefetch_count=1, global_qos=False)) + send_unwrap(server, m.BasicQosOk) + consume(server) + deliver(server, 1) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + +def test_prefetch_size_is_not_implemented() -> None: + server = channelled() + + receive_unwrap(server, m.BasicQos(prefetch_size=1)) + + assert error_replies_to(server, m.BasicQos) == connection_errors( + mf.ReplyCode.NOT_IMPLEMENTED + ) + + +def test_zero_prefetch_never_fills() -> None: + server = consuming_zero_prefetch_count() + + for delivery_tag in range(1, HIGH_PREFETCH): + deliver(server, delivery_tag) + + assert subsets_any_sendable(server, m.BasicDeliver) + + +def test_absent_qos_never_fills() -> None: + server = consuming_no_qos() + + for delivery_tag in range(1, HIGH_PREFETCH): + deliver(server, delivery_tag) + + assert subsets_any_sendable(server, m.BasicDeliver) + + +def test_qos_only_after_consume() -> None: + server = consuming_no_qos() + + deliver(server, 1) + receive_unwrap(server, m.BasicQos(prefetch_count=1)) + + assert subsets_any_sendable(server, m.BasicDeliver) + + send_unwrap(server, m.BasicQosOk) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + +def test_zero_prefetch_unblocks_a_full_channel() -> None: + server = prefetch_count_filled(1) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + receive_unwrap(server, m.BasicQos(prefetch_count=0)) + send_unwrap(server, m.BasicQosOk) + + assert subsets_any_sendable(server, m.BasicDeliver) + + +def test_prefetch_after_zero_blocks() -> None: + server = consuming_zero_prefetch_count() + + deliver(server, 1) + receive_unwrap(server, m.BasicQos(prefetch_count=1)) + send_unwrap(server, m.BasicQosOk) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + +def test_repeat_qos_raises_the_bound() -> None: + server = consuming(prefetch_count=1) + + deliver(server, 1) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + receive_unwrap(server, m.BasicQos(prefetch_count=2)) + send_unwrap(server, m.BasicQosOk) + + deliver(server, 2) + + assert not subsets_any_sendable(server, m.BasicDeliver) diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_reject_nack.py b/src/tests/amqp_imposter/tests/model/class_basic/test_reject_nack.py new file mode 100644 index 00000000..84de5b1e --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_reject_nack.py @@ -0,0 +1,186 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import Gen +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + ACKNOWLEDGEMENT_TYPES, + channel_errors, + consuming, + error_replies_to, + lookup_sendables, + lookup_unique_sendable, + match_fresh_delivery, + message_content, + receive_unwrap, + send_unwrap, + subsets_any_sendable, +) + + +def test_consumer_reject_nack() -> None: + server = consuming(prefetch_count=1) + + send_unwrap(server, m.BasicDeliver, delivery_tag=1) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + receive_unwrap(server, m.BasicReject(delivery_tag=1, requeue=False)) + + assert lookup_unique_sendable(server, m.BasicDeliver).delivery_tag == 2 + + send_unwrap(server, m.BasicDeliver, delivery_tag=2) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + receive_unwrap(server, m.BasicNack(delivery_tag=2, requeue=False)) + + assert lookup_unique_sendable(server, m.BasicDeliver).delivery_tag == 3 + + +def test_requeued_message_comes_back() -> None: + server = consuming(prefetch_count=1) + + first = send_unwrap(server, m.BasicDeliver, delivery_tag=1) + receive_unwrap(server, m.BasicReject(delivery_tag=1, requeue=True)) + + again = lookup_unique_sendable(server, m.BasicDeliver, redelivered=True) + + assert again.delivery_tag == 2 + assert message_content(again) == message_content(first) + + +def test_fresh_delivery_and_requeue_delivery() -> None: + # after Reject with requeue=True we should have: + # - one fresh delivery with an arbitrary body and arbitrary requeued bit + # - one requeued delivery with a concrete body and a concrete requeued bit + server = consuming(prefetch_count=1) + + send_unwrap(server, m.BasicDeliver, delivery_tag=1) + receive_unwrap(server, m.BasicReject(delivery_tag=1, requeue=True)) + + fresh = match_fresh_delivery(server) + + assert len(lookup_sendables(server, m.BasicDeliver)) == 2 + assert fresh.delivery_tag == 2 + assert all(isinstance(field, Gen) for field in message_content(fresh)) + + +def test_reject_no_requeue() -> None: + server = consuming(prefetch_count=1) + + send_unwrap(server, m.BasicDeliver, delivery_tag=1) + receive_unwrap(server, m.BasicReject(delivery_tag=1, requeue=False)) + + assert not subsets_any_sendable(server, m.BasicDeliver, redelivered=True) + + +def test_accumulate_requeues() -> None: + server = consuming(prefetch_count=2) + + first = send_unwrap(server, m.BasicDeliver, delivery_tag=1) + second = send_unwrap(server, m.BasicDeliver, delivery_tag=2) + receive_unwrap(server, m.BasicNack(delivery_tag=1, requeue=True)) + receive_unwrap(server, m.BasicNack(delivery_tag=2, requeue=True)) + + redeliveries = lookup_sendables(server, m.BasicDeliver, redelivered=True) + + assert {message_content(d) for d in redeliveries} == { + message_content(first), + message_content(second), + } + assert {d.delivery_tag for d in redeliveries} == {3} + + +def test_redelivery_persists() -> None: + server = consuming(prefetch_count=1) + + send_unwrap(server, m.BasicDeliver, delivery_tag=1) + receive_unwrap(server, m.BasicReject(delivery_tag=1, requeue=True)) + send_unwrap(server, m.BasicDeliver, delivery_tag=2, redelivered=True) + receive_unwrap(server, m.BasicReject(delivery_tag=2, requeue=True)) + + assert subsets_any_sendable(server, m.BasicDeliver, redelivered=True) + + +def test_tag_not_clobbered_after_reject() -> None: + server = consuming(prefetch_count=1) + + send_unwrap(server, m.BasicDeliver, delivery_tag=1) + receive_unwrap(server, m.BasicReject(delivery_tag=1)) + + assert not subsets_any_sendable(server, m.BasicDeliver, delivery_tag=1) + + +@pytest.mark.parametrize("acknowledge", ACKNOWLEDGEMENT_TYPES, ids=lambda k: k.__name__) +def test_unknown_tag_closes_channel( + acknowledge: type[m.BasicAck | m.BasicNack | m.BasicReject], +) -> None: + server = consuming(prefetch_count=1) + + send_unwrap(server, m.BasicDeliver, delivery_tag=1) + receive_unwrap(server, acknowledge(99)) + + assert error_replies_to(server, acknowledge) == channel_errors( + mf.ReplyCode.PRECONDITION_FAILED + ) + + +@pytest.mark.parametrize("acknowledge", ACKNOWLEDGEMENT_TYPES, ids=lambda k: k.__name__) +def test_repeat_acknowledgement_closes_channel( + acknowledge: type[m.BasicAck | m.BasicNack | m.BasicReject], +) -> None: + server = consuming(prefetch_count=1) + + send_unwrap(server, m.BasicDeliver, delivery_tag=1) + receive_unwrap(server, acknowledge(1)) + receive_unwrap(server, acknowledge(1)) + + assert error_replies_to(server, acknowledge) == channel_errors( + mf.ReplyCode.PRECONDITION_FAILED + ) + + +@pytest.mark.parametrize("acknowledge", ACKNOWLEDGEMENT_TYPES, ids=lambda k: k.__name__) +def test_undelivered_tag_closes_channel( + acknowledge: type[m.BasicAck | m.BasicNack | m.BasicReject], +) -> None: + server = consuming(prefetch_count=1) + + receive_unwrap(server, acknowledge(1)) + + assert error_replies_to(server, acknowledge) == channel_errors( + mf.ReplyCode.PRECONDITION_FAILED + ) + + +@pytest.mark.parametrize( + "acknowledge", (m.BasicAck, m.BasicNack), ids=lambda k: k.__name__ +) +def test_multiple_tag_too_high_closes_channel( + acknowledge: type[m.BasicAck | m.BasicNack], +) -> None: + server = consuming(prefetch_count=3) + + send_unwrap(server, m.BasicDeliver, delivery_tag=1) + receive_unwrap(server, acknowledge(delivery_tag=5, multiple=True)) + + assert error_replies_to(server, acknowledge) == channel_errors( + mf.ReplyCode.PRECONDITION_FAILED + ) diff --git a/src/tests/amqp_imposter/tests/model/class_basic/test_return.py b/src/tests/amqp_imposter/tests/model/class_basic/test_return.py new file mode 100644 index 00000000..3833d82a --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_basic/test_return.py @@ -0,0 +1,203 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + QUEUE, + channelled, + confirming, + delivery_tags, + error_replies_to, + lookup_sendables, + lookup_unique_sendable, + publish, + receive_unwrap, + send_unwrap, + subsets_any_sendable, +) + + +def test_return_destroys_corresponding_nack() -> None: + server = confirming() + + publish(server, body=b"one", mandatory=True) + publish(server, body=b"two", mandatory=True) + publish(server, body=b"three", mandatory=True) + publish(server, body=b"four", mandatory=True) + + server.send(lookup_unique_sendable(server, m.BasicReturn, body=b"two")) + + # once Return is sent, we can no longer Nack the message + # nor with any multiple Nack that includes it + assert delivery_tags(server, m.BasicNack) == { + (1, False), + (1, True), + (3, False), + (4, False), + } + # Ack is what's meant to come after Return + assert delivery_tags(server, m.BasicAck) == { + (1, False), + (1, True), + (2, False), + (2, True), + (3, False), + (3, True), + (4, False), + (4, True), + } + + +def test_return_all_destroys_all_nacks() -> None: + server = confirming() + + publish(server, body=b"one", mandatory=True) + publish(server, body=b"two", mandatory=True) + publish(server, body=b"three", mandatory=True) + + for unconfirmed_states in lookup_sendables(server, m.BasicReturn): + server.send(unconfirmed_states) + + assert delivery_tags(server, m.BasicNack) == set() + assert delivery_tags(server, m.BasicAck) == { + (1, False), + (1, True), + (2, False), + (2, True), + (3, False), + (3, True), + } + + +def test_new_publish_cannot_multiple_nack_past_a_return() -> None: + server = confirming() + + publish(server, body=b"one", mandatory=True) + publish(server, body=b"two") + + send_unwrap(server, m.BasicReturn) + publish(server, body=b"three") + + assert subsets_any_sendable(server, m.BasicNack, delivery_tag=3, multiple=False) + assert not subsets_any_sendable(server, m.BasicNack, delivery_tag=3, multiple=True) + + +def test_multiple_ack_destroys_corresponding_returns() -> None: + server = confirming() + + publish(server, body=b"one", mandatory=True) + publish(server, body=b"two", mandatory=True) + + send_unwrap(server, m.BasicAck, delivery_tag=2, multiple=True) + + assert not subsets_any_sendable(server, m.BasicReturn) + + +def test_multiple_ack_spares_uncovered_returns() -> None: + server = confirming() + + publish(server, body=b"one", mandatory=True) + publish(server, body=b"two", mandatory=True) + + send_unwrap(server, m.BasicAck, delivery_tag=1, multiple=True) + + assert len(lookup_sendables(server, m.BasicReturn)) == 1 + + +def test_multiple_nack_cannot_span_a_returned_tag() -> None: + server = confirming() + + publish(server, body=b"one", mandatory=True) + publish(server, body=b"two") + publish(server, body=b"three") + + send_unwrap(server, m.BasicReturn) + + assert delivery_tags(server, m.BasicNack) == {(2, False), (3, False)} + assert delivery_tags(server, m.BasicAck) == { + (1, False), + (2, False), + (3, False), + (1, True), + (2, True), + (3, True), + } + + +def test_multiple_ack_may_span_a_returned_tag() -> None: + server = confirming() + + publish(server, body=b"one", mandatory=True) + publish(server, body=b"two") + + send_unwrap(server, m.BasicReturn) + send_unwrap(server, m.BasicAck, delivery_tag=2, multiple=True) + + assert delivery_tags(server, m.BasicAck) == set() + + +def test_return_has_cluster_id_void() -> None: + server = channelled() + + publish(server, mandatory=True) + + returned = lookup_unique_sendable(server, m.BasicReturn) + + assert isinstance(returned.properties, mf.MessageProperties) + assert returned.properties.cluster_id == mf.Void() + + +def test_identical_returns_for_different_tags() -> None: + server = channelled() + + publish(server, body=b"same", mandatory=True) + publish(server, body=b"same", mandatory=True) + + unroutable = lookup_sendables(server, m.BasicReturn)[0] + + assert len(lookup_sendables(server, m.BasicReturn)) == 2 + + server.send(unroutable) + + assert len(lookup_sendables(server, m.BasicReturn)) == 1 + assert error_replies_to(server, m.BasicPublish) + + server.send(unroutable) + + assert not subsets_any_sendable(server, m.BasicReturn) + + +def test_identical_returns_with_one_unconfirmed() -> None: + server = channelled() + + publish(server, body=b"same", mandatory=True) + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + send_unwrap(server, m.QueueDeclareOk) + publish(server, body=b"same", mandatory=True) + + unroutable = lookup_sendables(server, m.BasicReturn)[0] + + assert len(lookup_sendables(server, m.BasicReturn)) == 2 + + server.send(unroutable) + + assert len(lookup_sendables(server, m.BasicReturn)) == 1 + assert error_replies_to(server, m.BasicPublish) + + server.send(unroutable) + + assert not subsets_any_sendable(server, m.BasicReturn) diff --git a/src/tests/amqp_imposter/tests/model/class_channel/__init__.py b/src/tests/amqp_imposter/tests/model/class_channel/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/tests/model/class_channel/test_channel.py b/src/tests/amqp_imposter/tests/model/class_channel/test_channel.py new file mode 100644 index 00000000..675cdf63 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_channel/test_channel.py @@ -0,0 +1,76 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + DO_CHANNEL_CLOSE, + DoTeardown, + channelled, + connected, + connection_errors, + error_replies_to, + receive_unwrap, + subsets_any_sendable, +) + + +def test_no_open_ok_before_open() -> None: + assert not subsets_any_sendable(connected(), m.ChannelOpenOk) + + +def test_open_optional_errors() -> None: + server = connected() + + receive_unwrap(server, m.ChannelOpen()) + + assert error_replies_to(server, m.ChannelOpen) == connection_errors( + mf.ReplyCode.RESOURCE_ERROR + ) + + +@pytest.mark.parametrize("close", DO_CHANNEL_CLOSE, ids=lambda c: c.__name__) +def test_reopen_channel(close: DoTeardown) -> None: + server = channelled() + + close(server) + receive_unwrap(server, m.ChannelOpen()) + + assert subsets_any_sendable(server, m.ChannelOpenOk) + + +def test_reopen_open_channel_closes_connection() -> None: + server = channelled() + + receive_unwrap(server, m.ChannelOpen()) + + assert error_replies_to(server, m.ChannelOpen) == connection_errors( + mf.ReplyCode.CHANNEL_ERROR + ) + + +@pytest.mark.parametrize("close", DO_CHANNEL_CLOSE, ids=lambda c: c.__name__) +def test_method_on_closed_channel_closes_connection(close: DoTeardown) -> None: + server = channelled() + + close(server) + receive_unwrap(server, m.ConfirmSelect()) + + assert error_replies_to(server, m.ConfirmSelect) == connection_errors( + mf.ReplyCode.CHANNEL_ERROR + ) diff --git a/src/tests/amqp_imposter/tests/model/class_channel/test_teardown.py b/src/tests/amqp_imposter/tests/model/class_channel/test_teardown.py new file mode 100644 index 00000000..93430b66 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_channel/test_teardown.py @@ -0,0 +1,175 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import ModelServer +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + CONNECTED_STATES, + DO_CHANNEL_CLOSE, + GRACEFUL_CHANNEL_CLOSE, + QUEUE, + DoTeardown, + MakeServer, + channelled, + confirming, + consume, + consuming, + deliver, + enable_channel_error, + lookup_sendables, + message_content, + publish, + receive_unwrap, + reopen_channel, + send_channel_error, + send_unwrap, + subsets_any_sendable, +) + + +@pytest.mark.parametrize("make_server", CONNECTED_STATES, ids=lambda b: b.__name__) +def test_no_unprompted_channel_close(make_server: MakeServer) -> None: + assert not subsets_any_sendable( + make_server(), + m.ChannelClose, + class_id=mf.UNPROMPTED_CLASS_ID, + method_id=mf.UNPROMPTED_METHOD_ID, + ) + + +def test_channel_close_awaits_close_ok() -> None: + server = consuming() + + enable_channel_error(server) + send_channel_error(server) + + assert not subsets_any_sendable(server, m.BasicDeliver) + assert not subsets_any_sendable(server, m.ChannelClose) + assert subsets_any_sendable(server, m.ConnectionClose) + + +def test_channel_close_destroys_confirms() -> None: + server = confirming() + + publish(server) + enable_channel_error(server) + send_channel_error(server) + + assert not subsets_any_sendable(server, m.BasicAck) + assert not subsets_any_sendable(server, m.BasicNack) + + +def test_close_preempts_confirms() -> None: + server = confirming() + + publish(server) + enable_channel_error(server) + + assert subsets_any_sendable(server, m.ChannelClose) + + +@pytest.mark.parametrize("close", DO_CHANNEL_CLOSE, ids=lambda c: c.__name__) +def test_channel_close_requeues_unacked(close: DoTeardown) -> None: + server = consuming(prefetch_count=2) + + delivered = tuple(deliver(server, delivery_tag) for delivery_tag in (1, 2)) + + close(server) + reopen_channel(server) + consume(server) + + redeliveries = lookup_sendables(server, m.BasicDeliver, redelivered=True) + + assert {message_content(d) for d in redeliveries} == { + message_content(d) for d in delivered + } + + +def test_received_methods_discarded_after_channel_close() -> None: + server = channelled() + + enable_channel_error(server) + send_channel_error(server) + + closing = server.sendables() + + receive_unwrap(server, m.ConfirmSelect()) + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + receive_unwrap(server, m.BasicAck(delivery_tag=99)) + publish(server, mandatory=True) + + assert server.sendables() == closing + + receive_unwrap(server, m.ChannelCloseOk()) + reopen_channel(server) + + assert not subsets_any_sendable(server, m.ConfirmSelectOk) + assert not subsets_any_sendable(server, m.QueueDeclareOk) + assert not subsets_any_sendable(server, m.BasicReturn) + + +def test_received_methods_discarded_while_the_close_is_owed() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + enable_channel_error(server) + receive_unwrap(server, m.ConfirmSelect()) + + assert not subsets_any_sendable(server, m.ConfirmSelectOk) + + +def test_channel_close_bags_close_ok() -> None: + server = channelled() + + enable_channel_error(server) + send_channel_error(server) + receive_unwrap(server, GRACEFUL_CHANNEL_CLOSE) + + assert subsets_any_sendable(server, m.ChannelCloseOk) + assert not subsets_any_sendable(server, m.ChannelClose) + + +def both_sides_close_channel() -> ModelServer: + server = channelled() + enable_channel_error(server) + send_channel_error(server) + receive_unwrap(server, GRACEFUL_CHANNEL_CLOSE) + return server + + +@pytest.mark.xfail( + strict=True, reason="ideally both sides send ok when intending to close channel" +) +def test_both_sides_close_channel_can_accept_close_ok() -> None: + # if the consumer is intending to close too, and we send our Ok, we still need to accept their Ok + server = both_sides_close_channel() + send_unwrap(server, m.ChannelCloseOk) + + receive_unwrap(server, m.ChannelCloseOk()) + + +@pytest.mark.xfail( + strict=True, reason="ideally both sides send ok when intending to close channel" +) +def test_both_sides_close_channel_closes_owe_close_ok() -> None: + # if the consumer is intending to close too, and we get their Ok, we still need to send our Ok + server = both_sides_close_channel() + receive_unwrap(server, m.ChannelCloseOk()) + + assert subsets_any_sendable(server, m.ChannelCloseOk) diff --git a/src/tests/amqp_imposter/tests/model/class_confirm/__init__.py b/src/tests/amqp_imposter/tests/model/class_confirm/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/tests/model/class_confirm/test_select.py b/src/tests/amqp_imposter/tests/model/class_confirm/test_select.py new file mode 100644 index 00000000..c8d20a5c --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_confirm/test_select.py @@ -0,0 +1,150 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import methods as m + +from ..helpers import ( + QUEUE, + channelled, + confirming, + delivery_tags, + lookup_sendables, + publish, + receive_unwrap, + send_unwrap, + subsets_any_sendable, +) + + +def test_no_select_ok_if_nowait() -> None: + server = channelled() + + receive_unwrap(server, m.ConfirmSelect(no_wait=True)) + + assert not subsets_any_sendable(server, m.ConfirmSelectOk) + + +def test_publish_before_select_ok() -> None: + server = channelled() + + receive_unwrap(server, m.ConfirmSelect()) + publish(server, body=b"early") + + # the broker doesn't do confirms until it's sent the Confirm.SelectOk + assert not subsets_any_sendable(server, m.BasicAck) + + send_unwrap(server, m.ConfirmSelectOk) + + assert subsets_any_sendable(server, m.BasicAck, delivery_tag=1) + + publish(server, body=b"late") + + assert subsets_any_sendable(server, m.BasicAck, delivery_tag=2) + + +def test_return_before_select_ok() -> None: + server = channelled() + + receive_unwrap(server, m.ConfirmSelect()) + publish(server, body=b"early", mandatory=True) + + assert not subsets_any_sendable(server, m.BasicReturn) + + send_unwrap(server, m.ConfirmSelectOk) + + assert subsets_any_sendable(server, m.BasicReturn) + + +def test_second_select() -> None: + server = confirming() + + publish(server, body=b"early") + receive_unwrap(server, m.ConfirmSelect()) + + assert subsets_any_sendable(server, m.BasicAck, delivery_tag=1) + + send_unwrap(server, m.ConfirmSelectOk) + publish(server, body=b"late") + + assert subsets_any_sendable(server, m.BasicAck, delivery_tag=2) + + +def test_nowait_select_orthogonal_to_acknowledgements() -> None: + server = channelled() + + receive_unwrap(server, m.ConfirmSelect(no_wait=True)) + publish(server) + + assert subsets_any_sendable(server, m.BasicAck, delivery_tag=1) + assert subsets_any_sendable(server, m.BasicNack, delivery_tag=1) + + +def test_confirm_deferred_until_declare_ok() -> None: + server = confirming() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + publish(server, body=b"after") + + assert not subsets_any_sendable(server, m.BasicAck) + + send_unwrap(server, m.QueueDeclareOk) + + assert subsets_any_sendable(server, m.BasicAck, delivery_tag=1) + + +def test_return_deferred_until_declare_ok() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + publish(server, body=b"after", mandatory=True) + + assert not subsets_any_sendable(server, m.BasicReturn) + + send_unwrap(server, m.QueueDeclareOk) + + assert subsets_any_sendable(server, m.BasicReturn) + + +def test_confirm_not_deferred_until_declare_ok() -> None: + server = confirming() + + publish(server, body=b"before") + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + + assert subsets_any_sendable(server, m.BasicAck, delivery_tag=1) + assert subsets_any_sendable(server, m.QueueDeclareOk) + + +def test_many_deferred_publishes_become_sendable() -> None: + server = confirming() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + publish(server, body=b"one") + publish(server, body=b"two") + publish(server, body=b"three") + send_unwrap(server, m.QueueDeclareOk) + + assert delivery_tags(server, m.BasicAck) >= {(1, False), (2, False), (3, False)} + + +def test_many_deferred_returns_become_sendable() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + publish(server, body=b"one", mandatory=True) + publish(server, body=b"two", mandatory=True) + send_unwrap(server, m.QueueDeclareOk) + + assert len(lookup_sendables(server, m.BasicReturn)) == 2 diff --git a/src/tests/amqp_imposter/tests/model/class_connection/__init__.py b/src/tests/amqp_imposter/tests/model/class_connection/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/tests/model/class_connection/test_connection.py b/src/tests/amqp_imposter/tests/model/class_connection/test_connection.py new file mode 100644 index 00000000..c261df96 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_connection/test_connection.py @@ -0,0 +1,112 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import ( + ModelServer, + Unrepresentable, +) +from amqp_imposter.model import methods as m + +from ..helpers import ( + DO_CONNECTION_CLOSE, + GRACEFUL_CHANNEL_CLOSE, + GRACEFUL_CONNECTION_CLOSE, + DoTeardown, + channelled, + connected, + greeted, + lookup_unique_sendable, + receive_unwrap, + send_unwrap, + start_ok_of, + subsets_any_sendable, + tune_ok_of, + tuned, + tuning, +) + + +def test_open_and_close() -> None: + server = ModelServer() + + server.receive_protocol_header() + receive_unwrap(server, start_ok_of(send_unwrap(server, m.ConnectionStart))) + + receive_unwrap(server, tune_ok_of(send_unwrap(server, m.ConnectionTune))) + receive_unwrap(server, m.ConnectionOpen()) + + send_unwrap(server, m.ConnectionOpenOk) + receive_unwrap(server, m.ChannelOpen()) + + send_unwrap(server, m.ChannelOpenOk) + receive_unwrap(server, GRACEFUL_CHANNEL_CLOSE) + + send_unwrap(server, m.ChannelCloseOk) + receive_unwrap(server, GRACEFUL_CONNECTION_CLOSE) + + send_unwrap(server, m.ConnectionCloseOk) + + assert server.sendables() == () + + +def test_start_has_capabilities() -> None: + start = lookup_unique_sendable(greeted(), m.ConnectionStart) + + assert start.publisher_confirms + assert start.basic_nack + assert start.consumer_cancel_notify + + +def test_start_is_alone_in_bag() -> None: + assert len(greeted().sendables()) == 1 + + +def test_heartbeat_is_zero() -> None: + assert lookup_unique_sendable(tuning(), m.ConnectionTune).heartbeat == 0 + + +def test_tune_ok_is_noop() -> None: + server = tuned() + + assert not subsets_any_sendable(server, m.ConnectionTune) + assert not subsets_any_sendable(server, m.ConnectionOpenOk) + + +def test_no_open_ok_before_open() -> None: + assert not subsets_any_sendable(tuned(), m.ConnectionOpenOk) + + +@pytest.mark.parametrize("close", DO_CONNECTION_CLOSE, ids=lambda c: c.__name__) +def test_reopen_connection(close: DoTeardown) -> None: + server = channelled() + + close(server) + server.receive_protocol_header() + + assert subsets_any_sendable(server, m.ConnectionStart) + + +def test_simultaneous_connections_raise() -> None: + server = connected() + + assert isinstance(server.receive_protocol_header(), Unrepresentable) + + +def test_dupe_header_raises() -> None: + server = tuning() + + assert isinstance(server.receive_protocol_header(), Unrepresentable) diff --git a/src/tests/amqp_imposter/tests/model/class_connection/test_handshake.py b/src/tests/amqp_imposter/tests/model/class_connection/test_handshake.py new file mode 100644 index 00000000..8a5a1f1a --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_connection/test_handshake.py @@ -0,0 +1,146 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import replace + +import pytest + +from amqp_imposter.model import ( + NegotiationOptions, + Unrepresentable, + constants, +) +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + connection_errors, + error_replies_to, + greeted, + lookup_unique_sendable, + opening, + receive_unwrap, + send_unwrap, + start_ok_of, + subsets_any_sendable, + tune_ok_of, + tuned, + tuning, +) + +NEGOTIATION_OPTIONS = NegotiationOptions( + mechanisms=("EXTERNAL", "PLAIN"), + locales=("en_GB",), + publisher_confirms=False, + basic_nack=False, + consumer_cancel_notify=False, + channel_max=7, + frame_max=constants.FRAME_MIN_FLOOR, + heartbeat=60, +) + + +def test_start_with_options() -> None: + start = lookup_unique_sendable(greeted(NEGOTIATION_OPTIONS), m.ConnectionStart) + + assert start == m.ConnectionStart( + version_major=constants.PROTOCOL_MAJOR, + version_minor=constants.PROTOCOL_MINOR, + mechanisms=NEGOTIATION_OPTIONS.mechanisms, + locales=NEGOTIATION_OPTIONS.locales, + publisher_confirms=NEGOTIATION_OPTIONS.publisher_confirms, + basic_nack=NEGOTIATION_OPTIONS.basic_nack, + consumer_cancel_notify=NEGOTIATION_OPTIONS.consumer_cancel_notify, + ) + + +def test_tune_with_options() -> None: + tune = lookup_unique_sendable(tuning(NEGOTIATION_OPTIONS), m.ConnectionTune) + + assert tune == m.ConnectionTune( + channel_max=NEGOTIATION_OPTIONS.channel_max, + frame_max=NEGOTIATION_OPTIONS.frame_max, + heartbeat=NEGOTIATION_OPTIONS.heartbeat, + ) + + +def test_start_ok_enables_tune() -> None: + assert subsets_any_sendable(tuning(), m.ConnectionTune) + + +def test_tune_ok_enables_open() -> None: + server = tuned(NEGOTIATION_OPTIONS) + + receive_unwrap(server, m.ConnectionOpen()) + + assert subsets_any_sendable(server, m.ConnectionOpenOk) + + +def test_open_optional_errors() -> None: + assert error_replies_to(opening(), m.ConnectionOpen) == connection_errors( + mf.ReplyCode.INVALID_PATH, mf.ReplyCode.NOT_ALLOWED + ) + + +def test_open_error_closes_connection() -> None: + server = opening() + + send_unwrap(server, m.ConnectionClose, reply_code=mf.ReplyCode.NOT_ALLOWED) + + assert not subsets_any_sendable(server, m.ConnectionOpenOk) + + receive_unwrap(server, m.ConnectionCloseOk()) + + assert server.receive_protocol_header() is None + + +@pytest.mark.parametrize( + "mechanism", + ("whatever", ""), +) +def test_start_ok_contradicts_mechanism(mechanism: str) -> None: + # TODO edit once meaningful negotiation is implemented + server = greeted(NEGOTIATION_OPTIONS) + start_ok = start_ok_of(send_unwrap(server, m.ConnectionStart)) + + assert isinstance( + server.receive(replace(start_ok, mechanism=mechanism)), Unrepresentable + ) + + +def test_start_ok_contradicts_locale() -> None: + # TODO edit once meaningful negotiation is implemented, we should remove this + server = greeted(NEGOTIATION_OPTIONS) + start_ok = start_ok_of(send_unwrap(server, m.ConnectionStart)) + + assert isinstance( + server.receive(replace(start_ok, locale="fr_FR")), Unrepresentable + ) + + +@pytest.mark.parametrize( + "alteration", + ( + {"channel_max": NEGOTIATION_OPTIONS.channel_max + 1}, + {"frame_max": NEGOTIATION_OPTIONS.frame_max + 1}, + {"heartbeat": NEGOTIATION_OPTIONS.heartbeat + 1}, + ), + ids=("channel_max", "frame_max", "heartbeat"), +) +def test_tune_ok_contradicts_tune(alteration: dict[str, int]) -> None: + server = tuning(NEGOTIATION_OPTIONS) + tune_ok = tune_ok_of(send_unwrap(server, m.ConnectionTune)) + + assert isinstance(server.receive(replace(tune_ok, **alteration)), Unrepresentable) diff --git a/src/tests/amqp_imposter/tests/model/class_connection/test_teardown.py b/src/tests/amqp_imposter/tests/model/class_connection/test_teardown.py new file mode 100644 index 00000000..0e43646d --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_connection/test_teardown.py @@ -0,0 +1,169 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import ModelServer +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + CONNECTED_STATES, + CONNECTING_STATES, + DO_CONNECTION_CLOSE, + GRACEFUL_CONNECTION_CLOSE, + DoTeardown, + MakeServer, + channelled, + closed, + confirming, + consume, + consuming, + deliver, + lookup_sendables, + message_content, + publish, + receive_unwrap, + reconnect, + send_unwrap, + subsets_any_sendable, +) + + +def test_teardown() -> None: + server = channelled() + + send_unwrap(server, m.ConnectionClose, reply_code=mf.ReplyCode.CONNECTION_FORCED) + + assert server.sendables() == () + + receive_unwrap(server, m.ConnectionCloseOk()) + + assert server.sendables() == () + + +def test_closed_connection_has_nothing_to_send() -> None: + assert closed().sendables() == () + + +@pytest.mark.parametrize("make_server", CONNECTING_STATES, ids=lambda b: b.__name__) +def test_no_unprompted_close_before_open(make_server: MakeServer) -> None: + assert not subsets_any_sendable( + make_server(), + m.ConnectionClose, + class_id=mf.UNPROMPTED_CLASS_ID, + method_id=mf.UNPROMPTED_METHOD_ID, + ) + + +@pytest.mark.parametrize("make_server", CONNECTED_STATES, ids=lambda b: b.__name__) +def test_has_close_after_open(make_server: MakeServer) -> None: + assert subsets_any_sendable(make_server(), m.ConnectionClose) + + +@pytest.mark.parametrize("make_server", CONNECTED_STATES, ids=lambda b: b.__name__) +def test_unprompted_close_codes(make_server: MakeServer) -> None: + closes = lookup_sendables( + make_server(), + m.ConnectionClose, + class_id=mf.UNPROMPTED_CLASS_ID, + method_id=mf.UNPROMPTED_METHOD_ID, + ) + + codes = sorted(mf.assert_concrete(close.reply_code) for close in closes) + + assert codes == sorted(mf.UNPROMPTED_ERRORS) + + +@pytest.mark.parametrize("close", DO_CONNECTION_CLOSE, ids=lambda c: c.__name__) +def test_connection_close_requeues_unacked(close: DoTeardown) -> None: + server = consuming(prefetch_count=2) + + delivered = tuple(deliver(server, delivery_tag) for delivery_tag in (1, 2)) + + close(server) + reconnect(server) + consume(server) + + redeliveries = lookup_sendables(server, m.BasicDeliver, redelivered=True) + + assert {message_content(d) for d in redeliveries} == { + message_content(d) for d in delivered + } + + +def test_received_methods_discarded_after_connection_close() -> None: + server = channelled() + + send_unwrap(server, m.ConnectionClose, reply_code=mf.ReplyCode.CONNECTION_FORCED) + receive_unwrap(server, m.ConfirmSelect()) + receive_unwrap(server, m.ChannelOpen()) + receive_unwrap(server, m.BasicAck(delivery_tag=99)) + publish(server, mandatory=True) + + assert server.sendables() == () + + receive_unwrap(server, m.ConnectionCloseOk()) + reconnect(server) + + assert not subsets_any_sendable(server, m.ConfirmSelectOk) + assert not subsets_any_sendable(server, m.ChannelOpenOk) + assert not subsets_any_sendable(server, m.BasicReturn) + + +def test_connection_close_bags_close_ok() -> None: + server = channelled() + + send_unwrap(server, m.ConnectionClose, reply_code=mf.ReplyCode.CONNECTION_FORCED) + receive_unwrap(server, GRACEFUL_CONNECTION_CLOSE) + send_unwrap(server, m.ConnectionCloseOk) + + assert not subsets_any_sendable(server, m.ConnectionClose) + + +def test_connection_close_destroys_everything() -> None: + server = confirming() + + publish(server) + send_unwrap(server, m.ConnectionClose, reply_code=mf.ReplyCode.CONNECTION_FORCED) + + assert server.sendables() == () + + +def both_sides_close_connection() -> ModelServer: + server = channelled() + send_unwrap(server, m.ConnectionClose, reply_code=mf.ReplyCode.CONNECTION_FORCED) + receive_unwrap(server, GRACEFUL_CONNECTION_CLOSE) + return server + + +@pytest.mark.xfail( + strict=True, reason="ideally both sides send ok when intending to close connection" +) +def test_both_sides_close_connection_can_accept_close_ok() -> None: + server = both_sides_close_connection() + send_unwrap(server, m.ConnectionCloseOk) + + receive_unwrap(server, m.ConnectionCloseOk()) + + +@pytest.mark.xfail( + strict=True, reason="ideally both sides send ok when intending to close connection" +) +def test_both_sides_close_connection_owes_close_ok() -> None: + server = both_sides_close_connection() + receive_unwrap(server, m.ConnectionCloseOk()) + + assert subsets_any_sendable(server, m.ConnectionCloseOk) diff --git a/src/tests/amqp_imposter/tests/model/class_exchange/__init__.py b/src/tests/amqp_imposter/tests/model/class_exchange/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/tests/model/class_exchange/test_bind.py b/src/tests/amqp_imposter/tests/model/class_exchange/test_bind.py new file mode 100644 index 00000000..05a33943 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_exchange/test_bind.py @@ -0,0 +1,131 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + EXCHANGE, + QUEUE_ARGUMENTS, + ROUTING_KEY, + channel_errors, + channelled, + connection_errors, + error_replies_to, + receive_unwrap, + send_unwrap, + subsets_any_sendable, +) + +SOURCE_EXCHANGE = EXCHANGE + "source" + + +def test_no_bind_ok_before_bind() -> None: + assert not subsets_any_sendable(channelled(), m.ExchangeBindOk) + + +def test_declare_bind() -> None: + server = channelled() + + receive_unwrap(server, m.ExchangeDeclare(exchange=EXCHANGE)) + send_unwrap(server, m.ExchangeDeclareOk) + + receive_unwrap(server, m.ExchangeDeclare(exchange=SOURCE_EXCHANGE)) + send_unwrap(server, m.ExchangeDeclareOk) + + receive_unwrap( + server, + m.ExchangeBind( + destination=EXCHANGE, source=SOURCE_EXCHANGE, routing_key=ROUTING_KEY + ), + ) + send_unwrap(server, m.ExchangeBindOk) + + assert not subsets_any_sendable(server, m.ExchangeBindOk) + + +def test_exchange_bind_cyclical() -> None: + server = channelled() + + receive_unwrap(server, m.ExchangeDeclare(exchange=EXCHANGE)) + send_unwrap(server, m.ExchangeDeclareOk) + + receive_unwrap( + server, + m.ExchangeBind(destination=EXCHANGE, source=EXCHANGE, routing_key=ROUTING_KEY), + ) + send_unwrap(server, m.ExchangeBindOk) + + assert not subsets_any_sendable(server, m.ChannelClose) + + +def test_bind_without_exchanges() -> None: + server = channelled() + + receive_unwrap( + server, + m.ExchangeBind( + destination=EXCHANGE, source=SOURCE_EXCHANGE, routing_key=ROUTING_KEY + ), + ) + + assert subsets_any_sendable(server, m.ExchangeBindOk) + + +def test_no_bind_ok_if_nowait() -> None: + server = channelled() + + receive_unwrap( + server, + m.ExchangeBind( + destination=EXCHANGE, + source=SOURCE_EXCHANGE, + routing_key=ROUTING_KEY, + no_wait=True, + ), + ) + + assert not subsets_any_sendable(server, m.ExchangeBindOk) + + +@pytest.mark.parametrize("no_wait", (False, True), ids=("wait", "no_wait")) +def test_bind_optional_errors(no_wait: bool) -> None: + server = channelled() + + receive_unwrap( + server, + m.ExchangeBind(destination=EXCHANGE, source=SOURCE_EXCHANGE, no_wait=no_wait), + ) + + assert error_replies_to(server, m.ExchangeBind) == channel_errors( + mf.ReplyCode.NOT_FOUND, mf.ReplyCode.ACCESS_REFUSED + ) | connection_errors(mf.ReplyCode.RESOURCE_ERROR) + + +def test_bind_with_arguments() -> None: + server = channelled() + + receive_unwrap( + server, + m.ExchangeBind( + destination=EXCHANGE, + source=SOURCE_EXCHANGE, + arguments=QUEUE_ARGUMENTS, + ), + ) + + assert subsets_any_sendable(server, m.ExchangeBindOk) diff --git a/src/tests/amqp_imposter/tests/model/class_exchange/test_declare.py b/src/tests/amqp_imposter/tests/model/class_exchange/test_declare.py new file mode 100644 index 00000000..4b926fd7 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_exchange/test_declare.py @@ -0,0 +1,125 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + EXCHANGE, + QUEUE_ARGUMENTS, + ErrorReply, + channel_errors, + channelled, + confirming, + connection_errors, + error_replies_to, + publish, + receive_unwrap, + send_unwrap, + subsets_any_sendable, +) + + +@pytest.mark.parametrize("no_wait", (False, True), ids=("wait", "no_wait")) +@pytest.mark.parametrize( + ("passive", "errors"), + ( + ( + True, + channel_errors(mf.ReplyCode.NOT_FOUND, mf.ReplyCode.ACCESS_REFUSED), + ), + ( + False, + channel_errors( + mf.ReplyCode.ACCESS_REFUSED, mf.ReplyCode.PRECONDITION_FAILED + ) + | connection_errors(mf.ReplyCode.NOT_ALLOWED, mf.ReplyCode.RESOURCE_ERROR), + ), + ), + ids=("passive", "active"), +) +def test_declare_optional_errors( + passive: bool, no_wait: bool, errors: set[ErrorReply] +) -> None: + server = channelled() + + receive_unwrap( + server, m.ExchangeDeclare(exchange=EXCHANGE, passive=passive, no_wait=no_wait) + ) + + assert error_replies_to(server, m.ExchangeDeclare) == errors + + +def test_declare_error_closes_connection() -> None: + server = channelled() + + receive_unwrap(server, m.ExchangeDeclare(exchange=EXCHANGE)) + send_unwrap(server, m.ConnectionClose, reply_code=mf.ReplyCode.NOT_ALLOWED) + receive_unwrap(server, m.ConnectionCloseOk()) + + assert server.receive_protocol_header() is None + + +def test_topology_orthogonal_to_routability() -> None: + # we treat topology as volatile, so the return is always available + # irrespective of whether we have declared the exchange or not + # (N.b. in real life someone could also delete the exchange concurrently) + server = confirming() + + receive_unwrap(server, m.ExchangeDeclare(exchange=EXCHANGE)) + send_unwrap(server, m.ExchangeDeclareOk) + publish(server, mandatory=True) # the only message on this channel + + assert subsets_any_sendable(server, m.BasicReturn) # the channel has a return + + +def test_no_declare_ok_if_nowait() -> None: + server = channelled() + + receive_unwrap(server, m.ExchangeDeclare(exchange=EXCHANGE, no_wait=True)) + + assert not subsets_any_sendable(server, m.ExchangeDeclareOk) + + +def test_nowait_declare_orthogonal_to_acknowledgements() -> None: + server = confirming() + + receive_unwrap(server, m.ExchangeDeclare(exchange=EXCHANGE, no_wait=True)) + publish(server, mandatory=True) + + assert subsets_any_sendable(server, m.BasicReturn) + assert subsets_any_sendable(server, m.BasicAck) + + +def test_declare_auto_delete_and_internal() -> None: + server = channelled() + + receive_unwrap( + server, m.ExchangeDeclare(exchange=EXCHANGE, auto_delete=True, internal=True) + ) + + assert subsets_any_sendable(server, m.ExchangeDeclareOk) + + +def test_declare_with_arguments() -> None: + server = channelled() + + receive_unwrap( + server, m.ExchangeDeclare(exchange=EXCHANGE, arguments=QUEUE_ARGUMENTS) + ) + + assert subsets_any_sendable(server, m.ExchangeDeclareOk) diff --git a/src/tests/amqp_imposter/tests/model/class_queue/__init__.py b/src/tests/amqp_imposter/tests/model/class_queue/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/tests/model/class_queue/test_declare.py b/src/tests/amqp_imposter/tests/model/class_queue/test_declare.py new file mode 100644 index 00000000..4a44fbf0 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/class_queue/test_declare.py @@ -0,0 +1,194 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + EXCHANGE, + QUEUE, + QUEUE_ARGUMENTS, + ROUTING_KEY, + ErrorReply, + channel_errors, + channelled, + connection_errors, + error_replies_to, + lookup_unique_sendable, + receive_unwrap, + send_unwrap, + subsets_any_sendable, +) + + +def test_no_declare_ok_before_declare() -> None: + assert not subsets_any_sendable(channelled(), m.QueueDeclareOk) + assert not subsets_any_sendable(channelled(), m.ExchangeDeclareOk) + assert not subsets_any_sendable(channelled(), m.QueueBindOk) + + +def test_declare_bind() -> None: + server = channelled() + + receive_unwrap(server, m.ExchangeDeclare(exchange=EXCHANGE)) + send_unwrap(server, m.ExchangeDeclareOk) + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + + assert lookup_unique_sendable(server, m.QueueDeclareOk).queue == QUEUE + + send_unwrap(server, m.QueueDeclareOk) + + receive_unwrap( + server, m.QueueBind(queue=QUEUE, exchange=EXCHANGE, routing_key=ROUTING_KEY) + ) + send_unwrap(server, m.QueueBindOk) + + assert not subsets_any_sendable(server, m.QueueBindOk) + + +def test_queue_without_exchange() -> None: + # we don't model topology, so every queue declare can be answered with ok + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + + assert lookup_unique_sendable(server, m.QueueDeclareOk).queue == QUEUE + + +def test_bind_without_queue() -> None: + # we don't model topology, so every queue bind can be answered with ok + server = channelled() + + receive_unwrap( + server, m.QueueBind(queue=QUEUE, exchange=EXCHANGE, routing_key=ROUTING_KEY) + ) + + assert subsets_any_sendable(server, m.QueueBindOk) + + +def test_declare_anonymous_queue() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue="")) + + assert lookup_unique_sendable(server, m.QueueDeclareOk).queue + + +def test_no_declare_ok_if_nowait() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE, no_wait=True)) + + assert not subsets_any_sendable(server, m.QueueDeclareOk) + + +@pytest.mark.parametrize("no_wait", (False, True), ids=("wait", "no_wait")) +@pytest.mark.parametrize( + ("passive", "errors"), + ( + ( + True, + channel_errors( + mf.ReplyCode.NOT_FOUND, + mf.ReplyCode.ACCESS_REFUSED, + mf.ReplyCode.RESOURCE_LOCKED, + ), + ), + ( + False, + channel_errors( + mf.ReplyCode.ACCESS_REFUSED, + mf.ReplyCode.RESOURCE_LOCKED, + mf.ReplyCode.PRECONDITION_FAILED, + ) + | connection_errors(mf.ReplyCode.RESOURCE_ERROR), + ), + ), + ids=("passive", "active"), +) +def test_declare_optional_errors( + passive: bool, no_wait: bool, errors: set[ErrorReply] +) -> None: + server = channelled() + + receive_unwrap( + server, m.QueueDeclare(queue=QUEUE, passive=passive, no_wait=no_wait) + ) + + assert error_replies_to(server, m.QueueDeclare) == errors + + +@pytest.mark.parametrize("no_wait", (False, True), ids=("wait", "no_wait")) +def test_bind_optional_errors(no_wait: bool) -> None: + server = channelled() + + receive_unwrap(server, m.QueueBind(queue=QUEUE, exchange=EXCHANGE, no_wait=no_wait)) + + assert error_replies_to(server, m.QueueBind) == channel_errors( + mf.ReplyCode.NOT_FOUND, + mf.ReplyCode.ACCESS_REFUSED, + mf.ReplyCode.RESOURCE_LOCKED, + ) | connection_errors(mf.ReplyCode.RESOURCE_ERROR) + + +def test_no_bind_ok_if_nowait() -> None: + server = channelled() + + receive_unwrap( + server, + m.QueueBind( + queue=QUEUE, exchange=EXCHANGE, routing_key=ROUTING_KEY, no_wait=True + ), + ) + + assert not subsets_any_sendable(server, m.QueueBindOk) + + +def test_nowait_declare_then_bind_ok() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE, no_wait=True)) + receive_unwrap( + server, m.QueueBind(queue=QUEUE, exchange=EXCHANGE, routing_key=ROUTING_KEY) + ) + + assert not subsets_any_sendable(server, m.QueueDeclareOk) + assert subsets_any_sendable(server, m.QueueBindOk) + + +def test_declare_with_arguments() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE, arguments=QUEUE_ARGUMENTS)) + + assert subsets_any_sendable(server, m.QueueDeclareOk) + + +def test_bind_with_arguments() -> None: + server = channelled() + + receive_unwrap( + server, + m.QueueBind( + queue=QUEUE, + exchange=EXCHANGE, + arguments=QUEUE_ARGUMENTS, # the exact keys are not important + ), + ) + + assert subsets_any_sendable(server, m.QueueBindOk) diff --git a/src/tests/amqp_imposter/tests/model/generative/test_generators.py b/src/tests/amqp_imposter/tests/model/generative/test_generators.py new file mode 100644 index 00000000..15ae9430 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/generative/test_generators.py @@ -0,0 +1,104 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import GenState +from amqp_imposter.model import generators as g + +STATE = GenState(12345) +SAMPLES = 200 + + +def test_comparable() -> None: + assert g.RandomBytes(0, 64) == g.RandomBytes(0, 64) + assert g.RandomBytes(0, 64) != g.RandomBytes(0, 32) + assert g.RandomText(1, 4) != g.RandomText(1, 8) + + +def test_hashable() -> None: + assert len({g.RandomBytes(0, 64), g.RandomBytes(0, 64), g.RandomBytes(0, 32)}) == 2 + + +def test_pure() -> None: + assert g.RandomBytes(1, 8).generate(STATE) == g.RandomBytes(1, 8).generate(STATE) + + +def test_advance() -> None: + _, gen_state = g.RangedInt(1, 2).generate(STATE) + + assert gen_state != STATE + + +def test_constant() -> None: + assert g.Constant(b"k").generate(STATE) == (b"k", STATE) + + +def test_advance_diverges() -> None: + states = [STATE] + for _ in range(SAMPLES - 1): + states.append(states[-1].advance()) + + assert len(set(states)) == SAMPLES + + +def test_int_within_range() -> None: + drawn = {g.RangedInt(3, 7).generate(GenState(seed))[0] for seed in range(SAMPLES)} + + assert drawn <= set(range(3, 8)) + assert len(drawn) > 1 + + +def test_bytes_within_range() -> None: + sizes = { + len(g.RandomBytes(2, 5).generate(GenState(seed))[0]) for seed in range(SAMPLES) + } + + assert sizes <= set(range(2, 6)) + assert len(sizes) > 1 + + +def test_random_text_within_length_bounds() -> None: + lengths = { + len(g.RandomText(2, 5).generate(GenState(seed))[0]) for seed in range(SAMPLES) + } + + assert lengths <= set(range(2, 6)) + assert len(lengths) > 1 + + +def test_even_chance_gives_both() -> None: + assert { + g.NonzeroChance(0.5).generate(GenState(seed))[0] for seed in range(SAMPLES) + } == {True, False} + + +def test_certain_chance() -> None: + assert all( + g.NonzeroChance(1).generate(GenState(seed))[0] for seed in range(SAMPLES) + ) + + +def test_bad_bounds() -> None: + with pytest.raises(g.BadBounds): + g.RangedInt(5, 1) + with pytest.raises(g.BadBounds): + g.NonzeroChance(0) + with pytest.raises(g.BadBounds): + g.NonzeroChance(1.5) + with pytest.raises(g.BadBounds): + g.RandomBytes(5, 1) + with pytest.raises(g.BadBounds): + g.RandomText(-1, 1) diff --git a/src/tests/amqp_imposter/tests/model/generative/test_sampler.py b/src/tests/amqp_imposter/tests/model/generative/test_sampler.py new file mode 100644 index 00000000..220c6fc2 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/generative/test_sampler.py @@ -0,0 +1,259 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import ( + GenState, + ModelServer, + Unrepresentable, + WeightedPattern, +) +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import method_sampling as msa +from amqp_imposter.model import method_weights as mw +from amqp_imposter.model import methods as m + +from ..helpers import ( + CONSUMER_TAG, + EXCHANGE, + GREETED_STATES, + QUEUE, + MakeServer, + channelled, + confirming, + connected, + consuming, + polling, + publish, + receive_unwrap, + subsets_any_sendable, + tuned, +) + +SAMPLES = 100 +WEIGHT = 16 +PREFETCH_COUNT = 1000 # so that we don't run out +STOP_WEIGHT_LONG_BURSTS = 1 + +OPTIONAL_ERROR_CAUSES: tuple[tuple[MakeServer, m.Method], ...] = ( + (tuned, m.ConnectionOpen()), + (connected, m.ChannelOpen()), + (channelled, m.ExchangeDeclare(exchange=EXCHANGE, passive=True)), + (channelled, m.ExchangeDeclare(exchange=EXCHANGE, no_wait=True)), + (channelled, m.ExchangeBind(destination=EXCHANGE, source=EXCHANGE, no_wait=True)), + (channelled, m.QueueDeclare(queue=QUEUE, passive=True)), + (channelled, m.QueueDeclare(queue=QUEUE, no_wait=True)), + (channelled, m.QueueBind(queue=QUEUE, exchange=EXCHANGE, no_wait=True)), + (channelled, m.BasicConsume(queue=QUEUE, consumer_tag=CONSUMER_TAG, no_wait=True)), + (channelled, m.BasicGet(queue=QUEUE)), + (channelled, m.BasicPublish(exchange=EXCHANGE)), +) +FORCED_ERROR_CAUSES: tuple[tuple[MakeServer, m.Method], ...] = ( + (consuming, m.BasicAck(delivery_tag=0, multiple=False)), + (channelled, m.ChannelOpen()), + (connected, m.ConfirmSelect()), + (channelled, m.BasicQos(prefetch_size=1)), + (channelled, m.BasicPublish(exchange=EXCHANGE, immediate=True)), +) + + +def param_name(value: object) -> str: + return getattr(value, "__name__", type(value).__name__) + + +def polling_seeded(seed: int) -> ModelServer: + server = polling() + server.gen_state = GenState(seed) + return server + + +def consuming_seeded(seed: int) -> ModelServer: + server = consuming(prefetch_count=PREFETCH_COUNT) + server.gen_state = GenState(seed) + return server + + +def connected_seeded(seed: int) -> ModelServer: + server = connected() + server.gen_state = GenState(seed) + return server + + +def test_before_header() -> None: + assert isinstance(ModelServer().send_gen(), msa.Stop) + assert ModelServer().send_gens() == [] + + +def test_no_default_unprompted_teardown() -> None: + server = consuming() + + assert subsets_any_sendable( + server, + m.ConnectionClose, + class_id=mf.UNPROMPTED_CLASS_ID, + method_id=mf.UNPROMPTED_METHOD_ID, + ) + assert subsets_any_sendable(server, m.BasicCancel) + + for _ in range(SAMPLES): + assert not isinstance( + server.send_gen(), (m.ConnectionClose, m.ChannelClose, m.BasicCancel) + ) + + +def test_no_forced_teardown() -> None: + teardown = (WeightedPattern(mw.UNPROMPTED_CONNECTION_CLOSE, WEIGHT),) + + sent = { + type(connected_seeded(seed).send_gen(weighted_patterns=teardown)) + for seed in range(SAMPLES) + } + + assert sent == {m.ConnectionClose, msa.Stop} + + +@pytest.mark.parametrize(("make_server", "cause"), FORCED_ERROR_CAUSES, ids=param_name) +def test_owed_close_not_too_late(make_server: MakeServer, cause: m.Method) -> None: + server = make_server() + + receive_unwrap(server, cause) + + sampler = [server.send_gen() for _ in range(SAMPLES)] + + assert any( + isinstance(sent, (m.ChannelClose, m.ConnectionClose)) for sent in sampler + ) + + +@pytest.mark.parametrize( + ("make_server", "cause"), OPTIONAL_ERROR_CAUSES, ids=param_name +) +def test_no_default_optional_errors(make_server: MakeServer, cause: m.Method) -> None: + for seed in range(SAMPLES): + server = make_server() + server.gen_state = GenState(seed) + receive_unwrap(server, cause) + + assert not isinstance(server.send_gen(), (m.ChannelClose, m.ConnectionClose)) + + +def test_enable_optional_error() -> None: + not_found = ( + WeightedPattern(m.ChannelClose(reply_code=mf.ReplyCode.NOT_FOUND), WEIGHT), + ) + + sent = { + type(polling_seeded(seed).send_gen(weighted_patterns=not_found)) + for seed in range(SAMPLES) + } + + assert m.ChannelClose in sent + + +def test_pattern_too_specific() -> None: + server = consuming() + specific = WeightedPattern(m.BasicCancel(consumer_tag=CONSUMER_TAG), WEIGHT) + + sampler = msa.make_sampler(server.sendables(), (specific,), mw.STOP_WEIGHT) + + cancels = [ + (wp.pattern, len(unified)) + for wp, unified in sampler.unified_by_weighted_pattern + if isinstance(wp.pattern, m.BasicCancel) + ] + assert cancels == [(specific.pattern, 1)] + + +def test_enable_unprompted_cancel() -> None: + server = consuming() + unprompted_cancel = (WeightedPattern(m.BasicCancel(), WEIGHT),) + + sampler = [ + server.send_gen(weighted_patterns=unprompted_cancel) for _ in range(SAMPLES) + ] + + assert any(isinstance(sent, m.BasicCancel) for sent in sampler) + + +def test_spurious_weighted_pattern() -> None: + server = consuming() + + spurious = (WeightedPattern(m.BasicCancel(consumer_tag="other"), WEIGHT),) + + sampler = msa.make_sampler(server.sendables(), spurious, mw.STOP_WEIGHT) + + weights = [wp.weight for wp, _ in sampler.unified_by_weighted_pattern] + assert WEIGHT not in weights + + +def test_deterministic() -> None: + def take_samples(seed: int) -> list[str]: + server = consuming_seeded(seed) + return [repr(server.send_gen()) for _ in range(SAMPLES)] + + SEED = 1 + OTHER_SEED = 2 + + assert take_samples(SEED) == take_samples(SEED) + assert take_samples(SEED) != take_samples(OTHER_SEED) + + +@pytest.mark.parametrize("make_server", GREETED_STATES, ids=lambda b: b.__name__) +def test_never_sample_unrepresentable(make_server: MakeServer) -> None: + server = make_server() + + for _ in range(SAMPLES): + assert not isinstance(server.send_gen(), Unrepresentable) + + +def test_never_sample_zero() -> None: + + def first_sample(seed: int) -> m.Method | msa.Stop: + server = confirming() + server.gen_state = GenState(seed) + + publish(server) + publish(server) + publish(server) + publish(server) + + never_sample_tag_2 = ( + WeightedPattern(m.BasicAck(delivery_tag=2), 0), + WeightedPattern(m.BasicNack(delivery_tag=2), 0), + ) + return server.send_gen(weighted_patterns=never_sample_tag_2) + + confirmed = { + sent.delivery_tag + for seed in range(SAMPLES) + if isinstance(sent := first_sample(seed), (m.BasicAck, m.BasicNack)) + } + + assert 2 not in confirmed + + +def test_gens_deterministic() -> None: + def send_long_burst(seed: int) -> list[m.Method]: + return consuming_seeded(seed).send_gens(stop_weight=STOP_WEIGHT_LONG_BURSTS) + + assert send_long_burst(1) == send_long_burst(1) + assert send_long_burst(1) != send_long_burst(2) + + +@pytest.mark.parametrize("stop_weight", (0, -1)) +def test_bad_stop_weight(stop_weight: int) -> None: + with pytest.raises(mw.BadWeight): + consuming().send_gen(stop_weight=stop_weight) diff --git a/src/tests/amqp_imposter/tests/model/generative/test_script.py b/src/tests/amqp_imposter/tests/model/generative/test_script.py new file mode 100644 index 00000000..227e861c --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/generative/test_script.py @@ -0,0 +1,201 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import ( + GenState, + Received, + ScriptRule, + Sent, + WeightedPattern, +) +from amqp_imposter.model import method_weights as mw +from amqp_imposter.model import methods as m + +from ..helpers import confirming, consume, consuming, publish + +SAMPLES = 100 +MAX_SENDS = 5 +STOP_WEIGHT_LONG_BURSTS = 1 + +WEIGHTS_NACK_NEVER = (WeightedPattern(m.BasicNack(), 0),) +WEIGHTS_ALL_NEVER = tuple(WeightedPattern(kind(), 0) for kind in m.METHOD_KINDS) + +SCRIPT_NACK = (ScriptRule(Received(m.BasicPublish()), then_send=m.BasicNack()),) +SCRIPT_DELIVERY_LOOP = ( + ScriptRule(Received(m.BasicPublish()), then_send=m.BasicDeliver()), + ScriptRule(Sent(m.BasicDeliver()), then_send=m.BasicDeliver()), +) + + +def test_received_overrides_weights() -> None: + for seed in range(SAMPLES): + server = confirming() + server.gen_state = GenState(seed) + publish(server) + + burst = server.send_gens(SCRIPT_NACK, WEIGHTS_NACK_NEVER) + + assert isinstance(burst[0], m.BasicNack) + + +def test_received_not_after_send() -> None: + for seed in range(SAMPLES): + server = confirming() + server.gen_state = GenState(seed) + publish(server) + publish(server) + server.send(m.BasicAck(delivery_tag=2)) + + burst = server.send_gens(SCRIPT_NACK, WEIGHTS_NACK_NEVER) + + assert not any(isinstance(sent, m.BasicNack) for sent in burst) + + +def test_received_no_match() -> None: + server = confirming() + publish(server) + script = (ScriptRule(Received(m.BasicGet()), then_send=m.BasicNack()),) + + burst = server.send_gens(script, WEIGHTS_NACK_NEVER) + + assert not any(isinstance(sent, m.BasicNack) for sent in burst) + + +def test_sent_does_not_match_received() -> None: + server = confirming() + publish(server) + script = (ScriptRule(Sent(m.BasicPublish()), then_send=m.BasicNack()),) + + burst = server.send_gens(script, WEIGHTS_NACK_NEVER) + + assert not any(isinstance(sent, m.BasicNack) for sent in burst) + + +@pytest.mark.parametrize(("delivery_tag", "fires"), ((1, False), (2, True))) +def test_when_exact_fields(delivery_tag: int, fires: bool) -> None: + server = confirming() + publish(server) + publish(server) + server.send(m.BasicAck(delivery_tag=delivery_tag)) + script = ( + ScriptRule( + Sent(m.BasicAck(delivery_tag=2)), then_send=mw.UNPROMPTED_CONNECTION_CLOSE + ), + ) + + sent = server.send_gen(script) + + assert isinstance(sent, m.ConnectionClose) == fires + + +def test_received_not_sendable() -> None: + server = confirming() + publish(server) + script = ( + ScriptRule(Received(m.BasicPublish()), then_send=m.BasicDeliver()), + ScriptRule(Received(m.BasicPublish()), then_send=m.BasicAck()), + ScriptRule(Received(m.BasicPublish()), then_send=m.BasicNack()), + ) + + burst = server.send_gens(script=script) + + assert isinstance(burst[0], m.BasicAck) + + +def test_received_not_sendable_fallback() -> None: + server = confirming() + publish(server) + script = (ScriptRule(Received(m.BasicPublish()), then_send=m.BasicDeliver()),) + + burst = server.send_gens(script=script) + + assert not any(isinstance(sent, m.BasicDeliver) for sent in burst) + + +def test_then_send_updating_state() -> None: + acks: set[m.BasicAck] = set() + + for seed in range(SAMPLES): + server = confirming() + server.gen_state = GenState(seed) + publish(server) + publish(server) + publish(server) + script = (ScriptRule(Received(m.BasicPublish()), then_send=m.BasicAck()),) + + sent = server.send_gen(script) + + assert isinstance(sent, m.BasicAck) + acks.add(sent) + + assert len(acks) > 1 + + +def test_sent_chain() -> None: + server = confirming() + consume(server) + publish(server) + script = ( + ScriptRule(Received(m.BasicPublish()), then_send=m.BasicAck()), + ScriptRule(Sent(m.BasicAck()), then_send=m.BasicDeliver()), + ) + + burst = server.send_gens(script=script) + + assert [type(sent) for sent in burst[:2]] == [m.BasicAck, m.BasicDeliver] + + +def test_not_across_bursts() -> None: + server = confirming() + publish(server) + + assert server.send_gens(weighted_patterns=WEIGHTS_ALL_NEVER) == [] + # should've passed in the rule before! + assert server.send_gens(SCRIPT_NACK, WEIGHTS_ALL_NEVER) == [] + + +def test_max_sends() -> None: + server = consuming(no_ack=True) + publish(server) + + burst = server.send_gens(script=SCRIPT_DELIVERY_LOOP, max_sends=MAX_SENDS) + + assert len(burst) == MAX_SENDS + assert all(isinstance(sent, m.BasicDeliver) for sent in burst) + + # once broken, the cause is forgotten + assert server.send_gens(SCRIPT_DELIVERY_LOOP, WEIGHTS_ALL_NEVER) == [] + + +def test_max_sends_sampled() -> None: + for seed in range(SAMPLES): + server = consuming(no_ack=True) + server.gen_state = GenState(seed) + + burst = server.send_gens( + stop_weight=STOP_WEIGHT_LONG_BURSTS, max_sends=MAX_SENDS + ) + + assert len(burst) <= MAX_SENDS + + +def test_arbitrary_not_matchable() -> None: + with pytest.raises(m.BadPattern): + ScriptRule(Received(m.BasicPublish(mandatory=True)), then_send=m.BasicAck()) + + with pytest.raises(m.BadPattern): + ScriptRule(Sent(m.BasicAck()), then_send=m.BasicDeliver(redelivered=True)) diff --git a/src/tests/amqp_imposter/tests/model/generative/test_weights.py b/src/tests/amqp_imposter/tests/model/generative/test_weights.py new file mode 100644 index 00000000..15c8ccab --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/generative/test_weights.py @@ -0,0 +1,64 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import BASE_WEIGHT, WeightedPattern +from amqp_imposter.model import method_weights as w +from amqp_imposter.model import methods as m +from tests.model.helpers import CONSUMER_TAG + + +def test_init_with_default_weight() -> None: + assert WeightedPattern(m.BasicAck()).weight == BASE_WEIGHT + + +def test_exact_field_is_matchable() -> None: + WeightedPattern(m.BasicAck(delivery_tag=2), 0) + WeightedPattern(m.BasicCancel(consumer_tag=CONSUMER_TAG), 8) + WeightedPattern(w.UNPROMPTED_CONNECTION_CLOSE, 0) + WeightedPattern(m.ChannelClose(reply_text="NOT_FOUND"), 0) + WeightedPattern(m.ConnectionClose(reply_text="NOT_ALLOWED"), 0) + + +def test_arbitrary_field_is_not_matchable() -> None: + with pytest.raises(m.BadPattern): + WeightedPattern(m.BasicNack(requeue=True)) + + with pytest.raises(m.BadPattern): + WeightedPattern(m.BasicDeliver(redelivered=True)) + + +def test_bad_weight() -> None: + with pytest.raises(w.BadWeight): + WeightedPattern(m.BasicAck(), -1) + + +def test_unprompted_teardown_implicitly_disabled() -> None: + weighted_patterns = w.implicit_weighted_patterns( + (m.BasicAck, m.BasicCancel, m.ConnectionClose) + ) + zeros = { + c.pattern for c in weighted_patterns if c.weight == w.UNPROMPTED_TEARDOWN_WEIGHT + } + + assert { + w.UNPROMPTED_CONNECTION_CLOSE, + w.UNPROMPTED_CHANNEL_CLOSE, + w.UNPROMPTED_CANCEL, + } <= zeros + assert all( + wp.weight == BASE_WEIGHT for wp in weighted_patterns if wp.pattern not in zeros + ) diff --git a/src/tests/amqp_imposter/tests/model/helpers.py b/src/tests/amqp_imposter/tests/model/helpers.py new file mode 100644 index 00000000..70070ceb --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/helpers.py @@ -0,0 +1,394 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from collections.abc import Callable +from dataclasses import replace + +from amqp_imposter.model import ( + DEFAULT_NEGOTIATION_OPTIONS, + Arbitrary, + Exact, + Method, + ModelServer, + NegotiationOptions, + Unrepresentable, + assert_concrete, +) +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +EXCHANGE = "ex" +ROUTING_KEY = "rk" +QUEUE = "q" +OTHER_QUEUE = "q-other" +CONSUMER_TAG = "ctag-1" +OTHER_CONSUMER_TAG = "ctag-2" + +MESSAGE_HEADERS = mf.Table({"x-origin": "imposter", "x-retry": 1}) +QUEUE_ARGUMENTS = mf.Table({"x-max-length": 10, "x-queue-type": "quorum"}) +MESSAGE_PROPERTIES = mf.MessageProperties( + content_type="text/plain", message_id="m-1", headers=MESSAGE_HEADERS +) + +GRACEFUL_CHANNEL_CLOSE = m.ChannelClose( + mf.ReplyCode.SUCCESS, + "Normal shutdown", + mf.UNPROMPTED_CLASS_ID, + mf.UNPROMPTED_METHOD_ID, +) +GRACEFUL_CONNECTION_CLOSE = m.ConnectionClose( + mf.ReplyCode.SUCCESS, + "Normal shutdown", + mf.UNPROMPTED_CLASS_ID, + mf.UNPROMPTED_METHOD_ID, +) +FRESH_DELIVERY = m.BasicDeliver(consumer_tag=CONSUMER_TAG, delivery_tag=1) + +ACKNOWLEDGEMENT_TYPES = (m.BasicAck, m.BasicNack, m.BasicReject) + +ErrorReply = tuple[type[m.ChannelClose | m.ConnectionClose], int] + + +def lookup_sendables[M: Method]( + server: ModelServer, kind: type[M], **fields: object +) -> tuple[M, ...]: + return tuple( + method + for method in server.sendables() + if isinstance(method, kind) + and all(getattr(method, name) == value for name, value in fields.items()) + ) + + +def lookup_unique_sendable[M: Method]( + server: ModelServer, kind: type[M], **fields: object +) -> M: + found = lookup_sendables(server, kind, **fields) + assert len(found) == 1, found + return found[0] + + +def subsets_any_sendable( + server: ModelServer, kind: type[Method], **fields: object +) -> bool: + return lookup_sendables(server, kind, **fields) != () + + +def error_replies_to(server: ModelServer, kind: type[Method]) -> set[ErrorReply]: + return { + (type(method), assert_concrete(method.reply_code)) + for method in server.sendables() + if isinstance(method, (m.ChannelClose, m.ConnectionClose)) + and (method.class_id, method.method_id) == (kind.CLASS_ID, kind.METHOD_ID) + } + + +def channel_errors(*reply_codes: mf.ReplyCode) -> set[ErrorReply]: + return {(m.ChannelClose, reply_code) for reply_code in reply_codes} + + +def connection_errors(*reply_codes: mf.ReplyCode) -> set[ErrorReply]: + return {(m.ConnectionClose, reply_code) for reply_code in reply_codes} + + +def match_fresh_delivery(server: ModelServer) -> m.BasicDeliver: + # the fresh delivery is the only one where its redelivery status is arbitrary! + return lookup_unique_sendable(server, m.BasicDeliver, redelivered=mf.GEN_BIT) + + +def match_fresh_get_ok(server: ModelServer) -> m.BasicGetOk: + return lookup_unique_sendable(server, m.BasicGetOk, redelivered=mf.GEN_BIT) + + +def send_unwrap[M: Method](server: ModelServer, kind: type[M], **fields: object) -> M: + method = lookup_unique_sendable(server, kind, **fields) + sent = server.send(method) + assert isinstance(sent, kind), sent + return sent + + +def receive_unwrap(server: ModelServer, method: Method) -> Method: + received = server.receive(method) + assert not isinstance(received, Unrepresentable), (method, received) + return received + + +def delivery_tags( + server: ModelServer, kind: type[m.BasicAck | m.BasicNack] +) -> set[tuple[Exact[int], Arbitrary[bool]]]: + # get the (delivery_tag, multiple) tuples for all sendable BasicAcks and BasicNacks + # N.b. there is no BasicReject as the server doesn't send that + return { + (method.delivery_tag, method.multiple) + for method in server.sendables() + if isinstance(method, kind) + } + + +def message_content( + message: m.BasicDeliver | m.BasicGetOk | m.BasicPublish | m.BasicReturn, +) -> tuple[ + Arbitrary[str] | Exact[str], + Arbitrary[str] | Exact[str], + Arbitrary[mf.MessageProperties | None], + Arbitrary[bytes], +]: + return (message.exchange, message.routing_key, message.properties, message.body) + + +def greeted(options: NegotiationOptions = DEFAULT_NEGOTIATION_OPTIONS) -> ModelServer: + server = ModelServer(options) + assert server.receive_protocol_header() is None + return server + + +def start_ok_of(start: m.ConnectionStart) -> m.ConnectionStartOk: + mechanisms = assert_concrete(start.mechanisms) + locales = assert_concrete(start.locales) + return m.ConnectionStartOk(mechanism=mechanisms[0], locale=locales[0]) + + +def tune_ok_of(tune: m.ConnectionTune) -> m.ConnectionTuneOk: + return m.ConnectionTuneOk(tune.channel_max, tune.frame_max, tune.heartbeat) + + +def starting(options: NegotiationOptions = DEFAULT_NEGOTIATION_OPTIONS) -> ModelServer: + server = greeted(options) + send_unwrap(server, m.ConnectionStart) + return server + + +def tuning(options: NegotiationOptions = DEFAULT_NEGOTIATION_OPTIONS) -> ModelServer: + server = greeted(options) + receive_unwrap(server, start_ok_of(send_unwrap(server, m.ConnectionStart))) + return server + + +def tuned(options: NegotiationOptions = DEFAULT_NEGOTIATION_OPTIONS) -> ModelServer: + server = tuning(options) + receive_unwrap(server, tune_ok_of(send_unwrap(server, m.ConnectionTune))) + return server + + +def opening(options: NegotiationOptions = DEFAULT_NEGOTIATION_OPTIONS) -> ModelServer: + server = tuned(options) + receive_unwrap(server, m.ConnectionOpen()) + return server + + +def connected(options: NegotiationOptions = DEFAULT_NEGOTIATION_OPTIONS) -> ModelServer: + server = opening(options) + send_unwrap(server, m.ConnectionOpenOk) + return server + + +def channelled( + options: NegotiationOptions = DEFAULT_NEGOTIATION_OPTIONS, +) -> ModelServer: + server = connected(options) + receive_unwrap(server, m.ChannelOpen()) + send_unwrap(server, m.ChannelOpenOk) + return server + + +def confirming() -> ModelServer: + server = channelled() + receive_unwrap(server, m.ConfirmSelect()) + send_unwrap(server, m.ConfirmSelectOk) + return server + + +def consume(server: ModelServer, no_ack: bool = False) -> None: + receive_unwrap( + server, m.BasicConsume(queue=QUEUE, consumer_tag=CONSUMER_TAG, no_ack=no_ack) + ) + send_unwrap(server, m.BasicConsumeOk) + + +def consuming(prefetch_count: int = 1, no_ack: bool = False) -> ModelServer: + server = channelled() + receive_unwrap(server, m.BasicQos(prefetch_count=prefetch_count)) + send_unwrap(server, m.BasicQosOk) + consume(server, no_ack=no_ack) + return server + + +def consuming_zero_prefetch_count() -> ModelServer: + return consuming(prefetch_count=0) + + +def consuming_no_ack() -> ModelServer: + return consuming(no_ack=True) + + +def consuming_no_qos() -> ModelServer: + server = channelled() + consume(server) + return server + + +def deliver(server: ModelServer, delivery_tag: int) -> m.BasicDeliver: + return send_unwrap( + server, m.BasicDeliver, delivery_tag=delivery_tag, redelivered=mf.GEN_BIT + ) + + +def prefetch_count_filled(prefetch_count: int) -> ModelServer: + server = consuming(prefetch_count=prefetch_count) + for delivery_tag in range(1, prefetch_count + 1): + deliver(server, delivery_tag) + return server + + +def publish( + server: ModelServer, body: bytes = b"m", mandatory: bool = False +) -> m.BasicPublish: + published = m.BasicPublish( + exchange=EXCHANGE, + routing_key=ROUTING_KEY, + mandatory=mandatory, + properties=MESSAGE_PROPERTIES, + body=body, + ) + receive_unwrap(server, published) + return published + + +def publishing() -> ModelServer: + server = confirming() + publish(server) + return server + + +def declaring() -> ModelServer: + server = channelled() + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + return server + + +def get(server: ModelServer, no_ack: bool = False) -> None: + receive_unwrap(server, m.BasicGet(queue=QUEUE, no_ack=no_ack)) + + +def polling() -> ModelServer: + server = channelled() + get(server) + return server + + +def polled() -> ModelServer: + server = polling() + send_unwrap(server, m.BasicGetOk) + return server + + +def send_get_ok(server: ModelServer, body: bytes = b"m") -> m.BasicGetOk: + got = server.send(replace(match_fresh_get_ok(server), body=body)) + assert isinstance(got, m.BasicGetOk), got + return got + + +def send_cancel(server: ModelServer) -> None: + send_unwrap(server, m.BasicCancel, consumer_tag=CONSUMER_TAG) + + +def receive_cancel(server: ModelServer) -> None: + receive_unwrap(server, m.BasicCancel(consumer_tag=CONSUMER_TAG)) + send_unwrap(server, m.BasicCancelOk, consumer_tag=CONSUMER_TAG) + + +def enable_channel_error(server: ModelServer) -> None: + receive_unwrap(server, m.BasicAck(delivery_tag=0)) + + +def send_channel_error(server: ModelServer) -> m.ChannelClose: + return send_unwrap( + server, + m.ChannelClose, + reply_code=mf.ReplyCode.PRECONDITION_FAILED, + class_id=m.BasicAck.CLASS_ID, + method_id=m.BasicAck.METHOD_ID, + ) + + +def close_channel_from_server(server: ModelServer) -> None: + enable_channel_error(server) + send_channel_error(server) + receive_unwrap(server, m.ChannelCloseOk()) + + +def close_channel_from_client(server: ModelServer) -> None: + receive_unwrap(server, GRACEFUL_CHANNEL_CLOSE) + send_unwrap(server, m.ChannelCloseOk) + + +def close_connection_from_server(server: ModelServer) -> None: + send_unwrap(server, m.ConnectionClose, reply_code=mf.ReplyCode.CONNECTION_FORCED) + receive_unwrap(server, m.ConnectionCloseOk()) + + +def close_connection_from_client(server: ModelServer) -> None: + receive_unwrap(server, GRACEFUL_CONNECTION_CLOSE) + send_unwrap(server, m.ConnectionCloseOk) + + +def closed() -> ModelServer: + server = connected() + close_connection_from_client(server) + return server + + +def reopen_channel(server: ModelServer) -> None: + receive_unwrap(server, m.ChannelOpen()) + send_unwrap(server, m.ChannelOpenOk) + + +def reconnect(server: ModelServer) -> None: + assert server.receive_protocol_header() is None + receive_unwrap(server, start_ok_of(send_unwrap(server, m.ConnectionStart))) + receive_unwrap(server, tune_ok_of(send_unwrap(server, m.ConnectionTune))) + receive_unwrap(server, m.ConnectionOpen()) + send_unwrap(server, m.ConnectionOpenOk) + reopen_channel(server) + + +MakeServer = Callable[[], ModelServer] +DoTeardown = Callable[[ModelServer], None] + +DO_CANCEL: tuple[DoTeardown, ...] = (send_cancel, receive_cancel) +DO_CHANNEL_CLOSE: tuple[DoTeardown, ...] = ( + close_channel_from_server, + close_channel_from_client, +) +DO_CONNECTION_CLOSE: tuple[DoTeardown, ...] = ( + close_connection_from_server, + close_connection_from_client, +) + +CONNECTING_STATES: tuple[MakeServer, ...] = (starting, tuning, tuned, opening) +CONNECTED_STATES: tuple[MakeServer, ...] = ( + connected, + channelled, + confirming, + consuming, + consuming_zero_prefetch_count, + consuming_no_qos, + consuming_no_ack, + publishing, + declaring, + polling, + polled, +) +GREETED_STATES: tuple[MakeServer, ...] = CONNECTING_STATES + CONNECTED_STATES diff --git a/src/tests/amqp_imposter/tests/model/model_server/test_enumeration.py b/src/tests/amqp_imposter/tests/model/model_server/test_enumeration.py new file mode 100644 index 00000000..d061215b --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/model_server/test_enumeration.py @@ -0,0 +1,66 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from amqp_imposter.model import Hole, ModelServer, Unrepresentable +from amqp_imposter.model import methods as m + +from ..helpers import ( + GREETED_STATES, + MakeServer, + channelled, + receive_unwrap, + starting, +) + + +def test_nothing_sendable_before_the_header() -> None: + assert ModelServer().sendables() == () + + +@pytest.mark.parametrize("make_server", GREETED_STATES, ids=lambda b: b.__name__) +def test_deterministic(make_server: MakeServer) -> None: + server = make_server() + + assert server.sendables() == server.sendables() + + +@pytest.mark.parametrize("make_server", GREETED_STATES, ids=lambda b: b.__name__) +def test_no_holes(make_server: MakeServer) -> None: + for method in make_server().sendables(): + assert Hole() not in m.get_fields(method).values(), method + + +@pytest.mark.parametrize("make_server", GREETED_STATES, ids=lambda b: b.__name__) +def test_every_enumerated_method_is_sendable(make_server: MakeServer) -> None: + for method in make_server().sendables(): + assert not isinstance(make_server().send(method), Unrepresentable) + + +def test_unenumerated_method_is_illegal_send() -> None: + server = starting() + + assert isinstance(server.send(m.ConnectionOpenOk()), Unrepresentable) + + +def test_send_is_destructive() -> None: + server = channelled() + + receive_unwrap(server, m.ConfirmSelect()) + select_ok = m.ConfirmSelectOk() + server.send(select_ok) + + assert select_ok not in server.sendables() diff --git a/src/tests/amqp_imposter/tests/model/model_server/test_receive.py b/src/tests/amqp_imposter/tests/model/model_server/test_receive.py new file mode 100644 index 00000000..35ee7866 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/model_server/test_receive.py @@ -0,0 +1,39 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import Unrepresentable +from amqp_imposter.model import methods as m + +from ..helpers import channelled, connected, receive_unwrap + + +def test_illegal_receive_does_not_advance() -> None: + server = channelled() + before_sendable = server.sendables() + before_gen = server.gen_state + + assert isinstance(server.receive(m.ConnectionTuneOk(0, 0, 0)), Unrepresentable) + + assert server.sendables() == before_sendable + assert server.gen_state == before_gen + + +def test_fully_specified_receive_splits_gen() -> None: + server = connected() + before = server.gen_state + + receive_unwrap(server, m.ChannelOpen()) + + assert server.gen_state != before diff --git a/src/tests/amqp_imposter/tests/model/model_server/test_send.py b/src/tests/amqp_imposter/tests/model/model_server/test_send.py new file mode 100644 index 00000000..573977b1 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/model_server/test_send.py @@ -0,0 +1,127 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import replace + +from amqp_imposter.model import Unrepresentable +from amqp_imposter.model import methods as m + +from ..helpers import ( + QUEUE, + channelled, + confirming, + consuming, + lookup_sendables, + lookup_unique_sendable, + match_fresh_delivery, + publish, + receive_unwrap, + send_unwrap, +) + + +def test_send_removes_one_match() -> None: + server = channelled() + + publish(server, body=b"same", mandatory=True) + publish(server, body=b"same", mandatory=True) + + unroutable = lookup_sendables(server, m.BasicReturn)[0] + server.send(unroutable) + + assert len(lookup_sendables(server, m.BasicReturn)) == 1 + + +def test_send_bad_field_match() -> None: + server = confirming() + + publish(server) + + assert isinstance( + server.send(m.BasicAck(delivery_tag=99, multiple=False)), Unrepresentable + ) + + +def test_send_bad_content_match() -> None: + server = channelled() + other = channelled() + + publish(server, body=b"limestone", mandatory=True) + publish(other, body=b"granite", mandatory=True) + + assert isinstance( + server.send(lookup_unique_sendable(other, m.BasicReturn)), Unrepresentable + ) + + +def test_illegal_send_does_not_advance() -> None: + server = consuming(prefetch_count=1) + before_sendable = server.sendables() + before_gen = server.gen_state + + assert isinstance(server.send(m.BasicDeliver("nobody", 99)), Unrepresentable) + + assert server.sendables() == before_sendable + # critical for anything generative to be deterministic! + assert server.gen_state == before_gen + + +def test_fully_specified_send_splits_gen() -> None: + server = channelled() + receive_unwrap(server, m.ConfirmSelect()) + before = server.gen_state + + send_unwrap(server, m.ConfirmSelectOk) + + assert server.gen_state != before + + +def test_deliver_uses_overriden_body() -> None: + server = consuming(prefetch_count=1) + + fresh = match_fresh_delivery(server) + server.send(replace(fresh, body=b"blabla")) + receive_unwrap(server, m.BasicReject(delivery_tag=1, requeue=True)) + + again = lookup_unique_sendable(server, m.BasicDeliver, redelivered=True) + + assert again.body == b"blabla" + + +def test_old_redelivery_still_available() -> None: + server = consuming(prefetch_count=2) + + fresh = match_fresh_delivery(server) + server.send(replace(fresh, body=b"blabla")) + receive_unwrap(server, m.BasicReject(delivery_tag=1, requeue=True)) + + again = lookup_unique_sendable(server, m.BasicDeliver, redelivered=True) + server.send(replace(again, body=b"something else")) + + still_here = lookup_unique_sendable(server, m.BasicDeliver, redelivered=True) + + assert still_here.body == b"blabla" + + +def test_get_ok_uses_overriden_body() -> None: + server = channelled() + + receive_unwrap(server, m.BasicGet(queue=QUEUE)) + got = lookup_unique_sendable(server, m.BasicGetOk) + + sent = server.send(replace(got, body=b"blabla")) + + assert isinstance(sent, m.BasicGetOk) + assert sent.body == b"blabla" diff --git a/src/tests/amqp_imposter/tests/model/scenarios/__init__.py b/src/tests/amqp_imposter/tests/model/scenarios/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/amqp_imposter/tests/model/scenarios/test_consumer.py b/src/tests/amqp_imposter/tests/model/scenarios/test_consumer.py new file mode 100644 index 00000000..706716f1 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/scenarios/test_consumer.py @@ -0,0 +1,136 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import methods as m + +from ..helpers import ( + CONSUMER_TAG, + OTHER_CONSUMER_TAG, + QUEUE, + channelled, + consume, + consuming, + deliver, + lookup_unique_sendable, + message_content, + receive_unwrap, + reopen_channel, + send_channel_error, + send_unwrap, + subsets_any_sendable, +) + + +def test_example_consumer() -> None: + server = channelled() + + receive_unwrap(server, m.BasicQos(prefetch_count=2)) + send_unwrap(server, m.BasicQosOk) + receive_unwrap(server, m.BasicConsume(queue=QUEUE, consumer_tag=CONSUMER_TAG)) + + assert not subsets_any_sendable(server, m.BasicDeliver) + assert not subsets_any_sendable(server, m.BasicCancel) + + send_unwrap(server, m.BasicConsumeOk) + + assert lookup_unique_sendable(server, m.BasicCancel).consumer_tag == CONSUMER_TAG + assert lookup_unique_sendable(server, m.BasicDeliver).delivery_tag == 1 + + send_unwrap(server, m.BasicDeliver, delivery_tag=1) + + assert lookup_unique_sendable(server, m.BasicDeliver).delivery_tag == 2 + + send_unwrap(server, m.BasicDeliver, delivery_tag=2) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + receive_unwrap(server, m.BasicAck(delivery_tag=1)) + + send_unwrap(server, m.BasicDeliver, delivery_tag=3) + receive_unwrap(server, m.BasicQos(prefetch_count=1)) + send_unwrap(server, m.BasicQosOk) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + receive_unwrap(server, m.BasicAck(delivery_tag=2)) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + receive_unwrap(server, m.BasicAck(delivery_tag=3)) + + assert lookup_unique_sendable(server, m.BasicDeliver).delivery_tag == 4 + + +def test_example_resilient_consumer() -> None: + server = consuming() + + deliver(server, 1) + receive_unwrap(server, m.BasicAck(delivery_tag=1)) + deliver(server, 2) + + # no wait, cancel is effective immediately + send_unwrap(server, m.BasicCancel, consumer_tag=CONSUMER_TAG, no_wait=True) + receive_unwrap(server, m.BasicAck(delivery_tag=2)) + consume(server) + + third = deliver(server, 3) + receive_unwrap(server, m.BasicAck(delivery_tag=2)) # double ack + send_channel_error(server) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + receive_unwrap(server, m.ChannelCloseOk()) + reopen_channel(server) + receive_unwrap(server, m.BasicQos(prefetch_count=1)) + send_unwrap(server, m.BasicQosOk) + consume(server) + + redelivery = lookup_unique_sendable(server, m.BasicDeliver, redelivered=True) + + assert message_content(redelivery) == message_content(third) + assert redelivery.delivery_tag == 1 + + server.send(redelivery) + receive_unwrap(server, m.BasicAck(delivery_tag=1)) + + +def test_example_reconsumer() -> None: + server = consuming() + + send_unwrap(server, m.BasicDeliver, delivery_tag=1) + receive_unwrap(server, m.BasicAck(delivery_tag=1)) + receive_unwrap(server, m.BasicCancel(consumer_tag=CONSUMER_TAG)) + send_unwrap(server, m.BasicCancelOk, consumer_tag=CONSUMER_TAG) + + assert not subsets_any_sendable(server, m.BasicDeliver) + + receive_unwrap(server, m.BasicConsume(queue=QUEUE, consumer_tag=OTHER_CONSUMER_TAG)) + send_unwrap(server, m.BasicConsumeOk, consumer_tag=OTHER_CONSUMER_TAG) + + assert not subsets_any_sendable(server, m.BasicDeliver, consumer_tag=CONSUMER_TAG) + + delivery = lookup_unique_sendable(server, m.BasicDeliver) + + assert delivery.consumer_tag == OTHER_CONSUMER_TAG + assert delivery.delivery_tag == 2 + + server.send(delivery) + receive_unwrap(server, m.BasicCancel(consumer_tag=OTHER_CONSUMER_TAG)) + send_unwrap(server, m.BasicCancelOk, consumer_tag=OTHER_CONSUMER_TAG) + + assert not subsets_any_sendable(server, m.BasicDeliver) + assert not subsets_any_sendable(server, m.BasicCancel) + + receive_unwrap(server, m.BasicAck(delivery_tag=2)) diff --git a/src/tests/amqp_imposter/tests/model/scenarios/test_pipelined_methods.py b/src/tests/amqp_imposter/tests/model/scenarios/test_pipelined_methods.py new file mode 100644 index 00000000..6426b09f --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/scenarios/test_pipelined_methods.py @@ -0,0 +1,349 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import Unrepresentable +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + CONSUMER_TAG, + EXCHANGE, + QUEUE, + ROUTING_KEY, + channel_errors, + channelled, + confirming, + connected, + connection_errors, + consuming, + deliver, + delivery_tags, + enable_channel_error, + error_replies_to, + lookup_unique_sendable, + publish, + receive_unwrap, + send_unwrap, + subsets_any_sendable, + tuned, +) + + +def test_pipelined_replies_come_in_order() -> None: + # + # AFAICT, 0-9-1 only requires reply ordering within each method class. + # For example, Exhange.Declare -> Queue.Bind can be replied to in the reverse order. + # Further, while the spec specifies for many methods that they only mutate state once their reply is sent, + # this is not the case for .Declare. + # To illustrate (say there is no topology): Queue.BindOk -> Exchange.DeclareOk could be legal + # in response to Exhange.Declare -> Queue.Bind, + # since the timing of the state change from the Exchange.Declare is unspecified. + # + # We strengthen our model here to require that all replies come in order, regardless of method class. + # Could weaken this later. + # + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + receive_unwrap( + server, m.QueueBind(queue=QUEUE, exchange=EXCHANGE, routing_key=ROUTING_KEY) + ) + + assert not subsets_any_sendable(server, m.QueueBindOk) + + send_unwrap(server, m.QueueDeclareOk) + + assert subsets_any_sendable(server, m.QueueBindOk) + + +def test_out_of_order_reply_is_illegal() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + receive_unwrap( + server, m.QueueBind(queue=QUEUE, exchange=EXCHANGE, routing_key=ROUTING_KEY) + ) + + assert isinstance(server.send(m.QueueBindOk()), Unrepresentable) + + +def test_same_method_replies_come_in_order() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + receive_unwrap(server, m.QueueDeclare(queue=QUEUE + "other")) + + assert send_unwrap(server, m.QueueDeclareOk).queue == QUEUE + assert lookup_unique_sendable(server, m.QueueDeclareOk).queue == QUEUE + "other" + + +def test_both_replies_to_get_become_sendable() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + receive_unwrap(server, m.BasicGet(queue=QUEUE)) + + assert not subsets_any_sendable(server, m.BasicGetOk) + assert not subsets_any_sendable(server, m.BasicGetEmpty) + + send_unwrap(server, m.QueueDeclareOk) + + assert subsets_any_sendable(server, m.BasicGetOk) + assert subsets_any_sendable(server, m.BasicGetEmpty) + + +def test_pending_reply_does_not_block_asynchronous_sends() -> None: + server = consuming() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + deliver(server, 1) + + assert subsets_any_sendable(server, m.BasicCancel) + assert subsets_any_sendable(server, m.ConnectionClose) + assert subsets_any_sendable(server, m.QueueDeclareOk) + + +def test_channel_error_comes_in_order() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + receive_unwrap(server, m.QueueBind(queue=QUEUE, exchange=EXCHANGE)) + enable_channel_error(server) + receive_unwrap(server, m.QueueDeclare(queue=QUEUE + "later")) + + assert not error_replies_to(server, m.BasicAck) + + send_unwrap(server, m.QueueDeclareOk, queue=QUEUE) + + assert not error_replies_to(server, m.BasicAck) + + send_unwrap(server, m.QueueBindOk) + + assert error_replies_to(server, m.BasicAck) == channel_errors( + mf.ReplyCode.PRECONDITION_FAILED + ) + assert not subsets_any_sendable(server, m.QueueDeclareOk) + + +def test_ok_discards_errors() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + + assert error_replies_to(server, m.QueueDeclare) + + send_unwrap(server, m.QueueDeclareOk) + + assert not error_replies_to(server, m.QueueDeclare) + + +def test_error_comes_in_order() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + receive_unwrap(server, m.QueueBind(queue=QUEUE, exchange=EXCHANGE)) + + assert not error_replies_to(server, m.QueueBind) + + send_unwrap(server, m.QueueDeclareOk) + + assert error_replies_to(server, m.QueueBind) + + +def test_error_discards_pipelined_replies() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE, passive=True)) + receive_unwrap(server, m.QueueBind(queue=QUEUE, exchange=EXCHANGE)) + send_unwrap(server, m.ChannelClose, reply_code=mf.ReplyCode.NOT_FOUND) + + assert not subsets_any_sendable(server, m.QueueDeclareOk) + assert not subsets_any_sendable(server, m.QueueBindOk) + assert not error_replies_to(server, m.QueueBind) + + +def test_connection_error_comes_in_order() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + receive_unwrap(server, m.QueueBind(queue=QUEUE, exchange=EXCHANGE)) + receive_unwrap(server, m.BasicQos(prefetch_size=1)) + receive_unwrap(server, m.QueueDeclare(queue=QUEUE + "later")) + + # other closes are still sendable + assert not error_replies_to(server, m.BasicQos) + + send_unwrap(server, m.QueueDeclareOk, queue=QUEUE) + send_unwrap(server, m.QueueBindOk) + + assert error_replies_to(server, m.BasicQos) == connection_errors( + mf.ReplyCode.NOT_IMPLEMENTED + ) + assert not subsets_any_sendable(server, m.QueueDeclareOk) + + +def test_nowait_request_never_pends() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE, no_wait=True)) + receive_unwrap( + server, m.QueueBind(queue=QUEUE, exchange=EXCHANGE, routing_key=ROUTING_KEY) + ) + + assert subsets_any_sendable(server, m.QueueBindOk) + + +def test_reply_discards_older_nowait_error() -> None: + # the error-reply for a nowait method is only an option until + # another reply is sent; then we've forfeited our chance to send it + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE, no_wait=True)) + receive_unwrap(server, m.QueueBind(queue=QUEUE, exchange=EXCHANGE)) + + assert subsets_any_sendable(server, m.ChannelClose) + assert subsets_any_sendable(server, m.QueueBindOk) + + send_unwrap(server, m.QueueBindOk) + + assert not subsets_any_sendable(server, m.ChannelClose) + + +def test_ack_with_older_nowait_error() -> None: + server = confirming() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE, no_wait=True)) + publish(server, body=b"one") + publish(server, body=b"two") + + assert subsets_any_sendable(server, m.ChannelClose) + + send_unwrap(server, m.BasicAck, delivery_tag=2, multiple=False) + + assert not subsets_any_sendable(server, m.ChannelClose) + assert delivery_tags(server, m.BasicAck) == {(1, False), (1, True)} + + +def test_return_with_older_nowait_error() -> None: + server = confirming() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE, no_wait=True)) + publish(server, body=b"one", mandatory=True) + publish(server, body=b"two", mandatory=True) + + assert subsets_any_sendable(server, m.ChannelClose) + + server.send(lookup_unique_sendable(server, m.BasicReturn, body=b"two")) + + assert not subsets_any_sendable(server, m.ChannelClose) + assert delivery_tags(server, m.BasicNack) == {(1, False), (1, True)} + + +def test_return_with_older_reply() -> None: + server = confirming() + + publish(server, body=b"same", mandatory=True) + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + publish(server, body=b"same", mandatory=True) + + send_unwrap(server, m.BasicReturn) + + assert subsets_any_sendable(server, m.QueueDeclareOk) + + +def test_send_nowait_error() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE, no_wait=True)) + send_unwrap(server, m.ChannelClose, reply_code=mf.ReplyCode.PRECONDITION_FAILED) + + # of course there's still ok even if the causing method was nowait + assert not isinstance(server.receive(m.ChannelCloseOk()), Unrepresentable) + + +def test_pipelined_declare_after_channel_open() -> None: + server = connected() + + receive_unwrap(server, m.ChannelOpen()) + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + + assert not subsets_any_sendable( + server, m.ConnectionClose, reply_code=mf.ReplyCode.CHANNEL_ERROR + ) + assert not subsets_any_sendable(server, m.QueueDeclareOk) + + send_unwrap(server, m.ChannelOpenOk) + + assert subsets_any_sendable(server, m.QueueDeclareOk, queue=QUEUE) + + +def test_pipelined_publish_after_channel_open() -> None: + server = connected() + + receive_unwrap(server, m.ChannelOpen()) + publish(server, mandatory=True) + send_unwrap(server, m.ChannelOpenOk) + + assert subsets_any_sendable(server, m.BasicReturn) + + +def test_pipelined_consume_after_channel_open() -> None: + server = connected() + + receive_unwrap(server, m.ChannelOpen()) + receive_unwrap(server, m.BasicConsume(queue=QUEUE, consumer_tag=CONSUMER_TAG)) + send_unwrap(server, m.ChannelOpenOk) + send_unwrap(server, m.BasicConsumeOk, consumer_tag=CONSUMER_TAG) + + assert subsets_any_sendable(server, m.BasicDeliver, consumer_tag=CONSUMER_TAG) + + +def test_pipelined_nowait_consume_after_declare() -> None: + server = channelled() + + receive_unwrap(server, m.QueueDeclare(queue=QUEUE)) + receive_unwrap( + server, m.BasicConsume(queue=QUEUE, consumer_tag=CONSUMER_TAG, no_wait=True) + ) + + assert not subsets_any_sendable(server, m.BasicDeliver) + assert not subsets_any_sendable(server, m.BasicCancel) + + send_unwrap(server, m.QueueDeclareOk) + + assert subsets_any_sendable(server, m.BasicDeliver, consumer_tag=CONSUMER_TAG) + + +def test_nowait_declare_after_channel_open() -> None: + server = connected() + + receive_unwrap(server, m.ChannelOpen()) + receive_unwrap(server, m.QueueDeclare(queue=QUEUE, no_wait=True)) + + assert not subsets_any_sendable( + server, m.ConnectionClose, reply_code=mf.ReplyCode.CHANNEL_ERROR + ) + + send_unwrap(server, m.ChannelOpenOk) + + assert not subsets_any_sendable(server, m.QueueDeclareOk) + + +def test_pipelined_channel_open_before_connection_open_ok() -> None: + server = tuned() + + receive_unwrap(server, m.ConnectionOpen("/")) + + assert isinstance(server.receive(m.ChannelOpen()), Unrepresentable) diff --git a/src/tests/amqp_imposter/tests/model/scenarios/test_poller.py b/src/tests/amqp_imposter/tests/model/scenarios/test_poller.py new file mode 100644 index 00000000..57339d1f --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/scenarios/test_poller.py @@ -0,0 +1,69 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import methods as m + +from ..helpers import ( + channelled, + get, + lookup_unique_sendable, + message_content, + receive_unwrap, + send_get_ok, + send_unwrap, + subsets_any_sendable, +) + + +def test_example_poller() -> None: + server = channelled() + + assert not subsets_any_sendable(server, m.BasicGetOk) + + get(server) + + assert lookup_unique_sendable(server, m.BasicGetOk).delivery_tag == 1 + + send_unwrap(server, m.BasicGetEmpty) + get(server) + + first = send_get_ok(server, body=b"one") + + assert first.delivery_tag == 1 + + receive_unwrap(server, m.BasicAck(delivery_tag=1)) + get(server) + + second = send_get_ok(server, body=b"two") + + assert second.delivery_tag == 2 + + receive_unwrap(server, m.BasicReject(delivery_tag=2, requeue=True)) + get(server) + + again = lookup_unique_sendable(server, m.BasicGetOk, redelivered=True) + + assert message_content(again) == message_content(second) + assert again.delivery_tag == 3 + + server.send(again) + receive_unwrap(server, m.BasicAck(delivery_tag=3)) + get(server) + + assert not subsets_any_sendable(server, m.BasicGetOk, redelivered=True) + + send_unwrap(server, m.BasicGetEmpty) + + assert not subsets_any_sendable(server, m.ChannelClose) diff --git a/src/tests/amqp_imposter/tests/model/scenarios/test_produce_consume.py b/src/tests/amqp_imposter/tests/model/scenarios/test_produce_consume.py new file mode 100644 index 00000000..09d272be --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/scenarios/test_produce_consume.py @@ -0,0 +1,36 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import methods as m + +from ..helpers import ( + CONSUMER_TAG, + confirming, + consume, + deliver, + publish, + subsets_any_sendable, +) + + +def test_confirm_and_consume() -> None: + server = confirming() + consume(server) + + publish(server) + deliver(server, 1) + + assert subsets_any_sendable(server, m.BasicAck, delivery_tag=1) + assert subsets_any_sendable(server, m.BasicDeliver, consumer_tag=CONSUMER_TAG) diff --git a/src/tests/amqp_imposter/tests/model/scenarios/test_publisher.py b/src/tests/amqp_imposter/tests/model/scenarios/test_publisher.py new file mode 100644 index 00000000..89d6ed1a --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/scenarios/test_publisher.py @@ -0,0 +1,121 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import method_fields as mf +from amqp_imposter.model import methods as m + +from ..helpers import ( + channelled, + confirming, + delivery_tags, + enable_channel_error, + lookup_sendables, + message_content, + publish, + receive_unwrap, + reconnect, + reopen_channel, + send_channel_error, + send_unwrap, + subsets_any_sendable, +) + + +def test_example_publisher() -> None: + server = channelled() + + receive_unwrap(server, m.ConfirmSelect()) + send_unwrap(server, m.ConfirmSelectOk) + + hello = publish(server, body=b"hello", mandatory=True) + publish(server, body=b"word") + + assert delivery_tags(server, m.BasicAck) == { + (1, False), + (2, False), + (1, True), + (2, True), + } + assert delivery_tags(server, m.BasicNack) == { + (1, False), + (2, False), + (1, True), + (2, True), + } + + returned = send_unwrap(server, m.BasicReturn) + + assert returned.reply_code == mf.ReplyCode.UNROUTABLE + assert message_content(returned) == message_content(hello) + + assert delivery_tags(server, m.BasicAck) == { + (1, False), + (2, False), + (1, True), + (2, True), + } + assert delivery_tags(server, m.BasicNack) == {(2, False)} + assert not subsets_any_sendable(server, m.BasicReturn) + + send_unwrap(server, m.BasicAck, delivery_tag=1, multiple=False) + + assert delivery_tags(server, m.BasicAck) == {(2, False), (2, True)} + assert delivery_tags(server, m.BasicNack) == {(2, False), (2, True)} + + send_unwrap(server, m.BasicAck, delivery_tag=2, multiple=False) + + assert lookup_sendables(server, m.BasicAck) == () + assert lookup_sendables(server, m.BasicNack) == () + + publish(server, body=b"hello") + + assert delivery_tags(server, m.BasicAck) == {(3, False), (3, True)} + assert delivery_tags(server, m.BasicNack) == {(3, False), (3, True)} + + +def test_example_resilient_publisher() -> None: + server = confirming() + + publish(server, body=b"one") + send_unwrap(server, m.BasicAck, delivery_tag=1, multiple=False) + publish(server, body=b"two") + enable_channel_error(server) + send_channel_error(server) + + assert not subsets_any_sendable(server, m.BasicAck) + assert not subsets_any_sendable(server, m.BasicNack) + + receive_unwrap(server, m.ChannelCloseOk()) + reopen_channel(server) + receive_unwrap(server, m.ConfirmSelect()) + send_unwrap(server, m.ConfirmSelectOk) + + publish(server, body=b"two") + + assert delivery_tags(server, m.BasicAck) == {(1, False), (1, True)} + + send_unwrap(server, m.BasicAck, delivery_tag=1, multiple=False) + send_unwrap(server, m.ConnectionClose, reply_code=mf.ReplyCode.CONNECTION_FORCED) + + assert server.sendables() == () + + receive_unwrap(server, m.ConnectionCloseOk()) + reconnect(server) + receive_unwrap(server, m.ConfirmSelect()) + send_unwrap(server, m.ConfirmSelectOk) + + publish(server, body=b"three") + + assert delivery_tags(server, m.BasicAck) == {(1, False), (1, True)} diff --git a/src/tests/amqp_imposter/tests/model/scenarios/test_trace.py b/src/tests/amqp_imposter/tests/model/scenarios/test_trace.py new file mode 100644 index 00000000..e6a006cc --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/scenarios/test_trace.py @@ -0,0 +1,120 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import ModelServer, NegotiationOptions +from amqp_imposter.model import methods as m +from amqp_imposter.model.constants import CHANNEL_MAX_CEIL + +from ..helpers import ( + CONSUMER_TAG, + EXCHANGE, + QUEUE, + ROUTING_KEY, + close_channel_from_client, + close_connection_from_client, + close_connection_from_server, + consume, + publish, + receive_unwrap, + send_unwrap, + start_ok_of, +) + +PREFETCH_COUNT = 10 +NEGOTIATION_OPTIONS = NegotiationOptions(channel_max=CHANNEL_MAX_CEIL) + + +def traced_connect(server: ModelServer) -> None: + server.receive_protocol_header() + receive_unwrap(server, start_ok_of(send_unwrap(server, m.ConnectionStart))) + + tune = send_unwrap(server, m.ConnectionTune) + + receive_unwrap( + server, m.ConnectionTuneOk(CHANNEL_MAX_CEIL, tune.frame_max, tune.heartbeat) + ) + receive_unwrap(server, m.ConnectionOpen()) + send_unwrap(server, m.ConnectionOpenOk) + receive_unwrap(server, m.ChannelOpen()) + send_unwrap(server, m.ChannelOpenOk) + + +def traced_declare(server: ModelServer) -> None: + receive_unwrap(server, m.QueueDeclare(queue=QUEUE, durable=True)) + send_unwrap(server, m.QueueDeclareOk, queue=QUEUE) + receive_unwrap( + server, + m.ExchangeDeclare(exchange=EXCHANGE, exchange_type="direct", durable=True), + ) + send_unwrap(server, m.ExchangeDeclareOk) + receive_unwrap( + server, m.QueueBind(queue=QUEUE, exchange=EXCHANGE, routing_key=ROUTING_KEY) + ) + send_unwrap(server, m.QueueBindOk) + + +def traced_produce(server: ModelServer) -> None: + receive_unwrap(server, m.ConfirmSelect()) + send_unwrap(server, m.ConfirmSelectOk) + publish(server, mandatory=True) + send_unwrap(server, m.BasicAck, delivery_tag=1, multiple=False) + + +def traced_consume(server: ModelServer) -> None: + receive_unwrap(server, m.BasicQos(prefetch_count=PREFETCH_COUNT)) + send_unwrap(server, m.BasicQosOk) + consume(server) + + +def traced_teardown(server: ModelServer) -> None: + close_channel_from_client(server) + close_connection_from_client(server) + + +def test_traced_producer() -> None: + server = ModelServer(NEGOTIATION_OPTIONS) + + traced_connect(server) + traced_declare(server) + traced_produce(server) + + close_connection_from_server(server) + + traced_connect(server) + traced_declare(server) + traced_produce(server) + traced_teardown(server) + + assert server.sendables() == () + + +def test_traced_consumer() -> None: + server = ModelServer(NEGOTIATION_OPTIONS) + + traced_connect(server) + traced_declare(server) + traced_consume(server) + + close_connection_from_server(server) + + traced_connect(server) + traced_declare(server) + traced_consume(server) + + receive_unwrap(server, m.BasicCancel(consumer_tag=CONSUMER_TAG)) + send_unwrap(server, m.BasicCancelOk, consumer_tag=CONSUMER_TAG) + traced_teardown(server) + + assert server.sendables() == () diff --git a/src/tests/amqp_imposter/tests/model/test_reply_codes.py b/src/tests/amqp_imposter/tests/model/test_reply_codes.py new file mode 100644 index 00000000..493a157c --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/test_reply_codes.py @@ -0,0 +1,22 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model import method_fields as mf + + +def test_partitions() -> None: + assert mf.SOFT_ERRORS | mf.HARD_ERRORS | {mf.ReplyCode.SUCCESS} == set(mf.ReplyCode) + assert mf.UNPROMPTED_ERRORS <= mf.HARD_ERRORS + assert not mf.SOFT_ERRORS & mf.HARD_ERRORS diff --git a/src/tests/amqp_imposter/tests/model/test_revealing_line.py b/src/tests/amqp_imposter/tests/model/test_revealing_line.py new file mode 100644 index 00000000..94cba074 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/test_revealing_line.py @@ -0,0 +1,88 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from amqp_imposter.model.helpers.revealing_line import ( + Opaque, + RevealingLine, + Transparent, + is_transparent, +) + + +def test_empty() -> None: + assert RevealingLine[int]().observe() == () + + +def test_push_back() -> None: + line = RevealingLine[int]().push(Opaque(1)).push(Opaque(2)) + assert tuple(revealed.item for revealed in line) == (1, 2) + + +def test_observe_until_opaque() -> None: + line = ( + RevealingLine[int]() + .push(Transparent(1)) + .push(Transparent(2)) + .push(Opaque(3)) + .push(Transparent(4)) + ) + assert line.observe() == (1, 2, 3) + + +def test_observe_no_opaque() -> None: + line = RevealingLine[int]().push(Transparent(1)).push(Transparent(2)) + assert line.observe() == (1, 2) + + +def test_observe_head_opaque() -> None: + line = RevealingLine[int]().push(Opaque(1)).push(Opaque(2)) + assert line.observe() == (1,) + + +def test_split() -> None: + line = ( + RevealingLine[int]().push(Transparent(1)).push(Opaque(2)).push(Transparent(3)) + ) + front, back = line.split(2) + assert tuple(revealed.item for revealed in front) == (1, 2) + assert tuple(revealed.item for revealed in back) == (3,) + + +def test_every_split() -> None: + line = RevealingLine[int]() + line = ( + line.push(Transparent(1)) + .push(Opaque(2)) + .push(Transparent(3)) + .push(Transparent(4)) + .push(Transparent(1)) + .push(Opaque(1)) + ) + for n in range(len(line) + 1): + front, back = line.split(n) + assert (*front, *back) == tuple(line) + + +def test_split_while() -> None: + line = ( + RevealingLine[int]() + .push(Transparent(1)) + .push(Transparent(2)) + .push(Opaque(3)) + .push(Transparent(4)) + ) + front, back = line.split_while(is_transparent) + assert tuple(revealed.item for revealed in front) == (1, 2) + assert tuple(revealed.item for revealed in back) == (3, 4) diff --git a/src/tests/amqp_imposter/tests/model/unification/test_concretise.py b/src/tests/amqp_imposter/tests/model/unification/test_concretise.py new file mode 100644 index 00000000..39533d37 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/unification/test_concretise.py @@ -0,0 +1,79 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import fields, replace + +from amqp_imposter.model import generators as g +from amqp_imposter.model import method_unification as mu +from amqp_imposter.model import methods as m + +from ..helpers import FRESH_DELIVERY + + +def test_no_arbitraries_remain() -> None: + concrete, _ = mu.concretise(FRESH_DELIVERY, g.DEFAULT_GEN_STATE) + + assert all( + not isinstance(getattr(concrete, field.name), g.Gen) + for field in fields(concrete) + ) + + +def test_nothing_to_do() -> None: + concrete, _ = mu.concretise(m.ChannelOpenOk(), g.DEFAULT_GEN_STATE) + + assert concrete == m.ChannelOpenOk() + + +def test_deterministic() -> None: + assert mu.concretise(FRESH_DELIVERY, g.DEFAULT_GEN_STATE) == mu.concretise( + FRESH_DELIVERY, g.DEFAULT_GEN_STATE + ) + + +def test_predefined_fields_remain() -> None: + concrete, _ = mu.concretise(FRESH_DELIVERY, g.DEFAULT_GEN_STATE) + + assert concrete == replace( + FRESH_DELIVERY, + redelivered=concrete.redelivered, + exchange=concrete.exchange, + routing_key=concrete.routing_key, + properties=concrete.properties, + body=concrete.body, + ) + + +def test_concretise_but_one_field() -> None: + free, free_gen_state = mu.concretise(FRESH_DELIVERY, g.DEFAULT_GEN_STATE) + + # now one where an arbitrary field has already become concrete + concrete, concrete_gen_state = mu.concretise( + replace(FRESH_DELIVERY, exchange="ex"), g.DEFAULT_GEN_STATE + ) + + assert concrete.exchange == "ex" + assert (concrete.routing_key, concrete.properties, concrete.body) == ( + free.routing_key, + free.properties, + free.body, + ) + assert concrete_gen_state == free_gen_state + + +def test_advance() -> None: + _, gen_state = mu.concretise(FRESH_DELIVERY, g.DEFAULT_GEN_STATE) + + assert gen_state != g.DEFAULT_GEN_STATE diff --git a/src/tests/amqp_imposter/tests/model/unification/test_unify.py b/src/tests/amqp_imposter/tests/model/unification/test_unify.py new file mode 100644 index 00000000..d47e2b61 --- /dev/null +++ b/src/tests/amqp_imposter/tests/model/unification/test_unify.py @@ -0,0 +1,94 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from dataclasses import replace +from typing import get_args + +from amqp_imposter.model import generators as g +from amqp_imposter.model import method_unification as mu +from amqp_imposter.model import methods as m + +from ..helpers import FRESH_DELIVERY + + +def test_method_kinds_are_distinct() -> None: + # if this fails, it indicates an issue with our spec for AMQP 0-9-1 + kinds = get_args(m.Method.__value__) + ids = {(kind.CLASS_ID, kind.METHOD_ID) for kind in kinds} + + assert len(ids) == len(kinds) + + +def test_unify_myself() -> None: + assert mu.unify(FRESH_DELIVERY, FRESH_DELIVERY) == FRESH_DELIVERY + + +def test_unify_myself_with_nones() -> None: + published = m.BasicPublish(exchange="ex", routing_key="rk", properties=None) + + assert mu.unify(published, published) == published + + +def test_cannot_unify_kinds() -> None: + assert mu.unify_any(FRESH_DELIVERY, (m.BasicAck(delivery_tag=1),)) is None + + +def test_cannot_unify_fields() -> None: + assert mu.unify(FRESH_DELIVERY, replace(FRESH_DELIVERY, delivery_tag=2)) is None + + +def test_unify_prefer_given() -> None: + unified = mu.unify(replace(FRESH_DELIVERY, body=b"chosen"), FRESH_DELIVERY) + + assert unified == replace(FRESH_DELIVERY, body=b"chosen") + + +def test_unify_prefer_against() -> None: + unified = mu.unify(FRESH_DELIVERY, replace(FRESH_DELIVERY, body=b"fixed")) + + assert unified == replace(FRESH_DELIVERY, body=b"fixed") + + +def test_unify_prefer_arbitrary_against() -> None: + entry = replace(FRESH_DELIVERY, body=g.RandomBytes(1, 1)) + + assert mu.unify(FRESH_DELIVERY, entry) == entry + + +def test_unify_take_both_sides() -> None: + unified = mu.unify( + replace(FRESH_DELIVERY, body=b"a"), replace(FRESH_DELIVERY, exchange="ex") + ) + + assert unified == replace(FRESH_DELIVERY, body=b"a", exchange="ex") + + +def test_unify_any_skip_impossible() -> None: + entry = replace(FRESH_DELIVERY, body=b"fixed") + + assert mu.unify_any(FRESH_DELIVERY, (m.BasicAck(delivery_tag=1), entry)) == entry + + +def test_unify_any_greedy() -> None: + first = replace(FRESH_DELIVERY, body=b"one") + second = replace(FRESH_DELIVERY, body=b"two") + + assert mu.unify_any(FRESH_DELIVERY, (first, second)) == first + + +def test_unify_any_impossible() -> None: + assert ( + mu.unify_any(FRESH_DELIVERY, (replace(FRESH_DELIVERY, delivery_tag=2),)) is None + ) diff --git a/src/tests/amqp_imposter/tests/test_banned_style.py b/src/tests/amqp_imposter/tests/test_banned_style.py new file mode 100644 index 00000000..4b34e2ac --- /dev/null +++ b/src/tests/amqp_imposter/tests/test_banned_style.py @@ -0,0 +1,50 @@ +# Copyright 2026 Bloomberg Finance L.P. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ast +from pathlib import Path + +SOURCE_ROOT = Path(__file__).parent.parent / "amqp_imposter" + + +def is_isinstance_invocation(node: ast.expr) -> bool: + match node: + case ast.UnaryOp(op=ast.Not(), operand=operand): + return is_isinstance_invocation(operand) + case ast.Call(func=ast.Name(id="isinstance")): + return True + case _: + return False + + +def find_isinstance_branches(source: str) -> list[int]: + return [ + node.lineno + for node in ast.walk(ast.parse(source)) + if isinstance(node, ast.If) + and node.orelse + and is_isinstance_invocation(node.test) + ] + + +def test_ban_isinstance_branches() -> None: + # Prefer: match statements + # Rationale: preserve exhaustive typechecking and keep code readable as a finite state machine + assert not [ + f"{path.relative_to(SOURCE_ROOT.parent)}:{line}" + for path in sorted(SOURCE_ROOT.rglob("*.py")) + if "_pb2" not in path.name + for line in find_isinstance_branches(path.read_text()) + ] From c08a7b79d290e35f4e60efc335c5e4cb615abaa0 Mon Sep 17 00:00:00 2001 From: Stavros Michalovits Date: Tue, 27 Oct 2026 18:05:38 +0000 Subject: [PATCH 2/5] Freshen rmqapitests --- Makefile | 15 +- .../integration/rmqapitests/fuzz/__init__.py | 0 .../rmqapitests/fuzz/consumer_fuzztests.py | 196 +++++++++ .../integration/rmqapitests/fuzz/fixtures.py | 29 ++ .../rmqapitests/fuzz/generators.py | 24 ++ .../rmqapitests/fuzz/producer_fuzztests.py | 219 ++++++++++ .../rmqapitests/fuzz/prop_consumer.py | 182 +++++++++ .../rmqapitests/fuzz/prop_general.py | 382 ++++++++++++++++++ .../rmqapitests/fuzz/prop_producer.py | 245 +++++++++++ .../integration/rmqapitests/fuzz/runner.py | 70 ++++ 10 files changed, 1361 insertions(+), 1 deletion(-) create mode 100644 src/tests/integration/rmqapitests/fuzz/__init__.py create mode 100644 src/tests/integration/rmqapitests/fuzz/consumer_fuzztests.py create mode 100644 src/tests/integration/rmqapitests/fuzz/fixtures.py create mode 100644 src/tests/integration/rmqapitests/fuzz/generators.py create mode 100644 src/tests/integration/rmqapitests/fuzz/producer_fuzztests.py create mode 100644 src/tests/integration/rmqapitests/fuzz/prop_consumer.py create mode 100644 src/tests/integration/rmqapitests/fuzz/prop_general.py create mode 100644 src/tests/integration/rmqapitests/fuzz/prop_producer.py create mode 100644 src/tests/integration/rmqapitests/fuzz/runner.py diff --git a/Makefile b/Makefile index 6a7fb28b..5a9ccaa6 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,9 @@ RMQ_MGMT?="http://localhost:15672" IMPOSTER_FOLDER?=$(SRC_FOLDER)/src/tests/amqp_imposter IMPOSTER_VENV?=$(BUILD_FOLDER)/imposter-venv +FUZZ_BUILD_FOLDER?=$(BUILD_FOLDER)/fuzz-asan +FUZZ_CXX_FLAGS?=-fsanitize=address -fno-omit-frame-pointer +FUZZ_ARGS?=-n auto default: build unit @@ -38,6 +41,16 @@ imposter-venv: imposter-test: imposter-venv cd $(IMPOSTER_FOLDER) && $(abspath $(IMPOSTER_VENV))/bin/python -m pytest -n auto +fuzz-build: + cmake --preset ${CMAKE_PRESET} -B ${FUZZ_BUILD_FOLDER} -S ${SRC_FOLDER} -G Ninja -DCMAKE_CXX_FLAGS="$(FUZZ_CXX_FLAGS)" + cmake --build ${FUZZ_BUILD_FOLDER} -t rmqperftest + +fuzz: imposter-venv fuzz-build + cd $(SRC_FOLDER)/src/tests && \ + RMQ_EXE=$(abspath $(FUZZ_BUILD_FOLDER))/examples/rmqperftest/rmqperftest ASAN_OPTIONS=detect_leaks=0 PYTHONPATH=. \ + $(abspath $(IMPOSTER_VENV))/bin/python -m pytest -p integration.rmqapitests.fuzz.fixtures -o python_files='*_fuzztests.py' \ + --timeout 600 $(FUZZ_ARGS) --pyargs integration.rmqapitests.fuzz + clean: rm -rf $(BUILD_FOLDER) @@ -53,4 +66,4 @@ docker-test: docker-shell: cd $(SRC_FOLDER)/dockerfiles && docker compose run dev -.PHONY: build init unit integration clean full full-test imposter-venv imposter-test +.PHONY: build init unit integration clean full full-test imposter-venv imposter-test fuzz-build fuzz diff --git a/src/tests/integration/rmqapitests/fuzz/__init__.py b/src/tests/integration/rmqapitests/fuzz/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/tests/integration/rmqapitests/fuzz/consumer_fuzztests.py b/src/tests/integration/rmqapitests/fuzz/consumer_fuzztests.py new file mode 100644 index 00000000..ec27086e --- /dev/null +++ b/src/tests/integration/rmqapitests/fuzz/consumer_fuzztests.py @@ -0,0 +1,196 @@ +"""Copyright 2026 Bloomberg Finance L.P. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +from __future__ import annotations + +import random +from dataclasses import dataclass +from functools import partial + +import pytest +from amqp_imposter.control.client import ( + NOT_FORCED_REPLY_TEXT, + AmqpImposter, + pb, + weighted, +) + +from .generators import random_amqp_name +from .prop_consumer import ( + check_ack_count, + check_consumer_recovers_within, + check_consumer_stays_stopped, + check_consumes, + check_delivery_count, + check_expected_requeue, + check_qos_before_consuming, +) +from .prop_general import ( + Property, + assert_properties, + check_connections_closed, + check_declarations_before_work, + check_expected_declarations, + check_open_backoff, + check_protocol_legal, +) +from .runner import NEGOTIATION_OPTIONS, perftest_args, run_client + +__all__ = [ + "test_consumer_with_teardowns", + "test_consumer_without_teardowns", +] + +SEEDS = range(500) + + +@dataclass(frozen=True, slots=True) +class ConsumerScenario: + exchange: str + queue: str + routing_key: str + messages: int + prefetch_count: int + priority: int + auto_delete: bool + + +def consumer_scenario(seed: int) -> ConsumerScenario: + rng = random.Random(seed) + return ConsumerScenario( + exchange=random_amqp_name(rng), + queue=random_amqp_name(rng), + routing_key=random_amqp_name(rng), + messages=rng.randint(1, 30), + prefetch_count=rng.randint(1, 30), + priority=rng.randint(-10, 10), + auto_delete=rng.random() < 0.5, + ) + + +def consumer_args(consumer: ConsumerScenario) -> tuple[str, ...]: + return perftest_args( + { + "--exchange": consumer.exchange, + "--type": "direct", + "--queue": consumer.queue, + "--routing-key": consumer.routing_key, + "--consumers": "1", + "--producers": "0", + "--cmessages": str(consumer.messages), + "--qos": str(consumer.prefetch_count), + "--consumer-args": f"x-priority={consumer.priority}", + "--auto-delete": str(consumer.auto_delete).lower(), + **({} if consumer.auto_delete else {"--flag": "persistent"}), + # TODO supporting --consumer-latency and varying by seed would help get to more bugs + } + ) + + +def consumer_properties( + consumer: ConsumerScenario, *, minimum_acks: int +) -> tuple[Property, ...]: + exchange_declare = pb.ExchangeDeclare( + exchange=consumer.exchange, exchange_type="direct", durable=True + ) + queue_declare = pb.QueueDeclare( + queue=consumer.queue, + durable=not consumer.auto_delete, + auto_delete=consumer.auto_delete, + ) + queue_bind = pb.QueueBind( + queue=consumer.queue, + exchange=consumer.exchange, + routing_key=consumer.routing_key, + ) + return ( + check_protocol_legal, + check_open_backoff, + check_connections_closed, + partial(check_qos_before_consuming, prefetch_count=consumer.prefetch_count), + partial(check_consumes, queue=consumer.queue, priority=consumer.priority), + partial(check_delivery_count, messages=consumer.messages), + partial(check_ack_count, minimum=minimum_acks, maximum=consumer.messages), + partial(check_consumer_stays_stopped, messages=consumer.messages), + partial(check_consumer_recovers_within, messages=consumer.messages), + partial( + check_expected_requeue, + requeue=False, # TODO could try to vary this via perftest + ), + partial( + check_declarations_before_work, + required=(exchange_declare, queue_declare, queue_bind), + ), + partial( + check_expected_declarations, expected=(exchange_declare, queue_declare) + ), + ) + + +@pytest.mark.parametrize("seed", SEEDS) +def test_consumer_without_teardowns(seed: int, imposter: AmqpImposter) -> None: + # GIVEN + consumer = consumer_scenario(seed) + imposter.configure( + pb.SessionConfig( + seed=seed, + negotiation_options=NEGOTIATION_OPTIONS, + ) + ) + + # WHEN + run = run_client(imposter, consumer_args(consumer)) + + # THEN + assert_properties( + run, consumer_properties(consumer, minimum_acks=consumer.messages) + ) + + +@pytest.mark.parametrize("seed", SEEDS) +def test_consumer_with_teardowns(seed: int, imposter: AmqpImposter) -> None: + # GIVEN + consumer = consumer_scenario(seed) + weights = ( + weighted( + pb.ConnectionClosePattern(reply_text=NOT_FORCED_REPLY_TEXT), + pb.BASE_WEIGHT // 20, + ), + weighted( + pb.ChannelClosePattern(reply_text=NOT_FORCED_REPLY_TEXT), + pb.BASE_WEIGHT // 20, + ), + weighted(pb.BasicCancelPattern(), pb.BASE_WEIGHT // 20), + ) + # clients with backoff have long idle periods, so let them recover + idle_weights = ( + weighted(pb.ConnectionClosePattern(class_id=0, method_id=0), 0), + weighted(pb.BasicCancelPattern(), 0), + ) + + imposter.configure( + pb.SessionConfig( + seed=seed, + negotiation_options=NEGOTIATION_OPTIONS, + weights=weights, + idle_weights=idle_weights, + ) + ) + + # WHEN + run = run_client(imposter, consumer_args(consumer)) + + # THEN + assert_properties(run, consumer_properties(consumer, minimum_acks=0)) diff --git a/src/tests/integration/rmqapitests/fuzz/fixtures.py b/src/tests/integration/rmqapitests/fuzz/fixtures.py new file mode 100644 index 00000000..5dea5a8a --- /dev/null +++ b/src/tests/integration/rmqapitests/fuzz/fixtures.py @@ -0,0 +1,29 @@ +"""Copyright 2026 Bloomberg Finance L.P. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +from __future__ import annotations + +from collections.abc import Generator + +import pytest +from amqp_imposter.control.client import AmqpImposter, amqp_imposter + +__all__ = ["imposter"] + + +@pytest.fixture +def imposter() -> Generator[AmqpImposter]: + with amqp_imposter() as ai: + yield ai diff --git a/src/tests/integration/rmqapitests/fuzz/generators.py b/src/tests/integration/rmqapitests/fuzz/generators.py new file mode 100644 index 00000000..625d5afd --- /dev/null +++ b/src/tests/integration/rmqapitests/fuzz/generators.py @@ -0,0 +1,24 @@ +"""Copyright 2026 Bloomberg Finance L.P. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +from __future__ import annotations + +import random +import string + + +def random_amqp_name(rng: random.Random) -> str: + charset = string.ascii_letters + string.digits + "_.:" + return "".join(rng.choices(charset, k=rng.randint(1, 255))) diff --git a/src/tests/integration/rmqapitests/fuzz/producer_fuzztests.py b/src/tests/integration/rmqapitests/fuzz/producer_fuzztests.py new file mode 100644 index 00000000..d4cc275e --- /dev/null +++ b/src/tests/integration/rmqapitests/fuzz/producer_fuzztests.py @@ -0,0 +1,219 @@ +"""Copyright 2026 Bloomberg Finance L.P. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +from __future__ import annotations + +import random +from dataclasses import dataclass +from functools import partial + +import pytest +from amqp_imposter.control.client import ( + NOT_FORCED_REPLY_TEXT, + AmqpImposter, + pb, + weighted, + when_receive, +) + +from .generators import random_amqp_name +from .prop_general import ( + Property, + assert_properties, + check_connections_closed, + check_declarations_before_work, + check_expected_declarations, + check_open_backoff, + check_protocol_legal, +) +from .prop_producer import ( + check_confirm_select_before_publishing, + check_every_publish_settled, + check_expected_publishes, + check_max_unconfirmed, + check_message_count, + check_producer_recovers_within, + check_producer_stays_stopped, +) +from .runner import NEGOTIATION_OPTIONS, perftest_args, run_client + +__all__ = [ + "test_producer_with_returns", + "test_producer_with_teardowns", + "test_producer_without_teardowns", +] + +SEEDS = range(500) + + +@dataclass(frozen=True, slots=True) +class ProducerScenario: + exchange: str + queue: str + routing_key: str + messages: int + confirm: int + body_size: int + auto_delete: bool + + +def producer_scenario(seed: int) -> ProducerScenario: + rng = random.Random(seed) + return ProducerScenario( + exchange=random_amqp_name(rng), + queue=random_amqp_name(rng), + routing_key=random_amqp_name(rng), + messages=rng.randint(1, 30), + confirm=rng.randint(1, 30), + body_size=rng.randint(1, 4096), + auto_delete=rng.random() < 0.5, + ) + + +def producer_args(producer: ProducerScenario) -> tuple[str, ...]: + return perftest_args( + { + "--exchange": producer.exchange, + "--type": "direct", + "--queue": producer.queue, + "--routing-key": producer.routing_key, + "--consumers": "0", + "--producers": "1", + "--pmessages": str(producer.messages), + "--confirm": str(producer.confirm), + "--size": str(producer.body_size), + "--auto-delete": str(producer.auto_delete).lower(), + **({} if producer.auto_delete else {"--flag": "persistent"}), + # TODO support --message-properties + } + ) + + +def producer_properties(producer: ProducerScenario) -> tuple[Property, ...]: + exchange_declare = pb.ExchangeDeclare( + exchange=producer.exchange, exchange_type="direct", durable=True + ) + queue_declare = pb.QueueDeclare( + queue=producer.queue, + durable=not producer.auto_delete, + auto_delete=producer.auto_delete, + ) + return ( + check_protocol_legal, + check_open_backoff, + check_connections_closed, + check_confirm_select_before_publishing, + check_every_publish_settled, + partial(check_max_unconfirmed, max_unconfirmed=producer.confirm), + partial(check_message_count, messages=producer.messages), + partial(check_producer_stays_stopped, messages=producer.messages), + partial(check_producer_recovers_within, messages=producer.messages), + partial(check_declarations_before_work, required=(exchange_declare,)), + partial( + check_expected_declarations, expected=(exchange_declare, queue_declare) + ), + partial( + check_expected_publishes, + exchange=producer.exchange, + routing_key=producer.routing_key, + persistent=not producer.auto_delete, + body_size=producer.body_size, + ), + ) + + +@pytest.mark.parametrize("seed", SEEDS) +def test_producer_without_teardowns(seed: int, imposter: AmqpImposter) -> None: + # GIVEN + producer = producer_scenario(seed) + imposter.configure( + pb.SessionConfig( + seed=seed, + negotiation_options=NEGOTIATION_OPTIONS, + ) + ) + + # WHEN + run = run_client(imposter, producer_args(producer)) + + # THEN + assert_properties(run, producer_properties(producer)) + + +@pytest.mark.parametrize("seed", SEEDS) +def test_producer_with_teardowns(seed: int, imposter: AmqpImposter) -> None: + # GIVEN + producer = producer_scenario(seed) + weights = ( + weighted( + pb.ConnectionClosePattern(reply_text=NOT_FORCED_REPLY_TEXT), + pb.BASE_WEIGHT // 20, + ), + weighted( + pb.ChannelClosePattern(reply_text=NOT_FORCED_REPLY_TEXT), + pb.BASE_WEIGHT // 20, + ), + ) + + # clients with backoff have long idle periods, so let them recover + idle_weights = (weighted(pb.ConnectionClosePattern(class_id=0, method_id=0), 0),) + + imposter.configure( + pb.SessionConfig( + seed=seed, + negotiation_options=NEGOTIATION_OPTIONS, + weights=weights, + idle_weights=idle_weights, + ) + ) + + # WHEN + run = run_client(imposter, producer_args(producer)) + + # THEN + assert_properties(run, producer_properties(producer)) + + +@pytest.mark.parametrize("seed", SEEDS) +def test_producer_with_returns(seed: int, imposter: AmqpImposter) -> None: + # GIVEN + producer = producer_scenario(seed) + script = ( + when_receive(pb.BasicPublishPattern(), then_send=pb.BasicReturnPattern()), + ) + weights = ( + weighted(pb.BasicAckPattern(), pb.BASE_WEIGHT // 2), + weighted( + pb.ConnectionClosePattern(class_id=0, method_id=0), + pb.BASE_WEIGHT // 20, + ), + ) + idle_weights = (weighted(pb.ConnectionClosePattern(class_id=0, method_id=0), 0),) + + imposter.configure( + pb.SessionConfig( + seed=seed, + negotiation_options=NEGOTIATION_OPTIONS, + script=script, + weights=weights, + idle_weights=idle_weights, + ) + ) + + # WHEN + run = run_client(imposter, producer_args(producer)) + + # THEN + assert_properties(run, producer_properties(producer)) diff --git a/src/tests/integration/rmqapitests/fuzz/prop_consumer.py b/src/tests/integration/rmqapitests/fuzz/prop_consumer.py new file mode 100644 index 00000000..fffa645f --- /dev/null +++ b/src/tests/integration/rmqapitests/fuzz/prop_consumer.py @@ -0,0 +1,182 @@ +"""Copyright 2026 Bloomberg Finance L.P. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +from __future__ import annotations + +from collections.abc import Iterator + +from amqp_imposter.control.client import Transcript, pb +from amqp_imposter.control.client.transcript_util import ( + format_line_name, + format_proto_message, +) + +from .prop_general import ( + PropertyViolation, + check_recovers_within, + check_stays_stopped, + client_stop_index, + event_and_method, + matches_event_method, + settled_tags, +) +from .runner import ImposterCapture + + +def delivery_ack_indices(transcript: Transcript) -> Iterator[int]: + unsettled: frozenset[int] = frozenset() + + for line in transcript: + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.ChannelOpen()) | ( + pb.TRANSCRIPT_EVENT_METHOD_INGRESS | pb.TRANSCRIPT_EVENT_METHOD_EGRESS, + pb.ChannelClose() | pb.ConnectionClose(), + ): + unsettled = frozenset() + case (pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.BasicDeliver(delivery_tag=tag)): + unsettled |= {tag} + case ( + pb.TRANSCRIPT_EVENT_METHOD_INGRESS, + pb.BasicAck(delivery_tag=tag, multiple=multiple), + ): + acked = settled_tags(unsettled, tag, multiple) + yield from [line.index] * len(acked) + unsettled -= acked + case ( + pb.TRANSCRIPT_EVENT_METHOD_INGRESS, + pb.BasicNack(delivery_tag=tag, multiple=multiple), + ): + unsettled -= settled_tags(unsettled, tag, multiple) + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicReject(delivery_tag=tag)): + unsettled -= {tag} + + +def check_expected_requeue( + capture: ImposterCapture, requeue: bool +) -> PropertyViolation | None: + for line in capture.transcript: + match event_and_method(line): + case ( + pb.TRANSCRIPT_EVENT_METHOD_INGRESS, + (pb.BasicNack() | pb.BasicReject()) as method, + ) if method.requeue == requeue: + return PropertyViolation( + f"Dismissed delivery without requeuing: {format_line_name(line)}", + line.index, + ) + + return None + + +def check_consumes( + capture: ImposterCapture, queue: str, priority: int +) -> PropertyViolation | None: + expected = (queue, False, pb.AmqpValue(long=priority)) + + for line in capture.transcript: + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicConsume() as consume) if ( + consume.queue, + consume.no_ack, + consume.arguments.entries.get("x-priority"), + ) != expected: + # TODO we could generalise this check if we expose more in perftest + return PropertyViolation( + f"Consumed {format_proto_message(consume)!r}: " + f"expected queue={queue!r}, no_ack=false, x-priority={priority}", + line.index, + ) + + return None + + +def check_qos_before_consuming( + capture: ImposterCapture, prefetch_count: int +) -> PropertyViolation | None: + expected_qos = pb.BasicQos(prefetch_count=prefetch_count) + prior_qos: pb.BasicQos | None = None + + for line in capture.transcript: + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.ChannelOpen()): + prior_qos = None + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicQos() as qos): + prior_qos = qos + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicConsume()) if ( + prior_qos != expected_qos + ): + return PropertyViolation( + f"Missed QoS before consuming: {format_proto_message(expected_qos)!r}", + line.index, + ) + + return None + + +def check_delivery_count( + capture: ImposterCapture, messages: int +) -> PropertyViolation | None: + deliveries = sum( + matches_event_method(line, pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.BasicDeliver) + for line in capture.transcript + ) + + if deliveries < messages: + return PropertyViolation( + f"Got {deliveries} deliveries: expected >={messages}", None + ) + + return None + + +def check_ack_count( + capture: ImposterCapture, minimum: int, maximum: int +) -> PropertyViolation | None: + ack_indices = tuple(delivery_ack_indices(capture.transcript)) + + if len(ack_indices) < minimum: + return PropertyViolation( + f"Acked {len(ack_indices)} deliveries: expected >={minimum}", None + ) + if len(ack_indices) > maximum: + return PropertyViolation( + f"Acked {len(ack_indices)} deliveries: expected <={maximum}", + ack_indices[maximum], + ) + + return None + + +def check_consumer_stays_stopped( + capture: ImposterCapture, messages: int +) -> PropertyViolation | None: + i_stop = client_stop_index( + capture.transcript, delivery_ack_indices(capture.transcript), messages + ) + return check_stays_stopped(capture.transcript, i_stop) + + +def check_consumer_recovers_within( + capture: ImposterCapture, messages: int +) -> PropertyViolation | None: + delivery_indices = ( + line.index + for line in capture.transcript + if matches_event_method( + line, pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.BasicDeliver + ) + ) + i_stop = client_stop_index(capture.transcript, delivery_indices, messages) + return check_recovers_within(capture.transcript, i_stop) diff --git a/src/tests/integration/rmqapitests/fuzz/prop_general.py b/src/tests/integration/rmqapitests/fuzz/prop_general.py new file mode 100644 index 00000000..1753213b --- /dev/null +++ b/src/tests/integration/rmqapitests/fuzz/prop_general.py @@ -0,0 +1,382 @@ +"""Copyright 2026 Bloomberg Finance L.P. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Callable, Iterable, Iterator +from copy import deepcopy +from dataclasses import dataclass +from itertools import islice + +import pytest +from amqp_imposter.control.client import ( + FORCED_REPLY_TEXT, + Transcript, + format_transcript_around, + pb, + transcript_line_method, +) +from amqp_imposter.control.client.transcript_util import ( + format_line_name, +) +from google.protobuf.message import Message + +from .runner import ImposterCapture + + +@dataclass(frozen=True, slots=True) +class PropertyViolation: + reason: str + i_line: int | None + + +type Property = Callable[[ImposterCapture], PropertyViolation | None] + + +# N.b. the imposter supports one channel +# If that changes, change to track by channel number, e.g. return it here +def event_and_method( + line: pb.TranscriptLine, +) -> tuple[pb.TranscriptEvent.ValueType, Message] | None: + if (method := transcript_line_method(line)) is not None: + return line.event, method + return None + + +def matches_event_method( + line: pb.TranscriptLine, + event: pb.TranscriptEvent.ValueType, + *methods: type[Message], +) -> bool: + match event_and_method(line): + case (line_event, method): + return line_event == event and isinstance(method, methods) + case None: + return False + + +def settled_tags( + unsettled: frozenset[int], delivery_tag: int, multiple: bool +) -> frozenset[int]: + if multiple: + return frozenset( + tag for tag in unsettled if delivery_tag == 0 or tag <= delivery_tag + ) + return unsettled & {delivery_tag} + + +def check_protocol_legal(capture: ImposterCapture) -> PropertyViolation | None: + for line in capture.transcript: + match line.event: + case pb.TRANSCRIPT_EVENT_METHOD_UNREPRESENTABLE: + return PropertyViolation( + f"Imposter refused to continue after: {format_line_name(line)}, {line.detail}", + line.index, + ) + case pb.TRANSCRIPT_EVENT_TCP_REJECTED: + return PropertyViolation( + f"Imposter rejected connection: {line.detail}", line.index + ) + case pb.TRANSCRIPT_EVENT_FRAME_MALFORMED: + return PropertyViolation(f"Malformed frame: {line.detail}", line.index) + + match event_and_method(line): + case ( + pb.TRANSCRIPT_EVENT_METHOD_EGRESS, + pb.ConnectionClose() | pb.ChannelClose() as close, + ) if close.reply_text == FORCED_REPLY_TEXT: + return PropertyViolation( + f"Forced AMQP error: {format_line_name(line)}", line.index + ) + + return None + + +type TopologyMethod = pb.ExchangeDeclare | pb.QueueDeclare | pb.QueueBind + + +def topology_name(method: TopologyMethod) -> tuple[str, ...]: + match method: + case pb.ExchangeDeclare(exchange=exchange): + return ("exchange", exchange) + case pb.QueueDeclare(queue=queue): + return ("queue", queue) + case pb.QueueBind(queue=queue, exchange=exchange, routing_key=routing_key): + return ("binding", queue, exchange, routing_key) + + +def check_declarations_before_work( + capture: ImposterCapture, required: Iterable[TopologyMethod] +) -> PropertyViolation | None: + required_names = set(map(topology_name, required)) + declared_names: set[tuple[str, ...]] = set() + + for line in capture.transcript: + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.ChannelOpen()) | ( + pb.TRANSCRIPT_EVENT_METHOD_EGRESS, + pb.BasicCancel(), + ): + declared_names = set() + case ( + pb.TRANSCRIPT_EVENT_METHOD_INGRESS, + pb.ExchangeDeclare() | pb.QueueDeclare() | pb.QueueBind() as method, + ): + declared_names.add(topology_name(method)) + case ( + pb.TRANSCRIPT_EVENT_METHOD_INGRESS, + pb.BasicConsume() | pb.BasicPublish(), + ) if missing := required_names - declared_names: + return PropertyViolation( + f"Missed topology declarations before {format_line_name(line)}: " + f"{', '.join(sorted(map(repr, missing)))}", + line.index, + ) + + return None + + +def declaration_identity( + declare: pb.ExchangeDeclare | pb.QueueDeclare, +) -> pb.ExchangeDeclare | pb.QueueDeclare: + identity = deepcopy(declare) + identity.ClearField("passive") + identity.ClearField("no_wait") + if not identity.arguments.entries: + identity.ClearField("arguments") + + return identity + + +def field_mismatches(actual: Message, expected: Message) -> list[str]: + return [ + f"{field.name}={getattr(actual, field.name)!r}, " + f"expected {getattr(expected, field.name)!r}" + for field in actual.DESCRIPTOR.fields + if getattr(actual, field.name) != getattr(expected, field.name) + ] + + +def check_expected_declarations( + capture: ImposterCapture, expected: Iterable[pb.ExchangeDeclare | pb.QueueDeclare] +) -> PropertyViolation | None: + expected_by_name = { + topology_name(declare): declaration_identity(declare) for declare in expected + } + + for line in capture.transcript: + match event_and_method(line): + case ( + pb.TRANSCRIPT_EVENT_METHOD_INGRESS, + pb.ExchangeDeclare(passive=False) + | pb.QueueDeclare(passive=False) as declare, + ): + name = topology_name(declare) + identity = declaration_identity(declare) + match expected_by_name.get(name): + case None: + return PropertyViolation( + f"Unexpected declaration of {name}", line.index + ) + case expected_identity if identity != expected_identity: + return PropertyViolation( + f"Declared {name} with " + f"{'; '.join(field_mismatches(identity, expected_identity))}", + line.index, + ) + + return None + + +def client_shutdown_indices(transcript: Transcript) -> Iterator[int]: + # track methods to be ignored as they are continuations of the shutdown sequence + # so we shouldn't mistake them as having initiated it! + to_ignore: frozenset[type[Message]] = frozenset() + + for line in transcript: + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.BasicCancel()): + to_ignore |= {pb.ChannelClose, pb.ConnectionClose} + case (pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.ChannelClose()): + to_ignore |= {pb.ConnectionClose} + case ( + pb.TRANSCRIPT_EVENT_METHOD_INGRESS, + pb.ChannelOpen() | pb.BasicConsume(), + ): + to_ignore = frozenset() + case ( + pb.TRANSCRIPT_EVENT_METHOD_INGRESS, + pb.ChannelClose() | pb.ConnectionClose() as close, + ) if type(close) in to_ignore: + to_ignore -= {type(close)} + case ( + pb.TRANSCRIPT_EVENT_METHOD_INGRESS, + pb.BasicCancel() | pb.ChannelClose() | pb.ConnectionClose(), + ): + yield line.index + + +def client_stop_index( + transcript: Transcript, completion_indices: Iterable[int], messages: int +) -> int | None: + final_completion = islice(completion_indices, messages - 1, messages) + return min((*final_completion, *client_shutdown_indices(transcript)), default=None) + + +def check_stays_stopped( + transcript: Transcript, i_stop: int | None +) -> PropertyViolation | None: + if i_stop is None: + return None + + for line in transcript: + restarted = ( + line.event == pb.TRANSCRIPT_EVENT_TCP_CONNECTED + or matches_event_method( + line, + pb.TRANSCRIPT_EVENT_METHOD_INGRESS, + pb.ChannelOpen, + pb.BasicConsume, + pb.ConfirmSelect, + pb.BasicPublish, + ) + ) + if restarted and line.index > i_stop: + return PropertyViolation( + f"{format_line_name(line)} after client shutdown at index {i_stop}", + line.index, + ) + + return None + + +def check_recovers_within( + transcript: Transcript, i_stop: int | None, max_secs: float = 120 +) -> PropertyViolation | None: + teardown_line: pb.TranscriptLine | None = None + + for line in transcript: + if i_stop is not None and line.index > i_stop: + return None + if line.event == pb.TRANSCRIPT_EVENT_TCP_DOWN or matches_event_method( + line, + pb.TRANSCRIPT_EVENT_METHOD_EGRESS, + pb.BasicCancel, + pb.ChannelClose, + pb.ConnectionClose, + ): + teardown_line = line + elif teardown_line is not None and matches_event_method( + line, pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicConsume, pb.BasicPublish + ): + secs = (line.micros - teardown_line.micros) / 1_000_000 + if secs > max_secs: + return PropertyViolation( + f"Resumed {secs:.1f} sec after {format_line_name(teardown_line)}: " + f"expected <={max_secs:.1f} sec", + line.index, + ) + teardown_line = None + + if teardown_line is not None: + return PropertyViolation( + f"Never resumed after {format_line_name(teardown_line)}", + teardown_line.index, + ) + return None + + +def check_connections_closed(capture: ImposterCapture) -> PropertyViolation | None: + i_openok: int | None = None + + for line in capture.transcript: + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.ConnectionOpenOk()): + i_openok = line.index + case ( + pb.TRANSCRIPT_EVENT_METHOD_INGRESS | pb.TRANSCRIPT_EVENT_METHOD_EGRESS, + pb.ConnectionClose(), + ): + i_openok = None + case _ if ( + line.event == pb.TRANSCRIPT_EVENT_TCP_DOWN and i_openok is not None + ): + # imposter exited gracefully, if we don't have a close then we've leaked the connection + break + + if i_openok is not None: + return PropertyViolation( + f"Connection leaked or hung: open_ok at index {i_openok} was never closed", + i_openok, + ) + return None + + +def check_open_backoff( + capture: ImposterCapture, + window_opens: int = 10, + min_window_secs: float = 5, # N opens should be spread over at least T seconds +) -> PropertyViolation | None: + for open_kind in (pb.ConnectionOpen, pb.ChannelOpen): + opens = [ + line + for line in capture.transcript + if matches_event_method(line, pb.TRANSCRIPT_EVENT_METHOD_INGRESS, open_kind) + ] + + for window_start, window_end in zip(opens, opens[window_opens:], strict=False): + window_secs = (window_end.micros - window_start.micros) / 1_000_000 + if window_secs < min_window_secs: + return PropertyViolation( + f">{window_opens} of {format_line_name(window_end)} in {window_secs:.1f} sec: " + f"expected >={min_window_secs:.1f} sec", + window_end.index, + ) + + return None + + +def check_clean_exit(capture: ImposterCapture) -> PropertyViolation | None: + match capture.rc: + case 0: + return None + case None: + return PropertyViolation("Client did not exit in time", None) + case rc: + return PropertyViolation(f"Client exited ungracefully: rc={rc}", None) + + +def assert_properties(capture: ImposterCapture, properties: Iterable[Property]) -> None: + # embed the clean exit check + results = ( + check_clean_exit(capture), + *(prop(capture) for prop in properties), + ) + + reasons_by_line: defaultdict[int | None, list[str]] = defaultdict(list) + for failure in filter(None, results): + reasons_by_line[failure.i_line].append(failure.reason) + + if reasons_by_line: + pytest.fail( + "\n\n".join( + "\n".join( + (*reasons, format_transcript_around(capture.transcript, i_line)) + ) + for i_line, reasons in reasons_by_line.items() + ), + pytrace=False, + ) diff --git a/src/tests/integration/rmqapitests/fuzz/prop_producer.py b/src/tests/integration/rmqapitests/fuzz/prop_producer.py new file mode 100644 index 00000000..0d73913a --- /dev/null +++ b/src/tests/integration/rmqapitests/fuzz/prop_producer.py @@ -0,0 +1,245 @@ +"""Copyright 2026 Bloomberg Finance L.P. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +from __future__ import annotations + +from collections.abc import Iterator +from itertools import islice + +from amqp_imposter.control.client import Transcript, pb + +from .prop_general import ( + PropertyViolation, + check_recovers_within, + check_stays_stopped, + client_stop_index, + event_and_method, + matches_event_method, + settled_tags, +) +from .runner import ImposterCapture + + +def ends_channel(line: pb.TranscriptLine) -> bool: + return ( + line.event in (pb.TRANSCRIPT_EVENT_TCP_CONNECTED, pb.TRANSCRIPT_EVENT_TCP_DOWN) + or matches_event_method( + line, + pb.TRANSCRIPT_EVENT_METHOD_INGRESS, + pb.ChannelOpen, + pb.ChannelClose, + pb.ConnectionClose, + ) + or matches_event_method( + line, pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.ChannelClose, pb.ConnectionClose + ) + ) + + +def settled_publishes_by_line( + transcript: Transcript, +) -> Iterator[tuple[pb.TranscriptLine, list[bytes]]]: + unconfirmed_publishes: dict[int, bytes] = {} + next_delivery_tag: int | None = None + + for line in transcript: + if ends_channel(line): + unconfirmed_publishes = {} + next_delivery_tag = None + + settled_publishes: list[bytes] = [] + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.ConfirmSelect()) if ( + next_delivery_tag is None + ): + next_delivery_tag = 1 + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicPublish() as publish) if ( + next_delivery_tag is not None + ): + # N.b. perftest generates unique message bodies + unconfirmed_publishes[next_delivery_tag] = publish.SerializeToString( + deterministic=True + ) + next_delivery_tag += 1 + case ( + pb.TRANSCRIPT_EVENT_METHOD_EGRESS, + pb.BasicAck() | pb.BasicNack() as confirm, + ): + settled_publishes = [ + unconfirmed_publishes.pop(tag) + for tag in sorted( + settled_tags( + frozenset(unconfirmed_publishes), + confirm.delivery_tag, + confirm.multiple, + ) + ) + ] + + yield line, settled_publishes + + +def check_confirm_select_before_publishing( + capture: ImposterCapture, +) -> PropertyViolation | None: + confirming = False + + for line in capture.transcript: + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.ChannelOpen()): + confirming = False + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.ConfirmSelect()): + confirming = True + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicPublish()) if ( + not confirming + ): + return PropertyViolation( + "Missed Confirm.Select before publishing", line.index + ) + + return None + + +def check_expected_publishes( + capture: ImposterCapture, + exchange: str, + routing_key: str, + persistent: bool, + body_size: int, +) -> PropertyViolation | None: + for line in capture.transcript: + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicPublish() as publish) if ( + mismatches := [ + f"{field}={actual!r}, expected {expected!r}" + for field, actual, expected in ( + ("exchange", publish.exchange, exchange), + ("routing_key", publish.routing_key, routing_key), + ("body_size", len(publish.body), body_size), + ( + "persistent", + publish.properties.delivery_mode == 2, + persistent, + ), + ) + if actual != expected + ] + ): + return PropertyViolation( + f"Published with {'; '.join(mismatches)}", line.index + ) + + return None + + +def check_max_unconfirmed( + capture: ImposterCapture, max_unconfirmed: int +) -> PropertyViolation | None: + n_unconfirmed = 0 + + for line, settled_publish in settled_publishes_by_line(capture.transcript): + if ends_channel(line): + n_unconfirmed = 0 + + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_EGRESS, pb.BasicAck() | pb.BasicNack()): + n_unconfirmed -= len(settled_publish) + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicPublish()): + n_unconfirmed += 1 + if n_unconfirmed > max_unconfirmed: + return PropertyViolation( + f"Published with {n_unconfirmed} messages unconfirmed: " + f"expected <={max_unconfirmed}", + line.index, + ) + + return None + + +def check_message_count( + capture: ImposterCapture, messages: int +) -> PropertyViolation | None: + first_publish_indices: dict[bytes, int] = {} + for line in capture.transcript: + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicPublish() as publish): + first_publish_indices.setdefault( + publish.SerializeToString(deterministic=True), line.index + ) + + if len(first_publish_indices) != messages: + return PropertyViolation( + f"Published {len(first_publish_indices)} distinct messages: " + f"expected {messages}", + next(islice(first_publish_indices.values(), messages, None), None), + ) + + return None + + +def check_every_publish_settled(capture: ImposterCapture) -> PropertyViolation | None: + first_publish_indices: dict[bytes, int] = {} + settled_publishes: set[bytes] = set() + + for line, settled_publish in settled_publishes_by_line(capture.transcript): + match event_and_method(line): + case (pb.TRANSCRIPT_EVENT_METHOD_INGRESS, pb.BasicPublish() as publish): + first_publish_indices.setdefault( + publish.SerializeToString(deterministic=True), line.index + ) + settled_publishes.update(settled_publish) + + if unsettled_publishes := [ + i_publish + for message, i_publish in first_publish_indices.items() + if message not in settled_publishes + ]: + return PropertyViolation( + f"{len(unsettled_publishes)} of {len(first_publish_indices)} messages never settled", + unsettled_publishes[0], + ) + + return None + + +def producer_stop_index(transcript: Transcript, messages: int) -> int | None: + # With perftest, ack and nack both mean we're done with the message + # So both count towards the stop target + settled_messages: set[bytes] = set() + first_settle_indices: list[int] = [] + + for line, settled in settled_publishes_by_line(transcript): + for message in settled: + if message not in settled_messages: + settled_messages.add(message) + first_settle_indices.append(line.index) + + return client_stop_index(transcript, first_settle_indices, messages) + + +def check_producer_stays_stopped( + capture: ImposterCapture, messages: int +) -> PropertyViolation | None: + return check_stays_stopped( + capture.transcript, producer_stop_index(capture.transcript, messages) + ) + + +def check_producer_recovers_within( + capture: ImposterCapture, messages: int +) -> PropertyViolation | None: + return check_recovers_within( + capture.transcript, producer_stop_index(capture.transcript, messages) + ) diff --git a/src/tests/integration/rmqapitests/fuzz/runner.py b/src/tests/integration/rmqapitests/fuzz/runner.py new file mode 100644 index 00000000..9a07ba9b --- /dev/null +++ b/src/tests/integration/rmqapitests/fuzz/runner.py @@ -0,0 +1,70 @@ +"""Copyright 2026 Bloomberg Finance L.P. +SPDX-License-Identifier: Apache-2.0 + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" +from __future__ import annotations + +import os +import subprocess +from collections.abc import Generator, Mapping, Sequence +from contextlib import contextmanager +from dataclasses import dataclass +from itertools import chain + +from amqp_imposter.control.client import AmqpImposter, Transcript, pb + +TIMEOUT_CLIENT = 300.0 + +CLIENT_VHOST = "fuzz" + +# TODO amqp_imposter adhere to negotiated parameters +NEGOTIATION_OPTIONS = pb.NegotiationOptions(heartbeat=60) + + +@dataclass(frozen=True, slots=True) +class ImposterCapture: + transcript: Transcript + rc: int | None + + +def perftest_args(options: Mapping[str, str]) -> tuple[str, ...]: + return tuple(chain.from_iterable(options.items())) + + +@contextmanager +def kill_on_exit[T: (str, bytes)]( + process: subprocess.Popen[T], +) -> Generator[subprocess.Popen[T]]: + with process: + try: + yield process + finally: + process.kill() + + +def run_client( + imposter: AmqpImposter, + args: Sequence[str], + timeout: float = TIMEOUT_CLIENT, +) -> ImposterCapture: + uri = f"amqp://guest:guest@127.0.0.1:{imposter.amqp_port}/{CLIENT_VHOST}" + cmd = [os.environ["RMQ_EXE"], *args, "--uri", uri] + env = {**os.environ, "PERF_TEST_LOG": "TRACE"} + + with kill_on_exit(subprocess.Popen(cmd, env=env)) as client: + try: + rc = client.wait(timeout=timeout) + except subprocess.TimeoutExpired: + rc = None + return ImposterCapture(imposter.transcript(), rc) From 6f1a044f0282c8a3ea62264a0e2c964feec8559a Mon Sep 17 00:00:00 2001 From: Stavros Michalovits Date: Tue, 27 Oct 2026 18:05:49 +0000 Subject: [PATCH 3/5] Track future work in amqp-imposter --- src/tests/amqp_imposter/TODO.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/tests/amqp_imposter/TODO.md diff --git a/src/tests/amqp_imposter/TODO.md b/src/tests/amqp_imposter/TODO.md new file mode 100644 index 00000000..bb6aa952 --- /dev/null +++ b/src/tests/amqp_imposter/TODO.md @@ -0,0 +1,21 @@ +# TODO + +Gaps +- Also redeliver across a disconnect +- Allow two `CloseOk`s when client and server simultaneously `Close` + +Enhancements +- Support N consumers on the same channel +- Support N channels on the same connection +- Support N connections in the same session +- Fully adhere to the negotiated capabilities +- Support anonymous consumer when the `Basic.Consume` is `no-wait` +- Extend table generator to produce nesting +- Add property-writing utilities for the common patterns +- Add heartbeat support (e.g. chance of delay) + +Documentation +- Document interpretation of AMQP 0-9-1 ambiguities: "a client should not depend on this" + +Maybe +- Allow requeued messages to disappear From 341d22351ad34e35ff71429a608bd5001e198790 Mon Sep 17 00:00:00 2001 From: Stavros Michalovits Date: Tue, 27 Oct 2026 21:34:43 +0000 Subject: [PATCH 4/5] Inline a constant + add method kind to a reason string --- src/tests/amqp_imposter/amqp_imposter/amqp/server.py | 3 +-- src/tests/integration/rmqapitests/fuzz/consumer_fuzztests.py | 2 +- src/tests/integration/rmqapitests/fuzz/prop_consumer.py | 4 ++-- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/tests/amqp_imposter/amqp_imposter/amqp/server.py b/src/tests/amqp_imposter/amqp_imposter/amqp/server.py index 3e77bef2..b1b44ead 100644 --- a/src/tests/amqp_imposter/amqp_imposter/amqp/server.py +++ b/src/tests/amqp_imposter/amqp_imposter/amqp/server.py @@ -23,7 +23,6 @@ HOST = "127.0.0.1" EPHEMERAL_PORT = 0 READ_BYTES = 64 * 1024 -MILLIS = 1_000.0 @dataclass(slots=True) @@ -109,7 +108,7 @@ async def loop( self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter ) -> None: loop = asyncio.get_running_loop() - idle = self.session.config.idle_millis / MILLIS + idle = self.session.config.idle_millis / 1_000.0 deadline = loop.time() + idle while True: diff --git a/src/tests/integration/rmqapitests/fuzz/consumer_fuzztests.py b/src/tests/integration/rmqapitests/fuzz/consumer_fuzztests.py index ec27086e..2533130c 100644 --- a/src/tests/integration/rmqapitests/fuzz/consumer_fuzztests.py +++ b/src/tests/integration/rmqapitests/fuzz/consumer_fuzztests.py @@ -127,7 +127,7 @@ def consumer_properties( partial(check_consumer_recovers_within, messages=consumer.messages), partial( check_expected_requeue, - requeue=False, # TODO could try to vary this via perftest + requeue=True, # TODO could try to vary this via perftest ), partial( check_declarations_before_work, diff --git a/src/tests/integration/rmqapitests/fuzz/prop_consumer.py b/src/tests/integration/rmqapitests/fuzz/prop_consumer.py index fffa645f..a732e420 100644 --- a/src/tests/integration/rmqapitests/fuzz/prop_consumer.py +++ b/src/tests/integration/rmqapitests/fuzz/prop_consumer.py @@ -71,9 +71,9 @@ def check_expected_requeue( case ( pb.TRANSCRIPT_EVENT_METHOD_INGRESS, (pb.BasicNack() | pb.BasicReject()) as method, - ) if method.requeue == requeue: + ) if method.requeue != requeue: return PropertyViolation( - f"Dismissed delivery without requeuing: {format_line_name(line)}", + f"{format_line_name(line)} with requeue={method.requeue}", line.index, ) From e07d5c716f49f60b7bd09d1531b2aaf93a20af30 Mon Sep 17 00:00:00 2001 From: Stavros Michalovits Date: Thu, 29 Oct 2026 14:29:12 +0000 Subject: [PATCH 5/5] Rm ASAN_OPTIONS Signed-off-by: Stavros Michalovits --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5a9ccaa6..347d0055 100644 --- a/Makefile +++ b/Makefile @@ -47,7 +47,7 @@ fuzz-build: fuzz: imposter-venv fuzz-build cd $(SRC_FOLDER)/src/tests && \ - RMQ_EXE=$(abspath $(FUZZ_BUILD_FOLDER))/examples/rmqperftest/rmqperftest ASAN_OPTIONS=detect_leaks=0 PYTHONPATH=. \ + RMQ_EXE=$(abspath $(FUZZ_BUILD_FOLDER))/examples/rmqperftest/rmqperftest PYTHONPATH=. \ $(abspath $(IMPOSTER_VENV))/bin/python -m pytest -p integration.rmqapitests.fuzz.fixtures -o python_files='*_fuzztests.py' \ --timeout 600 $(FUZZ_ARGS) --pyargs integration.rmqapitests.fuzz