diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 88ffc94..d4c98e4 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -39,13 +39,13 @@ jobs: - name: poetry install run: poetry install --no-root - name: isort check-only - run: poetry run isort --check-only . + run: poetry run isort --skip rabbitmq_amqp_python_client/qpid --check-only . - name: black check run: poetry run black --check . - name: flake8 - run: poetry run flake8 --exclude=venv,local_tests,docs/examples --max-line-length=120 --ignore=E203,W503 + run: poetry run flake8 --exclude=venv,local_tests,docs/examples,rabbitmq_amqp_python_client/qpid --max-line-length=120 --ignore=E203,W503 - name: mypy run: | - poetry run mypy . + poetry run mypy --exclude=rabbitmq_amqp_python_client/qpid . - name: poetry run pytest run: poetry run pytest \ No newline at end of file diff --git a/README.md b/README.md index 6b98afb..98e0029 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,17 @@ This library is in early stages of development. It is meant to be used with RabbitMQ 4.0. -## How to Run +## How to Build the project and run the tests + +- Start a RabbitMQ 4.x broker +- poetry build: build the source project +- poetry install: resolves and install dependencies +- poetry run pytest: run the tests ## Getting Started +An example is provide in ./getting_started_main.py you can run it after starting a RabbitMQ 4.0 broker with: + +poetry run python ./examples/getting_started/main.py + diff --git a/examples/getting_started/main.py b/examples/getting_started/main.py new file mode 100644 index 0000000..43938fb --- /dev/null +++ b/examples/getting_started/main.py @@ -0,0 +1,63 @@ +from rabbitmq_amqp_python_client import ( + BindingSpecification, + Connection, + ExchangeSpecification, + Message, + QuorumQueueSpecification, + exchange_address, +) + + +def main() -> None: + exchange_name = "test-exchange" + queue_name = "example-queue" + routing_key = "routing-key" + connection = Connection("amqp://guest:guest@localhost:5672/") + + print("connection to amqp server") + connection.dial() + + management = connection.management() + + print("declaring exchange and queue") + management.declare_exchange(ExchangeSpecification(name=exchange_name, arguments={})) + + management.declare_queue(QuorumQueueSpecification(name=queue_name)) + + print("binding queue to exchange") + bind_name = management.bind( + BindingSpecification( + source_exchange=exchange_name, + destination_queue=queue_name, + binding_key=routing_key, + ) + ) + + addr = exchange_address(exchange_name, routing_key) + + print("create a publisher and publish a test message") + publisher = connection.publisher(addr) + + publisher.publish(Message(body="test")) + + publisher.close() + + print("unbind") + management.unbind(bind_name) + + print("purging the queue") + management.purge_queue(queue_name) + + print("delete queue") + management.delete_queue(queue_name) + + print("delete exchange") + management.delete_exchange(exchange_name) + + print("closing connections") + management.close() + connection.close() + + +if __name__ == "__main__": + main() diff --git a/poetry.lock b/poetry.lock index 48d59dc..72ed139 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,33 +2,33 @@ [[package]] name = "black" -version = "24.3.0" +version = "24.10.0" description = "The uncompromising code formatter." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "black-24.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395"}, - {file = "black-24.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995"}, - {file = "black-24.3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7"}, - {file = "black-24.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0"}, - {file = "black-24.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9"}, - {file = "black-24.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597"}, - {file = "black-24.3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d"}, - {file = "black-24.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5"}, - {file = "black-24.3.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f"}, - {file = "black-24.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11"}, - {file = "black-24.3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4"}, - {file = "black-24.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5"}, - {file = "black-24.3.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837"}, - {file = "black-24.3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd"}, - {file = "black-24.3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213"}, - {file = "black-24.3.0-cp38-cp38-win_amd64.whl", hash = "sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959"}, - {file = "black-24.3.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb"}, - {file = "black-24.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7"}, - {file = "black-24.3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7"}, - {file = "black-24.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f"}, - {file = "black-24.3.0-py3-none-any.whl", hash = "sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93"}, - {file = "black-24.3.0.tar.gz", hash = "sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f"}, + {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, + {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, + {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, + {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, + {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, + {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, + {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, + {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, + {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, + {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, + {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, + {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, + {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, + {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, + {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, + {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, + {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, + {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, + {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, + {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, + {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, + {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, ] [package.dependencies] @@ -42,7 +42,7 @@ typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] -d = ["aiohttp (>=3.7.4)", "aiohttp (>=3.7.4,!=3.9.0)"] +d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] @@ -127,13 +127,13 @@ pycparser = "*" [[package]] name = "click" -version = "8.1.7" +version = "8.1.8" description = "Composable command line interface toolkit" optional = false python-versions = ">=3.7" files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, + {file = "click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2"}, + {file = "click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a"}, ] [package.dependencies] @@ -458,5 +458,5 @@ files = [ [metadata] lock-version = "2.0" -python-versions = "^3.9" -content-hash = "bfe651ba3823b09c6d96c9e066187567a750a502a186c9a95955a6535a7134a1" +python-versions = "^3.10" +content-hash = "1d6eaec017e031690d3de190f4e59fbac5ad528724b05086f347758ec991b020" diff --git a/rabbitmq_amqp_python_client/__init__.py b/rabbitmq_amqp_python_client/__init__.py index e69de29..91bf819 100644 --- a/rabbitmq_amqp_python_client/__init__.py +++ b/rabbitmq_amqp_python_client/__init__.py @@ -0,0 +1,41 @@ +from importlib import metadata + +from .address_helper import exchange_address, queue_address +from .common import QueueType +from .connection import Connection +from .entities import ( + BindingSpecification, + ExchangeSpecification, +) +from .management import Management +from .publisher import Publisher +from .qpid.proton._message import Message +from .queues import ( + ClassicQueueSpecification, + QuorumQueueSpecification, + StreamSpecification, +) + +try: + __version__ = metadata.version(__package__) + __license__ = metadata.metadata(__package__)["license"] +except metadata.PackageNotFoundError: + __version__ = "dev" + __license__ = None + +del metadata + +__all__ = [ + "Connection", + "Management", + "ExchangeSpecification", + "QuorumQueueSpecification", + "ClassicQueueSpecification", + "StreamSpecification", + "BindingSpecification", + "QueueType", + "Publisher", + "exchange_address", + "queue_address", + "Message", +] diff --git a/rabbitmq_amqp_python_client/address_helper.py b/rabbitmq_amqp_python_client/address_helper.py new file mode 100644 index 0000000..68a0ef6 --- /dev/null +++ b/rabbitmq_amqp_python_client/address_helper.py @@ -0,0 +1,69 @@ +from .entities import BindingSpecification + + +def is_unreserved(char: str) -> bool: + # According to RFC 3986, unreserved characters are A-Z, a-z, 0-9, '-', '.', '_', and '~' + return char.isalnum() or char in "-._~" + + +def encode_path_segment(input_string: str) -> str: + encoded = [] + + # Iterate over each character in the input string + for char in input_string: + # Check if the character is an unreserved character + if is_unreserved(char): + encoded.append(char) # Append as is + else: + # Encode character to %HH format + encoded.append(f"%{ord(char):02X}") + + return "".join(encoded) + + +def exchange_address(exchange_name: str, routing_key: str = "") -> str: + if routing_key == "": + path = "/exchanges/" + encode_path_segment(exchange_name) + else: + path = ( + "/exchanges/" + + encode_path_segment(exchange_name) + + "/" + + encode_path_segment(routing_key) + ) + + return path + + +def queue_address(queue_name: str) -> str: + path = "/queues/" + encode_path_segment(queue_name) + + return path + + +def purge_queue_address(queue_name: str) -> str: + path = "/queues/" + encode_path_segment(queue_name) + "/messages" + + return path + + +def path_address() -> str: + path = "/bindings" + + return path + + +def binding_path_with_exchange_queue(bind_specification: BindingSpecification) -> str: + binding_path_wth_exchange_queue_key = ( + "/bindings" + + "/" + + "src=" + + encode_path_segment(bind_specification.source_exchange) + + ";" + + "dstq=" + + encode_path_segment(bind_specification.destination_queue) + + ";key=" + + encode_path_segment(bind_specification.binding_key) + + ";args=" + ) + return binding_path_wth_exchange_queue_key diff --git a/rabbitmq_amqp_python_client/common.py b/rabbitmq_amqp_python_client/common.py new file mode 100644 index 0000000..eeef54f --- /dev/null +++ b/rabbitmq_amqp_python_client/common.py @@ -0,0 +1,32 @@ +import enum + + +class CommonValues(enum.Enum): + response_code_200 = 200 + response_code_201 = 201 + response_code_204 = 204 + response_code_404 = 404 + response_code_409 = 409 + command_put = "PUT" + command_get = "GET" + command_post = "POST" + command_delete = "DELETE" + command_reply_to = "$me" + management_node_address = "/management" + link_pair_name = "management-link-pair" + exchanges = "exchanges" + key = "key" + queue = "queues" + bindings = "bindings" + + +class ExchangeType(enum.Enum): + direct = "direct" + topic = "topic" + fanout = "fanout" + + +class QueueType(enum.Enum): + quorum = "quorum" + classic = "classic" + stream = "stream" diff --git a/rabbitmq_amqp_python_client/connection.py b/rabbitmq_amqp_python_client/connection.py new file mode 100644 index 0000000..b9a9b96 --- /dev/null +++ b/rabbitmq_amqp_python_client/connection.py @@ -0,0 +1,36 @@ +import logging + +from .management import Management +from .publisher import Publisher +from .qpid.proton.utils import BlockingConnection + +logger = logging.getLogger(__name__) + + +class Connection: + def __init__(self, addr: str): + self._addr: str = addr + self._conn: BlockingConnection + self._management: Management + + def dial(self) -> None: + logger.debug("Establishing a connection to the amqp server") + self._conn = BlockingConnection(self._addr) + self._open() + logger.debug("Connection to the server established") + + def _open(self) -> None: + self._management = Management(self._conn) + self._management.open() + + def management(self) -> Management: + return self._management + + # closes the connection to the AMQP 1.0 server. + def close(self) -> None: + logger.debug("Closing connection") + self._conn.close() + + def publisher(self, destination: str) -> Publisher: + publisher = Publisher(self._conn, destination) + return publisher diff --git a/rabbitmq_amqp_python_client/entities.py b/rabbitmq_amqp_python_client/entities.py new file mode 100644 index 0000000..bed44b1 --- /dev/null +++ b/rabbitmq_amqp_python_client/entities.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass +from typing import Any, Optional + +from .common import ExchangeType, QueueType + + +@dataclass +class ExchangeSpecification: + name: str + arguments: dict[str, str] + exchange_type: ExchangeType = ExchangeType.direct + is_auto_delete: bool = False + is_internal: bool = False + is_durable: bool = True + + +@dataclass +class QueueInfo: + name: str + arguments: dict[str, Any] + queue_type: QueueType = QueueType.quorum + is_exclusive: Optional[bool] = None + is_auto_delete: bool = False + is_durable: bool = True + leader: str = "" + members: str = "" + message_count: int = 0 + consumer_count: int = 0 + + +@dataclass +class BindingSpecification: + source_exchange: str + destination_queue: str + binding_key: str diff --git a/rabbitmq_amqp_python_client/exceptions.py b/rabbitmq_amqp_python_client/exceptions.py new file mode 100644 index 0000000..141e73f --- /dev/null +++ b/rabbitmq_amqp_python_client/exceptions.py @@ -0,0 +1,7 @@ +class ValidationCodeException(BaseException): + # Constructor or Initializer + def __init__(self, msg: str): + self.msg = msg + + def __str__(self) -> str: + return repr(self.msg) diff --git a/rabbitmq_amqp_python_client/management.py b/rabbitmq_amqp_python_client/management.py new file mode 100644 index 0000000..23d3067 --- /dev/null +++ b/rabbitmq_amqp_python_client/management.py @@ -0,0 +1,376 @@ +import logging +import uuid +from typing import Any, Optional, Union + +from .address_helper import ( + binding_path_with_exchange_queue, + exchange_address, + path_address, + purge_queue_address, + queue_address, +) +from .common import CommonValues, QueueType +from .entities import ( + BindingSpecification, + ExchangeSpecification, + QueueInfo, +) +from .exceptions import ValidationCodeException +from .options import ReceiverOption, SenderOption +from .qpid.proton._message import Message +from .qpid.proton.utils import ( + BlockingConnection, + BlockingReceiver, + BlockingSender, +) +from .queues import ( + ClassicQueueSpecification, + QuorumQueueSpecification, + StreamSpecification, +) + +logger = logging.getLogger(__name__) + + +class Management: + def __init__(self, conn: BlockingConnection): + self._sender: Optional[BlockingSender] = None + self._receiver: Optional[BlockingReceiver] = None + self._conn = conn + + def open(self) -> None: + if self._sender is None: + logger.debug("Creating Sender") + self._sender = self._create_sender( + CommonValues.management_node_address.value + ) + if self._receiver is None: + logger.debug("Creating Receiver") + self._receiver = self._create_receiver( + CommonValues.management_node_address.value, + ) + + def _create_sender(self, addr: str) -> BlockingSender: + return self._conn.create_sender(addr, options=SenderOption(addr)) + + def _create_receiver(self, addr: str) -> BlockingReceiver: + return self._conn.create_receiver(addr, options=ReceiverOption(addr)) + + # closes the connection to the AMQP 1.0 server. + def close(self) -> None: + logger.debug("Closing Sender and Receiver") + if self._sender is not None: + self._sender.close() + if self._receiver is not None: + self._receiver.close() + + def request( + self, + body: Any, + path: str, + method: str, + expected_response_codes: list[int], + ) -> Message: + return self._request( + str(uuid.uuid4()), body, path, method, expected_response_codes + ) + + def _request( + self, + id: str, + body: Any, + path: str, + method: str, + expected_response_codes: list[int], + ) -> Message: + amq_message = Message( + id=id, + body=body, + reply_to="$me", + address=path, + subject=method, + ) + + if self._sender is not None: + logger.debug("Sending message: " + str(amq_message)) + self._sender.send(amq_message) + + if self._receiver is not None: + msg = self._receiver.receive() + logger.debug("Received message: " + str(msg)) + + self._validate_reponse_code(int(msg.subject), expected_response_codes) + return msg + + def declare_exchange( + self, exchange_specification: ExchangeSpecification + ) -> ExchangeSpecification: + logger.debug("declare_exchange operation called") + body = {} + body["auto_delete"] = exchange_specification.is_auto_delete + body["durable"] = exchange_specification.is_durable + body["type"] = exchange_specification.exchange_type.value # type: ignore + body["internal"] = exchange_specification.is_internal + body["arguments"] = exchange_specification.arguments # type: ignore + + path = exchange_address(exchange_specification.name) + + self.request( + body, + path, + CommonValues.command_put.value, + [ + CommonValues.response_code_201.value, + CommonValues.response_code_204.value, + CommonValues.response_code_409.value, + ], + ) + + return exchange_specification + + def declare_queue( + self, + queue_specification: Union[ + ClassicQueueSpecification, QuorumQueueSpecification, StreamSpecification + ], + ) -> Union[ + ClassicQueueSpecification, QuorumQueueSpecification, StreamSpecification + ]: + logger.debug("declare_queue operation called") + + if isinstance(queue_specification, ClassicQueueSpecification) or isinstance( + queue_specification, QuorumQueueSpecification + ): + body = self._declare_queue(queue_specification) + + elif isinstance(queue_specification, StreamSpecification): + body = self._declare_stream(queue_specification) + + path = queue_address(queue_specification.name) + + self.request( + body, + path, + CommonValues.command_put.value, + [ + CommonValues.response_code_200.value, + CommonValues.response_code_201.value, + CommonValues.response_code_409.value, + ], + ) + + return queue_specification + + def _declare_queue( + self, + queue_specification: Union[ClassicQueueSpecification, QuorumQueueSpecification], + ) -> dict[str, Any]: + + body = {} + args: dict[str, Any] = {} + + body["auto_delete"] = queue_specification.is_auto_delete + body["durable"] = queue_specification.is_durable + + if queue_specification.dead_letter_exchange is not None: + args["x-dead-letter-exchange"] = queue_specification.dead_letter_exchange + if queue_specification.dead_letter_routing_key is not None: + args["x-dead-letter-routing-key"] = ( + queue_specification.dead_letter_routing_key + ) + if queue_specification.overflow is not None: + args["x-overflow"] = queue_specification.overflow + if queue_specification.max_len is not None: + args["x-max-length"] = queue_specification.max_len + if queue_specification.max_len_bytes is not None: + args["x-max-length-bytes"] = queue_specification.max_len_bytes + if queue_specification.message_ttl is not None: + args["x-message-ttl"] = queue_specification.message_ttl + if queue_specification.expires is not None: + args["x-expires"] = queue_specification.expires + if queue_specification.single_active_consumer is not None: + args["x-single-active-consumer"] = ( + queue_specification.single_active_consumer + ) + + if isinstance(queue_specification, ClassicQueueSpecification): + args["x-queue-type"] = QueueType.classic.value + if queue_specification.maximum_priority is not None: + args["x-maximum-priority"] = queue_specification.maximum_priority + + if isinstance(queue_specification, QuorumQueueSpecification): + args["x-queue-type"] = QueueType.quorum.value + if queue_specification.deliver_limit is not None: + args["x-deliver-limit"] = queue_specification.deliver_limit + + if queue_specification.dead_letter_strategy is not None: + args["x-dead-letter-strategy"] = ( + queue_specification.dead_letter_strategy + ) + + if queue_specification.quorum_initial_group_size is not None: + args["x-initial-quorum-group-size"] = ( + queue_specification.quorum_initial_group_size + ) + + if queue_specification.cluster_target_size is not None: + args["cluster_target_size"] = queue_specification.cluster_target_size + + body["arguments"] = args # type: ignore + + return body + + def _declare_stream( + self, stream_specification: StreamSpecification + ) -> dict[str, Any]: + + body = {} + args: dict[str, Any] = {} + + args["x-queue-type"] = QueueType.stream.value + + if stream_specification.max_len_bytes is not None: + args["x-max-length-bytes"] = stream_specification.max_len_bytes + + if stream_specification.max_time_retention is not None: + args["x-max-time-retention"] = stream_specification.max_time_retention + + if stream_specification.max_segment_size_in_bytes is not None: + args["x-max-segment-size-in-bytes"] = ( + stream_specification.max_segment_size_in_bytes + ) + + if stream_specification.filter_size is not None: + args["x-filter-size"] = stream_specification.filter_size + + if stream_specification.initial_group_size is not None: + args["x-initial-group-size"] = stream_specification.initial_group_size + + if stream_specification.leader_locator is not None: + args["x-leader-locator"] = stream_specification.leader_locator + + body["arguments"] = args + + return body + + def delete_exchange(self, exchange_name: str) -> None: + logger.debug("delete_exchange operation called") + path = exchange_address(exchange_name) + + self.request( + None, + path, + CommonValues.command_delete.value, + [ + CommonValues.response_code_204.value, + ], + ) + + def delete_queue(self, queue_name: str) -> None: + logger.debug("delete_queue operation called") + path = queue_address(queue_name) + + self.request( + None, + path, + CommonValues.command_delete.value, + [ + CommonValues.response_code_200.value, + ], + ) + + def _validate_reponse_code( + self, response_code: int, expected_response_codes: list[int] + ) -> None: + logger.debug("response_code received: " + str(response_code)) + if response_code == CommonValues.response_code_409.value: + raise ValidationCodeException("ErrPreconditionFailed") + + for code in expected_response_codes: + if code == response_code: + return None + + raise ValidationCodeException( + "wrong response code received: " + str(response_code) + ) + + def bind(self, bind_specification: BindingSpecification) -> str: + logger.debug("Bind Operation called") + body = {} + body["binding_key"] = bind_specification.binding_key + body["source"] = bind_specification.source_exchange + body["destination_queue"] = bind_specification.destination_queue + body["arguments"] = {} # type: ignore + + path = path_address() + + self.request( + body, + path, + CommonValues.command_post.value, + [ + CommonValues.response_code_204.value, + ], + ) + + binding_path_with_queue = binding_path_with_exchange_queue(bind_specification) + return binding_path_with_queue + + def unbind(self, binding_exchange_queue_path: str) -> None: + logger.debug("UnBind Operation called") + self.request( + None, + binding_exchange_queue_path, + CommonValues.command_delete.value, + [ + CommonValues.response_code_204.value, + ], + ) + + def purge_queue(self, queue_name: str) -> int: + logger.debug("purge_queue operation called") + path = purge_queue_address(queue_name) + + response = self.request( + None, + path, + CommonValues.command_delete.value, + [ + CommonValues.response_code_200.value, + ], + ) + + return int(response.body["message_count"]) + + def queue_info(self, queue_name: str) -> QueueInfo: + logger.debug("queue_info operation called") + path = queue_address(queue_name) + + message = self.request( + None, + path, + CommonValues.command_get.value, + [ + CommonValues.response_code_200.value, + ], + ) + + queue_info: dict[str, Any] = message.body + + if queue_info["type"] == "quorum": + queue_type = QueueType.quorum + elif queue_info["type"] == "stream": + queue_type = QueueType.stream + else: + queue_type = QueueType.classic + + return QueueInfo( + name=queue_info["name"], + is_durable=queue_info["durable"], + is_auto_delete=queue_info["auto_delete"], + is_exclusive=queue_info["exclusive"], + queue_type=queue_type, + leader=queue_info["leader"], + members=queue_info["replicas"], + arguments=queue_info["arguments"], + ) diff --git a/rabbitmq_amqp_python_client/options.py b/rabbitmq_amqp_python_client/options.py new file mode 100644 index 0000000..17398c8 --- /dev/null +++ b/rabbitmq_amqp_python_client/options.py @@ -0,0 +1,36 @@ +from .qpid.proton._data import ( # noqa: E402 + PropertyDict, + symbol, +) +from .qpid.proton._endpoints import Link # noqa: E402 +from .qpid.proton.reactor import LinkOption # noqa: E402 + + +class SenderOption(LinkOption): # type: ignore + def __init__(self, addr: str): + self._addr = addr + + def apply(self, link: Link) -> None: + link.source.address = self._addr + link.snd_settle_mode = Link.SND_SETTLED + link.rcv_settle_mode = Link.RCV_FIRST + link.properties = PropertyDict({symbol("paired"): True}) + link.source.dynamic = False + + def test(self, link: Link) -> bool: + return bool(link.is_sender) + + +class ReceiverOption(LinkOption): # type: ignore + def __init__(self, addr: str): + self._addr = addr + + def apply(self, link: Link) -> None: + link.target.address = self._addr + link.snd_settle_mode = Link.SND_SETTLED + link.rcv_settle_mode = Link.RCV_FIRST + link.properties = PropertyDict({symbol("paired"): True}) + link.source.dynamic = False + + def test(self, link: Link) -> bool: + return bool(link.is_receiver) diff --git a/rabbitmq_amqp_python_client/publisher.py b/rabbitmq_amqp_python_client/publisher.py new file mode 100644 index 0000000..25b6a69 --- /dev/null +++ b/rabbitmq_amqp_python_client/publisher.py @@ -0,0 +1,36 @@ +import logging +from typing import Optional + +from .options import SenderOption +from .qpid.proton._message import Message +from .qpid.proton.utils import ( + BlockingConnection, + BlockingSender, +) + +logger = logging.getLogger(__name__) + + +class Publisher: + def __init__(self, conn: BlockingConnection, addr: str): + self._sender: Optional[BlockingSender] = None + self._conn = conn + self._addr = addr + self._open() + + def _open(self) -> None: + if self._sender is None: + logger.debug("Creating Sender") + self._sender = self._create_sender(self._addr) + + def publish(self, message: Message) -> None: + if self._sender is not None: + self._sender.send(message) + + def close(self) -> None: + logger.debug("Closing Sender") + if self._sender is not None: + self._sender.close() + + def _create_sender(self, addr: str) -> BlockingSender: + return self._conn.create_sender(addr, options=SenderOption(addr)) diff --git a/rabbitmq_amqp_python_client/qpid/proton/__init__.py b/rabbitmq_amqp_python_client/qpid/proton/__init__.py new file mode 100644 index 0000000..c731f41 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/__init__.py @@ -0,0 +1,174 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# + +""" +The proton module defines a suite of APIs that implement the AMQP 1.0 +protocol. + +The proton APIs consist of the following classes: + + - :class:`Message` -- A class for creating and/or accessing AMQP message content. + - :class:`Data` -- A class for creating and/or accessing arbitrary AMQP encoded data. +""" + +import logging +import logging.config +import os + +from cproton import ( + PN_VERSION_MAJOR, + PN_VERSION_MINOR, + PN_VERSION_POINT, +) + +from ._condition import Condition +from ._data import ( + UNDESCRIBED, + AnnotationDict, + Array, + Data, + Described, + PropertyDict, + SymbolList, + byte, + char, + decimal32, + decimal64, + decimal128, + float32, + int32, + short, + symbol, + timestamp, + ubyte, + uint, + ulong, + ushort, +) +from ._delivery import Delivery, Disposition +from ._endpoints import ( + Connection, + Endpoint, + Link, + Receiver, + Sender, + Session, + Terminus, +) +from ._events import Collector, Event, EventType +from ._exceptions import ( + ConnectionException, + DataException, + Interrupt, + LinkException, + MessageException, + ProtonException, + SessionException, + SSLException, + SSLUnavailable, + Timeout, + TransportException, +) +from ._handler import Handler +from ._message import Message +from ._transport import ( + SASL, + SSL, + SSLDomain, + SSLSessionDetails, + Transport, +) +from ._url import Url + +__all__ = [ + "API_LANGUAGE", + "IMPLEMENTATION_LANGUAGE", + "UNDESCRIBED", + "AnnotationDict", + "Array", + "Collector", + "Condition", + "Connection", + "ConnectionException", + "Data", + "DataException", + "Delivery", + "Disposition", + "Described", + "Endpoint", + "Event", + "EventType", + "Handler", + "Link", + "LinkException", + "Message", + "MessageException", + "PropertyDict", + "ProtonException", + "VERSION_MAJOR", + "VERSION_MINOR", + "Receiver", + "SASL", + "Sender", + "Session", + "SessionException", + "SSL", + "SSLDomain", + "SSLSessionDetails", + "SSLUnavailable", + "SSLException", + "SymbolList", + "Terminus", + "Timeout", + "Interrupt", + "Transport", + "TransportException", + "Url", + "char", + "symbol", + "timestamp", + "ulong", + "byte", + "short", + "int32", + "ubyte", + "ushort", + "uint", + "float32", + "decimal32", + "decimal64", + "decimal128", +] + +VERSION_MAJOR = PN_VERSION_MAJOR +VERSION_MINOR = PN_VERSION_MINOR +VERSION_POINT = PN_VERSION_POINT +VERSION = (VERSION_MAJOR, VERSION_MINOR, VERSION_POINT) +API_LANGUAGE = "C" +IMPLEMENTATION_LANGUAGE = "C" + + +handler = logging.NullHandler() + +logconfigfile = os.getenv("PNPY_LOGGER_CONFIG", None) +if logconfigfile: + logging.config.fileConfig(logconfigfile, None, False) +else: + log = logging.getLogger("proton") + log.addHandler(handler) diff --git a/rabbitmq_amqp_python_client/qpid/proton/_common.py b/rabbitmq_amqp_python_client/qpid/proton/_common.py new file mode 100644 index 0000000..dde07a2 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_common.py @@ -0,0 +1,36 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 Union + + +class Constant(object): + def __init__(self, name: str) -> None: + self.name = name + + def __repr__(self) -> str: + return self.name + + +def secs2millis(secs: Union[float, int]) -> int: + return int(secs * 1000) + + +def millis2secs(millis: int) -> float: + return float(millis) / 1000.0 diff --git a/rabbitmq_amqp_python_client/qpid/proton/_condition.py b/rabbitmq_amqp_python_client/qpid/proton/_condition.py new file mode 100644 index 0000000..ac9504e --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_condition.py @@ -0,0 +1,110 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 TYPE_CHECKING, Optional + +from cproton import ( + pn_condition_clear, + pn_condition_get_description, + pn_condition_get_name, + pn_condition_info, + pn_condition_is_set, + pn_condition_set_description, + pn_condition_set_name, +) + +from ._data import Data, dat2obj + +if TYPE_CHECKING: + from ._data import PythonAMQPData + + +class Condition: + """ + An AMQP Condition object. Conditions hold exception information + pertaining to the closing of an AMQP endpoint such as a :class:`Connection`, + :class:`Session`, or :class:`Link`. Conditions also hold similar information + pertaining to deliveries that have reached terminal states. + Connections, Sessions, Links, and Deliveries may all have local and + remote conditions associated with them. + + The local condition may be modified by the local endpoint to signal + a particular condition to the remote peer. The remote condition may + be examined by the local endpoint to detect whatever condition the + remote peer may be signaling. Although often conditions are used to + indicate errors, not all conditions are errors *per/se*, e.g. + conditions may be used to redirect a connection from one host to + another. + + Every condition has a short symbolic name, a longer description, + and an additional info map associated with it. The name identifies + the formally defined condition, and the map contains additional + information relevant to the identified condition. + + :ivar ~.name: The name of the condition. + :ivar ~.description: A description of the condition. + :ivar ~.info: A data object that holds the additional information associated + with the condition. The data object may be used both to access and to + modify the additional information associated with the condition. + """ + + def __init__( + self, + name: str, + description: Optional[str] = None, + info: Optional["PythonAMQPData"] = None, + ) -> None: + self.name = name + self.description = description + self.info = info + + def __repr__(self) -> str: + return "Condition(%s)" % ", ".join( + [repr(x) for x in (self.name, self.description, self.info) if x] + ) + + def __eq__(self, o: "Condition") -> bool: + if not isinstance(o, Condition): + return False + return ( + self.name == o.name + and self.description == o.description + and self.info == o.info + ) + + +def obj2cond(obj, cond: Condition) -> None: + pn_condition_clear(cond) + if obj: + pn_condition_set_name(cond, obj.name) + pn_condition_set_description(cond, obj.description) + info = Data(pn_condition_info(cond)) + if obj.info: + info.put_object(obj.info) + + +def cond2obj(cond) -> Optional[Condition]: + if pn_condition_is_set(cond): + return Condition( + pn_condition_get_name(cond), + pn_condition_get_description(cond), + dat2obj(pn_condition_info(cond)), + ) + else: + return None diff --git a/rabbitmq_amqp_python_client/qpid/proton/_data.py b/rabbitmq_amqp_python_client/qpid/proton/_data.py new file mode 100644 index 0000000..dbfa377 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_data.py @@ -0,0 +1,1771 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 uuid +from typing import ( + Any, + Callable, + Dict, + Iterable, + List, + Optional, + Tuple, + TypeVar, + Union, +) + +try: + from typing import Literal +except ImportError: + # https://www.python.org/dev/peps/pep-0560/#class-getitem + class GenericMeta(type): + def __getitem__(self, item): + pass + + class Literal(metaclass=GenericMeta): # type: ignore[no-redef] + pass + + +from cproton import ( + PN_ARRAY, + PN_BINARY, + PN_BOOL, + PN_BYTE, + PN_CHAR, + PN_DECIMAL32, + PN_DECIMAL64, + PN_DECIMAL128, + PN_DESCRIBED, + PN_DOUBLE, + PN_FLOAT, + PN_INT, + PN_LIST, + PN_LONG, + PN_MAP, + PN_NULL, + PN_OVERFLOW, + PN_SHORT, + PN_STRING, + PN_SYMBOL, + PN_TIMESTAMP, + PN_UBYTE, + PN_UINT, + PN_ULONG, + PN_USHORT, + PN_UUID, + pn_data, + pn_data_clear, + pn_data_copy, + pn_data_decode, + pn_data_dump, + pn_data_encode, + pn_data_encoded_size, + pn_data_enter, + pn_data_error, + pn_data_exit, + pn_data_format, + pn_data_free, + pn_data_get_array, + pn_data_get_array_type, + pn_data_get_binary, + pn_data_get_bool, + pn_data_get_byte, + pn_data_get_char, + pn_data_get_decimal32, + pn_data_get_decimal64, + pn_data_get_decimal128, + pn_data_get_double, + pn_data_get_float, + pn_data_get_int, + pn_data_get_list, + pn_data_get_long, + pn_data_get_map, + pn_data_get_short, + pn_data_get_string, + pn_data_get_symbol, + pn_data_get_timestamp, + pn_data_get_ubyte, + pn_data_get_uint, + pn_data_get_ulong, + pn_data_get_ushort, + pn_data_get_uuid, + pn_data_is_array_described, + pn_data_is_described, + pn_data_is_null, + pn_data_lookup, + pn_data_narrow, + pn_data_next, + pn_data_prev, + pn_data_put_array, + pn_data_put_binary, + pn_data_put_bool, + pn_data_put_byte, + pn_data_put_char, + pn_data_put_decimal32, + pn_data_put_decimal64, + pn_data_put_decimal128, + pn_data_put_described, + pn_data_put_double, + pn_data_put_float, + pn_data_put_int, + pn_data_put_list, + pn_data_put_long, + pn_data_put_map, + pn_data_put_null, + pn_data_put_short, + pn_data_put_string, + pn_data_put_symbol, + pn_data_put_timestamp, + pn_data_put_ubyte, + pn_data_put_uint, + pn_data_put_ulong, + pn_data_put_ushort, + pn_data_put_uuid, + pn_data_rewind, + pn_data_type, + pn_data_widen, + pn_error_text, +) + +from ._common import Constant +from ._exceptions import EXCEPTIONS, DataException + +long = int +unicode = str + +_T = TypeVar("_T") + +PythonAMQPData = Union[ + Dict["PythonAMQPData", "PythonAMQPData"], + List["PythonAMQPData"], + "Described", + "Array", + int, + str, + "symbol", + bytes, + float, + None, +] +"""This type annotation represents Python data structures that can be encoded as AMQP Data""" + + +class UnmappedType: + def __init__(self, msg: str) -> None: + self.msg = msg + + def __repr__(self) -> str: + return "UnmappedType(%s)" % self.msg + + +class ulong(long): + """ + The ulong AMQP type. + + An unsigned 64 bit integer in the range :math:`0` to :math:`2^{64} - 1` inclusive. + """ + + def __init__(self, u64: int) -> None: + if u64 < 0: + raise AssertionError("initializing ulong with negative value") + super(ulong, self).__new__(ulong, u64) + + def __repr__(self) -> str: + return "ulong(%s)" % long.__repr__(self) + + +class timestamp(long): + """ + The timestamp AMQP type. + + An absolute point in time, represented by a signed 64 bit value measuring + milliseconds since the epoch. This value is encoded using the Unix ``time_t`` + [IEEE1003] encoding of UTC, but with a precision of milliseconds. For + example, ``1311704463521`` represents the moment ``2011-07-26T18:21:03.521Z``. + """ + + def __repr__(self) -> str: + return "timestamp(%s)" % long.__repr__(self) + + +class symbol(unicode): + """ + The symbol AMQP type. + + Symbolic values from a constrained domain, represented by a sequence of ASCII characters. + """ + + def __repr__(self) -> str: + return "symbol(%s)" % unicode.__repr__(self) + + +class char(unicode): + """ + The char AMQP type. + + A 32 bit UTF-32BE encoded Unicode character. + """ + + def __repr__(self) -> str: + return "char(%s)" % unicode.__repr__(self) + + +class byte(int): + """ + The byte AMQP type. + + An 8 bit signed integer in the range :math:`-(2^7)` to :math:`2^7 - 1` inclusive. + """ + + def __repr__(self) -> str: + return "byte(%s)" % int.__repr__(self) + + +class short(int): + """ + The short AMQP type. + + A 16 bit signed integer in the range :math:`-(2^{15})` to :math:`2^{15} - 1` inclusive. + """ + + def __repr__(self) -> str: + return "short(%s)" % int.__repr__(self) + + +class int32(int): + """ + The signed int AMQP type. + + A 32 bit signed integer in the range :math:`-(2^{31})` to :math:`2^{31} - 1` inclusive. + """ + + def __repr__(self) -> str: + return "int32(%s)" % int.__repr__(self) + + +class ubyte(int): + """ + The unsigned byte AMQP type. + + An 8 bit unsigned integer in the range :math:`0` to :math:`2^8 - 1` inclusive. + """ + + def __init__(self, i: int) -> None: + if i < 0: + raise AssertionError("initializing ubyte with negative value") + super(ubyte, self).__new__(ubyte, i) + + def __repr__(self) -> str: + return "ubyte(%s)" % int.__repr__(self) + + +class ushort(int): + """ + The unsigned short AMQP type. + + A 16 bit unsigned integer in the range :math:`0` to :math:`2^{16} - 1` inclusive. + """ + + def __init__(self, i: int) -> None: + if i < 0: + raise AssertionError("initializing ushort with negative value") + super(ushort, self).__new__(ushort, i) + + def __repr__(self) -> str: + return "ushort(%s)" % int.__repr__(self) + + +class uint(long): + """ + The unsigned int AMQP type. + + A 32 bit unsigned integer in the range :math:`0` to :math:`2^{32} - 1` inclusive. + """ + + def __init__(self, u32: int) -> None: + if u32 < 0: + raise AssertionError("initializing uint with negative value") + super(uint, self).__new__(uint, u32) + + def __repr__(self) -> str: + return "uint(%s)" % long.__repr__(self) + + +class float32(float): + """ + The float AMQP type. + + A 32 bit floating point number (IEEE 754-2008 binary32). + """ + + def __repr__(self) -> str: + return "float32(%s)" % float.__repr__(self) + + +class decimal32(int): + """ + The decimal32 AMQP type. + + A 32 bit decimal floating point number (IEEE 754-2008 decimal32). + """ + + def __repr__(self) -> str: + return "decimal32(%s)" % int.__repr__(self) + + +class decimal64(long): + """ + The decimal64 AMQP type. + + A 64 bit decimal floating point number (IEEE 754-2008 decimal64). + """ + + def __repr__(self) -> str: + return "decimal64(%s)" % long.__repr__(self) + + +class decimal128(bytes): + """ + The decimal128 AMQP type. + + A 128-bit decimal floating-point number (IEEE 754-2008 decimal128). + """ + + def __repr__(self) -> str: + return "decimal128(%s)" % bytes.__repr__(self) + + +class Described(object): + """ + A described AMQP type. + + :ivar descriptor: Any AMQP value can be a descriptor + :ivar value: The described value + """ + + def __init__( + self, + descriptor: PythonAMQPData, + value: PythonAMQPData, + ) -> None: + self.descriptor = descriptor + self.value = value + + def __repr__(self) -> str: + return "Described(%r, %r)" % (self.descriptor, self.value) + + def __eq__(self, o: Any) -> bool: + if isinstance(o, Described): + return self.descriptor == o.descriptor and self.value == o.value + else: + return False + + +UNDESCRIBED = Constant("UNDESCRIBED") + + +class Array(object): + """ + An AMQP array, a sequence of AMQP values of a single type. + + This class provides a convenient way to handle AMQP arrays when used with + convenience methods :func:`Data.get_py_array` and :func:`Data.put_py_array`. + + :ivar descriptor: Optional descriptor if the array is to be described, otherwise ``None`` + :ivar type: Array element type, as an integer. The :class:`Data` class has constants defined + for all the valid AMQP types. For example, for an array of double values, use + :const:`Data.DOUBLE`, which has integer value 14. + :ivar elements: A Python list of elements of the appropriate type. + """ + + def __init__(self, descriptor: PythonAMQPData, type: int, *elements) -> None: + self.descriptor = descriptor + self.type = type + self.elements = elements + + def __iter__(self): + return iter(self.elements) + + def __repr__(self) -> str: + if self.elements: + els = ", %s" % (", ".join(map(repr, self.elements))) + else: + els = "" + return "Array(%r, %r%s)" % (self.descriptor, self.type, els) + + def __eq__(self, o: Any) -> bool: + if isinstance(o, Array): + return ( + self.descriptor == o.descriptor + and self.type == o.type + and self.elements == o.elements + ) + else: + return False + + +def _check_type( + s: _T, allow_ulong: bool = False, raise_on_error: bool = True +) -> Union[symbol, ulong, _T]: + if isinstance(s, symbol): + return s + if allow_ulong and isinstance(s, ulong): + return s + if isinstance(s, str): + return symbol(s) + if raise_on_error: + raise TypeError("Non-symbol type %s: %s" % (type(s), s)) + return s + + +def _check_is_symbol(s: _T, raise_on_error: bool = True) -> Union[symbol, ulong, _T]: + return _check_type(s, allow_ulong=False, raise_on_error=raise_on_error) + + +def _check_is_symbol_or_ulong( + s: _T, raise_on_error: bool = True +) -> Union[symbol, ulong, _T]: + return _check_type(s, allow_ulong=True, raise_on_error=raise_on_error) + + +class RestrictedKeyDict(dict): + """Parent class for :class:`PropertyDict` and :class:`AnnotationDict`""" + + def __init__( + self, + validation_fn: Callable[[_T, bool], _T], + e: Optional[Any] = None, + raise_on_error: bool = True, + **kwargs + ) -> None: + super(RestrictedKeyDict, self).__init__() + self.validation_fn = validation_fn + self.raise_on_error = raise_on_error + self.update(e, **kwargs) + + def __setitem__(self, key: Union[symbol, str], value: Any) -> None: + """Checks if the key is a :class:`symbol` type before setting the value""" + try: + return super(RestrictedKeyDict, self).__setitem__( + self.validation_fn(key, self.raise_on_error), value + ) + except TypeError: + pass + # __setitem__() must raise a KeyError, not TypeError + raise KeyError("invalid non-symbol key: %s: %s" % (type(key), key)) + + def update(self, e: Optional[Any] = None, **kwargs) -> None: + """ + Equivalent to dict.update(), but it was needed to call :meth:`__setitem__()` + instead of ``dict.__setitem__()``. + """ + if e: + try: + for k in e: + self.__setitem__(k, e[k]) + except TypeError: + self.__setitem__(k[0], k[1]) # use tuple consumed from from zip + for k, v in e: + self.__setitem__(k, v) + for k in kwargs: + self.__setitem__(k, kwargs[k]) + + +class PropertyDict(RestrictedKeyDict): + """ + A dictionary that only takes :class:`symbol` types as a key. + However, if a string key is provided, it will be silently converted + into a symbol key. + + >>> from proton import symbol, ulong, PropertyDict + >>> a = PropertyDict(one=1, two=2) + >>> b = PropertyDict({'one':1, symbol('two'):2}) + >>> c = PropertyDict(zip(['one', symbol('two')], [1, 2])) + >>> d = PropertyDict([(symbol('one'), 1), ('two', 2)]) + >>> e = PropertyDict(a) + >>> a == b == c == d == e + True + + By default, non-string and non-symbol keys cause a ``KeyError`` to be raised: + + >>> PropertyDict({'one':1, 2:'two'}) + ... + KeyError: "invalid non-symbol key: : 2" + + but by setting ``raise_on_error=False``, non-string and non-symbol keys will be ignored: + + >>> PropertyDict({'one':1, 2:'two'}, raise_on_error=False) + PropertyDict({2: 'two', symbol(u'one'): 1}) + + :param e: Initialization for ``dict`` + :type e: ``dict`` or ``list`` of ``tuple`` or ``zip`` object + :param raise_on_error: If ``True``, will raise an ``KeyError`` if a non-string or non-symbol + is encountered as a key in the initialization, or in a subsequent operation which + adds such an key. If ``False``, non-strings and non-symbols will be added as keys + to the dictionary without an error. + :param kwargs: Keyword args for initializing a ``dict`` of the form key1=val1, key2=val2, ... + """ + + def __init__( + self, e: Optional[Any] = None, raise_on_error: bool = True, **kwargs + ) -> None: + super(PropertyDict, self).__init__( + _check_is_symbol, e, raise_on_error, **kwargs + ) + + def __repr__(self): + """Representation of PropertyDict""" + return "PropertyDict(%s)" % super(PropertyDict, self).__repr__() + + +class AnnotationDict(RestrictedKeyDict): + """ + A dictionary that only takes :class:`symbol` or :class:`ulong` types + as a key. However, if a string key is provided, it will be silently + converted into a symbol key. + + >>> from proton import symbol, ulong, AnnotationDict + >>> a = AnnotationDict(one=1, two=2) + >>> a[ulong(3)] = 'three' + >>> b = AnnotationDict({'one':1, symbol('two'):2, ulong(3):'three'}) + >>> c = AnnotationDict(zip([symbol('one'), 'two', ulong(3)], [1, 2, 'three'])) + >>> d = AnnotationDict([('one', 1), (symbol('two'), 2), (ulong(3), 'three')]) + >>> e = AnnotationDict(a) + >>> a == b == c == d == e + True + + By default, non-string, non-symbol and non-ulong keys cause a ``KeyError`` to be raised: + + >>> AnnotationDict({'one': 1, 2: 'two'}) + ... + KeyError: "invalid non-symbol key: : 2" + + but by setting ``raise_on_error=False``, non-string, non-symbol and non-ulong keys will be ignored: + + >>> AnnotationDict({'one': 1, 2: 'two'}, raise_on_error=False) + AnnotationDict({2: 'two', symbol(u'one'): 1}) + + :param e: Initializer for ``dict``: a ``dict`` or ``list`` of ``tuple`` or ``zip`` object + :param raise_on_error: If ``True``, will raise an ``KeyError`` if a non-string, non-symbol or + non-ulong is encountered as a key in the initialization, or in a subsequent + operation which adds such an key. If ``False``, non-strings, non-ulongs and non-symbols + will be added as keys to the dictionary without an error. + :param kwargs: Keyword args for initializing a ``dict`` of the form key1=val1, key2=val2, ... + """ + + def __init__( + self, + e: Optional[Union[Dict, List, Tuple, Iterable]] = None, + raise_on_error: bool = True, + **kwargs + ) -> None: + super(AnnotationDict, self).__init__( + _check_is_symbol_or_ulong, e, raise_on_error, **kwargs + ) + + def __repr__(self): + """Representation of AnnotationDict""" + return "AnnotationDict(%s)" % super(AnnotationDict, self).__repr__() + + +class SymbolList(list): + """ + A list that can only hold :class:`symbol` elements. However, if any string elements + are present, they will be converted to symbols. + + >>> a = SymbolList(['one', symbol('two'), 'three']) + >>> b = SymbolList([symbol('one'), 'two', symbol('three')]) + >>> c = SymbolList(a) + >>> a == b == c + True + + By default, using any key other than a symbol or string will result in a ``TypeError``: + + >>> SymbolList(['one', symbol('two'), 3]) + ... + TypeError: Non-symbol type : 3 + + but by setting ``raise_on_error=False``, non-symbol and non-string keys will be ignored: + + >>> SymbolList(['one', symbol('two'), 3], raise_on_error=False) + SymbolList([symbol(u'one'), symbol(u'two'), 3]) + + :param t: Initializer for list + :param raise_on_error: If ``True``, will raise an ``TypeError`` if a non-string or non-symbol is + encountered in the initialization list, or in a subsequent operation which adds such + an element. If ``False``, non-strings and non-symbols will be added to the list without + an error. + """ + + def __init__( + self, t: Optional[List[Any]] = None, raise_on_error: bool = True + ) -> None: + super(SymbolList, self).__init__() + self.raise_on_error = raise_on_error + if isinstance(t, (str, symbol)): + self.append(t) + else: + self.extend(t) + + def _check_list(self, t: Iterable[Any]) -> List[Any]: + """Check all items in list are :class:`symbol`s (or are converted to symbols).""" + item = [] + if t: + for v in t: + item.append(_check_is_symbol(v, self.raise_on_error)) + return item + + def to_array(self): + return Array(UNDESCRIBED, PN_SYMBOL, *self) + + def append(self, v: str) -> None: + """Add a single value v to the end of the list""" + return super(SymbolList, self).append(_check_is_symbol(v, self.raise_on_error)) + + def extend(self, t: Iterable[str]) -> None: + """Add all elements of an iterable t to the end of the list""" + return super(SymbolList, self).extend(self._check_list(t)) + + def insert(self, i: int, v: str) -> None: + """Insert a value v at index i""" + return super(SymbolList, self).insert( + i, _check_is_symbol(v, self.raise_on_error) + ) + + def __add__(self, t: Iterable[Any]) -> "SymbolList": + """Handles list1 + list2""" + return SymbolList( + super(SymbolList, self).__add__(self._check_list(t)), + raise_on_error=self.raise_on_error, + ) + + def __iadd__(self, t): + """Handles list1 += list2""" + return super(SymbolList, self).__iadd__(self._check_list(t)) + + def __eq__(self, other): + """Handles list1 == list2""" + return super().__eq__(SymbolList(other, raise_on_error=False)) + + def __setitem__(self, i: int, t: Any) -> None: + """Handles list[i] = v""" + return super(SymbolList, self).__setitem__( + i, _check_is_symbol(t, self.raise_on_error) + ) + + def __repr__(self) -> str: + """Representation of SymbolList""" + return "SymbolList(%s)" % super(SymbolList, self).__repr__() + + +class Data: + """ + The :class:`Data` class provides an interface for decoding, extracting, + creating, and encoding arbitrary AMQP data. A :class:`Data` object + contains a tree of AMQP values. Leaf nodes in this tree correspond + to scalars in the AMQP type system such as :const:`ints ` or + :const:`strings `. Non-leaf nodes in this tree correspond to + compound values in the AMQP type system such as :const:`lists `, + :const:`maps `, :const:`arrays `, or :const:`described values `. + The root node of the tree is the :class:`Data` object itself and can have + an arbitrary number of children. + + A :class:`Data` object maintains the notion of the current sibling node + and a current parent node. Siblings are ordered within their parent. + Values are accessed and/or added by using the :meth:`next`, :meth:`prev`, + :meth:`enter`, and :meth:`exit` methods to navigate to the desired location in + the tree and using the supplied variety of ``put_*`` / ``get_*`` methods to + access or add a value of the desired type. + + The ``put_*`` methods will always add a value *after* the current node + in the tree. If the current node has a next sibling the ``put_*`` method + will overwrite the value on this node. If there is no current node + or the current node has no next sibling then one will be added. The + ``put_*`` methods always set the added/modified node to the current + node. The ``get_*`` methods read the value of the current node and do + not change which node is current. + + The following types of scalar values are supported: + + * :const:`NULL` + * :const:`BOOL` + * :const:`UBYTE` + * :const:`USHORT` + * :const:`SHORT` + * :const:`UINT` + * :const:`INT` + * :const:`ULONG` + * :const:`LONG` + * :const:`FLOAT` + * :const:`DOUBLE` + * :const:`BINARY` + * :const:`STRING` + * :const:`SYMBOL` + + The following types of compound values are supported: + + * :const:`DESCRIBED` + * :const:`ARRAY` + * :const:`LIST` + * :const:`MAP` + """ + + NULL = PN_NULL #: A null value. + BOOL = PN_BOOL #: A boolean value. + UBYTE = PN_UBYTE #: An unsigned byte value. + BYTE = PN_BYTE #: A signed byte value. + USHORT = PN_USHORT #: An unsigned short value. + SHORT = PN_SHORT #: A short value. + UINT = PN_UINT #: An unsigned int value. + INT = PN_INT #: A signed int value. + CHAR = PN_CHAR #: A character value. + ULONG = PN_ULONG #: An unsigned long value. + LONG = PN_LONG #: A signed long value. + TIMESTAMP = PN_TIMESTAMP #: A timestamp value. + FLOAT = PN_FLOAT #: A float value. + DOUBLE = PN_DOUBLE #: A double value. + DECIMAL32 = PN_DECIMAL32 #: A DECIMAL32 value. + DECIMAL64 = PN_DECIMAL64 #: A DECIMAL64 value. + DECIMAL128 = PN_DECIMAL128 #: A DECIMAL128 value. + UUID = PN_UUID #: A UUID value. + BINARY = PN_BINARY #: A binary string. + STRING = PN_STRING #: A unicode string. + SYMBOL = PN_SYMBOL #: A symbolic string. + DESCRIBED = PN_DESCRIBED #: A described value. + ARRAY = PN_ARRAY #: An array value. + LIST = PN_LIST #: A list value. + MAP = PN_MAP #: A map value. + + type_names = { + NULL: "null", + BOOL: "bool", + BYTE: "byte", + UBYTE: "ubyte", + SHORT: "short", + USHORT: "ushort", + INT: "int", + UINT: "uint", + CHAR: "char", + LONG: "long", + ULONG: "ulong", + TIMESTAMP: "timestamp", + FLOAT: "float", + DOUBLE: "double", + DECIMAL32: "decimal32", + DECIMAL64: "decimal64", + DECIMAL128: "decimal128", + UUID: "uuid", + BINARY: "binary", + STRING: "string", + SYMBOL: "symbol", + DESCRIBED: "described", + ARRAY: "array", + LIST: "list", + MAP: "map", + } + """ + A map which uses the enumerated type as a key to get a text name for the type. + """ + + @classmethod + def type_name(cls, amqptype: int) -> str: + """ + Return a string name for an AMQP type. + + :param amqptype: Numeric Proton AMQP type (`enum pn_type_t`) + :returns: String describing the AMQP type with numeric value `amqptype` + """ + return Data.type_names[amqptype] + + def __init__(self, capacity: int = 16) -> None: + if isinstance(capacity, (int, long)): + self._data = pn_data(capacity) + self._free = True + else: + self._data = capacity + self._free = False + + def __del__(self) -> None: + if self._free and hasattr(self, "_data"): + pn_data_free(self._data) + del self._data + + def _check(self, err: int) -> int: + if err < 0: + exc = EXCEPTIONS.get(err, DataException) + raise exc("[%s]: %s" % (err, pn_error_text(pn_data_error(self._data)))) + else: + return err + + def clear(self) -> None: + """ + Clears the data object. + """ + pn_data_clear(self._data) + + def rewind(self) -> None: + """ + Clears current node and sets the parent to the root node. Clearing the + current node sets it _before_ the first node, calling next() will advance to + the first node. + """ + assert self._data is not None + pn_data_rewind(self._data) + + def next(self) -> Optional[int]: + """ + Advances the current node to its next sibling and returns its + type. If there is no next sibling the current node remains + unchanged and ``None`` is returned. + + :return: Node type or ``None`` + """ + found = pn_data_next(self._data) + if found: + return self.type() + else: + return None + + def prev(self) -> Optional[int]: + """ + Advances the current node to its previous sibling and returns its + type. If there is no previous sibling the current node remains + unchanged and ``None`` is returned. + + :return: Node type or ``None`` + """ + found = pn_data_prev(self._data) + if found: + return self.type() + else: + return None + + def enter(self) -> bool: + """ + Sets the parent node to the current node and clears the current node. + Clearing the current node sets it *before* the first child, + call :meth:`next` to advance to the first child. + + :return: ``True`` iff the pointers to the current/parent nodes are changed, + ``False`` otherwise. + """ + return pn_data_enter(self._data) + + def exit(self) -> bool: + """ + Sets the current node to the parent node and the parent node to + its own parent. + + :return: ``True`` iff the pointers to the current/parent nodes are changed, + ``False`` otherwise. + """ + return pn_data_exit(self._data) + + def lookup(self, name: str) -> bool: + return pn_data_lookup(self._data, name) + + def narrow(self) -> None: + """ + Modify this :class:`Data` object to behave as if the current node is the + root node of the tree. This impacts the behavior of :meth:`rewind`, + :meth:`next`, :meth:`prev`, and anything else that depends on the + navigational state of the :class:`Data` object. Use :meth:`widen` + to reverse the effect of this operation. + """ + pn_data_narrow(self._data) + + def widen(self) -> None: + """Reverse the effect of :meth:`narrow`.""" + pn_data_widen(self._data) + + def type(self) -> Optional[int]: + """ + Returns the type of the current node. Returns `None` if there is no + current node. + + :return: The current node type enumeration. + """ + dtype = pn_data_type(self._data) + if dtype == -1: + return None + else: + return dtype + + def encoded_size(self) -> int: + """ + Returns the size in bytes needed to encode the data in AMQP format. + + :return: The size of the encoded data or an error code if data is invalid. + """ + return pn_data_encoded_size(self._data) + + def encode(self) -> bytes: + """ + Returns a binary representation of the data encoded in AMQP format. + + :return: The encoded data + :raise: :exc:`DataException` if there is a Proton error. + """ + size = 1024 + while True: + cd, enc = pn_data_encode(self._data, size) + if cd == PN_OVERFLOW: + size *= 2 + elif cd >= 0: + return enc + else: + self._check(cd) + + def decode(self, encoded: bytes) -> int: + """ + Decodes the first value from supplied AMQP data and returns the + number of bytes consumed. + + :param encoded: AMQP encoded binary data + :raise: :exc:`DataException` if there is a Proton error. + """ + return self._check(pn_data_decode(self._data, encoded)) + + def put_list(self) -> None: + """ + Puts a list value. Elements may be filled by entering the list + node and putting element values. + + >>> data = Data() + >>> data.put_list() + >>> data.enter() + >>> data.put_int(1) + >>> data.put_int(2) + >>> data.put_int(3) + >>> data.exit() + + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_list(self._data)) + + def put_map(self) -> None: + """ + Puts a map value. Elements may be filled by entering the map node + and putting alternating key value pairs. + + >>> data = Data() + >>> data.put_map() + >>> data.enter() + >>> data.put_string("key") + >>> data.put_string("value") + >>> data.exit() + + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_map(self._data)) + + def put_array(self, described: bool, element_type: int) -> None: + """ + Puts an array value. Elements may be filled by entering the array + node and putting the element values. The values must all be of the + specified array element type. If an array is described then the + first child value of the array is the descriptor and may be of any + type. + + >>> data = Data() + >>> + >>> data.put_array(False, Data.INT) + >>> data.enter() + >>> data.put_int(1) + >>> data.put_int(2) + >>> data.put_int(3) + >>> data.exit() + >>> + >>> data.put_array(True, Data.DOUBLE) + >>> data.enter() + >>> data.put_symbol("array-descriptor") + >>> data.put_double(1.1) + >>> data.put_double(1.2) + >>> data.put_double(1.3) + >>> data.exit() + + :param described: specifies whether the array is described + :param element_type: the type of the array elements + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_array(self._data, described, element_type)) + + def put_described(self) -> None: + """ + Puts a described value. A described node has two children, the + descriptor and the value. These are specified by entering the node + and putting the desired values. + + >>> data = Data() + >>> data.put_described() + >>> data.enter() + >>> data.put_symbol("value-descriptor") + >>> data.put_string("the value") + >>> data.exit() + + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_described(self._data)) + + def put_null(self) -> None: + """ + Puts a null value. + + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_null(self._data)) + + def put_bool(self, b: Union[bool, int]) -> None: + """ + Puts a boolean value. + + :param b: a boolean value + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_bool(self._data, b)) + + def put_ubyte(self, ub: Union[ubyte, int]) -> None: + """ + Puts an unsigned byte value. + + :param ub: an integral value in the range :math:`0` to :math:`2^8 - 1` inclusive + :raise: * ``AssertionError`` if parameter is out of the range :math:`0` to :math:`2^8 - 1` inclusive. + * :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_ubyte(self._data, ub)) + + def put_byte(self, b: Union[byte, int]) -> None: + """ + Puts a signed byte value. + + :param b: an integral value in the range :math:`-(2^7)` to :math:`2^7 - 1` inclusive. + :raise: * ``AssertionError`` if parameter is out of the range :math:`-(2^7)` to :math:`2^7 - 1` inclusive. + * :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_byte(self._data, b)) + + def put_ushort(self, us: Union[ushort, int]) -> None: + """ + Puts an unsigned short value. + + :param us: an integral value in the range :math:`0` to :math:`2^{16} - 1` inclusive. + :raise: * ``AssertionError`` if parameter is out of the range :math:`0` to :math:`2^{16} - 1` inclusive. + * :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_ushort(self._data, us)) + + def put_short(self, s: Union[short, int]) -> None: + """ + Puts a signed short value. + + :param s: an integral value in the range :math:`-(2^{15})` to :math:`2^{15} - 1` inclusive. + :raise: * ``AssertionError`` if parameter is out of the range :math:`-(2^{15})` to :math:`2^{15} - 1` inclusive. + * :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_short(self._data, s)) + + def put_uint(self, ui: Union[uint, int]) -> None: + """ + Puts an unsigned int value. + + :param ui: an integral value in the range :math:`0` to :math:`2^{32} - 1` inclusive. + :raise: * ``AssertionError`` if parameter is out of the range :math:`0` to :math:`2^{32} - 1` inclusive. + * :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_uint(self._data, ui)) + + def put_int(self, i: Union[int32, int]) -> None: + """ + Puts a signed int value. + + :param i: an integral value in the range :math:`-(2^{31})` to :math:`2^{31} - 1` inclusive. + :raise: * ``AssertionError`` if parameter is out of the range :math:`-(2^{31})` to :math:`2^{31} - 1` inclusive. + * :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_int(self._data, i)) + + def put_char(self, c: Union[char, str]) -> None: + """ + Puts a char value. + + :param c: a single character + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_char(self._data, ord(c))) + + def put_ulong(self, ul: Union[ulong, int]) -> None: + """ + Puts an unsigned long value. + + :param ul: an integral value in the range :math:`0` to :math:`2^{64} - 1` inclusive. + :raise: * ``AssertionError`` if parameter is out of the range :math:`0` to :math:`2^{64} - 1` inclusive. + * :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_ulong(self._data, ul)) + + def put_long(self, i64: Union[long, int]) -> None: + """ + Puts a signed long value. + + :param i64: an integral value in the range :math:`-(2^{63})` to :math:`2^{63} - 1` inclusive. + :raise: * ``AssertionError`` if parameter is out of the range :math:`-(2^{63})` to :math:`2^{63} - 1` inclusive. + * :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_long(self._data, i64)) + + def put_timestamp(self, t: Union[timestamp, int]) -> None: + """ + Puts a timestamp value. + + :param t: a positive integral value + :raise: * ``AssertionError`` if parameter is negative. + * :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_timestamp(self._data, t)) + + def put_float(self, f: Union[float32, float, int]) -> None: + """ + Puts a float value. + + :param f: a floating point value + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_float(self._data, f)) + + def put_double(self, d: Union[float, int]) -> None: + """ + Puts a double value. + + :param d: a floating point value. + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_double(self._data, d)) + + def put_decimal32(self, d: Union[decimal32, int]) -> None: + """ + Puts a decimal32 value. + + :param d: a decimal32 number encoded in an 32-bit integral value. + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_decimal32(self._data, d)) + + def put_decimal64(self, d: Union[decimal64, int]) -> None: + """ + Puts a decimal64 value. + + :param d: a decimal64 number encoded in an 32-bit integral value. + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_decimal64(self._data, d)) + + def put_decimal128(self, d: Union[decimal128, bytes]) -> None: + """ + Puts a decimal128 value. + + :param d: a decimal128 value encoded in a 16-byte binary value. + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_decimal128(self._data, d)) + + def put_uuid(self, u: uuid.UUID) -> None: + """ + Puts a UUID value. + + :param u: a uuid value. + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_uuid(self._data, u)) + + def put_binary(self, b: bytes) -> None: + """ + Puts a binary value. + + :param b: a binary value + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_binary(self._data, b)) + + def put_memoryview(self, mv: memoryview) -> None: + """ + Put a Python memoryview object as an AMQP binary value. + + :param mv: A Python memoryview object + :raise: :exc:`DataException` if there is a Proton error. + """ + self.put_binary(mv) + + def put_string(self, s: str) -> None: + """ + Puts a unicode value. + + :param s: a unicode string + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_string(self._data, s)) + + def put_symbol(self, s: Union[str, symbol]) -> None: + """ + Puts a symbolic value. + + :param s: the symbol name + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_put_symbol(self._data, s)) + + def get_list(self) -> int: + """ + If the current node is a list, return the number of elements, + otherwise return 0. List elements can be accessed by entering + the list. + + >>> count = data.get_list() + >>> data.enter() + >>> for i in range(count): + ... type = data.next() + ... if type == Data.STRING: + ... print data.get_string() + ... elif type == ...: + ... ... + >>> data.exit() + + :return: the number of child elements of a list node + """ + return pn_data_get_list(self._data) + + def get_map(self) -> int: + """ + If the current node is a map, return the number of child elements, + otherwise return 0. Key value pairs can be accessed by entering + the map. + + >>> count = data.get_map() + >>> data.enter() + >>> for i in range(count/2): + ... type = data.next() + ... if type == Data.STRING: + ... print data.get_string() + ... elif type == ...: + ... ... + >>> data.exit() + + :return: the number of child elements of a map node + """ + return pn_data_get_map(self._data) + + def get_array(self) -> Tuple[int, bool, Optional[int]]: + """ + If the current node is an array, return a tuple of the element + count, a boolean indicating whether the array is described, and + the type of each element, otherwise return ``None``. Array + data can be accessed by entering the array. + + >>> # read an array of strings with a symbolic descriptor + >>> count, described, type = data.get_array() + >>> data.enter() + >>> data.next() + >>> print "Descriptor:", data.get_symbol() + >>> for i in range(count): + ... data.next() + ... print "Element:", data.get_string() + >>> data.exit() + + :return: A tuple containing the number of array elements, a bool indicating + whether the array is described, and the enumerated array element type. + """ + count = pn_data_get_array(self._data) + described = pn_data_is_array_described(self._data) + type = pn_data_get_array_type(self._data) + if type == -1: + type = None + return count, described, type + + def is_described(self) -> bool: + """ + Checks if the current node is a described value. The descriptor + and value may be accessed by entering the described value. + + >>> # read a symbolically described string + >>> assert data.is_described() # will error if the current node is not described + >>> data.enter() + >>> data.next() + >>> print data.get_symbol() + >>> data.next() + >>> print data.get_string() + >>> data.exit() + + :return: ``True`` if the current node is a described type, ``False`` otherwise. + """ + return pn_data_is_described(self._data) + + def is_null(self) -> bool: + """ + Checks if the current node is the AMQP null type. + + :return: ``True`` if the current node is the AMQP null type, ``False`` otherwise. + """ + return pn_data_is_null(self._data) + + def get_bool(self) -> bool: + """ + Get the current node value as a ``bool``. + + :return: If the current node is a boolean type, returns its value, + ``False`` otherwise. + """ + return pn_data_get_bool(self._data) + + def get_ubyte(self) -> ubyte: + """ + Get the current node value as a :class:`ubyte`. + + :return: If the current node is an unsigned byte, its value, 0 otherwise. + """ + return ubyte(pn_data_get_ubyte(self._data)) + + def get_byte(self) -> byte: + """ + Get the current node value as a :class:`byte`. + + :return: If the current node is a signed byte, its value, 0 otherwise. + """ + return byte(pn_data_get_byte(self._data)) + + def get_ushort(self) -> ushort: + """ + Get the current node value as a :class:`ushort`. + + :return: If the current node is an unsigned short, its value, 0 otherwise. + """ + return ushort(pn_data_get_ushort(self._data)) + + def get_short(self) -> short: + """ + Get the current node value as a :class:`short`. + + :return: If the current node is a signed short, its value, 0 otherwise. + """ + return short(pn_data_get_short(self._data)) + + def get_uint(self) -> uint: + """ + Get the current node value as a :class:`uint`. + + :return: If the current node is an unsigned int, its value, 0 otherwise. + """ + return uint(pn_data_get_uint(self._data)) + + def get_int(self) -> int32: + """ + Get the current node value as a :class:`int32`. + + :return: If the current node is a signed int, its value, 0 otherwise. + """ + return int32(pn_data_get_int(self._data)) + + def get_char(self) -> char: + """ + Get the current node value as a :class:`char`. + + :return: If the current node is a char, its value, 0 otherwise. + """ + return char(chr(pn_data_get_char(self._data))) + + def get_ulong(self) -> ulong: + """ + Get the current node value as a :class:`ulong`. + + :return: If the current node is an unsigned long, its value, 0 otherwise. + """ + return ulong(pn_data_get_ulong(self._data)) + + def get_long(self) -> long: + """ + Get the current node value as a :class:`long`. + + :return: If the current node is an signed long, its value, 0 otherwise. + """ + return long(pn_data_get_long(self._data)) + + def get_timestamp(self) -> timestamp: + """ + Get the current node value as a :class:`timestamp`. + + :return: If the current node is a timestamp, its value, 0 otherwise. + """ + return timestamp(pn_data_get_timestamp(self._data)) + + def get_float(self) -> float32: + """ + Get the current node value as a :class:`float32`. + + :return: If the current node is a float, its value, 0 otherwise. + """ + return float32(pn_data_get_float(self._data)) + + def get_double(self) -> float: + """ + Get the current node value as a ``double``. + + :return: If the current node is a double, its value, 0 otherwise. + """ + return pn_data_get_double(self._data) + + # XXX: need to convert + def get_decimal32(self) -> decimal32: + """ + Get the current node value as a :class:`decimal32`. + + :return: If the current node is a decimal32, its value, 0 otherwise. + """ + return decimal32(pn_data_get_decimal32(self._data)) + + # XXX: need to convert + def get_decimal64(self) -> decimal64: + """ + Get the current node value as a :class:`decimal64`. + + :return: If the current node is a decimal64, its value, 0 otherwise. + """ + return decimal64(pn_data_get_decimal64(self._data)) + + # XXX: need to convert + def get_decimal128(self) -> decimal128: + """ + Get the current node value as a :class:`decimal128`. + + :return: If the current node is a decimal128, its value, 0 otherwise. + """ + return decimal128(pn_data_get_decimal128(self._data)) + + def get_uuid(self) -> Optional[uuid.UUID]: + """ + Get the current node value as a ``uuid.UUID``. + + :return: If the current node is a UUID, its value, ``None`` otherwise. + """ + if pn_data_type(self._data) == Data.UUID: + return pn_data_get_uuid(self._data) + else: + return None + + def get_binary(self) -> bytes: + """ + Get the current node value as ``bytes``. + + :return: If the current node is binary, its value, ``b""`` otherwise. + """ + return pn_data_get_binary(self._data) + + def get_string(self) -> str: + """ + Get the current node value as ``str``. + + :return: If the current node is a string, its value, ``""`` otherwise. + """ + return pn_data_get_string(self._data) + + def get_symbol(self) -> symbol: + """ + Get the current node value as :class:`symbol`. + + :return: If the current node is a symbol, its value, ``""`` otherwise. + """ + return symbol(pn_data_get_symbol(self._data)) + + def copy(self, src: "Data") -> None: + """ + Copy the contents of another pn_data_t object. Any values in the + data object will be lost. + + :param src: The source object from which to copy + :raise: :exc:`DataException` if there is a Proton error. + """ + self._check(pn_data_copy(self._data, src._data)) + + def format(self) -> str: + """ + Formats the contents of this :class:`Data` object in a human readable way. + + :return: A Formatted string containing contents of this :class:`Data` object. + :raise: :exc:`DataException` if there is a Proton error. + """ + sz = 16 + while True: + err, result = pn_data_format(self._data, sz) + if err == PN_OVERFLOW: + sz *= 2 + continue + else: + self._check(err) + return result + + def dump(self) -> None: + """ + Dumps a debug representation of the internal state of this :class:`Data` + object that includes its navigational state to ``cout`` (``stdout``) for + debugging purposes. + """ + pn_data_dump(self._data) + + def put_dict(self, d: Dict[Any, Any]) -> None: + """ + A convenience method for encoding the contents of a Python ``dict`` + as an AMQP map. + + :param d: The dictionary to be encoded + :raise: :exc:`DataException` if there is a Proton error. + """ + self.put_map() + self.enter() + try: + for k, v in d.items(): + self.put_object(k) + self.put_object(v) + finally: + self.exit() + + def get_dict(self) -> Dict[Any, Any]: + """ + A convenience method for decoding an AMQP map as a Python ``dict``. + + :returns: The decoded dictionary. + """ + if self.enter(): + try: + result = {} + while self.next(): + k = self.get_object() + if self.next(): + v = self.get_object() + else: + v = None + result[k] = v + finally: + self.exit() + return result + + def put_sequence(self, s: List[Any]) -> None: + """ + A convenience method for encoding a Python ``list`` as an + AMQP list. + + :param s: The sequence to be encoded + :raise: :exc:`DataException` if there is a Proton error. + """ + self.put_list() + self.enter() + try: + for o in s: + self.put_object(o) + finally: + self.exit() + + def get_sequence(self) -> List[Any]: + """ + A convenience method for decoding an AMQP list as a Python ``list``. + + :returns: The decoded list. + """ + if self.enter(): + try: + result = [] + while self.next(): + result.append(self.get_object()) + finally: + self.exit() + return result + + def get_py_described(self) -> Described: + """ + A convenience method for decoding an AMQP described value. + + :returns: The decoded AMQP descriptor. + """ + if self.enter(): + try: + self.next() + descriptor = self.get_object() + self.next() + value = self.get_object() + finally: + self.exit() + return Described(descriptor, value) + + def put_py_described(self, d: Described) -> None: + """ + A convenience method for encoding a :class:`Described` object + as an AMQP described value. This method encapsulates all the steps + described in :func:`put_described` into a single method. + + :param d: The descriptor to be encoded + :type d: :class:`Described` + :raise: :exc:`DataException` if there is a Proton error. + """ + self.put_described() + self.enter() + try: + self.put_object(d.descriptor) + self.put_object(d.value) + finally: + self.exit() + + def get_py_array(self) -> Optional[Array]: + """ + A convenience method for decoding an AMQP array into an + :class:`Array` object. This method encapsulates all the + steps described in :func:`get_array` into a single function. + + If the current node is an array, return an Array object + representing the array and its contents. Otherwise return ``None``. + + :returns: The decoded AMQP array. + """ + + count, described, type = self.get_array() + if type is None: + return None + if self.enter(): + try: + if described: + self.next() + descriptor = self.get_object() + else: + descriptor = UNDESCRIBED + elements = [] + while self.next(): + elements.append(self.get_object()) + finally: + self.exit() + return Array(descriptor, type, *elements) + + def put_py_array(self, a: Array) -> None: + """ + A convenience method for encoding an :class:`Array` object as + an AMQP array. This method encapsulates the steps described in + :func:`put_array` into a single function. + + :param a: The array object to be encoded + :raise: :exc:`DataException` if there is a Proton error. + """ + described = a.descriptor != UNDESCRIBED + self.put_array(described, a.type) + self.enter() + try: + if described: + self.put_object(a.descriptor) + for e in a.elements: + self.put_object(e) + finally: + self.exit() + + put_mappings = { + None.__class__: lambda s, _: s.put_null(), + bool: put_bool, + ubyte: put_ubyte, + ushort: put_ushort, + uint: put_uint, + ulong: put_ulong, + byte: put_byte, + short: put_short, + int32: put_int, + long: put_long, + float32: put_float, + float: put_double, + decimal32: put_decimal32, + decimal64: put_decimal64, + decimal128: put_decimal128, + char: put_char, + timestamp: put_timestamp, + uuid.UUID: put_uuid, + bytes: put_binary, + bytearray: put_binary, + unicode: put_string, + symbol: put_symbol, + list: put_sequence, + tuple: put_sequence, + dict: put_dict, + Described: put_py_described, + Array: put_py_array, + AnnotationDict: put_dict, + PropertyDict: put_dict, + SymbolList: put_sequence, + memoryview: put_binary, + } + get_mappings = { + NULL: lambda s: None, + BOOL: get_bool, + BYTE: get_byte, + UBYTE: get_ubyte, + SHORT: get_short, + USHORT: get_ushort, + INT: get_int, + UINT: get_uint, + CHAR: get_char, + LONG: get_long, + ULONG: get_ulong, + TIMESTAMP: get_timestamp, + FLOAT: get_float, + DOUBLE: get_double, + DECIMAL32: get_decimal32, + DECIMAL64: get_decimal64, + DECIMAL128: get_decimal128, + UUID: get_uuid, + BINARY: get_binary, + STRING: get_string, + SYMBOL: get_symbol, + DESCRIBED: get_py_described, + ARRAY: get_py_array, + LIST: get_sequence, + MAP: get_dict, + } + + def put_object(self, obj: Any) -> None: + putter = self.put_mappings[obj.__class__] + putter(self, obj) + + def get_object(self) -> Optional[Any]: + type = self.type() + if type is None: + return None + getter = self.get_mappings.get(type) + if getter: + return getter(self) + else: + return UnmappedType(str(type)) + + +def dat2obj(dimpl): + if dimpl: + d = Data(dimpl) + d.rewind() + d.next() + obj = d.get_object() + d.rewind() + return obj + + +def obj2dat(obj, dimpl): + if isinstance(obj, SymbolList): + if len(obj) == 0: + return + obj = obj.to_array() + if obj is not None: + d = Data(dimpl) + d.put_object(obj) diff --git a/rabbitmq_amqp_python_client/qpid/proton/_delivery.py b/rabbitmq_amqp_python_client/qpid/proton/_delivery.py new file mode 100644 index 0000000..4a95978 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_delivery.py @@ -0,0 +1,459 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 enum import IntEnum +from typing import ( + TYPE_CHECKING, + Dict, + List, + Optional, + Union, +) + +from cproton import ( + PN_ACCEPTED, + PN_MODIFIED, + PN_RECEIVED, + PN_REJECTED, + PN_RELEASED, + isnull, + pn_delivery_abort, + pn_delivery_aborted, + pn_delivery_attachments, + pn_delivery_link, + pn_delivery_local, + pn_delivery_local_state, + pn_delivery_partial, + pn_delivery_pending, + pn_delivery_readable, + pn_delivery_remote, + pn_delivery_remote_state, + pn_delivery_settle, + pn_delivery_settled, + pn_delivery_tag, + pn_delivery_update, + pn_delivery_updated, + pn_delivery_writable, + pn_disposition_annotations, + pn_disposition_condition, + pn_disposition_data, + pn_disposition_get_section_number, + pn_disposition_get_section_offset, + pn_disposition_is_failed, + pn_disposition_is_undeliverable, + pn_disposition_set_failed, + pn_disposition_set_section_number, + pn_disposition_set_section_offset, + pn_disposition_set_undeliverable, + pn_disposition_type, +) + +from ._condition import cond2obj, obj2cond +from ._data import dat2obj, obj2dat +from ._wrapper import Wrapper + +if TYPE_CHECKING: + from ._condition import Condition + from ._data import PythonAMQPData, symbol + from ._endpoints import ( # circular import + Receiver, + Sender, + ) + from ._reactor import Connection, Session, Transport + + +class DispositionType(IntEnum): + RECEIVED = PN_RECEIVED + """ + A non terminal state indicating how much (if any) message data + has been received for a delivery. + """ + + ACCEPTED = PN_ACCEPTED + """ + A terminal state indicating that the delivery was successfully + processed. Once in this state there will be no further state + changes prior to the delivery being settled. + """ + + REJECTED = PN_REJECTED + """ + A terminal state indicating that the delivery could not be + processed due to some error condition. Once in this state + there will be no further state changes prior to the delivery + being settled. + """ + + RELEASED = PN_RELEASED + """ + A terminal state indicating that the delivery is being + returned to the sender. Once in this state there will be no + further state changes prior to the delivery being settled. + """ + + MODIFIED = PN_MODIFIED + """ + A terminal state indicating that the delivery is being + returned to the sender and should be annotated by the + sender prior to further delivery attempts. Once in this + state there will be no further state changes prior to the + delivery being settled. + """ + + @classmethod + def or_int(cls, i: int) -> Union[int, "DispositionType"]: + return cls(i) if i in cls._value2member_map_ else i + + +class Disposition(object): + """ + A delivery state. + + Dispositions record the current state or final outcome of a + transfer. Every delivery contains both a local and remote + disposition. The local disposition holds the local state of the + delivery, and the remote disposition holds the last known remote + state of the delivery. + """ + + RECEIVED = DispositionType.RECEIVED + ACCEPTED = DispositionType.ACCEPTED + REJECTED = DispositionType.REJECTED + RELEASED = DispositionType.RELEASED + MODIFIED = DispositionType.MODIFIED + + def __init__(self, impl, local): + self._impl = impl + self.local = local + self._data = None + self._condition = None + self._annotations = None + + @property + def type(self) -> Union[int, DispositionType]: + """ + Get the type of this disposition object. + + Defined values are: + + * :const:`RECEIVED` + * :const:`ACCEPTED` + * :const:`REJECTED` + * :const:`RELEASED` + * :const:`MODIFIED` + """ + return DispositionType.or_int(pn_disposition_type(self._impl)) + + @property + def section_number(self) -> int: + """The section number associated with a disposition.""" + return pn_disposition_get_section_number(self._impl) + + @section_number.setter + def section_number(self, n: int) -> None: + pn_disposition_set_section_number(self._impl, n) + + @property + def section_offset(self) -> int: + """The section offset associated with a disposition.""" + return pn_disposition_get_section_offset(self._impl) + + @section_offset.setter + def section_offset(self, n: int) -> None: + pn_disposition_set_section_offset(self._impl, n) + + @property + def failed(self) -> bool: + """The failed flag for this disposition.""" + return pn_disposition_is_failed(self._impl) + + @failed.setter + def failed(self, b: bool) -> None: + pn_disposition_set_failed(self._impl, b) + + @property + def undeliverable(self) -> bool: + """The undeliverable flag for this disposition.""" + return pn_disposition_is_undeliverable(self._impl) + + @undeliverable.setter + def undeliverable(self, b: bool) -> None: + pn_disposition_set_undeliverable(self._impl, b) + + @property + def data(self) -> Optional[List[int]]: + """Access the disposition as a :class:`Data` object. + + Dispositions are an extension point in the AMQP protocol. The + disposition interface provides setters/getters for those + dispositions that are predefined by the specification, however + access to the raw disposition data is provided so that other + dispositions can be used. + + The :class:`Data` object returned by this operation is valid until + the parent delivery is settled. + """ + if self.local: + return self._data + else: + r = dat2obj(pn_disposition_data(self._impl)) + return r if r != [] else None + + @data.setter + def data(self, obj: List[int]) -> None: + if self.local: + self._data = obj + else: + raise AttributeError("data attribute is read-only") + + @property + def annotations(self) -> Optional[Dict["symbol", "PythonAMQPData"]]: + """The annotations associated with a disposition. + + The :class:`Data` object retrieved by this operation may be modified + prior to updating a delivery. When a delivery is updated, the + annotations described by the :class:`Data` are reported to the peer + if applicable to the current delivery state, e.g. states such as + :const:`MODIFIED`. The :class:`Data` must be empty or contain a symbol + keyed map. + + The :class:`Data` object returned by this operation is valid until + the parent delivery is settled. + """ + if self.local: + return self._annotations + else: + return dat2obj(pn_disposition_annotations(self._impl)) + + @annotations.setter + def annotations(self, obj: Dict[str, "PythonAMQPData"]) -> None: + if self.local: + self._annotations = obj + else: + raise AttributeError("annotations attribute is read-only") + + @property + def condition(self) -> Optional["Condition"]: + """The condition object associated with a disposition. + + The :class:`Condition` object retrieved by this operation may be + modified prior to updating a delivery. When a delivery is updated, + the condition described by the disposition is reported to the peer + if applicable to the current delivery state, e.g. states such as + :const:`REJECTED`. + """ + if self.local: + return self._condition + else: + return cond2obj(pn_disposition_condition(self._impl)) + + @condition.setter + def condition(self, obj: "Condition") -> None: + if self.local: + self._condition = obj + else: + raise AttributeError("condition attribute is read-only") + + +class Delivery(Wrapper): + """ + Tracks and/or records the delivery of a message over a link. + """ + + RECEIVED = Disposition.RECEIVED + """ + A non terminal state indicating how much (if any) message data + has been received for a delivery. + """ + + ACCEPTED = Disposition.ACCEPTED + """ + A terminal state indicating that the delivery was successfully + processed. Once in this state there will be no further state + changes prior to the delivery being settled. + """ + + REJECTED = Disposition.REJECTED + """ + A terminal state indicating that the delivery could not be + processed due to some error condition. Once in this state + there will be no further state changes prior to the delivery + being settled. + """ + + RELEASED = Disposition.RELEASED + """ + A terminal state indicating that the delivery is being + returned to the sender. Once in this state there will be no + further state changes prior to the delivery being settled. + """ + + MODIFIED = Disposition.MODIFIED + """ + A terminal state indicating that the delivery is being + returned to the sender and should be annotated by the + sender prior to further delivery attempts. Once in this + state there will be no further state changes prior to the + delivery being settled. + """ + + @staticmethod + def wrap(impl): + if isnull(impl): + return None + else: + return Delivery(impl) + + def __init__(self, impl): + Wrapper.__init__(self, impl, pn_delivery_attachments) + + def _init(self) -> None: + self.local = Disposition(pn_delivery_local(self._impl), True) + self.remote = Disposition(pn_delivery_remote(self._impl), False) + + @property + def tag(self) -> str: + """ + The identifier for the delivery. + """ + return pn_delivery_tag(self._impl) + + @property + def writable(self) -> bool: + """ + ``True`` for an outgoing delivery to which data can now be written, + ``False`` otherwise.. + """ + return pn_delivery_writable(self._impl) + + @property + def readable(self) -> bool: + """ + ``True`` for an incoming delivery that has data to read, + ``False`` otherwise.. + """ + return pn_delivery_readable(self._impl) + + @property + def updated(self) -> bool: + """ + ``True`` if the state of the delivery has been updated + (e.g. it has been settled and/or accepted, rejected etc), + ``False`` otherwise. + """ + return pn_delivery_updated(self._impl) + + def update(self, state: Union[int, DispositionType]) -> None: + """ + Set the local state of the delivery e.g. :const:`ACCEPTED`, + :const:`REJECTED`, :const:`RELEASED`. + + :param state: State of delivery + """ + obj2dat(self.local._data, pn_disposition_data(self.local._impl)) + obj2dat(self.local._annotations, pn_disposition_annotations(self.local._impl)) + obj2cond(self.local._condition, pn_disposition_condition(self.local._impl)) + pn_delivery_update(self._impl, state) + + @property + def pending(self) -> int: + """ + The amount of pending message data for a delivery. + """ + return pn_delivery_pending(self._impl) + + @property + def partial(self) -> bool: + """ + ``True`` for an incoming delivery if not all the data is + yet available, ``False`` otherwise. + """ + return pn_delivery_partial(self._impl) + + @property + def local_state(self) -> Union[int, DispositionType]: + """A local state of the delivery.""" + return DispositionType.or_int(pn_delivery_local_state(self._impl)) + + @property + def remote_state(self) -> Union[int, DispositionType]: + """A remote state of the delivery as indicated by the remote peer.""" + return DispositionType.or_int(pn_delivery_remote_state(self._impl)) + + @property + def settled(self) -> bool: + """ + ``True`` if the delivery has been settled by the remote peer, + ``False`` otherwise. + """ + return pn_delivery_settled(self._impl) + + def settle(self) -> None: + """ + Settles the delivery locally. This indicates the application + considers the delivery complete and does not wish to receive any + further events about it. Every delivery should be settled locally. + """ + pn_delivery_settle(self._impl) + + @property + def aborted(self) -> bool: + """ + ``True`` if the delivery has been aborted, ``False`` otherwise. + """ + return pn_delivery_aborted(self._impl) + + def abort(self) -> None: + """ + Aborts the delivery. This indicates the application wishes to + invalidate any data that may have already been sent on this delivery. + The delivery cannot be aborted after it has been completely delivered. + """ + pn_delivery_abort(self._impl) + + @property + def link(self) -> Union["Receiver", "Sender"]: + """ + The :class:`Link` on which the delivery was sent or received. + """ + from . import _endpoints + + return _endpoints.Link.wrap(pn_delivery_link(self._impl)) + + @property + def session(self) -> "Session": + """ + The :class:`Session` over which the delivery was sent or received. + """ + return self.link.session + + @property + def connection(self) -> "Connection": + """ + The :class:`Connection` over which the delivery was sent or received. + """ + return self.session.connection + + @property + def transport(self) -> "Transport": + """ + The :class:`Transport` bound to the :class:`Connection` over which + the delivery was sent or received. + """ + return self.connection.transport diff --git a/rabbitmq_amqp_python_client/qpid/proton/_endpoints.py b/rabbitmq_amqp_python_client/qpid/proton/_endpoints.py new file mode 100644 index 0000000..1ae4288 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_endpoints.py @@ -0,0 +1,1583 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# + +""" +The proton.endpoints module +""" + +import weakref +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Optional, + Union, +) + +from cproton import ( + PN_CONFIGURATION, + PN_COORDINATOR, + PN_DELIVERIES, + PN_DIST_MODE_COPY, + PN_DIST_MODE_MOVE, + PN_DIST_MODE_UNSPECIFIED, + PN_EOS, + PN_EXPIRE_NEVER, + PN_EXPIRE_WITH_CONNECTION, + PN_EXPIRE_WITH_LINK, + PN_EXPIRE_WITH_SESSION, + PN_LOCAL_ACTIVE, + PN_LOCAL_CLOSED, + PN_LOCAL_UNINIT, + PN_NONDURABLE, + PN_RCV_FIRST, + PN_RCV_SECOND, + PN_REMOTE_ACTIVE, + PN_REMOTE_CLOSED, + PN_REMOTE_UNINIT, + PN_SND_MIXED, + PN_SND_SETTLED, + PN_SND_UNSETTLED, + PN_SOURCE, + PN_TARGET, + PN_UNSPECIFIED, + isnull, + pn_connection, + pn_connection_attachments, + pn_connection_close, + pn_connection_collect, + pn_connection_condition, + pn_connection_desired_capabilities, + pn_connection_error, + pn_connection_get_authorization, + pn_connection_get_container, + pn_connection_get_hostname, + pn_connection_get_user, + pn_connection_offered_capabilities, + pn_connection_open, + pn_connection_properties, + pn_connection_release, + pn_connection_remote_condition, + pn_connection_remote_container, + pn_connection_remote_desired_capabilities, + pn_connection_remote_hostname, + pn_connection_remote_offered_capabilities, + pn_connection_remote_properties, + pn_connection_set_authorization, + pn_connection_set_container, + pn_connection_set_hostname, + pn_connection_set_password, + pn_connection_set_user, + pn_connection_state, + pn_connection_transport, + pn_delivery, + pn_error_code, + pn_error_text, + pn_link_advance, + pn_link_attachments, + pn_link_available, + pn_link_close, + pn_link_condition, + pn_link_credit, + pn_link_current, + pn_link_detach, + pn_link_drain, + pn_link_drained, + pn_link_draining, + pn_link_error, + pn_link_flow, + pn_link_free, + pn_link_get_drain, + pn_link_head, + pn_link_is_receiver, + pn_link_is_sender, + pn_link_max_message_size, + pn_link_name, + pn_link_next, + pn_link_offered, + pn_link_open, + pn_link_properties, + pn_link_queued, + pn_link_rcv_settle_mode, + pn_link_recv, + pn_link_remote_condition, + pn_link_remote_max_message_size, + pn_link_remote_properties, + pn_link_remote_rcv_settle_mode, + pn_link_remote_snd_settle_mode, + pn_link_remote_source, + pn_link_remote_target, + pn_link_send, + pn_link_session, + pn_link_set_drain, + pn_link_set_max_message_size, + pn_link_set_rcv_settle_mode, + pn_link_set_snd_settle_mode, + pn_link_snd_settle_mode, + pn_link_source, + pn_link_state, + pn_link_target, + pn_link_unsettled, + pn_receiver, + pn_sender, + pn_session, + pn_session_attachments, + pn_session_close, + pn_session_condition, + pn_session_connection, + pn_session_free, + pn_session_get_incoming_capacity, + pn_session_get_outgoing_window, + pn_session_head, + pn_session_incoming_bytes, + pn_session_next, + pn_session_open, + pn_session_outgoing_bytes, + pn_session_remote_condition, + pn_session_set_incoming_capacity, + pn_session_set_outgoing_window, + pn_session_state, + pn_terminus_capabilities, + pn_terminus_copy, + pn_terminus_filter, + pn_terminus_get_address, + pn_terminus_get_distribution_mode, + pn_terminus_get_durability, + pn_terminus_get_expiry_policy, + pn_terminus_get_timeout, + pn_terminus_get_type, + pn_terminus_is_dynamic, + pn_terminus_outcomes, + pn_terminus_properties, + pn_terminus_set_address, + pn_terminus_set_distribution_mode, + pn_terminus_set_durability, + pn_terminus_set_dynamic, + pn_terminus_set_expiry_policy, + pn_terminus_set_timeout, + pn_terminus_set_type, +) + +from ._condition import cond2obj, obj2cond +from ._data import ( + Data, + PropertyDict, + SymbolList, + dat2obj, + obj2dat, +) +from ._delivery import Delivery +from ._exceptions import ( + EXCEPTIONS, + ConnectionException, + LinkException, + SessionException, +) +from ._handler import Handler +from ._transport import Transport +from ._wrapper import Wrapper + +if TYPE_CHECKING: + from ._condition import Condition + from ._data import Array, PythonAMQPData, symbol + from ._events import Collector + from ._message import Message + + +class Endpoint(object): + """ + Abstract class from which :class:`Connection`, :class:`Session` + and :class:`Link` are derived, and which defines the state + of these classes. + + The :class:`Endpoint` state is an integral value with flags that + encode both the local and remote state of an AMQP Endpoint + (:class:`Connection`, :class:`Link`, or :class:`Session`). + The individual bits may be accessed using :const:`LOCAL_UNINIT`, + :const:`LOCAL_ACTIVE`, :const:`LOCAL_CLOSED`, and + :const:`REMOTE_UNINIT`, :const:`REMOTE_ACTIVE`, :const:`REMOTE_CLOSED`. + + Every AMQP endpoint (:class:`Connection`, :class:`Link`, or + :class:`Session`) starts out in an uninitialized state and then + proceeds linearly to an active and then closed state. This + lifecycle occurs at both endpoints involved, and so the state + model for an endpoint includes not only the known local state, + but also the last known state of the remote endpoint. + """ + + LOCAL_UNINIT = PN_LOCAL_UNINIT + """ The local endpoint state is uninitialized. """ + + REMOTE_UNINIT = PN_REMOTE_UNINIT + """ The local endpoint state is active. """ + + LOCAL_ACTIVE = PN_LOCAL_ACTIVE + """ The local endpoint state is closed. """ + + REMOTE_ACTIVE = PN_REMOTE_ACTIVE + """ The remote endpoint state is uninitialized. """ + + LOCAL_CLOSED = PN_LOCAL_CLOSED + """ The remote endpoint state is active. """ + + REMOTE_CLOSED = PN_REMOTE_CLOSED + """ The remote endpoint state is closed. """ + + def _init(self) -> None: + self.condition: Optional["Condition"] = None + self._handler: Optional[Handler] = None + + def _update_cond(self) -> None: + obj2cond(self.condition, self._get_cond_impl()) + + @property + def remote_condition(self) -> Optional["Condition"]: + """ + The remote condition associated with the connection endpoint. + See :class:`Condition` for more information. + """ + return cond2obj(self._get_remote_cond_impl()) + + # the following must be provided by subclasses + def _get_cond_impl(self): + assert False, "Subclass must override this!" + + def _get_remote_cond_impl(self): + assert False, "Subclass must override this!" + + @property + def handler(self) -> Optional[Handler]: + """Handler for events. + + :getter: Get the event handler, or return ``None`` if no handler has been set. + :setter: Set the event handler.""" + return self._handler + + @handler.setter + def handler(self, handler: Optional[Handler]) -> None: + # TODO Hack This is here for some very odd (IMO) backwards compat behaviour + if handler is None: + self._handler = None + elif isinstance(handler, Handler): + self._handler = handler + else: + self._handler = Handler() + self._handler.add(handler) + + +class Connection(Wrapper, Endpoint): + """ + A representation of an AMQP connection. + """ + + @staticmethod + def wrap(impl): + if isnull(impl): + return None + else: + return Connection(impl) + + def __init__(self, impl: Any = None) -> None: + if impl is None: + Wrapper.__init__( + self, constructor=pn_connection, get_context=pn_connection_attachments + ) + else: + Wrapper.__init__(self, impl, pn_connection_attachments) + + def _init(self) -> None: + Endpoint._init(self) + self.offered_capabilities_list = None + self.desired_capabilities_list = None + self.properties = None + self.url = None + self._acceptor = None + + def _get_attachments(self): + return pn_connection_attachments(self._impl) + + @property + def connection(self) -> "Connection": + """ + Get this connection. + """ + return self + + @property + def transport(self) -> Optional[Transport]: + """ + The transport bound to this connection. If the connection + is unbound, then this operation will return ``None``. + """ + return Transport.wrap(pn_connection_transport(self._impl)) + + def _check(self, err: int) -> int: + if err < 0: + exc = EXCEPTIONS.get(err, ConnectionException) + raise exc("[%s]: %s" % (err, pn_connection_error(self._impl))) + else: + return err + + def _get_cond_impl(self): + return pn_connection_condition(self._impl) + + def _get_remote_cond_impl(self): + return pn_connection_remote_condition(self._impl) + + # TODO: Blacklisted API call + def collect(self, collector: "Collector") -> None: + if collector is None: + pn_connection_collect(self._impl, None) + else: + pn_connection_collect(self._impl, collector._impl) + self._collector = weakref.ref(collector) + + @property + def container(self) -> str: + """The container name for this connection object.""" + return pn_connection_get_container(self._impl) + + @container.setter + def container(self, name: str) -> None: + pn_connection_set_container(self._impl, name) + + @property + def hostname(self) -> Optional[str]: + """Set the name of the host (either fully qualified or relative) to which this + connection is connecting to. This information may be used by the remote + peer to determine the correct back-end service to connect the client to. + This value will be sent in the Open performative, and will be used by SSL + and SASL layers to identify the peer. + """ + return pn_connection_get_hostname(self._impl) + + @hostname.setter + def hostname(self, name: str) -> None: + pn_connection_set_hostname(self._impl, name) + + @property + def user(self) -> Optional[str]: + """The authentication username for a client connection. + + It is necessary to set the username and password before binding + the connection to a transport and it isn't allowed to change + after the binding. + + If not set then no authentication will be negotiated unless the + client sasl layer is explicitly created (this would be for something + like Kerberos where the credentials are implicit in the environment, + or to explicitly use the ``ANONYMOUS`` SASL mechanism) + """ + return pn_connection_get_user(self._impl) + + @user.setter + def user(self, name: str) -> None: + pn_connection_set_user(self._impl, name) + + @property + def authorization(self) -> str: + """The authorization username for a client connection. + + It is necessary to set the authorization before binding + the connection to a transport and it isn't allowed to change + after the binding. + + If not set then implicitly the requested authorization is the same as the + authentication user. + """ + return pn_connection_get_authorization(self._impl) + + @authorization.setter + def authorization(self, name: str) -> None: + pn_connection_set_authorization(self._impl, name) + + @property + def password(self) -> None: + """Set the authentication password for a client connection. + + It is necessary to set the username and password before binding the connection + to a transport and it isn't allowed to change after the binding. + + .. note:: Getting the password always returns ``None``. + """ + return None + + @password.setter + def password(self, name: str) -> None: + pn_connection_set_password(self._impl, name) + + @property + def remote_container(self) -> Optional[str]: + """ + The container identifier specified by the remote peer for this connection. + + This will return ``None`` until the :const:'REMOTE_ACTIVE` state is + reached. See :class:`Endpoint` for more details on endpoint state. + + Any (non ``None``) name returned by this operation will be valid until + the connection object is unbound from a transport or freed, + whichever happens sooner. + """ + return pn_connection_remote_container(self._impl) + + @property + def remote_hostname(self) -> Optional[str]: + """ + The hostname specified by the remote peer for this connection. + + This will return ``None`` until the :const:`REMOTE_ACTIVE` state is + reached. See :class:`Endpoint` for more details on endpoint state. + + Any (non ``None``) name returned by this operation will be valid until + the connection object is unbound from a transport or freed, + whichever happens sooner. + """ + return pn_connection_remote_hostname(self._impl) + + @property + def remote_offered_capabilities(self): + """ + The capabilities offered by the remote peer for this connection. + + This operation will return a :class:`Data` object that + is valid until the connection object is freed. This :class:`Data` + object will be empty until the remote connection is opened as + indicated by the :const:`REMOTE_ACTIVE` flag. + + :type: :class:`Data` + """ + c = dat2obj(pn_connection_remote_offered_capabilities(self._impl)) + return c and SymbolList(c) + + @property + def remote_desired_capabilities(self): + """ + The capabilities desired by the remote peer for this connection. + + This operation will return a :class:`Data` object that + is valid until the connection object is freed. This :class:`Data` + object will be empty until the remote connection is opened as + indicated by the :const:`REMOTE_ACTIVE` flag. + + :type: :class:`Data` + """ + c = dat2obj(pn_connection_remote_desired_capabilities(self._impl)) + return c and SymbolList(c) + + @property + def remote_properties(self): + """ + The properties specified by the remote peer for this connection. + + This operation will return a :class:`Data` object that + is valid until the connection object is freed. This :class:`Data` + object will be empty until the remote connection is opened as + indicated by the :const:`REMOTE_ACTIVE` flag. + + :type: :class:`Data` + """ + return dat2obj(pn_connection_remote_properties(self._impl)) + + @property + def connected_address(self) -> str: + """The address for this connection.""" + return self.url and str(self.url) + + def open(self) -> None: + """ + Opens the connection. + + In more detail, this moves the local state of the connection to + the ``ACTIVE`` state and triggers an open frame to be sent to the + peer. A connection is fully active once both peers have opened it. + """ + obj2dat( + self.offered_capabilities, pn_connection_offered_capabilities(self._impl) + ) + obj2dat( + self.desired_capabilities, pn_connection_desired_capabilities(self._impl) + ) + obj2dat(self.properties, pn_connection_properties(self._impl)) + pn_connection_open(self._impl) + + def close(self) -> None: + """ + Closes the connection. + + In more detail, this moves the local state of the connection to + the ``CLOSED`` state and triggers a close frame to be sent to the + peer. A connection is fully closed once both peers have closed it. + """ + self._update_cond() + pn_connection_close(self._impl) + if hasattr(self, "_session_policy"): + # break circular ref + del self._session_policy + t = self.transport + if t and t._connect_selectable: + # close() requested before TCP connect handshake completes on socket. + # Dismantle connection setup logic. + s = t._connect_selectable + t._connect_selectable = None + t.close_head() + t.close_tail() + s._transport = None + t._selectable = None + s.terminate() + s.update() + + @property + def state(self) -> int: + """ + The state of the connection as a bit field. The state has a local + and a remote component. Each of these can be in one of three + states: ``UNINIT``, ``ACTIVE`` or ``CLOSED``. These can be tested by masking + against :const:`LOCAL_UNINIT`, :const:`LOCAL_ACTIVE`, :const:`LOCAL_CLOSED`, :const:`REMOTE_UNINIT`, + :const:`REMOTE_ACTIVE` and :const:`REMOTE_CLOSED`. + """ + return pn_connection_state(self._impl) + + def session(self) -> "Session": + """ + Returns a new session on this connection. + + :return: New session + :raises: :class:`SessionException` + """ + ssn = pn_session(self._impl) + if ssn is None: + raise (SessionException("Session allocation failed.")) + else: + return Session(ssn) + + def session_head(self, mask: int) -> Optional["Session"]: + """ + Retrieve the first session from a given connection that matches the + specified state mask. + + Examines the state of each session owned by the connection, and + returns the first session that matches the given state mask. If + state contains both local and remote flags, then an exact match + against those flags is performed. If state contains only local or + only remote flags, then a match occurs if any of the local or + remote flags are set respectively. + + :param mask: State mask to match + :return: The first session owned by the connection that matches the + mask, else ``None`` if no sessions matches. + """ + return Session.wrap(pn_session_head(self._impl, mask)) + + def link_head(self, mask: int) -> Optional[Union["Sender", "Receiver"]]: + """ + Retrieve the first link that matches the given state mask. + + Examines the state of each link owned by the connection and returns + the first link that matches the given state mask. If state contains + both local and remote flags, then an exact match against those + flags is performed. If state contains only local or only remote + flags, then a match occurs if any of the local or remote flags are + set respectively. ``state==0`` matches all links. + + :param mask: State mask to match + :return: The first link owned by the connection that matches the + mask, else ``None`` if no link matches. + """ + return Link.wrap(pn_link_head(self._impl, mask)) + + @property + def error(self): + """ + Additional error information associated with the connection. + + Whenever a connection operation fails (i.e. returns an error code), + additional error details can be obtained using this property. The + returned value is the error code defined by Proton in ``pn_error_t`` + (see ``error.h``). + + :type: ``int`` + """ + return pn_error_code(pn_connection_error(self._impl)) + + def free(self) -> None: + """ + Releases this connection object. + + When a connection object is released, all :class:`Session` and + :class:`Link` objects associated with this connection are also + released and all :class:`Delivery` objects are settled. + """ + pn_connection_release(self._impl) + + @property + def offered_capabilities(self) -> Optional[Union["Array", SymbolList]]: + """Offered capabilities as a list of symbols. The AMQP 1.0 specification + restricts this list to symbol elements only. It is possible to use + the special ``list`` subclass :class:`SymbolList` as it will by + default enforce this restriction on construction. In addition, if a + string type is used, it will be silently converted into the required + symbol. + """ + return self.offered_capabilities_list + + @offered_capabilities.setter + def offered_capabilities( + self, + offered_capability_list: Optional[ + Union["Array", List["symbol"], SymbolList, List[str]] + ], + ) -> None: + self.offered_capabilities_list = SymbolList(offered_capability_list) + + @property + def desired_capabilities(self) -> Optional[Union["Array", SymbolList]]: + """Desired capabilities as a list of symbols. The AMQP 1.0 specification + restricts this list to symbol elements only. It is possible to use + the special ``list`` subclass :class:`SymbolList` which will by + default enforce this restriction on construction. In addition, if string + types are used, this class will be silently convert them into symbols. + """ + return self.desired_capabilities_list + + @desired_capabilities.setter + def desired_capabilities( + self, + desired_capability_list: Optional[ + Union["Array", List["symbol"], SymbolList, List[str]] + ], + ) -> None: + self.desired_capabilities_list = SymbolList(desired_capability_list) + + @property + def properties(self) -> Optional[PropertyDict]: + """Connection properties as a dictionary of key/values. The AMQP 1.0 + specification restricts this dictionary to have keys that are only + :class:`symbol` types. It is possible to use the special ``dict`` + subclass :class:`PropertyDict` which will by default enforce this + restrictions on construction. In addition, if strings type are used, + this will silently convert them into symbols. + """ + return self.properties_dict + + @properties.setter + def properties( + self, + properties_dict: Optional[Union[PropertyDict, Dict[str, "PythonAMQPData"]]], + ) -> None: + if isinstance(properties_dict, dict): + self.properties_dict = PropertyDict(properties_dict, raise_on_error=False) + else: + self.properties_dict = properties_dict + + +class Session(Wrapper, Endpoint): + """A container of links""" + + @staticmethod + def wrap(impl): + if isnull(impl): + return None + else: + return Session(impl) + + def __init__(self, impl): + Wrapper.__init__(self, impl, pn_session_attachments) + + def _get_attachments(self): + return pn_session_attachments(self._impl) + + def _get_cond_impl(self): + return pn_session_condition(self._impl) + + def _get_remote_cond_impl(self): + return pn_session_remote_condition(self._impl) + + @property + def incoming_capacity(self) -> int: + """The incoming capacity of this session in bytes. The incoming capacity + of a session determines how much incoming message data the session + can buffer. + + .. note:: If set, this value must be greater than or equal to the negotiated + frame size of the transport. The window is computed as a whole number of + frames when dividing remaining capacity at a given time by the connection + max frame size. As such, capacity and max frame size should be chosen so + as to ensure the frame window isn't unduly small and limiting performance. + """ + return pn_session_get_incoming_capacity(self._impl) + + @incoming_capacity.setter + def incoming_capacity(self, capacity: int) -> None: + pn_session_set_incoming_capacity(self._impl, capacity) + + @property + def outgoing_window(self) -> int: + """The outgoing window for this session.""" + return pn_session_get_outgoing_window(self._impl) + + @outgoing_window.setter + def outgoing_window(self, window: int) -> None: + pn_session_set_outgoing_window(self._impl, window) + + @property + def outgoing_bytes(self) -> int: + """ + The number of outgoing bytes currently buffered.""" + return pn_session_outgoing_bytes(self._impl) + + @property + def incoming_bytes(self) -> int: + """ + The number of incoming bytes currently buffered. + """ + return pn_session_incoming_bytes(self._impl) + + def open(self) -> None: + """ + Open a session. Once this operation has completed, the + :const:`LOCAL_ACTIVE` state flag will be set. + """ + pn_session_open(self._impl) + + def close(self) -> None: + """ + Close a session. + + Once this operation has completed, the :const:`LOCAL_CLOSED` state flag + will be set. This may be called without calling + :meth:`open`, in this case it is equivalent to calling + :meth:`open` followed by :meth:`close`. + + """ + self._update_cond() + pn_session_close(self._impl) + + def next(self, mask): + """ + Retrieve the next session for this connection that matches the + specified state mask. + + When used with :meth:`Connection.session_head`, application can + access all sessions on the connection that match the given state. + See :meth:`Connection.session_head` for description of match + behavior. + + :param mask: Mask to match. + :return: The next session owned by this connection that matches the + mask, else ``None`` if no sessions match. + :rtype: :class:`Session` or ``None`` + """ + return Session.wrap(pn_session_next(self._impl, mask)) + + @property + def state(self) -> int: + """ + The endpoint state flags for this session. See :class:`Endpoint` for + details of the flags. + """ + return pn_session_state(self._impl) + + @property + def connection(self) -> Connection: + """ + The parent connection for this session. + """ + return Connection.wrap(pn_session_connection(self._impl)) + + @property + def transport(self) -> Transport: + """ + The transport bound to the parent connection for this session. + """ + return self.connection.transport + + def sender(self, name: str) -> "Sender": + """ + Create a new :class:`Sender` on this session. + + :param name: Name of sender + """ + return Sender(pn_sender(self._impl, name)) + + def receiver(self, name: str) -> "Receiver": + """ + Create a new :class:`Receiver` on this session. + + :param name: Name of receiver + """ + return Receiver(pn_receiver(self._impl, name)) + + def free(self) -> None: + """ + Free this session. When a session is freed it will no + longer be retained by the connection once any internal + references to the session are no longer needed. Freeing + a session will free all links on that session and settle + any deliveries on those links. + """ + pn_session_free(self._impl) + + +class Link(Wrapper, Endpoint): + """ + A representation of an AMQP link (a unidirectional channel for + transferring messages), of which there are two concrete + implementations, :class:`Sender` and :class:`Receiver`. + """ + + SND_UNSETTLED = PN_SND_UNSETTLED + """The sender will send all deliveries initially unsettled.""" + SND_SETTLED = PN_SND_SETTLED + """The sender will send all deliveries settled to the receiver.""" + SND_MIXED = PN_SND_MIXED + """The sender may send a mixture of settled and unsettled deliveries.""" + + RCV_FIRST = PN_RCV_FIRST + """The receiver will settle deliveries regardless of what the sender does.""" + RCV_SECOND = PN_RCV_SECOND + """The receiver will only settle deliveries after the sender settles.""" + + @staticmethod + def wrap(impl): + if isnull(impl): + return None + if pn_link_is_sender(impl): + return Sender(impl) + else: + return Receiver(impl) + + def __init__(self, impl): + Wrapper.__init__(self, impl, pn_link_attachments) + + def _init(self) -> None: + Endpoint._init(self) + self.properties = None + + def _get_attachments(self): + return pn_link_attachments(self._impl) + + def _check(self, err: int) -> int: + if err < 0: + exc = EXCEPTIONS.get(err, LinkException) + raise exc("[%s]: %s" % (err, pn_error_text(pn_link_error(self._impl)))) + else: + return err + + def _get_cond_impl(self): + return pn_link_condition(self._impl) + + def _get_remote_cond_impl(self): + return pn_link_remote_condition(self._impl) + + def open(self) -> None: + """ + Opens the link. + + In more detail, this moves the local state of the link to the + :const:`LOCAL_ACTIVE` state and triggers an attach frame to be + sent to the peer. A link is fully active once both peers have + attached it. + """ + obj2dat(self.properties, pn_link_properties(self._impl)) + pn_link_open(self._impl) + + def close(self) -> None: + """ + Closes the link. + + In more detail, this moves the local state of the link to the + :const:`LOCAL_CLOSED` state and triggers an detach frame (with + the closed flag set) to be sent to the peer. A link is fully + closed once both peers have detached it. + + This may be called without calling :meth:`open`, in this case it + is equivalent to calling :meth:`open` followed by :meth:`close`. + """ + self._update_cond() + pn_link_close(self._impl) + + @property + def state(self) -> int: + """ + The state of the link as a bit field. The state has a local + and a remote component. Each of these can be in one of three + states: ``UNINIT``, ``ACTIVE`` or ``CLOSED``. These can be + tested by masking against :const:`LOCAL_UNINIT`, + :const:`LOCAL_ACTIVE`, :const:`LOCAL_CLOSED`, + :const:`REMOTE_UNINIT`, :const:`REMOTE_ACTIVE` and + :const:`REMOTE_CLOSED`. + """ + return pn_link_state(self._impl) + + @property + def source(self) -> "Terminus": + """ + The source of the link as described by the local peer. The + returned object is valid until the link is freed. + """ + return Terminus(pn_link_source(self._impl)) + + @property + def target(self) -> "Terminus": + """ + The target of the link as described by the local peer. The + returned object is valid until the link is freed. + """ + return Terminus(pn_link_target(self._impl)) + + @property + def remote_source(self) -> "Terminus": + """ + The source of the link as described by the remote peer. The + returned object is valid until the link is freed. The remote + :class:`Terminus` object will be empty until the link is + remotely opened as indicated by the :const:`REMOTE_ACTIVE` + flag. + """ + return Terminus(pn_link_remote_source(self._impl)) + + @property + def remote_target(self) -> "Terminus": + """ + The target of the link as described by the remote peer. The + returned object is valid until the link is freed. The remote + :class:`Terminus` object will be empty until the link is + remotely opened as indicated by the :const:`REMOTE_ACTIVE` + flag. + """ + return Terminus(pn_link_remote_target(self._impl)) + + @property + def session(self) -> Session: + """ + The parent session for this link. + """ + return Session.wrap(pn_link_session(self._impl)) + + @property + def connection(self) -> Connection: + """ + The connection on which this link was attached. + """ + return self.session.connection + + @property + def transport(self) -> Transport: + """ + The transport bound to the connection on which this link was attached. + """ + return self.session.transport + + def delivery(self, tag: str) -> Delivery: + """ + Create a delivery. Every delivery object within a + link must be supplied with a unique tag. Links + maintain a sequence of delivery object in the order that + they are created. + + :param tag: Delivery tag unique for this link. + """ + return Delivery(pn_delivery(self._impl, tag)) + + @property + def current(self) -> Optional[Delivery]: + """ + The current delivery for this link. + + Each link maintains a sequence of deliveries in the order + they were created, along with a pointer to the *current* + delivery. All send/recv operations on a link take place + on the *current* delivery. If a link has no current delivery, + the current delivery is automatically initialized to the + next delivery created on the link. Once initialized, the + current delivery remains the same until it is changed through + use of :meth:`advance` or until it is settled via + :meth:`Delivery.settle`. + """ + return Delivery.wrap(pn_link_current(self._impl)) + + def advance(self) -> bool: + """ + Advance the current delivery of this link to the next delivery. + + For sending links this operation is used to finish sending message + data for the current outgoing delivery and move on to the next + outgoing delivery (if any). + + For receiving links, this operation is used to finish accessing + message data from the current incoming delivery and move on to the + next incoming delivery (if any). + + Each link maintains a sequence of deliveries in the order they were + created, along with a pointer to the *current* delivery. The + :meth:`advance` operation will modify the *current* delivery on the + link to point to the next delivery in the sequence. If there is no + next delivery in the sequence, the current delivery will be set to + ``NULL``. + + :return: ``True`` if the value of the current delivery changed (even + if it was set to ``NULL``, ``False`` otherwise. + """ + return pn_link_advance(self._impl) + + @property + def unsettled(self) -> int: + """ + The number of unsettled deliveries for this link. + """ + return pn_link_unsettled(self._impl) + + @property + def credit(self) -> int: + """ + The amount of outstanding credit on this link. + + Links use a credit based flow control scheme. Every receiver + maintains a credit balance that corresponds to the number of + deliveries that the receiver can accept at any given moment. As + more capacity becomes available at the receiver (see + :meth:`Receiver.flow`), it adds credit to this balance and + communicates the new balance to the sender. Whenever a delivery + is sent/received, the credit balance maintained by the link is + decremented by one. Once the credit balance at the sender reaches + zero, the sender must pause sending until more credit is obtained + from the receiver. + + .. note:: A sending link may still be used to send deliveries even + if :attr:`credit` reaches zero, however those deliveries will end + up being buffered by the link until enough credit is obtained from + the receiver to send them over the wire. In this case the balance + reported by :attr:`credit` will go negative. + """ + return pn_link_credit(self._impl) + + @property + def available(self) -> int: + """ + The available deliveries hint for this link. + + The available count for a link provides a hint as to the number of + deliveries that might be able to be sent if sufficient credit were + issued by the receiving link endpoint. See :meth:`Sender.offered` for + more details. + """ + return pn_link_available(self._impl) + + @property + def queued(self) -> int: + """ + The number of queued deliveries for a link. + + Links may queue deliveries for a number of reasons, for example + there may be insufficient credit to send them to the receiver (see + :meth:`credit`), or they simply may not have yet had a chance to + be written to the wire. This operation will return the number of + queued deliveries on a link. + """ + return pn_link_queued(self._impl) + + def next(self, mask: int) -> Optional[Union["Sender", "Receiver"]]: + """ + Retrieve the next link that matches the given state mask. + + When used with :meth:`Connection.link_head`, the application + can access all links on the connection that match the given + state. See :meth:`Connection.link_head` for a description of + match behavior. + + :param mask: State mask to match + :return: The next link that matches the given state mask, or + ``None`` if no link matches. + """ + return Link.wrap(pn_link_next(self._impl, mask)) + + @property + def name(self) -> str: + """ + The name of the link. + """ + return pn_link_name(self._impl) + + @property + def is_sender(self) -> bool: + """ + ``True`` if this link is a sender, ``False`` otherwise. + """ + return pn_link_is_sender(self._impl) + + @property + def is_receiver(self) -> bool: + """ + ``True`` if this link is a receiver, ``False`` otherwise. + """ + return pn_link_is_receiver(self._impl) + + @property + def remote_snd_settle_mode(self) -> int: + """ + The remote sender settle mode for this link. One of + :const:`SND_UNSETTLED`, :const:`SND_SETTLED` or + :const:`SND_MIXED`. + """ + return pn_link_remote_snd_settle_mode(self._impl) + + @property + def remote_rcv_settle_mode(self) -> int: + """ + The remote receiver settle mode for this link. One of + :const:`RCV_FIRST` or :const:`RCV_SECOND`. + """ + return pn_link_remote_rcv_settle_mode(self._impl) + + @property + def snd_settle_mode(self) -> int: + """The local sender settle mode for this link. One of + :const:`SND_UNSETTLED`, :const:`SND_SETTLED` or + :const:`SND_MIXED`. + """ + return pn_link_snd_settle_mode(self._impl) + + @snd_settle_mode.setter + def snd_settle_mode(self, mode: int) -> None: + pn_link_set_snd_settle_mode(self._impl, mode) + + @property + def rcv_settle_mode(self) -> int: + """The local receiver settle mode for this link. One of + :const:`RCV_FIRST` or :const:`RCV_SECOND`.""" + return pn_link_rcv_settle_mode(self._impl) + + @rcv_settle_mode.setter + def rcv_settle_mode(self, mode: int) -> None: + pn_link_set_rcv_settle_mode(self._impl, mode) + + @property + def drain_mode(self) -> bool: + """The drain mode on this link. + + If a link is in drain mode (``True``), then the sending + endpoint of a link must immediately use up all available + credit on the link. If this is not possible, the excess + credit must be returned by invoking :meth:`drained`. Only + the receiving endpoint can set the drain mode. + + When ``False``, this link is not in drain mode. + """ + return pn_link_get_drain(self._impl) + + @drain_mode.setter + def drain_mode(self, b: bool): + pn_link_set_drain(self._impl, bool(b)) + + def drained(self) -> int: + """ + Drain excess credit for this link. + + When a link is in drain mode (see :attr:`drain_mode`), the + sender must use all excess credit immediately, and release + any excess credit back to the receiver if there are no + deliveries available to send. + + When invoked on a sending link that is in drain mode, this + operation will release all excess credit back to the receiver + and return the number of credits released back to the sender. + If the link is not in drain mode, this operation is a noop. + + When invoked on a receiving link, this operation will return + and reset the number of credits the sender has released back + to the receiver. + + :return: The number of credits drained. + """ + return pn_link_drained(self._impl) + + @property + def remote_max_message_size(self) -> int: + """ + Get the remote view of the maximum message size for this link. + + .. warning:: **Unsettled API** + + A zero value means the size is unlimited. + """ + return pn_link_remote_max_message_size(self._impl) + + @property + def max_message_size(self) -> int: + """The maximum message size for this link. A zero value means the + size is unlimited. + + .. warning:: **Unsettled API** + """ + return pn_link_max_message_size(self._impl) + + @max_message_size.setter + def max_message_size(self, mode: int) -> None: + pn_link_set_max_message_size(self._impl, mode) + + def detach(self) -> None: + """ + Detach this link. + """ + return pn_link_detach(self._impl) + + def free(self) -> None: + """ + Free this link object. When a link object is freed, + all :class:`Delivery` objects associated with the session (**<-- CHECK THIS**) + are also freed. Freeing a link will settle any unsettled + deliveries on the link. + """ + pn_link_free(self._impl) + + @property + def remote_properties(self): + """ + The properties specified by the remote peer for this link. + + This operation will return a :class:`Data` object that + is valid until the link object is freed. This :class:`Data` + object will be empty until the remote link is opened as + indicated by the :const:`REMOTE_ACTIVE` flag. + + :type: :class:`Data` + """ + return dat2obj(pn_link_remote_properties(self._impl)) + + @property + def properties(self) -> Optional[PropertyDict]: + """Link properties as a dictionary of key/values. The AMQP 1.0 + specification restricts this dictionary to have keys that are only + :class:`symbol` types. It is possible to use the special ``dict`` + subclass :class:`PropertyDict` which will by default enforce this + restrictions on construction. In addition, if strings type are used, + this will silently convert them into symbols. + """ + return self._properties_dict + + @properties.setter + def properties( + self, properties_dict: Optional[Dict["symbol", "PythonAMQPData"]] + ) -> None: + if isinstance(properties_dict, dict): + self._properties_dict = PropertyDict(properties_dict, raise_on_error=False) + else: + self._properties_dict = properties_dict + + +class Sender(Link): + """ + A link over which messages are sent. + """ + + def offered(self, n: int) -> None: + """ + Signal the availability of deliveries for this Sender. + + :param n: Credit the number of deliveries potentially + available for transfer. + """ + pn_link_offered(self._impl, n) + + def stream(self, data: bytes) -> int: + """ + Send specified data as part of the current delivery. + + :param data: Data to send + """ + return self._check(pn_link_send(self._impl, data)) + + def send( + self, obj: Union[bytes, "Message"], tag: Optional[str] = None + ) -> Union[int, Delivery]: + """ + A convenience method to send objects as message content. + + Send specified object over this sender; the object is expected to + have a ``send()`` method on it that takes the sender and an optional + tag as arguments. + + Where the object is a :class:`Message`, this will send the message over + this link, creating a new delivery for the purpose. + """ + if hasattr(obj, "send"): + return obj.send(self, tag=tag) + else: + # treat object as bytes + return self.stream(obj) + + def send_mngmnt( + self, obj: Union[bytes, "Message"], tag: Optional[str] = None + ) -> Union[int, Delivery]: + """ + A convenience method to send objects as message content. + + Send specified object over this sender; the object is expected to + have a ``send()`` method on it that takes the sender and an optional + tag as arguments. + + Where the object is a :class:`Message`, this will send the message over + this link, creating a new delivery for the purpose. + """ + if hasattr(obj, "send_mngmnt"): + return obj.send_mngmnt(self, tag=tag) + else: + # treat object as bytes + return self.stream(obj) + + def delivery_tag(self) -> str: + """Increments and returns a counter to be used as the next message tag.""" + if not hasattr(self, "tag_generator"): + + def simple_tags(): + count = 1 + while True: + yield str(count) + count += 1 + + self.tag_generator = simple_tags() + return next(self.tag_generator) + + +class Receiver(Link): + """ + A link over which messages are received. + """ + + def flow(self, n: int) -> None: + """ + Increases the credit issued to the remote sender by the specified number of messages. + + :param n: The credit to be issued to the remote sender. + """ + pn_link_flow(self._impl, n) + + def recv(self, limit: int) -> Optional[bytes]: + """ + Receive message data for the current delivery on this receiver. + + .. note:: The link API can be used to stream large messages across + the network, so just because there is no data to read does not + imply the message is complete. To ensure the entirety of the + message data has been read, either invoke :meth:`recv` until + ``None`` is returned. + + :param limit: the max data size to receive of this message + :return: The received message data, or ``None`` if the message + has been completely received. + :raise: * :class:`Timeout` if timed out + * :class:`Interrupt` if interrupted + * :class:`LinkException` for all other exceptions + """ + n, binary = pn_link_recv(self._impl, limit) + if n == PN_EOS: + return None + else: + self._check(n) + return binary + + def drain(self, n: int) -> None: + """ + Grant credit for incoming deliveries on this receiver, and + set drain mode to true. + + Use :attr:`drain_mode` to set the drain mode explicitly. + + :param n: The amount by which to increment the link credit + """ + pn_link_drain(self._impl, n) + + def draining(self) -> bool: + """ + Check if a link is currently draining. A link is defined + to be draining when drain mode is set to ``True``, and the + sender still has excess credit. + + :return: ``True`` if the link is currently draining, ``False`` otherwise. + """ + return pn_link_draining(self._impl) + + +class Terminus(object): + """ + A source or target for messages. + """ + + UNSPECIFIED = PN_UNSPECIFIED + """A nonexistent terminus, may used as a source or target.""" + SOURCE = PN_SOURCE + """A source of messages.""" + TARGET = PN_TARGET + """A target for messages.""" + COORDINATOR = PN_COORDINATOR + """A special target identifying a transaction coordinator.""" + + NONDURABLE = PN_NONDURABLE + """A non durable terminus.""" + CONFIGURATION = PN_CONFIGURATION + """A terminus with durably held configuration, but not delivery state.""" + DELIVERIES = PN_DELIVERIES + """A terminus with both durably held configuration and durably held delivery state.""" + + DIST_MODE_UNSPECIFIED = PN_DIST_MODE_UNSPECIFIED + """The behavior is defined by the node.""" + DIST_MODE_COPY = PN_DIST_MODE_COPY + """The receiver gets all messages.""" + DIST_MODE_MOVE = PN_DIST_MODE_MOVE + """The receiver competes for messages.""" + + EXPIRE_WITH_LINK = PN_EXPIRE_WITH_LINK + """The terminus is orphaned when the parent link is closed.""" + EXPIRE_WITH_SESSION = PN_EXPIRE_WITH_SESSION + """The terminus is orphaned when the parent session is closed""" + EXPIRE_WITH_CONNECTION = PN_EXPIRE_WITH_CONNECTION + """The terminus is orphaned when the parent connection is closed""" + EXPIRE_NEVER = PN_EXPIRE_NEVER + """The terminus is never considered orphaned""" + + def __init__(self, impl): + self._impl = impl + + def _check(self, err: int) -> int: + if err < 0: + exc = EXCEPTIONS.get(err, LinkException) + raise exc("[%s]" % err) + else: + return err + + @property + def type(self) -> int: + """The terminus type, must be one of :const:`UNSPECIFIED`, + :const:`SOURCE`, :const:`TARGET` or :const:`COORDINATOR`. + """ + return pn_terminus_get_type(self._impl) + + @type.setter + def type(self, type: int) -> None: + self._check(pn_terminus_set_type(self._impl, type)) + + @property + def address(self) -> Optional[str]: + """The address that identifies the source or target node""" + return pn_terminus_get_address(self._impl) + + @address.setter + def address(self, address: str) -> None: + self._check(pn_terminus_set_address(self._impl, address)) + + @property + def durability(self) -> int: + """The terminus durability mode, must be one of :const:`NONDURABLE`, + :const:`CONFIGURATION` or :const:`DELIVERIES`. + """ + return pn_terminus_get_durability(self._impl) + + @durability.setter + def durability(self, mode: int): + self._check(pn_terminus_set_durability(self._impl, mode)) + + @property + def expiry_policy(self) -> int: + """The terminus expiry policy, must be one of :const:`EXPIRE_WITH_LINK`, + :const:`EXPIRE_WITH_SESSION`, :const:`EXPIRE_WITH_CONNECTION` or + :const:`EXPIRE_NEVER`. + """ + return pn_terminus_get_expiry_policy(self._impl) + + @expiry_policy.setter + def expiry_policy(self, policy: int): + self._check(pn_terminus_set_expiry_policy(self._impl, policy)) + + @property + def timeout(self) -> int: + """The terminus timeout in seconds.""" + return pn_terminus_get_timeout(self._impl) + + @timeout.setter + def timeout(self, seconds: int) -> None: + self._check(pn_terminus_set_timeout(self._impl, seconds)) + + @property + def dynamic(self) -> bool: + """Indicates whether the source or target node was dynamically + created""" + return pn_terminus_is_dynamic(self._impl) + + @dynamic.setter + def dynamic(self, dynamic: bool) -> None: + self._check(pn_terminus_set_dynamic(self._impl, dynamic)) + + @property + def distribution_mode(self) -> int: + """The terminus distribution mode, must be one of :const:`DIST_MODE_UNSPECIFIED`, + :const:`DIST_MODE_COPY` or :const:`DIST_MODE_MOVE`. + """ + return pn_terminus_get_distribution_mode(self._impl) + + @distribution_mode.setter + def distribution_mode(self, mode: int) -> None: + self._check(pn_terminus_set_distribution_mode(self._impl, mode)) + + @property + def properties(self): + """ + Properties of a dynamic source or target. + + :type: :class:`Data` containing a map with :class:`symbol` keys. + """ + return Data(pn_terminus_properties(self._impl)) + + @property + def capabilities(self): + """ + Capabilities of the source or target. + + :type: :class:`Data` containing an array of :class:`symbol`. + """ + return Data(pn_terminus_capabilities(self._impl)) + + @property + def outcomes(self): + """ + Outcomes of the source or target. + + :type: :class:`Data` containing an array of :class:`symbol`. + """ + return Data(pn_terminus_outcomes(self._impl)) + + @property + def filter(self): + """ + A filter on a source allows the set of messages transferred over + the link to be restricted. The symbol-keyed map represents a' + filter set. + + :type: :class:`Data` containing a map with :class:`symbol` keys. + """ + return Data(pn_terminus_filter(self._impl)) + + def copy(self, src: "Terminus") -> None: + """ + Copy another terminus object. + + :param src: The terminus to be copied from + :raises: :class:`LinkException` if there is an error + """ + self._check(pn_terminus_copy(self._impl, src._impl)) diff --git a/rabbitmq_amqp_python_client/qpid/proton/_events.py b/rabbitmq_amqp_python_client/qpid/proton/_events.py new file mode 100644 index 0000000..d58a542 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_events.py @@ -0,0 +1,659 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 threading +from typing import TYPE_CHECKING, Any, Optional, Union + +from cproton import ( + PN_CONNECTION_BOUND, + PN_CONNECTION_FINAL, + PN_CONNECTION_INIT, + PN_CONNECTION_LOCAL_CLOSE, + PN_CONNECTION_LOCAL_OPEN, + PN_CONNECTION_REMOTE_CLOSE, + PN_CONNECTION_REMOTE_OPEN, + PN_CONNECTION_UNBOUND, + PN_DELIVERY, + PN_LINK_FINAL, + PN_LINK_FLOW, + PN_LINK_INIT, + PN_LINK_LOCAL_CLOSE, + PN_LINK_LOCAL_DETACH, + PN_LINK_LOCAL_OPEN, + PN_LINK_REMOTE_CLOSE, + PN_LINK_REMOTE_DETACH, + PN_LINK_REMOTE_OPEN, + PN_SESSION_FINAL, + PN_SESSION_INIT, + PN_SESSION_LOCAL_CLOSE, + PN_SESSION_LOCAL_OPEN, + PN_SESSION_REMOTE_CLOSE, + PN_SESSION_REMOTE_OPEN, + PN_TIMER_TASK, + PN_TRANSPORT, + PN_TRANSPORT_CLOSED, + PN_TRANSPORT_ERROR, + PN_TRANSPORT_HEAD_CLOSED, + PN_TRANSPORT_TAIL_CLOSED, + isnull, + pn_cast_pn_connection, + pn_cast_pn_delivery, + pn_cast_pn_link, + pn_cast_pn_session, + pn_cast_pn_transport, + pn_collector, + pn_collector_free, + pn_collector_more, + pn_collector_peek, + pn_collector_pop, + pn_collector_put_pyref, + pn_collector_release, + pn_event_class_name, + pn_event_connection, + pn_event_context, + pn_event_delivery, + pn_event_link, + pn_event_session, + pn_event_transport, + pn_event_type, + pn_event_type_name, + void2py, +) + +from ._delivery import Delivery +from ._endpoints import Connection, Link, Session +from ._handler import Handler +from ._transport import Transport + +if TYPE_CHECKING: + from ._endpoints import Receiver, Sender + from ._reactor import Container + + +class Collector: + def __init__(self) -> None: + self._impl = pn_collector() + + def put(self, obj: Any, etype: "EventType") -> None: + pn_collector_put_pyref(self._impl, obj, etype) + + def peek(self) -> Optional["Event"]: + return Event.wrap(pn_collector_peek(self._impl)) + + def more(self) -> bool: + return pn_collector_more(self._impl) + + def pop(self) -> None: + pn_collector_pop(self._impl) + + def release(self) -> None: + pn_collector_release(self._impl) + + def __del__(self) -> None: + pn_collector_free(self._impl) + del self._impl + + +if "TypeExtender" not in globals(): + + class TypeExtender: + def __init__(self, number: int) -> None: + self.number = number + + def next(self) -> int: + try: + return self.number + finally: + self.number += 1 + + +class EventType(object): + """ + Connects an event number to an event name, and is used + internally by :class:`Event` to represent all known + event types. A global list of events is maintained. An + :class:`EventType` created with a name but no number is + treated as an *extended* event, and is assigned an + internal event number starting at 10000. + """ + + _lock = threading.Lock() + _extended = TypeExtender(10000) + TYPES = {} + + def __init__( + self, + name: Optional[str] = None, + number: Optional[int] = None, + method: Optional[str] = None, + ) -> None: + if name is None and number is None: + raise TypeError("extended events require a name") + try: + self._lock.acquire() + if name is None: + name = pn_event_type_name(number) + + if number is None: + number = self._extended.next() + + if method is None: + method = "on_%s" % name + + self.name = name + self.number = number + self.method = method + + self.TYPES[number] = self + finally: + self._lock.release() + + def __repr__(self): + return "EventType(name=%s, number=%d)" % (self.name, self.number) + + def __str__(self): + return self.name + + +def _dispatch(handler: Any, method: str, *args) -> None: + m = getattr(handler, method, None) + if m: + m(*args) + elif hasattr(handler, "on_unhandled"): + handler.on_unhandled(method, *args) + + +class EventBase(object): + def __init__(self, type: EventType) -> None: + self._type = type + + @property + def type(self) -> EventType: + """The type of this event.""" + return self._type + + @property + def handler(self) -> Optional[Handler]: + """The handler for this event type. Not implemented, always returns ``None``.""" + return None + + def dispatch(self, handler: Handler, type: Optional[EventType] = None) -> None: + """ + Process this event by sending it to all known handlers that + are valid for this event type. + + :param handler: Parent handler to process this event + :param type: Event type + """ + type = type or self._type + _dispatch(handler, type.method, self) + if hasattr(handler, "handlers"): + for h in handler.handlers: + self.dispatch(h, type) + + def __repr__(self) -> str: + return "%s(%r)" % (self._type, self.context) + + +def _core(number: int, method: str) -> EventType: + return EventType(number=number, method=method) + + +def _internal(name: str) -> EventType: + return EventType(name=name) + + +wrappers = { + "pn_void": lambda x: void2py(x), + "pn_pyref": lambda x: void2py(x), + "pn_connection": lambda x: Connection.wrap(pn_cast_pn_connection(x)), + "pn_session": lambda x: Session.wrap(pn_cast_pn_session(x)), + "pn_link": lambda x: Link.wrap(pn_cast_pn_link(x)), + "pn_delivery": lambda x: Delivery.wrap(pn_cast_pn_delivery(x)), + "pn_transport": lambda x: Transport.wrap(pn_cast_pn_transport(x)), +} + + +class Event(EventBase): + """ + Notification of a state change in the protocol engine. + """ + + TIMER_TASK = _core(PN_TIMER_TASK, "on_timer_task") + """A timer event has occurred.""" + + CONNECTION_INIT = _core(PN_CONNECTION_INIT, "on_connection_init") + """ + The connection has been created. This is the first event that + will ever be issued for a connection. Events of this type point + to the relevant connection. + """ + + CONNECTION_BOUND = _core(PN_CONNECTION_BOUND, "on_connection_bound") + """ + The connection has been bound to a transport. This event is + issued when the :meth:`Transport.bind` operation is invoked. + """ + + CONNECTION_UNBOUND = _core(PN_CONNECTION_UNBOUND, "on_connection_unbound") + """ + The connection has been unbound from its transport. This event is + issued when the :meth:`Transport.unbind` operation is invoked. + """ + + CONNECTION_LOCAL_OPEN = _core(PN_CONNECTION_LOCAL_OPEN, "on_connection_local_open") + """ + The local connection endpoint has been closed. Events of this + type point to the relevant connection. + """ + + CONNECTION_LOCAL_CLOSE = _core( + PN_CONNECTION_LOCAL_CLOSE, "on_connection_local_close" + ) + """ + The local connection endpoint has been closed. Events of this + type point to the relevant connection. + """ + + CONNECTION_REMOTE_OPEN = _core( + PN_CONNECTION_REMOTE_OPEN, "on_connection_remote_open" + ) + """ + The remote endpoint has opened the connection. Events of this + type point to the relevant connection. + """ + + CONNECTION_REMOTE_CLOSE = _core( + PN_CONNECTION_REMOTE_CLOSE, "on_connection_remote_close" + ) + """ + The remote endpoint has closed the connection. Events of this + type point to the relevant connection. + """ + + CONNECTION_FINAL = _core(PN_CONNECTION_FINAL, "on_connection_final") + """ + The connection has been freed and any outstanding processing has + been completed. This is the final event that will ever be issued + for a connection. + """ + + SESSION_INIT = _core(PN_SESSION_INIT, "on_session_init") + """ + The session has been created. This is the first event that will + ever be issued for a session. + """ + + SESSION_LOCAL_OPEN = _core(PN_SESSION_LOCAL_OPEN, "on_session_local_open") + """ + The local session endpoint has been opened. Events of this type + point to the relevant session. + """ + + SESSION_LOCAL_CLOSE = _core(PN_SESSION_LOCAL_CLOSE, "on_session_local_close") + """ + The local session endpoint has been closed. Events of this type + point ot the relevant session. + """ + + SESSION_REMOTE_OPEN = _core(PN_SESSION_REMOTE_OPEN, "on_session_remote_open") + """ + The remote endpoint has opened the session. Events of this type + point to the relevant session. + """ + + SESSION_REMOTE_CLOSE = _core(PN_SESSION_REMOTE_CLOSE, "on_session_remote_close") + """ + The remote endpoint has closed the session. Events of this type + point to the relevant session. + """ + + SESSION_FINAL = _core(PN_SESSION_FINAL, "on_session_final") + """ + The session has been freed and any outstanding processing has + been completed. This is the final event that will ever be issued + for a session. + """ + + LINK_INIT = _core(PN_LINK_INIT, "on_link_init") + """ + The link has been created. This is the first event that will ever + be issued for a link. + """ + + LINK_LOCAL_OPEN = _core(PN_LINK_LOCAL_OPEN, "on_link_local_open") + """ + The local link endpoint has been opened. Events of this type + point ot the relevant link. + """ + + LINK_LOCAL_CLOSE = _core(PN_LINK_LOCAL_CLOSE, "on_link_local_close") + """ + The local link endpoint has been closed. Events of this type + point to the relevant link. + """ + + LINK_LOCAL_DETACH = _core(PN_LINK_LOCAL_DETACH, "on_link_local_detach") + """ + The local link endpoint has been detached. Events of this type + point to the relevant link. + """ + + LINK_REMOTE_OPEN = _core(PN_LINK_REMOTE_OPEN, "on_link_remote_open") + """ + The remote endpoint has opened the link. Events of this type + point to the relevant link. + """ + + LINK_REMOTE_CLOSE = _core(PN_LINK_REMOTE_CLOSE, "on_link_remote_close") + """ + The remote endpoint has closed the link. Events of this type + point to the relevant link. + """ + + LINK_REMOTE_DETACH = _core(PN_LINK_REMOTE_DETACH, "on_link_remote_detach") + """ + The remote endpoint has detached the link. Events of this type + point to the relevant link. + """ + + LINK_FLOW = _core(PN_LINK_FLOW, "on_link_flow") + """ + The flow control state for a link has changed. Events of this + type point to the relevant link. + """ + + LINK_FINAL = _core(PN_LINK_FINAL, "on_link_final") + """ + The link has been freed and any outstanding processing has been + completed. This is the final event that will ever be issued for a + link. Events of this type point to the relevant link. + """ + + DELIVERY = _core(PN_DELIVERY, "on_delivery") + """ + A delivery has been created or updated. Events of this type point + to the relevant delivery. + """ + + TRANSPORT = _core(PN_TRANSPORT, "on_transport") + """ + The transport has new data to read and/or write. Events of this + type point to the relevant transport. + """ + + TRANSPORT_ERROR = _core(PN_TRANSPORT_ERROR, "on_transport_error") + """ + Indicates that a transport error has occurred. Use :attr:`Transport.condition` + to access the details of the error from the associated transport. + """ + + TRANSPORT_HEAD_CLOSED = _core(PN_TRANSPORT_HEAD_CLOSED, "on_transport_head_closed") + """ + Indicates that the "head" or writing end of the transport has been closed. This + means the transport will never produce more bytes for output to + the network. Events of this type point to the relevant transport. + """ + + TRANSPORT_TAIL_CLOSED = _core(PN_TRANSPORT_TAIL_CLOSED, "on_transport_tail_closed") + """ + Indicates that the "tail" of the transport has been closed. This + means the transport will never be able to process more bytes from + the network. Events of this type point to the relevant transport. + """ + + TRANSPORT_CLOSED = _core(PN_TRANSPORT_CLOSED, "on_transport_closed") + """ + Indicates that the both the "head" and "tail" of the transport are + closed. Events of this type point to the relevant transport. + """ + + # These events are now internal events in the python code + REACTOR_INIT = _internal("reactor_init") + """ + A reactor has been started. Events of this type point to the + reactor. + """ + + REACTOR_QUIESCED = _internal("reactor_quiesced") + """ + A reactor has no more events to process. Events of this type + point to the reactor. + """ + + REACTOR_FINAL = _internal("reactor_final") + """ + A reactor has been stopped. Events of this type point to the + reactor. + """ + + SELECTABLE_INIT = _internal("selectable_init") + SELECTABLE_UPDATED = _internal("selectable_updated") + SELECTABLE_READABLE = _internal("selectable_readable") + SELECTABLE_WRITABLE = _internal("selectable_writable") + SELECTABLE_EXPIRED = _internal("selectable_expired") + SELECTABLE_ERROR = _internal("selectable_error") + SELECTABLE_FINAL = _internal("selectable_final") + + @staticmethod + def wrap(impl): + if isnull(impl): + return None + + number = pn_event_type(impl) + clsname = pn_event_class_name(impl) + if clsname: + context = wrappers[clsname](pn_event_context(impl)) + + # check for an application defined ApplicationEvent and return that. This + # avoids an expensive wrap operation invoked by event.context + if isinstance(context, EventBase): + return context + else: + context = None + + event = Event(impl, number, clsname, context) + return event + + def __init__(self, impl, number, clsname, context): + self._type = EventType.TYPES[number] + self._clsname = clsname + self._context = context + + # Do all this messing around to avoid duplicate wrappers + if issubclass(type(context), Delivery): + self._delivery = context + else: + self._delivery = Delivery.wrap(pn_event_delivery(impl)) + if self._delivery: + self._link = self._delivery.link + elif issubclass(type(context), Link): + self._link = context + else: + self._link = Link.wrap(pn_event_link(impl)) + if self._link: + self._session = self._link.session + elif issubclass(type(context), Session): + self._session = context + else: + self._session = Session.wrap(pn_event_session(impl)) + if self._session: + self._connection = self._session.connection + elif issubclass(type(context), Connection): + self._connection = context + else: + self._connection = Connection.wrap(pn_event_connection(impl)) + + if issubclass(type(context), Transport): + self._transport = context + else: + self._transport = Transport.wrap(pn_event_transport(impl)) + + @property + def clazz(self) -> str: + """ + The name of the class associated with the event context. + """ + return self._clsname + + @property + def context( + self, + ) -> Union[Optional[Any], Connection, Session, Link, Delivery, Transport]: + """ + The context object associated with the event. + + :type: Depends on the type of event, and include the following: + - :class:`Connection` + - :class:`Session` + - :class:`Link` + - :class:`Delivery` + - :class:`Transport` + """ + return self._context + + @property + def handler(self) -> Optional[Handler]: + """ + The handler for this event. The handler is determined by looking + at the following in order: + + - The link + - The session + - The connection + - The context object with an attribute "handler" + + If none of these has a handler, then ``None`` is returned. + """ + link = self.link + if link: + h = link.handler + if h: + return h + s = self.session + if s: + h = s.handler + if h: + return h + c = self.connection + if c: + h = c.handler + if h: + return h + c = self.context + if not c or not hasattr(c, "handler"): + return None + h = c.handler + return h + + @property + def reactor(self) -> "Container": + """ + **Deprecated** - The :class:`reactor.Container` (was reactor) associated with the event. + """ + return self.container + + @property + def container(self) -> "Container": + """ + The :class:`reactor.Container` associated with the event. + """ + return self._transport._reactor + + def __getattr__(self, name: str) -> Any: + """ + This will look for a property of the event as an attached context object of the same + type as the property (but lowercase) + """ + c = self.context + # Direct type or subclass of type + if type(c).__name__.lower() == name or name in [ + x.__name__.lower() for x in type(c).__bases__ + ]: + return c + + # If the attached object is the wrong type then see if *it* has a property of that name + return getattr(c, name, None) + + @property + def transport(self) -> Optional[Transport]: + """ + The transport associated with the event, or ``None`` if none + is associated with it. + """ + return self._transport + + @property + def connection(self) -> Optional[Connection]: + """ + The connection associated with the event, or ``None`` if none + is associated with it. + """ + return self._connection + + @property + def session(self) -> Optional[Session]: + """ + The session associated with the event, or ``None`` if none + is associated with it. + """ + return self._session + + @property + def link(self) -> Optional[Union["Receiver", "Sender"]]: + """ + The link associated with the event, or ``None`` if none + is associated with it. + """ + return self._link + + @property + def sender(self) -> Optional["Sender"]: + """ + The sender link associated with the event, or ``None`` if + none is associated with it. This is essentially an alias for + ``link`` property, that does an additional check on the type of the + link. + """ + link = self.link + if link and link.is_sender: + return link + else: + return None + + @property + def receiver(self) -> Optional["Receiver"]: + """ + The receiver link associated with the event, or ``None`` if + none is associated with it. This is essentially an alias for + ``link`` property, that does an additional check on the type of the link. + """ + link = self.link + if link and link.is_receiver: + return link + else: + return None + + @property + def delivery(self) -> Optional[Delivery]: + """ + The delivery associated with the event, or ``None`` if none + is associated with it. + """ + return self._delivery diff --git a/rabbitmq_amqp_python_client/qpid/proton/_exceptions.py b/rabbitmq_amqp_python_client/qpid/proton/_exceptions.py new file mode 100644 index 0000000..806961a --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_exceptions.py @@ -0,0 +1,124 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 cproton import PN_INTR, PN_TIMEOUT + + +class ProtonException(Exception): + """ + The root of the proton exception hierarchy. All proton exception + classes derive from this exception. + """ + + pass + + +class Timeout(ProtonException): + """ + A timeout exception indicates that a blocking operation has timed + out. + """ + + pass + + +class Interrupt(ProtonException): + """ + An interrupt exception indicates that a blocking operation was interrupted. + """ + + pass + + +EXCEPTIONS = {PN_TIMEOUT: Timeout, PN_INTR: Interrupt} + + +class MessageException(ProtonException): + """ + The MessageException class is the root of the message exception + hierarchy. All exceptions generated by the Message class derive from + this exception. + """ + + pass + + +class DataException(ProtonException): + """ + The DataException class is the root of the Data exception hierarchy. + All exceptions raised by the Data class extend this exception. + """ + + pass + + +class TransportException(ProtonException): + """ + An exception class raised when exceptions or errors related to the AMQP + transport arise. + """ + + pass + + +class SSLException(TransportException): + """ + An exception class raised when exceptions or errors related to SSL usage + arise. These typically include problems with initializing or configuring + SSL. + """ + + pass + + +class SSLUnavailable(SSLException): + """ + An exception class raised when exceptions or errors related to SSL + availability arise. These typically include problems finding the SSL + libraries. + """ + + pass + + +class ConnectionException(ProtonException): + """ + An exception class raised when exceptions or errors related to a + connection arise. + """ + + pass + + +class SessionException(ProtonException): + """ + An exception class raised when exceptions or errors related to a + session arise. + """ + + pass + + +class LinkException(ProtonException): + """ + An exception class raised when exceptions or errors related to a + link arise. + """ + + pass diff --git a/rabbitmq_amqp_python_client/qpid/proton/_handler.py b/rabbitmq_amqp_python_client/qpid/proton/_handler.py new file mode 100644 index 0000000..7572a83 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_handler.py @@ -0,0 +1,74 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 types import TracebackType +from typing import ( + Any, + Callable, + List, + Optional, + Tuple, + Type, + Union, +) + + +class LazyHandlers(object): + def __get__(self, obj: "Handler", clazz: Any) -> Union["LazyHandlers", List[Any]]: + if obj is None: + return self + ret = [] + obj.__dict__["handlers"] = ret + return ret + + +class Handler(object): + """ + An abstract handler for events which supports child handlers. + """ + + handlers = LazyHandlers() + + # TODO What to do with on_error? + def add( + self, + handler: Any, + on_error: Optional[ + Callable[[Tuple[Type[BaseException], BaseException, "TracebackType"]], None] + ] = None, + ) -> None: + """ + Add a child handler + + :param handler: A child handler + :type handler: :class:`Handler` or one of its derivatives. + :param on_error: Not used + """ + self.handlers.append(handler) + + def on_unhandled(self, method: str, *args) -> None: + """ + The callback for handling events which are not handled by + any other handler. + + :param method: The name of the intended handler method. + :param args: Arguments for the intended handler method. + """ + pass diff --git a/rabbitmq_amqp_python_client/qpid/proton/_handlers.py b/rabbitmq_amqp_python_client/qpid/proton/_handlers.py new file mode 100644 index 0000000..6bf5904 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_handlers.py @@ -0,0 +1,1440 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 errno +import logging +import socket +import time +import weakref +from typing import TYPE_CHECKING, Any, List, Optional, Union + +from ._condition import Condition +from ._delivery import Delivery +from ._endpoints import Endpoint +from ._events import Event, _dispatch +from ._exceptions import ProtonException +from ._handler import Handler +from ._io import IO +from ._message import Message +from ._selectable import Selectable +from ._transport import Transport +from ._url import Url + +if TYPE_CHECKING: + from ._delivery import DispositionType + from ._endpoints import Receiver, Sender + from ._reactor import Container, Transaction + +log = logging.getLogger("proton") + + +class OutgoingMessageHandler(Handler): + """ + A utility for simpler and more intuitive handling of delivery + events related to outgoing i.e. sent messages. + + :param auto_settle: If ``True`` (default), automatically settle messages + upon receiving a settled disposition for that delivery. Otherwise + messages must be explicitly settled. + :type auto_settle: ``bool`` + :param delegate: A client handler for the endpoint event + """ + + def __init__(self, auto_settle=True, delegate=None): + self.auto_settle = auto_settle + self.delegate = delegate + + def on_link_flow(self, event: Event): + if ( + event.link.is_sender + and event.link.credit + and event.link.state & Endpoint.LOCAL_ACTIVE + and event.link.state & Endpoint.REMOTE_ACTIVE + ): + self.on_sendable(event) + + def on_delivery(self, event: Event): + dlv = event.delivery + if dlv.link.is_sender and dlv.updated: + if dlv.remote_state == Delivery.ACCEPTED: + self.on_accepted(event) + elif dlv.remote_state == Delivery.REJECTED: + self.on_rejected(event) + elif ( + dlv.remote_state == Delivery.RELEASED + or dlv.remote_state == Delivery.MODIFIED + ): + self.on_released(event) + if dlv.settled: + self.on_settled(event) + if self.auto_settle: + dlv.settle() + + def on_sendable(self, event: Event): + """ + Called when the sender link has credit and messages can + therefore be transferred. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_sendable", event) + + def on_accepted(self, event: Event): + """ + Called when the remote peer accepts an outgoing message. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_accepted", event) + + def on_rejected(self, event: Event): + """ + Called when the remote peer rejects an outgoing message. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_rejected", event) + + def on_released(self, event: Event): + """ + Called when the remote peer releases an outgoing message. Note + that this may be in response to either the ``RELEASE`` or ``MODIFIED`` + state as defined by the AMQP specification. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_released", event) + + def on_settled(self, event: Event): + """ + Called when the remote peer has settled the outgoing + message. This is the point at which it should never be + retransmitted. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_settled", event) + + +def recv_msg(delivery: Delivery) -> Message: + msg = Message() + msg.decode(delivery.link.recv(delivery.pending)) + delivery.link.advance() + return msg + + +class Reject(ProtonException): + """ + An exception that indicates a message should be rejected. + """ + + pass + + +class Release(ProtonException): + """ + An exception that indicates a message should be released. + """ + + pass + + +class Acking(object): + """ + A class containing methods for handling received messages. + """ + + def accept(self, delivery: Delivery) -> None: + """ + Accepts a received message. + + .. note:: This method cannot currently be used in combination + with transactions. See :class:`proton.reactor.Transaction` + for transactional methods. + + :param delivery: The message delivery tracking object + """ + self.settle(delivery, Delivery.ACCEPTED) + + def reject(self, delivery: Delivery) -> None: + """ + Rejects a received message that is considered invalid or + unprocessable. + + .. note:: This method cannot currently be used in combination + with transactions. See :class:`proton.reactor.Transaction` + for transactional methods. + + :param delivery: The message delivery tracking object + """ + self.settle(delivery, Delivery.REJECTED) + + def release(self, delivery: Delivery, delivered: bool = True) -> None: + """ + Releases a received message, making it available at the source + for any (other) interested receiver. The ``delivered`` + parameter indicates whether this should be considered a + delivery attempt (and the delivery count updated) or not. + + .. note:: This method cannot currently be used in combination + with transactions. See :class:`proton.reactor.Transaction` + for transactional methods. + + :param delivery: The message delivery tracking object + :param delivered: If ``True``, the message will be annotated + with a delivery attempt (setting delivery flag + :const:`proton.Delivery.MODIFIED`). Otherwise, the message + will be returned without the annotation and released (setting + delivery flag :const:`proton.Delivery.RELEASED` + """ + if delivered: + self.settle(delivery, Delivery.MODIFIED) + else: + self.settle(delivery, Delivery.RELEASED) + + def settle( + self, delivery: Delivery, state: Optional["DispositionType"] = None + ) -> None: + """ + Settles the message delivery, and optionally updating the + delivery state. + + :param delivery: The message delivery tracking object + :param state: The delivery state, or ``None`` if no update + is to be performed. + """ + if state: + delivery.update(state) + delivery.settle() + + +class IncomingMessageHandler(Handler, Acking): + """ + A utility for simpler and more intuitive handling of delivery + events related to incoming i.e. received messages. + + :param auto_accept: If ``True``, accept all messages (default). Otherwise + messages must be individually accepted or rejected. + :param delegate: A client handler for the endpoint event + """ + + def __init__( + self, auto_accept: bool = True, delegate: Optional[Handler] = None + ) -> None: + self.delegate = delegate + self.auto_accept = auto_accept + + def on_delivery(self, event: Event) -> None: + dlv = event.delivery + if not dlv.link.is_receiver: + return + if dlv.aborted: + self.on_aborted(event) + dlv.settle() + elif dlv.readable and not dlv.partial: + event.message = recv_msg(dlv) + if event.link.state & Endpoint.LOCAL_CLOSED: + if self.auto_accept: + dlv.update(Delivery.RELEASED) + dlv.settle() + else: + try: + self.on_message(event) + if self.auto_accept: + dlv.update(Delivery.ACCEPTED) + dlv.settle() + except Reject: + dlv.update(Delivery.REJECTED) + dlv.settle() + except Release: + dlv.update(Delivery.MODIFIED) + dlv.settle() + elif dlv.updated and dlv.settled: + self.on_settled(event) + + def on_message(self, event: Event): + """ + Called when a message is received. The message itself can be + obtained as a property on the event. For the purpose of + referring to this message in further actions (e.g. if + explicitly accepting it, the ``delivery`` should be used, also + obtainable via a property on the event. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_message", event) + + def on_settled(self, event: Event): + """ + Callback for when a message delivery is settled by the remote peer. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_settled", event) + + def on_aborted(self, event: Event): + """ + Callback for when a message delivery is aborted by the remote peer. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_aborted", event) + + +class EndpointStateHandler(Handler): + """ + A utility that exposes 'endpoint' events - ie the open/close for + links, sessions and connections in a more intuitive manner. A + ``XXX_opened()`` method will be called when both local and remote peers + have opened the link, session or connection. This can be used to + confirm a locally initiated action for example. A ``XXX_opening()`` + method will be called when the remote peer has requested an open + that was not initiated locally. By default this will simply open + locally, which then triggers the ``XXX_opened()`` call. The same applies + to close. + + :param peer_close_is_error: If ``True``, a peer endpoint closing will be + treated as an error with an error callback. Otherwise (default), the + normal callbacks for the closing will occur. + :param delegate: A client handler for the endpoint event + """ + + def __init__( + self, peer_close_is_error: bool = False, delegate: Optional[Handler] = None + ) -> None: + self.delegate = delegate + self.peer_close_is_error = peer_close_is_error + + @classmethod + def is_local_open(cls, endpoint: Endpoint) -> bool: + """ + Test if local ``endpoint`` is open (ie has state + :const:`proton.Endpoint.LOCAL_ACTIVE`). + + :param endpoint: The local endpoint to be tested. + :return: ``True`` if local endpoint is in state + :const:`proton.Endpoint.LOCAL_ACTIVE`, ``False`` otherwise. + """ + return bool(endpoint.state & Endpoint.LOCAL_ACTIVE) + + @classmethod + def is_local_uninitialised(cls, endpoint: Endpoint) -> bool: + """ + Test if local ``endpoint`` is uninitialised (ie has state + :const:`proton.Endpoint.LOCAL_UNINIT`). + + :param endpoint: The local endpoint to be tested. + :return: ``True`` if local endpoint is in state + :const:`proton.Endpoint.LOCAL_UNINIT`, ``False`` otherwise. + """ + return bool(endpoint.state & Endpoint.LOCAL_UNINIT) + + @classmethod + def is_local_closed(cls, endpoint: Endpoint) -> bool: + """ + Test if local ``endpoint`` is closed (ie has state + :const:`proton.Endpoint.LOCAL_CLOSED`). + + :param endpoint: The local endpoint to be tested. + :return: ``True`` if local endpoint is in state + :const:`proton.Endpoint.LOCAL_CLOSED`, ``False`` otherwise. + """ + return bool(endpoint.state & Endpoint.LOCAL_CLOSED) + + @classmethod + def is_remote_open(cls, endpoint: Endpoint) -> bool: + """ + Test if remote ``endpoint`` is open (ie has state + :const:`proton.Endpoint.LOCAL_ACTIVE`). + + :param endpoint: The remote endpoint to be tested. + :return: ``True`` if remote endpoint is in state + :const:`proton.Endpoint.LOCAL_ACTIVE`, ``False`` otherwise. + """ + return bool(endpoint.state & Endpoint.REMOTE_ACTIVE) + + @classmethod + def is_remote_closed(cls, endpoint: Endpoint) -> bool: + """ + Test if remote ``endpoint`` is closed (ie has state + :const:`proton.Endpoint.REMOTE_CLOSED`). + + :param endpoint: The remote endpoint to be tested. + :return: ``True`` if remote endpoint is in state + :const:`proton.Endpoint.REMOTE_CLOSED`, ``False`` otherwise. + """ + return bool(endpoint.state & Endpoint.REMOTE_CLOSED) + + @classmethod + def print_error(cls, endpoint: Endpoint, endpoint_type: str) -> None: + """ + Logs an error message related to an error condition at an endpoint. + + :param endpoint: The endpoint to be tested + :param endpoint_type: The endpoint type as a string to be printed + in the log message. + """ + if endpoint.remote_condition: + log.error( + endpoint.remote_condition.description or endpoint.remote_condition.name + ) + elif cls.is_local_open(endpoint) and cls.is_remote_closed(endpoint): + log.error("%s closed by peer" % endpoint_type) + + def on_link_remote_close(self, event: Event) -> None: + if event.link.remote_condition: + self.on_link_error(event) + elif self.is_local_closed(event.link): + self.on_link_closed(event) + else: + self.on_link_closing(event) + event.link.close() + + def on_link_local_close(self, event: Event) -> None: + if self.is_remote_closed(event.link): + self.on_link_closed(event) + + def on_session_remote_close(self, event: Event) -> None: + if event.session.remote_condition: + self.on_session_error(event) + elif self.is_local_closed(event.session): + self.on_session_closed(event) + else: + self.on_session_closing(event) + event.session.close() + + def on_session_local_close(self, event: Event) -> None: + if self.is_remote_closed(event.session): + self.on_session_closed(event) + + def on_connection_remote_close(self, event: Event) -> None: + if event.connection.remote_condition: + if event.connection.remote_condition.name == "amqp:connection:forced": + # Treat this the same as just having the transport closed by the peer without + # sending any events. Allow reconnection to happen transparently. + return + self.on_connection_error(event) + elif self.is_local_closed(event.connection): + self.on_connection_closed(event) + else: + self.on_connection_closing(event) + event.connection.close() + + def on_connection_local_close(self, event: Event) -> None: + if self.is_remote_closed(event.connection): + self.on_connection_closed(event) + + def on_connection_local_open(self, event: Event) -> None: + if self.is_remote_open(event.connection): + self.on_connection_opened(event) + + def on_connection_remote_open(self, event: Event) -> None: + if self.is_local_open(event.connection): + self.on_connection_opened(event) + elif self.is_local_uninitialised(event.connection): + self.on_connection_opening(event) + event.connection.open() + + def on_session_local_open(self, event: Event) -> None: + if self.is_remote_open(event.session): + self.on_session_opened(event) + + def on_session_remote_open(self, event: Event) -> None: + if self.is_local_open(event.session): + self.on_session_opened(event) + elif self.is_local_uninitialised(event.session): + self.on_session_opening(event) + event.session.open() + + def on_link_local_open(self, event: Event) -> None: + if self.is_remote_open(event.link): + self.on_link_opened(event) + + def on_link_remote_open(self, event: Event) -> None: + if self.is_local_open(event.link): + self.on_link_opened(event) + elif self.is_local_uninitialised(event.link): + self.on_link_opening(event) + event.link.open() + + def on_connection_opened(self, event: Event) -> None: + """ + Callback for when both the local and remote endpoints of a + connection have opened. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_connection_opened", event) + + def on_session_opened(self, event: Event) -> None: + """ + Callback for when both the local and remote endpoints of a + session have opened. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_session_opened", event) + + def on_link_opened(self, event: Event) -> None: + """ + Callback for when both the local and remote endpoints of a + link have opened. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_link_opened", event) + + def on_connection_opening(self, event: Event) -> None: + """ + Callback for when a remote peer initiates the opening of + a connection. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_connection_opening", event) + + def on_session_opening(self, event: Event) -> None: + """ + Callback for when a remote peer initiates the opening of + a session. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_session_opening", event) + + def on_link_opening(self, event: Event) -> None: + """ + Callback for when a remote peer initiates the opening of + a link. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_link_opening", event) + + def on_connection_error(self, event: Event) -> None: + """ + Callback for when an initiated connection open fails. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_connection_error", event) + else: + self.print_error(event.connection, "connection") + + def on_session_error(self, event: Event) -> None: + """ + Callback for when an initiated session open fails. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_session_error", event) + else: + self.print_error(event.session, "session") + event.connection.close() + + def on_link_error(self, event: Event) -> None: + """ + Callback for when an initiated link open fails. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_link_error", event) + else: + self.print_error(event.link, "link") + event.connection.close() + + def on_connection_closed(self, event: Event) -> None: + """ + Callback for when both the local and remote endpoints of a + connection have closed. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_connection_closed", event) + + def on_session_closed(self, event: Event) -> None: + """ + Callback for when both the local and remote endpoints of a + session have closed. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_session_closed", event) + + def on_link_closed(self, event: Event) -> None: + """ + Callback for when both the local and remote endpoints of a + link have closed. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_link_closed", event) + + def on_connection_closing(self, event: Event) -> None: + """ + Callback for when a remote peer initiates the closing of + a connection. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_connection_closing", event) + elif self.peer_close_is_error: + self.on_connection_error(event) + + def on_session_closing(self, event: Event) -> None: + """ + Callback for when a remote peer initiates the closing of + a session. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_session_closing", event) + elif self.peer_close_is_error: + self.on_session_error(event) + + def on_link_closing(self, event: Event) -> None: + """ + Callback for when a remote peer initiates the closing of + a link. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if self.delegate is not None: + _dispatch(self.delegate, "on_link_closing", event) + elif self.peer_close_is_error: + self.on_link_error(event) + + def on_transport_tail_closed(self, event: Event) -> None: + """ + Callback for when the transport tail has closed (ie no further input will + be accepted by the transport). + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + self.on_transport_closed(event) + + def on_transport_closed(self, event: Event) -> None: + """ + Callback for when the transport has closed - ie both the head (input) and + tail (output) of the transport pipeline are closed. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if ( + self.delegate is not None + and event.connection + and self.is_local_open(event.connection) + ): + _dispatch(self.delegate, "on_disconnected", event) + + +class MessagingHandler(Handler, Acking): + """ + A general purpose handler that makes the proton-c events somewhat + simpler to deal with and/or avoids repetitive tasks for common use + cases. + + :param prefetch: Initial flow credit for receiving messages, defaults to 10. + :param auto_accept: If ``True``, accept all messages (default). Otherwise + messages must be individually accepted or rejected. + :param auto_settle: If ``True`` (default), automatically settle messages + upon receiving a settled disposition for that delivery. Otherwise + messages must be explicitly settled. + :param peer_close_is_error: If ``True``, a peer endpoint closing will be + treated as an error with an error callback. Otherwise (default), the + normal callbacks for the closing will occur. + """ + + def __init__( + self, + prefetch: int = 10, + auto_accept: bool = True, + auto_settle: bool = True, + peer_close_is_error: bool = False, + ) -> None: + self.handlers = [] + if prefetch: + self.handlers.append(FlowController(prefetch)) + self.handlers.append( + EndpointStateHandler(peer_close_is_error, weakref.proxy(self)) + ) + self.handlers.append(IncomingMessageHandler(auto_accept, weakref.proxy(self))) + self.handlers.append(OutgoingMessageHandler(auto_settle, weakref.proxy(self))) + self.fatal_conditions = ["amqp:unauthorized-access"] + + def on_transport_error(self, event: Event) -> None: + """ + Called when some error is encountered with the transport over + which the AMQP connection is to be established. This includes + authentication errors as well as socket errors. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if event.transport.condition: + if event.transport.condition.info: + log.error( + "%s: %s: %s" + % ( + event.transport.condition.name, + event.transport.condition.description, + event.transport.condition.info, + ) + ) + else: + log.error( + "%s: %s" + % ( + event.transport.condition.name, + event.transport.condition.description, + ) + ) + if event.transport.condition.name in self.fatal_conditions: + event.connection.close() + else: + logging.error("Unspecified transport error") + + def on_connection_error(self, event: Event) -> None: + """ + Called when the peer closes the connection with an error condition. + + :param event: The underlying event object. Use this to obtain further + information on the event. + :type event: :class:`proton.Event` + """ + EndpointStateHandler.print_error(event.connection, "connection") + + def on_session_error(self, event: Event) -> None: + """ + Called when the peer closes the session with an error condition. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + EndpointStateHandler.print_error(event.session, "session") + event.connection.close() + + def on_link_error(self, event: Event) -> None: + """ + Called when the peer closes the link with an error condition. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + EndpointStateHandler.print_error(event.link, "link") + event.connection.close() + + def on_reactor_init(self, event: Event) -> None: + """ + Called when the event loop - the reactor - starts. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + if hasattr(event.reactor, "subclass"): + setattr(event, event.reactor.subclass.__name__.lower(), event.reactor) + self.on_start(event) + + def on_start(self, event: Event) -> None: + """ + Called when the event loop starts. (Just an alias for on_reactor_init) + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_connection_closed(self, event: Event) -> None: + """ + Called when the connection is closed. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_session_closed(self, event: Event) -> None: + """ + Called when the session is closed. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_link_closed(self, event: Event) -> None: + """ + Called when the link is closed. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_connection_closing(self, event: Event) -> None: + """ + Called when the peer initiates the closing of the connection. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_session_closing(self, event: Event) -> None: + """ + Called when the peer initiates the closing of the session. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_link_closing(self, event: Event) -> None: + """ + Called when the peer initiates the closing of the link. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_disconnected(self, event: Event) -> None: + """ + Called when the socket is disconnected. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_sendable(self, event: Event) -> None: + """ + Called when the sender link has credit and messages can + therefore be transferred. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_accepted(self, event: Event) -> None: + """ + Called when the remote peer accepts an outgoing message. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_rejected(self, event: Event) -> None: + """ + Called when the remote peer rejects an outgoing message. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_released(self, event: Event) -> None: + """ + Called when the remote peer releases an outgoing message. Note + that this may be in response to either the RELEASE or MODIFIED + state as defined by the AMQP specification. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_settled(self, event: Event) -> None: + """ + Called when the remote peer has settled the outgoing + message. This is the point at which it should never be + retransmitted. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_message(self, event: Event) -> None: + """ + Called when a message is received. The message itself can be + obtained as a property on the event. For the purpose of + referring to this message in further actions (e.g. if + explicitly accepting it, the ``delivery`` should be used, also + obtainable via a property on the event. + + :param event: The underlying event object. Use this to obtain further + information on the event. In particular, the message itself may + be obtained by accessing ``event.message``. + """ + pass + + +class TransactionHandler(object): + """ + The interface for transaction handlers - ie objects that want to + be notified of state changes related to a transaction. + """ + + def on_transaction_declared(self, event: Event) -> None: + """ + Called when a local transaction is declared. + + :param event: The underlying event object. Use this to obtain further + information on the event. In particular, the :class:`proton.reactor.Transaction` + object may be obtained by accessing ``event.transaction``. + """ + pass + + def on_transaction_committed(self, event: Event) -> None: + """ + Called when a local transaction is discharged successfully + (committed). + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_transaction_aborted(self, event: Event) -> None: + """ + Called when a local transaction is discharged unsuccessfully + (aborted). + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_transaction_declare_failed(self, event: Event) -> None: + """ + Called when a local transaction declare fails. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + def on_transaction_commit_failed(self, event: Event) -> None: + """ + Called when the commit of a local transaction fails. + + :param event: The underlying event object. Use this to obtain further + information on the event. + """ + pass + + +class TransactionalClientHandler(MessagingHandler, TransactionHandler): + """ + An extension to the MessagingHandler for applications using + transactions. This handler provides all of the callbacks found + in :class:`MessagingHandler` and :class:`TransactionHandler`, + and provides a convenience method :meth:`accept` for performing + a transactional acceptance of received messages. + + :param prefetch: Initial flow credit for receiving messages, defaults to 10. + :param auto_accept: If ``True``, accept all messages. Otherwise (default), + messages must be individually accepted or rejected. + :param auto_settle: If ``True`` (default), automatically settle messages + upon receiving a settled disposition for that delivery. Otherwise + messages must be explicitly settled. + :param peer_close_is_error: If ``True``, a peer endpoint closing will be + treated as an error with an error callback. Otherwise (default), the + normal callbacks for the closing will occur. + """ + + def __init__( + self, + prefetch: int = 10, + auto_accept: bool = False, + auto_settle: bool = True, + peer_close_is_error: bool = False, + ) -> None: + super(TransactionalClientHandler, self).__init__( + prefetch, auto_accept, auto_settle, peer_close_is_error + ) + + def accept(self, delivery: Delivery, transaction: Optional["Transaction"] = None): + """ + A convenience method for accepting a received message as part of a + transaction. If no transaction object is supplied, a regular + non-transactional acceptance will be performed. + + :param delivery: Delivery tracking object for received message. + :param transaction: Transaction tracking object which is required if + the message is being accepted under the transaction. If ``None`` (default), + then a normal non-transactional accept occurs. + """ + if transaction: + transaction.accept(delivery) + else: + super(TransactionalClientHandler, self).accept(delivery) + + +class FlowController(Handler): + def __init__(self, window: int = 1024) -> None: + self._window = window + self._drained = 0 + + def on_link_local_open(self, event: Event) -> None: + self._flow(event.link) + + def on_link_remote_open(self, event: Event) -> None: + self._flow(event.link) + + def on_link_flow(self, event: Event) -> None: + self._flow(event.link) + + def on_delivery(self, event: Event) -> None: + self._flow(event.link) + + def _flow(self, link: Union["Sender", "Receiver"]) -> None: + if link.is_receiver: + self._drained += link.drained() + if self._drained == 0: + delta = self._window - link.credit + link.flow(delta) + + +class Handshaker(Handler): + @staticmethod + def on_connection_remote_open(event: Event) -> None: + conn = event.connection + if conn.state & Endpoint.LOCAL_UNINIT: + conn.open() + + @staticmethod + def on_session_remote_open(event: Event) -> None: + ssn = event.session + if ssn.state & Endpoint.LOCAL_UNINIT: + ssn.open() + + @staticmethod + def on_link_remote_open(event: Event) -> None: + link = event.link + if link.state & Endpoint.LOCAL_UNINIT: + link.source.copy(link.remote_source) + link.target.copy(link.remote_target) + link.open() + + @staticmethod + def on_connection_remote_close(event: Event) -> None: + conn = event.connection + if not conn.state & Endpoint.LOCAL_CLOSED: + conn.close() + + @staticmethod + def on_session_remote_close(event: Event) -> None: + ssn = event.session + if not ssn.state & Endpoint.LOCAL_CLOSED: + ssn.close() + + @staticmethod + def on_link_remote_close(event: Event) -> None: + link = event.link + if not link.state & Endpoint.LOCAL_CLOSED: + link.close() + + +# Back compatibility definitions +CFlowController = FlowController +CHandshaker = Handshaker + + +class PythonIO: + def __init__(self) -> None: + self.selectables = [] + self.delegate = IOHandler() + + def on_unhandled(self, method: str, event: Event) -> None: + event.dispatch(self.delegate) + + def on_selectable_init(self, event: Event) -> None: + self.selectables.append(event.context) + + def on_selectable_updated(self, event: Event) -> None: + pass + + def on_selectable_final(self, event: Event) -> None: + sel = event.context + if sel.is_terminal: + self.selectables.remove(sel) + sel.close() + + def on_reactor_quiesced(self, event: Event) -> None: + reactor = event.reactor + # check if we are still quiesced, other handlers of + # on_reactor_quiesced could have produced events to process + if not reactor.quiesced: + return + + reading = [] + writing = [] + deadline = None + for sel in self.selectables: + if sel.reading: + reading.append(sel) + if sel.writing: + writing.append(sel) + if sel.deadline: + if deadline is None: + deadline = sel.deadline + else: + deadline = min(sel.deadline, deadline) + + if deadline is not None: + timeout = deadline - time.time() + else: + timeout = reactor.timeout + if timeout < 0: + timeout = 0 + timeout = min(timeout, reactor.timeout) + readable, writable, _ = IO.select(reading, writing, [], timeout) + + now = reactor.mark() + + for s in readable: + s.readable() + for s in writable: + s.writable() + for s in self.selectables: + if s.deadline and now > s.deadline: + s.expired() + + reactor.yield_() + + +# For C style IO handler need to implement Selector +class IOHandler(Handler): + def __init__(self) -> None: + self._selector = IO.Selector() + + def on_selectable_init(self, event: Event) -> None: + s = event.selectable + self._selector.add(s) + s._reactor._selectables += 1 + + def on_selectable_updated(self, event: Event) -> None: + s = event.selectable + self._selector.update(s) + + def on_selectable_final(self, event: Event) -> None: + s = event.selectable + self._selector.remove(s) + s._reactor._selectables -= 1 + s.close() + + def on_reactor_quiesced(self, event: Event) -> None: + r = event.reactor + + if not r.quiesced: + return + + r.timer_deadline + readable, writable, expired = self._selector.select(r.timeout) + + r.mark() + + for s in readable: + s.readable() + for s in writable: + s.writable() + for s in expired: + s.expired() + + r.yield_() + + def on_selectable_readable(self, event: Event) -> None: + s = event.selectable + t = s._transport + + # If we're an acceptor we can't have a transport + # and we don't want to do anything here in any case + if not t: + return + + capacity = t.capacity() + if capacity > 0: + try: + b = s.recv(capacity) + if len(b) > 0: + t.push(b) + else: + # EOF handling + self.on_selectable_error(event) + except socket.error as e: + # TODO: What's the error handling to be here? + log.error("Couldn't recv: %r" % e) + t.close_tail() + + # Always update as we may have gone to not reading or from + # not writing to writing when processing the incoming bytes + r = s._reactor + self.update(t, s, r.now) + + def on_selectable_writable(self, event: Event) -> None: + s = event.selectable + t = s._transport + + # If we're an acceptor we can't have a transport + # and we don't want to do anything here in any case + if not t: + return + + pending = t.pending() + if pending > 0: + try: + n = s.send(t.peek(pending)) + t.pop(n) + except socket.error as e: + log.error("Couldn't send: %r" % e) + # TODO: Error? or actually an exception + t.close_head() + + newpending = t.pending() + if newpending != pending: + r = s._reactor + self.update(t, s, r.now) + + def on_selectable_error(self, event: Event) -> None: + s = event.selectable + t = s._transport + + t.close_head() + t.close_tail() + s.terminate() + s._transport = None + t._selectable = None + s.update() + + def on_selectable_expired(self, event: Event) -> None: + s = event.selectable + t = s._transport + r = s._reactor + + self.update(t, s, r.now) + + def on_connection_local_open(self, event: Event) -> None: + c = event.connection + if not c.state & Endpoint.REMOTE_UNINIT: + return + + t = Transport() + # It seems perverse, but the C code ignores bind errors too! + # and this is required or you get errors because Connector() has already + # bound the transport and connection! + t.bind_nothrow(c) + + def on_connection_bound(self, event: Event) -> None: + c = event.connection + t = event.transport + + reactor = c._reactor + + # link the new transport to its reactor: + t._reactor = reactor + + if c._acceptor: + # this connection was created by the acceptor. There is already a + # socket assigned to this connection. Nothing needs to be done. + return + + url = c.url or Url(c.hostname) + url.defaults() + + host = url.host + port = int(url.port) + + if not c.user: + user = url.username + if user: + c.user = user + password = url.password + if password: + c.password = password + + addrs = socket.getaddrinfo(host, port, socket.AF_UNSPEC, socket.SOCK_STREAM) + + # Try first possible address + log.debug("Connect trying first transport address: %s", addrs[0]) + sock = IO.connect(addrs[0]) + + # At this point we need to arrange to be called back when the socket is writable + ConnectSelectable(sock, reactor, addrs[1:], t, self) + + # TODO: Don't understand why we need this now - how can we get PN_TRANSPORT until the connection succeeds? + t._selectable = None + + @staticmethod + def update(transport: Transport, selectable: Selectable, now: float) -> None: + try: + capacity = transport.capacity() + selectable.reading = capacity > 0 + except ProtonException: + if transport.closed: + selectable.terminate() + selectable._transport = None + transport._selectable = None + try: + pending = transport.pending() + selectable.writing = pending > 0 + except ProtonException: + if transport.closed: + selectable.terminate() + selectable._transport = None + transport._selectable = None + selectable.deadline = transport.tick(now) + selectable.update() + + def on_transport(self, event: Event) -> None: + t = event.transport + r = t._reactor + s = t._selectable + if s and not s.is_terminal: + self.update(t, s, r.now) + + def on_transport_closed(self, event: Event) -> None: + t = event.transport + r = t._reactor + s = t._selectable + if s and not s.is_terminal: + s.terminate() + s._transport = None + t._selectable = None + r.update(s) + t.unbind() + + +class ConnectSelectable(Selectable): + def __init__( + self, + sock: socket.socket, + reactor: "Container", + addrs: List[Any], + transport: Transport, + iohandler: IOHandler, + ) -> None: + super(ConnectSelectable, self).__init__(sock, reactor) + self.writing = True + self._addrs = addrs + self._transport = transport + self._iohandler = iohandler + transport._connect_selectable = self + + def readable(self) -> None: + pass + + def writable(self) -> None: + e = self._delegate.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR) + t = self._transport + t._connect_selectable = None + + # Always cleanup this ConnectSelectable: either we failed or created a new one + # Do it first to ensure the socket gets deregistered before being registered again + # in the case of connecting + self.terminate() + self._transport = None + self.update() + + if e == 0: + log.debug("Connection succeeded") + + # Disassociate from the socket (which will be passed on) + self.release() + + s = self._reactor.selectable(delegate=self._delegate) + s._transport = t + t._selectable = s + self._iohandler.update(t, s, t._reactor.now) + + return + elif e == errno.ECONNREFUSED: + if len(self._addrs) > 0: + log.debug( + "Connection refused: trying next transport address: %s", + self._addrs[0], + ) + + sock = IO.connect(self._addrs[0]) + # New ConnectSelectable for the new socket with rest of addresses + ConnectSelectable( + sock, self._reactor, self._addrs[1:], t, self._iohandler + ) + return + else: + log.debug("Connection refused, but tried all transport addresses") + t.condition = Condition( + "proton.pythonio", "Connection refused to all addresses" + ) + else: + log.error("Couldn't connect: %s", e) + t.condition = Condition("proton.pythonio", "Connection error: %s" % e) + + t.close_tail() + t.close_head() diff --git a/rabbitmq_amqp_python_client/qpid/proton/_io.py b/rabbitmq_amqp_python_client/qpid/proton/_io.py new file mode 100644 index 0000000..0c889e5 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_io.py @@ -0,0 +1,170 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 errno +import select +import socket +import time +from typing import TYPE_CHECKING, List, Tuple + +if TYPE_CHECKING: + from proton._selectable import Selectable + +PN_INVALID_SOCKET = -1 + + +class IO(object): + @staticmethod + def _setupsocket(s: socket.socket) -> None: + s.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, True) + s.setblocking(False) + + @staticmethod + def close(s: socket.socket) -> None: + s.close() + + @staticmethod + def listen(host, port) -> socket.socket: + s = socket.socket() + IO._setupsocket(s) + s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, True) + s.bind((host, port)) + s.listen(10) + return s + + @staticmethod + def accept(s: socket.socket): + n = s.accept() + IO._setupsocket(n[0]) + return n + + @staticmethod + def connect(addr) -> socket.socket: + s = socket.socket(addr[0], addr[1], addr[2]) + IO._setupsocket(s) + try: + s.connect(addr[4]) + except socket.error as e: + if e.errno not in (errno.EINPROGRESS, errno.EWOULDBLOCK, errno.EAGAIN): + raise + return s + + @staticmethod + def select(*args, **kwargs): + return select.select(*args, **kwargs) + + @staticmethod + def sleep(t: float) -> None: + time.sleep(t) + return + + class Selector(object): + def __init__(self) -> None: + self._selectables = set() + self._reading = set() + self._writing = set() + self._deadline = None + + def add(self, selectable: "Selectable") -> None: + self._selectables.add(selectable) + if selectable.reading: + self._reading.add(selectable) + if selectable.writing: + self._writing.add(selectable) + if selectable.deadline: + if self._deadline is None: + self._deadline = selectable.deadline + else: + self._deadline = min(selectable.deadline, self._deadline) + + def remove(self, selectable: "Selectable") -> None: + self._selectables.discard(selectable) + self._reading.discard(selectable) + self._writing.discard(selectable) + self.update_deadline() + + @property + def selectables(self) -> int: + return len(self._selectables) + + def update_deadline(self) -> None: + for sel in self._selectables: + if sel.deadline: + if self._deadline is None: + self._deadline = sel.deadline + else: + self._deadline = min(sel.deadline, self._deadline) + + def update(self, selectable: "Selectable") -> None: + self._reading.discard(selectable) + self._writing.discard(selectable) + if selectable.reading: + self._reading.add(selectable) + if selectable.writing: + self._writing.add(selectable) + self.update_deadline() + + def select(self, timeout: float) -> Tuple[List, List, List]: + def select_inner(timeout): + # This inner select adds the writing fds to the exception fd set + # because Windows returns connected fds in the exception set not the + # writable set + r = self._reading + w = self._writing + + now = time.time() + + # No timeout or deadline + if timeout is None and self._deadline is None: + return IO.select(r, w, w) + + if timeout is None: + t = max(0, self._deadline - now) + return IO.select(r, w, w, t) + + if self._deadline is None: + return IO.select(r, w, w, timeout) + + t = max(0, min(timeout, self._deadline - now)) + if len(r) == 0 and len(w) == 0: + if t > 0: + IO.sleep(t) + return ([], [], []) + + return IO.select(r, w, w, t) + + # Need to allow for signals interrupting us on Python 2 + # In this case the signal handler could have messed up our internal state + # so don't retry just return with no handles. + try: + r, w, ex = select_inner(timeout) + except select.error as e: + if e.errno != errno.EINTR: + raise + r, w, ex = ([], [], []) + + # For windows non blocking connect we get exception not writable so add exceptions to writable + w += ex + + # Calculate timed out selectables + now = time.time() + t = [s for s in self._selectables if s.deadline and now > s.deadline] + self._deadline = None + self.update_deadline() + return r, w, t diff --git a/rabbitmq_amqp_python_client/qpid/proton/_message.py b/rabbitmq_amqp_python_client/qpid/proton/_message.py new file mode 100644 index 0000000..156687e --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_message.py @@ -0,0 +1,650 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 ( + TYPE_CHECKING, + Dict, + Optional, + Union, + overload, +) +from uuid import UUID + +from cproton import ( + PN_DEFAULT_PRIORITY, + PN_OVERFLOW, + PN_UUID, + pn_error_text, + pn_message, + pn_message_annotations, + pn_message_body, + pn_message_clear, + pn_message_decode, + pn_message_encode, + pn_message_error, + pn_message_free, + pn_message_get_address, + pn_message_get_content_encoding, + pn_message_get_content_type, + pn_message_get_correlation_id, + pn_message_get_creation_time, + pn_message_get_delivery_count, + pn_message_get_expiry_time, + pn_message_get_group_id, + pn_message_get_group_sequence, + pn_message_get_id, + pn_message_get_priority, + pn_message_get_reply_to, + pn_message_get_reply_to_group_id, + pn_message_get_subject, + pn_message_get_ttl, + pn_message_get_user_id, + pn_message_instructions, + pn_message_is_durable, + pn_message_is_first_acquirer, + pn_message_is_inferred, + pn_message_properties, + pn_message_set_address, + pn_message_set_content_encoding, + pn_message_set_content_type, + pn_message_set_correlation_id, + pn_message_set_creation_time, + pn_message_set_delivery_count, + pn_message_set_durable, + pn_message_set_expiry_time, + pn_message_set_first_acquirer, + pn_message_set_group_id, + pn_message_set_group_sequence, + pn_message_set_id, + pn_message_set_inferred, + pn_message_set_priority, + pn_message_set_reply_to, + pn_message_set_reply_to_group_id, + pn_message_set_subject, + pn_message_set_ttl, + pn_message_set_user_id, +) + +from ._common import millis2secs, secs2millis +from ._data import AnnotationDict, Data, char, symbol, ulong +from ._endpoints import Link +from ._exceptions import EXCEPTIONS, MessageException + +if TYPE_CHECKING: + from proton._data import Described, PythonAMQPData + from proton._delivery import Delivery + from proton._endpoints import Receiver, Sender + + +class Message(object): + """The :py:class:`Message` class is a mutable holder of message content. + + :ivar instructions: delivery instructions for the message ("Delivery Annotations" in the AMQP 1.0 spec) + :vartype instructions: ``dict`` + :ivar ~.annotations: infrastructure defined message annotations ("Message Annotations" in the AMQP 1.0 spec) + :vartype ~.annotations: ``dict`` + :ivar ~.properties: application defined message properties + :vartype ~.properties: ``dict`` + :ivar body: message body + + :param kwargs: Message property name/value pairs to initialize the Message + """ + + DEFAULT_PRIORITY = PN_DEFAULT_PRIORITY + """ Default AMQP message priority""" + + def __init__( + self, + body: Union[ + bytes, str, dict, list, int, float, "UUID", "Described", None + ] = None, + **kwargs + ) -> None: + self._msg = pn_message() + self.instructions = None + self.annotations = None + self.properties = None + self.body = body + for k, v in kwargs.items(): + getattr(self, k) # Raise exception if it's not a valid attribute. + setattr(self, k, v) + + def __del__(self) -> None: + if hasattr(self, "_msg"): + pn_message_free(self._msg) + del self._msg + + def _check(self, err: int) -> int: + if err < 0: + exc = EXCEPTIONS.get(err, MessageException) + raise exc("[%s]: %s" % (err, pn_error_text(pn_message_error(self._msg)))) + else: + return err + + def _check_property_keys(self) -> None: + """ + AMQP allows only string keys for properties. This function checks that this requirement is met + and raises a MessageException if not. However, in certain cases, conversions to string are + automatically performed: + + 1. When a key is a user-defined (non-AMQP) subclass of str. + AMQP types symbol and char, although derived from str, are not converted, + and result in an exception. + """ + # We cannot make changes to the dict while iterating, so we + # must save and make the changes afterwards + changed_keys = [] + for k in self.properties.keys(): + if isinstance(k, str): + # strings and their subclasses + if type(k) is symbol or type(k) is char: + # Exclude symbol and char + raise MessageException( + "Application property key is not string type: key=%s %s" + % (str(k), type(k)) + ) + if type(k) is not str: + # Only for string subclasses, convert to string + changed_keys.append((k, str(k))) + else: + # Anything else: raise exception + raise MessageException( + "Application property key is not string type: key=%s %s" + % (str(k), type(k)) + ) + # Make the key changes + for old_key, new_key in changed_keys: + self.properties[new_key] = self.properties.pop(old_key) + + def _pre_encode(self) -> None: + inst = Data(pn_message_instructions(self._msg)) + ann = Data(pn_message_annotations(self._msg)) + props = Data(pn_message_properties(self._msg)) + body = Data(pn_message_body(self._msg)) + + inst.clear() + if self.instructions is not None: + inst.put_object(self.instructions) + ann.clear() + if self.annotations is not None: + ann.put_object(self.annotations) + props.clear() + if self.properties is not None: + self._check_property_keys() + props.put_object(self.properties) + body.clear() + if self.body is not None: + body.put_object(self.body) + + def _post_decode(self) -> None: + inst = Data(pn_message_instructions(self._msg)) + ann = Data(pn_message_annotations(self._msg)) + props = Data(pn_message_properties(self._msg)) + body = Data(pn_message_body(self._msg)) + + if inst.next(): + self.instructions = inst.get_object() + else: + self.instructions = None + if ann.next(): + self.annotations = ann.get_object() + else: + self.annotations = None + if props.next(): + self.properties = props.get_object() + else: + self.properties = None + if body.next(): + self.body = body.get_object() + else: + self.body = None + + def clear(self) -> None: + """ + Clears the contents of the :class:`Message`. All fields will be reset to + their default values. + """ + pn_message_clear(self._msg) + self.instructions = None + self.annotations = None + self.properties = None + self.body = None + + @property + def inferred(self) -> bool: + """The inferred flag for a message indicates how the message content + is encoded into AMQP sections. If inferred is true then binary and + list values in the body of the message will be encoded as AMQP DATA + and AMQP SEQUENCE sections, respectively. If inferred is false, + then all values in the body of the message will be encoded as AMQP + VALUE sections regardless of their type. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return pn_message_is_inferred(self._msg) + + @inferred.setter + def inferred(self, value: bool) -> None: + self._check(pn_message_set_inferred(self._msg, bool(value))) + + @property + def durable(self) -> bool: + """The durable property indicates that the message should be held durably + by any intermediaries taking responsibility for the message. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return pn_message_is_durable(self._msg) + + @durable.setter + def durable(self, value: bool) -> None: + self._check(pn_message_set_durable(self._msg, bool(value))) + + @property + def priority(self) -> int: + """The relative priority of the message, with higher numbers indicating + higher priority. The number of available priorities depends + on the implementation, but AMQP defines the default priority as + the value ``4``. See the + `OASIS AMQP 1.0 standard + `_ + for more details on message priority. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return pn_message_get_priority(self._msg) + + @priority.setter + def priority(self, value: int) -> None: + self._check(pn_message_set_priority(self._msg, value)) + + @property + def ttl(self) -> float: + """The time to live of the message measured in seconds. Expired messages + may be dropped. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return millis2secs(pn_message_get_ttl(self._msg)) + + @ttl.setter + def ttl(self, value: Union[float, int]) -> None: + self._check(pn_message_set_ttl(self._msg, secs2millis(value))) + + @property + def first_acquirer(self) -> bool: + """``True`` iff the recipient is the first to acquire the message, + ``False`` otherwise. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return pn_message_is_first_acquirer(self._msg) + + @first_acquirer.setter + def first_acquirer(self, value: bool) -> None: + self._check(pn_message_set_first_acquirer(self._msg, bool(value))) + + @property + def delivery_count(self) -> int: + """The number of delivery attempts made for this message. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return pn_message_get_delivery_count(self._msg) + + @delivery_count.setter + def delivery_count(self, value: int) -> None: + self._check(pn_message_set_delivery_count(self._msg, value)) + + @property + def id(self) -> Optional[Union[str, bytes, "UUID", ulong]]: + """The globally unique id of the message, and can be used + to determine if a received message is a duplicate. The allowed + types to set the id are: + + :type: The valid AMQP types for an id are one of: + + * ``int`` (unsigned) + * ``uuid.UUID`` + * ``bytes`` + * ``str`` + """ + value = pn_message_get_id(self._msg) + if isinstance(value, tuple): + if value[0] == PN_UUID: + value = UUID(bytes=value[1]) + return value + + @id.setter + def id(self, value: Optional[Union[str, bytes, "UUID", int]]) -> None: + pn_message_set_id(self._msg, value) + + @property + def user_id(self) -> bytes: + """The user id of the message creator. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return pn_message_get_user_id(self._msg) + + @user_id.setter + def user_id(self, value: bytes) -> None: + self._check(pn_message_set_user_id(self._msg, value)) + + @property + def address(self) -> Optional[str]: + """The address of the message. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return pn_message_get_address(self._msg) + + @address.setter + def address(self, value: str) -> None: + self._check(pn_message_set_address(self._msg, value)) + + @property + def subject(self) -> Optional[str]: + """The subject of the message. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return pn_message_get_subject(self._msg) + + @subject.setter + def subject(self, value: str) -> None: + self._check(pn_message_set_subject(self._msg, value)) + + @property + def reply_to(self) -> Optional[str]: + """The reply-to address for the message. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return pn_message_get_reply_to(self._msg) + + @reply_to.setter + def reply_to(self, value: str) -> None: + self._check(pn_message_set_reply_to(self._msg, value)) + + @property + def correlation_id(self) -> Optional[Union["UUID", ulong, str, bytes]]: + """The correlation-id for the message. + + :type: The valid AMQP types for a correlation-id are one of: + + * ``int`` (unsigned) + * ``uuid.UUID`` + * ``bytes`` + * ``str`` + """ + value = pn_message_get_correlation_id(self._msg) + if isinstance(value, tuple): + if value[0] == PN_UUID: + value = UUID(bytes=value[1]) + return value + + @correlation_id.setter + def correlation_id(self, value: Optional[Union[str, bytes, "UUID", int]]) -> None: + pn_message_set_correlation_id(self._msg, value) + + @property + def content_type(self) -> symbol: + """The RFC-2046 [RFC2046] MIME type for the message body. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return symbol(pn_message_get_content_type(self._msg)) + + @content_type.setter + def content_type(self, value: str) -> None: + self._check(pn_message_set_content_type(self._msg, value)) + + @property + def content_encoding(self) -> symbol: + """The content-encoding of the message. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return symbol(pn_message_get_content_encoding(self._msg)) + + @content_encoding.setter + def content_encoding(self, value: str) -> None: + self._check(pn_message_set_content_encoding(self._msg, value)) + + @property + def expiry_time(self) -> float: # TODO doc said int + """The absolute expiry time of the message in seconds using the Unix time_t [IEEE1003] encoding. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return millis2secs(pn_message_get_expiry_time(self._msg)) + + @expiry_time.setter + def expiry_time(self, value: Union[float, int]) -> None: + self._check(pn_message_set_expiry_time(self._msg, secs2millis(value))) + + @property + def creation_time(self) -> float: + """The creation time of the message in seconds using the Unix time_t [IEEE1003] encoding. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return millis2secs(pn_message_get_creation_time(self._msg)) + + @creation_time.setter + def creation_time(self, value: Union[float, int]) -> None: + self._check(pn_message_set_creation_time(self._msg, secs2millis(value))) + + @property + def group_id(self) -> Optional[str]: + """The group id of the message. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return pn_message_get_group_id(self._msg) + + @group_id.setter + def group_id(self, value: str) -> None: + self._check(pn_message_set_group_id(self._msg, value)) + + @property + def group_sequence(self) -> int: + """The sequence of the message within its group. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return pn_message_get_group_sequence(self._msg) + + @group_sequence.setter + def group_sequence(self, value: int) -> None: + self._check(pn_message_set_group_sequence(self._msg, value)) + + @property + def reply_to_group_id(self) -> Optional[str]: + """The group-id for any replies. + + :raise: :exc:`MessageException` if there is any Proton error when using the setter. + """ + return pn_message_get_reply_to_group_id(self._msg) + + @reply_to_group_id.setter + def reply_to_group_id(self, value: str) -> None: + self._check(pn_message_set_reply_to_group_id(self._msg, value)) + + @property + def instructions(self) -> Optional[AnnotationDict]: + """Delivery annotations as a dictionary of key/values. The AMQP 1.0 + specification restricts this dictionary to have keys that are either + :class:`symbol` or :class:`ulong` types. It is possible to use + the special ``dict`` subclass :class:`AnnotationDict` which + will by default enforce these restrictions on construction. In addition, + if string types are used, this class will be silently convert them into + symbols. + + :type: :class:`AnnotationDict`. Any ``dict`` with :class:`ulong` or :class:`symbol` keys. + """ + return self.instruction_dict + + @instructions.setter + def instructions( + self, instructions: Optional[Dict[Union[str, int], "PythonAMQPData"]] + ) -> None: + if isinstance(instructions, dict): + self.instruction_dict = AnnotationDict(instructions, raise_on_error=False) + else: + self.instruction_dict = instructions + + @property + def annotations(self) -> Optional[AnnotationDict]: + """Message annotations as a dictionary of key/values. The AMQP 1.0 + specification restricts this dictionary to have keys that are either + :class:`symbol` or :class:`ulong` types. It is possible to use + the special ``dict`` subclass :class:`AnnotationDict` which + will by default enforce these restrictions on construction. In addition, + if a string types are used, this class will silently convert them into + symbols. + + :type: :class:`AnnotationDict`. Any ``dict`` with :class:`ulong` or :class:`symbol` keys. + """ + return self.annotation_dict + + @annotations.setter + def annotations( + self, annotations: Optional[Dict[Union[str, int], "PythonAMQPData"]] + ) -> None: + if isinstance(annotations, dict): + self.annotation_dict = AnnotationDict(annotations, raise_on_error=False) + else: + self.annotation_dict = annotations + + def encode(self) -> bytes: + self._pre_encode() + sz = 16 + while True: + err, data = pn_message_encode(self._msg, sz) + if err == PN_OVERFLOW: + sz *= 2 + continue + else: + self._check(err) + return data + + def encode_delete(self) -> bytes: + self._pre_encode() + sz = 16 + while True: + err, data = pn_message_encode(self._msg, sz) + if err == PN_OVERFLOW: + sz *= 2 + continue + else: + self._check(err) + # workaround because of: https://github.com/rabbitmq/rabbitmq-amqp-python-client/issues/1 + if self.body is None: + data[0] = 0 + data[1] = 83 + data[2] = 119 + data[3] = 64 + + return data + + def decode(self, data: bytes) -> None: + self._check(pn_message_decode(self._msg, data)) + self._post_decode() + + def send(self, sender: "Sender", tag: Optional[str] = None) -> "Delivery": + """ + Encodes and sends the message content using the specified sender, + and, if present, using the specified tag. Upon success, will + return the :class:`Delivery` object for the sent message. + + :param sender: The sender to send the message + :param tag: The delivery tag for the sent message + :return: The delivery associated with the sent message + """ + dlv = sender.delivery(tag or sender.delivery_tag()) + + # workaround because of: https://github.com/rabbitmq/rabbitmq-amqp-python-client/issues/1 + if sender.target.address == "/management": + encoded = self.encode_delete() + else: + encoded = self.encode() + + sender.stream(encoded) + sender.advance() + if sender.snd_settle_mode == Link.SND_SETTLED: + dlv.settle() + return dlv + + @overload + def recv(self, link: "Sender") -> None: ... + + def recv(self, link: "Receiver") -> Optional["Delivery"]: + """ + Receives and decodes the message content for the current :class:`Delivery` + from the link. Upon success it will return the current delivery + for the link. If there is no current delivery, or if the current + delivery is incomplete, or if the link is not a receiver, it will + return ``None``. + + :param link: The link to receive a message from + :return: the delivery associated with the decoded message (or None) + + """ + if link.is_sender: + return None + dlv = link.current + if not dlv or dlv.partial: + return None + dlv.encoded = link.recv(dlv.pending) + link.advance() + # the sender has already forgotten about the delivery, so we might + # as well too + if link.remote_snd_settle_mode == Link.SND_SETTLED: + dlv.settle() + self.decode(dlv.encoded) + return dlv + + def __repr__(self) -> str: + props = [] + for attr in ( + "inferred", + "address", + "reply_to", + "durable", + "ttl", + "priority", + "first_acquirer", + "delivery_count", + "id", + "correlation_id", + "user_id", + "group_id", + "group_sequence", + "reply_to_group_id", + "instructions", + "annotations", + "properties", + "body", + ): + value = getattr(self, attr) + if value: + props.append("%s=%r" % (attr, value)) + return "Message(%s)" % ", ".join(props) diff --git a/rabbitmq_amqp_python_client/qpid/proton/_reactor.py b/rabbitmq_amqp_python_client/qpid/proton/_reactor.py new file mode 100644 index 0000000..89b2e7c --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_reactor.py @@ -0,0 +1,1717 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 heapq +import json +import logging +import os +import queue +import re +from typing import ( + TYPE_CHECKING, + Any, + Callable, + Dict, + Iterator, + List, + Optional, + Tuple, + Type, + Union, +) + +try: + from typing import Literal +except ImportError: + # https://www.python.org/dev/peps/pep-0560/#class-getitem + class GenericMeta(type): + def __getitem__(self, item): + pass + + class Literal(metaclass=GenericMeta): + pass + + +import time +import traceback +import uuid +from functools import total_ordering + +from cproton import PN_ACCEPTED, PN_EVENT_NONE + +from ._data import Described, symbol, ulong +from ._delivery import Delivery +from ._endpoints import ( + Connection, + Endpoint, + Link, + Session, + Terminus, +) +from ._events import Collector, Event, EventBase, EventType +from ._exceptions import SSLUnavailable +from ._handler import Handler +from ._handlers import IOHandler, OutgoingMessageHandler +from ._io import IO +from ._message import Message +from ._selectable import Selectable +from ._transport import SSL, SSLDomain, Transport +from ._url import Url + +if TYPE_CHECKING: + from socket import socket + from types import TracebackType + from uuid import UUID + + from ._data import PythonAMQPData + from ._endpoints import Receiver, Sender + from ._handlers import TransactionHandler + + +_logger = logging.getLogger("proton") + + +def _generate_uuid() -> "UUID": + return uuid.uuid4() + + +def _now() -> float: + return time.time() + + +@total_ordering +class Task(object): + def __init__(self, reactor: "Container", deadline: float, handler: Handler) -> None: + self._deadline = deadline + self._handler = handler + self._reactor = reactor + self._cancelled = False + + def __lt__(self, rhs: "Task") -> bool: + return self._deadline < rhs._deadline + + def cancel(self) -> None: + self._cancelled = True + + @property + def handler(self) -> Handler: + return self._handler + + @property + def container(self) -> "Container": + return self._reactor + + +class TimerSelectable(Selectable): + def __init__(self, reactor: "Container") -> None: + super(TimerSelectable, self).__init__(None, reactor) + + def readable(self) -> None: + pass + + def writable(self) -> None: + pass + + def expired(self) -> None: + self._reactor.timer_tick() + self.deadline = self._reactor.timer_deadline + self.update() + + +class Reactor(object): + def __init__(self, *handlers, **kwargs) -> None: + self._previous = PN_EVENT_NONE + self._timeout = 0 + self.mark() + self._yield = False + self._stop = False + self._collector = Collector() + self._selectable = None + self._selectables = 0 + self._global_handler = IOHandler() + self._handler = Handler() + self._timerheap = [] + self._timers = 0 + self.errors: List[ + Tuple[Type[BaseException], BaseException, "TracebackType"] + ] = [] + for h in handlers: + self.handler.add(h, on_error=self.on_error) + + def on_error( + self, info: Tuple[Type[BaseException], BaseException, "TracebackType"] + ) -> None: + self.errors.append(info) + self.yield_() + + # TODO: need to make this actually return a proxy which catches exceptions and calls + # on error. + # [Or arrange another way to deal with exceptions thrown by handlers] + def _make_handler(self, handler: Handler) -> Handler: + """ + Return a proxy handler that dispatches to the provided handler. + + If handler throws an exception then on_error is called with info + """ + return handler + + @property + def global_handler(self) -> Handler: + return self._global_handler + + @global_handler.setter + def global_handler(self, handler: Handler) -> None: + self._global_handler = self._make_handler(handler) + + @property + def timeout(self) -> float: + return self._timeout + + @timeout.setter + def timeout(self, secs: float) -> None: + self._timeout = secs + + def yield_(self) -> None: + self._yield = True + + def mark(self) -> float: + """This sets the reactor now instant to the current time""" + self._now = _now() + return self._now + + @property + def now(self) -> float: + return self._now + + @property + def handler(self) -> Handler: + return self._handler + + @handler.setter + def handler(self, handler: Handler) -> None: + self._handler = self._make_handler(handler) + + def run(self) -> None: + """ + Start the processing of events and messages for this container. + """ + # TODO: Why do we timeout like this? + self.timeout = 3.14159265359 + self.start() + while self.process(): + pass + self.stop() + self.process() + # TODO: This isn't correct if we ever run again + self._global_handler = None + self._handler = None + + # Cross thread reactor wakeup + def wakeup(self) -> None: + # TODO: Do this with pipe and write? + # os.write(self._wakeup[1], "x", 1); + pass + + def start(self) -> None: + self.push_event(self, Event.REACTOR_INIT) + self._selectable = TimerSelectable(self) + self._selectable.deadline = self.timer_deadline + # TODO set up fd to read for wakeups - but problematic on windows + # self._selectable.fileno(self._wakeup[0]) + # self._selectable.reading = True + self.update(self._selectable) + + @property + def quiesced(self) -> bool: + event = self._collector.peek() + if not event: + return True + if self._collector.more(): + return False + return event.type is Event.REACTOR_QUIESCED + + def _check_errors(self) -> None: + """This""" + if self.errors: + for exc, value, tb in self.errors[:-1]: + traceback.print_exception(exc, value, tb) + exc, value, tb = self.errors[-1] + if value is None: + value = exc() + if tb is None: + raise value + else: + raise value.with_traceback(tb) + + def process(self) -> bool: + # result = pn_reactor_process(self._impl) + # self._check_errors() + # return result + self.mark() + previous = PN_EVENT_NONE + while True: + if self._yield: + self._yield = False + _logger.debug("%s Yielding", self) + return True + event = self._collector.peek() + if event: + _logger.debug("%s recvd Event: %r", self, event) + type = event.type + + # regular handler + handler = event.handler or self._handler + event.dispatch(handler) + + event.dispatch(self._global_handler) + + previous = type + self._previous = type + self._collector.pop() + elif not self._stop and (self._timers > 0 or self._selectables > 1): + if ( + previous is not Event.REACTOR_QUIESCED + and self._previous is not Event.REACTOR_FINAL + ): + self.push_event(self, Event.REACTOR_QUIESCED) + self.yield_() + else: + if self._selectable: + self._selectable.terminate() + self._selectable.update() + self._selectable = None + else: + if self._previous is not Event.REACTOR_FINAL: + self.push_event(self, Event.REACTOR_FINAL) + _logger.debug("%s Stopping", self) + return False + + def stop(self) -> None: + self._stop = True + self._check_errors() + + def stop_events(self) -> None: + self._collector.release() + + def schedule(self, delay: Union[float, int], handler: Handler) -> Task: + """ + Schedule a task to run on this container after a given delay, + and using the supplied handler. + + :param delay: + :param handler: + """ + himpl = self._make_handler(handler) + task = Task(self, self._now + delay, himpl) + heapq.heappush(self._timerheap, task) + self._timers += 1 + deadline = self._timerheap[0]._deadline + if self._selectable: + self._selectable.deadline = deadline + self.update(self._selectable) + return task + + def timer_tick(self) -> None: + while self._timers > 0: + t = self._timerheap[0] + if t._cancelled: + heapq.heappop(self._timerheap) + self._timers -= 1 + elif t._deadline > self._now: + return + else: + heapq.heappop(self._timerheap) + self._timers -= 1 + self.push_event(t, Event.TIMER_TASK) + + @property + def timer_deadline(self) -> Optional[float]: + while self._timers > 0: + t = self._timerheap[0] + if t._cancelled: + heapq.heappop(self._timerheap) + self._timers -= 1 + else: + return t._deadline + return None + + def acceptor( + self, + host: str, + port: Union[str, Url.Port], + handler: Optional[Handler] = None, + ) -> "Acceptor": + impl = self._make_handler(handler) + a = Acceptor(self, host, int(port), impl) + if a: + return a + else: + raise IOError("%s (%s:%s)" % (str(self.errors), host, port)) + + def connection(self, handler: Optional[Handler] = None) -> Connection: + """Deprecated: use connection_to_host() instead""" + impl = self._make_handler(handler) + result = Connection() + if impl: + result.handler = impl + result._reactor = self + result.collect(self._collector) + return result + + def connection_to_host( + self, host, port, handler: Optional[Handler] = None + ) -> Connection: + """Create an outgoing Connection that will be managed by the reactor. + The reactor's pn_iohandler will create a socket connection to the host + once the connection is opened. + """ + conn = self.connection(handler) + self.set_connection_host(conn, host, port) + return conn + + def set_connection_host(self, connection: Connection, host, port) -> None: + """Change the address used by the connection. The address is + used by the reactor's iohandler to create an outgoing socket + connection. This must be set prior to opening the connection. + """ + connection.set_address(host, port) + + def get_connection_address(self, connection: Connection) -> str: + """*Deprecated* in favor of the property proton.Connection.connected_address. + This may be used to retrieve the remote peer address. + :return: string containing the address in URL format or None if no + address is available. Use the proton.Url class to create a Url object + from the returned value. + """ + return connection.connected_address + + def selectable( + self, + handler: Optional[Union["Acceptor", "EventInjector"]] = None, + delegate: Optional["socket"] = None, + ) -> Selectable: + """ + NO IDEA! + + :param handler: no idea + :type handler: ? + :param delegate: no idea + :type delegate: ? + """ + if delegate is None: + delegate = handler + result = Selectable(delegate, self) + result.handler = handler + return result + + def update(self, selectable: Selectable) -> None: + selectable.update() + + def push_event( + self, obj: Union["Reactor", Task, "Container", Selectable], etype: EventType + ) -> None: + self._collector.put(obj, etype) + + +class EventInjector(object): + """ + Can be added to a :class:`Container` to allow events to be triggered by an + external thread but handled on the event thread associated with + the container. An instance of this class can be passed to the + :meth:`Container.selectable` method in order to activate + it. :meth:`close` should be called when it is no longer + needed, to allow the event loop to end if needed. + """ + + def __init__(self) -> None: + self.queue = queue.Queue() + self.pipe = os.pipe() + self._transport = None + self._closed = False + + def trigger(self, event: "ApplicationEvent") -> None: + """ + Request that the given event be dispatched on the event thread + of the container to which this EventInjector was added. + + :param event: Event to be injected + :type event: :class:`proton.Event`, :class:`ApplicationEvent` + """ + self.queue.put(event) + os.write(self.pipe[1], b"!") + + def close(self) -> None: + """ + Request that this EventInjector be closed. Existing events + will be dispatched on the container's event dispatch thread, + then this will be removed from the set of interest. + """ + self._closed = True + os.write(self.pipe[1], b"!") + + def fileno(self) -> int: + return self.pipe[0] + + def on_selectable_init(self, event: Event) -> None: + sel = event.context + # sel.fileno(self.fileno()) + sel.reading = True + sel.update() + + def on_selectable_readable(self, event: Event) -> None: + s = event.context + os.read(self.pipe[0], 512) + while not self.queue.empty(): + requested = self.queue.get() + s.push_event(requested.context, requested.type) + if self._closed: + s.terminate() + s.update() + + +class ApplicationEvent(EventBase): + """ + Application defined event, which can optionally be associated with + an engine object and or an arbitrary subject. This produces + extended event types - see :class:`proton.EventType` for details. + + :param typename: Event type name + :param connection: Associates this event with a connection. + :param session: Associates this event with a session. + :param link: Associate this event with a link. + :param delivery: Associate this event with a delivery. + :param subject: Associate this event with an arbitrary object + """ + + TYPES = {} + + def __init__( + self, + typename: str, + connection: Optional[Connection] = None, + session: Optional[Session] = None, + link: Optional[Link] = None, + delivery: Optional[Delivery] = None, + subject: Any = None, + ) -> None: + if isinstance(typename, EventType): + eventtype = typename + else: + try: + eventtype = self.TYPES[typename] + except KeyError: + eventtype = EventType(typename) + self.TYPES[typename] = eventtype + super(ApplicationEvent, self).__init__(eventtype) + self.connection = connection + self.session = session + self.link = link + self.delivery = delivery + if self.delivery: + self.link = self.delivery.link + if self.link: + self.session = self.link.session + if self.session: + self.connection = self.session.connection + self.subject = subject + + @property + def context(self) -> "ApplicationEvent": + """ + A reference to this event. + """ + return self + + def __repr__(self) -> str: + objects = [ + self.connection, + self.session, + self.link, + self.delivery, + self.subject, + ] + return "%s(%s)" % ( + self.type, + ", ".join([str(o) for o in objects if o is not None]), + ) + + +class Transaction(object): + """ + Tracks the state of an AMQP 1.0 local transaction. In typical usage, this + object is not created directly, but is obtained through the event returned + by :meth:`proton.handlers.TransactionHandler.on_transaction_declared` after + a call to :meth:`proton.reactor.Container.declare_transaction`. + + To send messages under this transaction, use :meth:`send`. + + To receive messages under this transaction, call :meth:`accept` once the + message is received (typically from the + :meth:`proton.handlers.MessagingHandler.on_message` callback). + + To discharge the transaction, call either :meth:`commit` + (for a successful transaction), or :meth:`abort` (for a failed transaction). + """ + + def __init__( + self, + txn_ctrl: "Sender", + handler: "TransactionHandler", + settle_before_discharge: bool = False, + ) -> None: + self.txn_ctrl = txn_ctrl + self.handler = handler + self.id = None + self._declare = None + self._discharge = None + self.failed = False + self._pending = [] + self.settle_before_discharge = settle_before_discharge + self.declare() + + def commit(self) -> None: + """ + Commit this transaction. Closes the transaction as a success. + """ + self.discharge(False) + + def abort(self) -> None: + """ + Abort or roll back this transaction. Closes the transaction as a failure, + and reverses, or rolls back all actions (sent and received messages) + performed under this transaction. + """ + self.discharge(True) + + def declare(self) -> None: + self._declare = self._send_ctrl(symbol("amqp:declare:list"), [None]) + + def discharge(self, failed: bool) -> None: + self.failed = failed + self._discharge = self._send_ctrl( + symbol("amqp:discharge:list"), [self.id, failed] + ) + + def _send_ctrl( + self, descriptor: "PythonAMQPData", value: "PythonAMQPData" + ) -> Delivery: + delivery = self.txn_ctrl.send(Message(body=Described(descriptor, value))) + delivery.transaction = self + return delivery + + def send( + self, + sender: "Sender", + msg: Message, + tag: Optional[str] = None, + ) -> Delivery: + """ + Send a message under this transaction. + + :param sender: Link over which to send the message. + :param msg: Message to be sent under this transaction. + :param tag: The delivery tag + :return: Delivery object for this message. + """ + dlv = sender.send(msg, tag=tag) + dlv.local.data = [self.id] + dlv.update(0x34) + return dlv + + def accept(self, delivery: Delivery) -> None: + """ + Accept a received message under this transaction. + + :param delivery: Delivery object for the received message. + """ + self.update(delivery, PN_ACCEPTED) + if self.settle_before_discharge: + delivery.settle() + else: + self._pending.append(delivery) + + def update(self, delivery: Delivery, state: Optional[ulong] = None) -> None: + if state: + delivery.local.data = [self.id, Described(ulong(state), [])] + delivery.update(0x34) + + def _release_pending(self): + for d in self._pending: + d.update(Delivery.RELEASED) + d.settle() + self._clear_pending() + + def _clear_pending(self): + self._pending = [] + + def handle_outcome(self, event): + if event.delivery == self._declare: + if event.delivery.remote.data: + self.id = event.delivery.remote.data[0] + self.handler.on_transaction_declared(event) + elif event.delivery.remote_state == Delivery.REJECTED: + self.handler.on_transaction_declare_failed(event) + else: + _logger.warning( + "Unexpected outcome for declare: %s" % event.delivery.remote_state + ) + self.handler.on_transaction_declare_failed(event) + elif event.delivery == self._discharge: + if event.delivery.remote_state == Delivery.REJECTED: + if not self.failed: + self.handler.on_transaction_commit_failed(event) + self._release_pending() # make this optional? + else: + if self.failed: + self.handler.on_transaction_aborted(event) + self._release_pending() + else: + self.handler.on_transaction_committed(event) + self._clear_pending() + + +class LinkOption(object): + """ + Abstract interface for link configuration options + """ + + def apply(self, link: Link) -> None: + """ + Subclasses will implement any configuration logic in this + method + """ + pass + + def test(self, link: Link) -> bool: + """ + Subclasses can override this to selectively apply an option + e.g. based on some link criteria + """ + return True + + +class AtMostOnce(LinkOption): + """ + Set at-most-once delivery semantics for message delivery. This is achieved by + setting the sender link settle mode to :const:`proton.Link.SND_SETTLED` + (ie pre-settled). + """ + + def apply(self, link: Link) -> None: + """ + Set the at-most-once delivery semantics on the link. + + :param link: The link on which this option is to be applied. + :type link: :class:`proton.Link` + """ + link.snd_settle_mode = Link.SND_SETTLED + + +class AtLeastOnce(LinkOption): + """ + Set at-least-once delivery semantics for message delivery. This is achieved + by setting the sender link settle mode to :const:`proton.Link.SND_UNSETTLED` + and the receiver link settle mode to :const:`proton.Link.RCV_FIRST`. This + forces the receiver to settle all messages once they are successfully received. + """ + + def apply(self, link: Link) -> None: + """ + Set the at-least-once delivery semantics on the link. + + :param link: The link on which this option is to be applied. + :type link: :class:`proton.Link` + """ + link.snd_settle_mode = Link.SND_UNSETTLED + link.rcv_settle_mode = Link.RCV_FIRST + + +class SenderOption(LinkOption): + """ + Abstract class for sender options. + """ + + def apply(self, sender: "Sender") -> None: + """ + Set the option on the sender. + + :param sender: The sender on which this option is to be applied. + """ + pass + + def test(self, link: Link) -> bool: + return link.is_sender + + +class ReceiverOption(LinkOption): + """ + Abstract class for receiver options + """ + + def apply(self, receiver: "Receiver") -> None: + """ + Set the option on the receiver. + + :param receiver: The receiver on which this option is to be applied. + """ + pass + + def test(self, link: Link) -> bool: + return link.is_receiver + + +class DynamicNodeProperties(LinkOption): + """ + Allows a map of link properties to be set on a link. The + keys may be :class:`proton.symbol` or strings (in which case + they will be converted to symbols before being applied). + + :param props: A map of link options to be applied to a link. + """ + + def __init__(self, props: dict = {}) -> None: + self.properties = {} + for k in props: + if isinstance(k, symbol): + self.properties[k] = props[k] + else: + self.properties[symbol(k)] = props[k] + + def apply(self, link: Link) -> None: + """ + Set the map of properties on the specified link. + + :param link: The link on which this property map is to be set. + """ + if link.is_receiver: + link.source.properties.put_dict(self.properties) + else: + link.target.properties.put_dict(self.properties) + + +class Filter(ReceiverOption): + """ + Receiver option which allows incoming messages to be filtered. + + :param filter_set: A map of filters with :class:`proton.symbol` keys + containing the filter name, and the value a filter string. + """ + + def __init__(self, filter_set: Dict[symbol, Described] = {}) -> None: + self.filter_set = filter_set + + def apply(self, receiver: "Receiver") -> None: + """ + Set the filter on the specified receiver. + + :param receiver: The receiver on which this filter is to be applied. + """ + receiver.source.filter.put_dict(self.filter_set) + + +class Selector(Filter): + """ + Configures a receiver with a message selector filter + + :param value: Selector filter string + :param name: Name of the selector, defaults to ``"selector"``. + """ + + def __init__(self, value: str, name: str = "selector") -> None: + super(Selector, self).__init__( + { + symbol(name): Described( + symbol("apache.org:selector-filter:string"), value + ) + } + ) + + +class DurableSubscription(ReceiverOption): + """ + Receiver option which sets both the configuration and delivery state + to durable. This is achieved by setting the receiver's source durability + to :const:`proton.Terminus.DELIVERIES` and the source expiry policy to + :const:`proton.Terminus.EXPIRE_NEVER`. + """ + + def apply(self, receiver: "Receiver"): + """ + Set durability on the specified receiver. + + :param receiver: The receiver on which durability is to be set. + """ + receiver.source.durability = Terminus.DELIVERIES + receiver.source.expiry_policy = Terminus.EXPIRE_NEVER + + +class Move(ReceiverOption): + """ + Receiver option which moves messages to the receiver (rather than copying). + This has the effect of distributing the incoming messages between the + receivers. This is achieved by setting the receiver source distribution + mode to :const:`proton.Terminus.DIST_MODE_MOVE`. + """ + + def apply(self, receiver: "Receiver"): + """ + Set message move semantics on the specified receiver. + + :param receiver: The receiver on which message move semantics is to be set. + """ + receiver.source.distribution_mode = Terminus.DIST_MODE_MOVE + + +class Copy(ReceiverOption): + """ + Receiver option which copies messages to the receiver. This ensures that all + receivers receive all incoming messages, no matter how many receivers there + are. This is achieved by setting the receiver source distribution mode to + :const:`proton.Terminus.DIST_MODE_COPY`. + """ + + def apply(self, receiver: "Receiver"): + """ + Set message copy semantics on the specified receiver. + + :param receiver: The receiver on which message copy semantics is to be set. + """ + receiver.source.distribution_mode = Terminus.DIST_MODE_COPY + + +def _apply_link_options( + options: Optional[Union[LinkOption, List[LinkOption]]], + link: Union["Sender", "Receiver"], +) -> None: + if options: + if isinstance(options, list): + for o in options: + if o.test(link): + o.apply(link) + else: + if options.test(link): + options.apply(link) + + +def _create_session( + connection: Connection, handler: Optional[Handler] = None +) -> Session: + session = connection.session() + session.open() + return session + + +def _get_attr(target: Any, name: str) -> Optional[Any]: + if hasattr(target, name): + return getattr(target, name) + else: + return None + + +class SessionPerConnection(object): + def __init__(self) -> None: + self._default_session = None + + def session(self, connection: Connection) -> Session: + if not self._default_session: + self._default_session = _create_session(connection) + return self._default_session + + +class GlobalOverrides(Handler): + """ + Internal handler that triggers the necessary socket connect for an + opened connection. + """ + + def __init__(self, base: IOHandler) -> None: + self.base = base + + def on_unhandled(self, name: str, event: Event) -> None: + if not self._override(event): + event.dispatch(self.base) + + def _override(self, event: Event) -> Optional[bool]: + conn = event.connection + return conn and hasattr(conn, "_overrides") and event.dispatch(conn._overrides) + + +class Acceptor(Handler): + def __init__( + self, + reactor: "Container", + host: str, + port: int, + handler: Optional[Handler] = None, + ) -> None: + self._ssl_domain = None + self._reactor = reactor + self._handler = handler + sock = IO.listen(host, port) + s = reactor.selectable(handler=self, delegate=sock) + s.reading = True + s._transport = None + self._selectable = s + reactor.update(s) + + def set_ssl_domain(self, ssl_domain: SSLDomain) -> None: + self._ssl_domain = ssl_domain + + def close(self) -> None: + if not self._selectable.is_terminal: + self._selectable.terminate() + self._selectable.update() + + def on_selectable_readable(self, event: Event) -> None: + s = event.selectable + + sock, name = IO.accept(self._selectable) + _logger.info("Accepted connection from %s", name) + + r = self._reactor + handler = self._handler or r.handler + c = r.connection(handler) + c._acceptor = self + c.url = Url(host=name[0], port=name[1]) + t = Transport(Transport.SERVER) + if self._ssl_domain: + t.ssl(self._ssl_domain) + t.bind(c) + + s = r.selectable(delegate=sock) + s._transport = t + t._selectable = s + IOHandler.update(t, s, r.now) + + +def delay_iter( + initial: float = 0.1, + factor: float = 2.0, + max_delay: float = 10.0, + max_tries: Optional[int] = None, +) -> Iterator[float]: + """ + iterator yielding the next delay in the sequence of delays. The first + delay is 0 seconds, the second 0.1 seconds, and each subsequent + call to :meth:`next` doubles the next delay period until a + maximum value of 10 seconds is reached. + """ + yield 0.0 + tries = 1 + delay = initial + while max_tries is None or tries < max_tries: + yield delay + tries += 1 + delay = min(max_delay, factor * delay) + + +class Backoff(object): + """ + A reconnect strategy involving an increasing delay between + retries, up to a maximum or 10 seconds. Repeated calls + to :meth:`next` returns a value for the next delay, starting + with an initial value of 0 seconds. + """ + + def __init__(self, **kwargs) -> None: + self.kwargs = kwargs + self.iter = delay_iter(**self.kwargs) + + def __iter__(self) -> Iterator[float]: + return self.iter + + +def make_backoff_wrapper( + backoff: Optional[Union[List[Union[float, int]], bool, Backoff]] +) -> Optional[Union[List[Union[float, int]], bool, Backoff]]: + """ + Make a wrapper for a backoff object: + If the object conforms to the old protocol (has reset and next methods) then + wrap it in an iterable that returns an iterator suitable for the new backoff approach + otherwise assume it is fine as it is! + """ + + class WrappedBackoff(object): + def __init__(self, backoff): + self.backoff = backoff + + def __iter__(self): + self.backoff.reset() + return self + + def __next__(self): + return self.backoff.next() + + if hasattr(backoff, "reset") and hasattr(backoff, "next"): + return WrappedBackoff(backoff) + else: + return backoff + + +class Urls(object): + def __init__(self, values: List[Union[Url, str]]) -> None: + self.values = [Url(v) for v in values] + + def __iter__(self) -> Iterator[Url]: + return iter(self.values) + + +class _Connector(Handler): + """ + Internal handler that triggers the necessary socket connect for an + opened connection. + """ + + def __init__(self, connection: Connection) -> None: + self.connection = connection + self.address = None + self.heartbeat = None + self.reconnect = None + self.ssl_domain = None + self.allow_insecure_mechs = True + self.allowed_mechs = None + self.sasl_enabled = True + self.user = None + self.password = None + self.virtual_host = None + self.ssl_sni = None + self.max_frame_size = None + self._connect_sequence = None + self._next_url = None + + def _connect(self, connection: Connection, url: Url) -> None: + connection.url = url + # if virtual-host not set, use host from address as default + if self.virtual_host is None: + connection.hostname = url.host + _logger.info("Connecting to %r..." % url) + + transport = Transport() + if self.sasl_enabled: + sasl = transport.sasl() + sasl.allow_insecure_mechs = self.allow_insecure_mechs + if url.username: + connection.user = url.username + elif self.user: + connection.user = self.user + if url.password: + connection.password = url.password + elif self.password: + connection.password = self.password + if self.allowed_mechs: + sasl.allowed_mechs(self.allowed_mechs) + transport.bind(connection) + if self.heartbeat: + transport.idle_timeout = self.heartbeat + if url.scheme == "amqps": + if not self.ssl_domain: + raise SSLUnavailable("amqps: SSL libraries not found") + self.ssl = SSL(transport, self.ssl_domain) + self.ssl.peer_hostname = self.ssl_sni or self.virtual_host or url.host + if self.max_frame_size: + transport.max_frame_size = self.max_frame_size + + def on_connection_local_open(self, event: Event) -> None: + if self.reconnect is None: + self._connect_sequence = ( + (delay, url) for delay in delay_iter() for url in self.address + ) + elif self.reconnect is False: + self._connect_sequence = ( + (delay, url) + for delay in delay_iter(max_tries=1) + for url in self.address + ) + else: + self._connect_sequence = ( + (delay, url) for delay in self.reconnect for url in self.address + ) + _, url = next( + self._connect_sequence + ) # Ignore delay as we assume first delay must be 0 + self._connect(event.connection, url) + + def on_connection_remote_open(self, event: Event) -> None: + _logger.info("Connected to %s" % event.connection.hostname) + if self.reconnect is None: + self._connect_sequence = ( + (delay, url) for delay in delay_iter() for url in self.address + ) + elif self.reconnect: + self._connect_sequence = ( + (delay, url) for delay in self.reconnect for url in self.address + ) + else: + self._connect_sequence = None # Help take out the garbage + + def on_transport_closed(self, event: Event) -> None: + if self.connection is None: + return + + if not self.connection.state & Endpoint.LOCAL_ACTIVE: + _logger.info("Disconnected, already closed") + elif self.reconnect is False: + _logger.info("Disconnected, reconnect disabled") + else: + try: + event.transport.unbind() + delay, url = next(self._connect_sequence) + if delay == 0: + _logger.info("Disconnected, reconnecting immediately...") + self._connect(self.connection, url) + return + else: + _logger.info( + "Disconnected will try to reconnect after %s seconds" % delay + ) + self._next_url = url + event.reactor.schedule(delay, self) + return + except StopIteration: + _logger.info("Disconnected, giving up retrying") + + # See connector.cpp: conn.free()/pn_connection_release() here? + self.connection = None + + def on_timer_task(self, event: Event) -> None: + if self._next_url: + self._connect(self.connection, self._next_url) + self._next_url = None + + +class SSLConfig(object): + def __init__(self) -> None: + self.client = SSLDomain(SSLDomain.MODE_CLIENT) + self.server = SSLDomain(SSLDomain.MODE_SERVER) + + def set_credentials(self, cert_file, key_file, password): + self.client.set_credentials(cert_file, key_file, password) + self.server.set_credentials(cert_file, key_file, password) + + def set_trusted_ca_db(self, certificate_db): + self.client.set_trusted_ca_db(certificate_db) + self.server.set_trusted_ca_db(certificate_db) + + +def _find_config_file() -> Optional[str]: + confname = "connect.json" + confpath = [".", os.path.expanduser("~/.config/messaging"), "/etc/messaging"] + for d in confpath: + f = os.path.join(d, confname) + if os.path.isfile(f): + return f + return None + + +def _get_default_config() -> Dict[str, Any]: + conf = os.environ.get("MESSAGING_CONNECT_FILE") or _find_config_file() + if conf and os.path.isfile(conf): + with open(conf, "r") as f: + json_text = f.read() + json_text = _strip_json_comments(json_text) + return json.loads(json_text) + else: + return {} + + +def _strip_json_comments(json_text: str) -> str: + """This strips c-style comments from text, taking into account '/*comments*/' and '//comments' + nested inside a string etc.""" + + def replacer(match): + s = match.group(0) + if s.startswith("/"): + return " " # note: a space and not an empty string + else: + return s + + pattern = re.compile( + r'//.*?$|/\*.*?\*/|\'(?:\\.|[^\\\'])*\'|"(?:\\.|[^\\"])*"', + re.DOTALL | re.MULTILINE, + ) + return re.sub(pattern, replacer, json_text) + + +def _get_default_port_for_scheme(scheme: str) -> int: + if scheme == "amqps": + return 5671 + else: + return 5672 + + +class Container(Reactor): + """ + A representation of the AMQP concept of a 'container', which + loosely speaking is something that establishes links to or from + another container, over which messages are transfered. This is + an extension to the Reactor class that adds convenience methods + for creating connections and sender- or receiver- links. + """ + + def __init__(self, *handlers, **kwargs) -> None: + super(Container, self).__init__(*handlers, **kwargs) + if "impl" not in kwargs: + try: + self.ssl = SSLConfig() + except SSLUnavailable: + self.ssl = None + self.global_handler = GlobalOverrides( + kwargs.get("global_handler", self.global_handler) + ) + self.trigger = None + self.container_id = kwargs.get("container_id", str(_generate_uuid())) + self.allow_insecure_mechs = True + self.allowed_mechs = None + self.sasl_enabled = True + self.user = None + self.password = None + + def connect( + self, + url: Optional[Union[str, Url]] = None, + urls: Optional[List[str]] = None, + address: Optional[str] = None, + handler: Optional[Handler] = None, + reconnect: Union[None, Literal[False], Backoff] = None, + heartbeat: Optional[float] = None, + ssl_domain: Optional[SSLDomain] = None, + **kwargs + ) -> Connection: + """ + Initiates the establishment of an AMQP connection. + + An optional JSON configuration file may be used to specify some connection + parameters. If present, these will override some of those given in this call + (see note below). Some connection parameters (for SSL/TLS) can only be + provided through this file. The configuration file is located by searching + for it as follows: + + 1. The location set in the environment variable ``MESSAGING_CONNECT_FILE`` + 2. ``./connect.json`` + 3. ``~/.config/messaging/connect.json`` + 4. ``/etc/messaging/connect.json`` + + To use SSL/TLS for encryption (when an ``amqps`` URL scheme is used), the above + configuration file must contain a ``tls`` submap containing the following + configuration entries (See :class:`proton.SSLDomain` for details): + + * ``ca``: Path to a database of trusted CAs that the server will advertise. + * ``cert``: Path to a file/database containing the identifying certificate. + * ``key``: An optional key to access the identifying certificate. + * ``verify``: If ``False``, do not verify the peer name + (:const:`proton.SSLDomain.ANONYMOUS_PEER`) or certificate. By default + (or if ``True``) verify the peer name and certificate using the + ``ca`` above (:const:`proton.SSLDomain.VERIFY_PEER_NAME`). + + :param url: URL string of process to connect to + :param urls: list of URL strings of process to try to connect to + + :param reconnect: Reconnect is enabled by default. You can + pass in an instance of :class:`Backoff` to control reconnect behavior. + A value of ``False`` will prevent the library from automatically + trying to reconnect if the underlying socket is disconnected + before the connection has been closed. + + :param heartbeat: A value in seconds indicating the + desired frequency of heartbeats used to test the underlying + socket is alive. + + :param ssl_domain: SSL configuration. + + :param handler: a connection scoped handler that will be + called to process any events in the scope of this connection + or its child links. + + :param kwargs: + + * ``sasl_enabled`` (``bool``), which determines whether a sasl layer + is used for the connection. + * ``allowed_mechs`` (``str``), an optional string specifying the + SASL mechanisms allowed for this connection; the value is a + space-separated list of mechanism names; the mechanisms allowed + by default are determined by your SASL library and system + configuration, with two exceptions: ``GSSAPI`` and ``GSS-SPNEGO`` + are disabled by default; to enable them, you must explicitly add + them using this option; clients must set the allowed mechanisms + before the outgoing connection is attempted; servers must set + them before the listening connection is setup. + * ``allow_insecure_mechs`` (``bool``), a flag indicating whether insecure + mechanisms, such as PLAIN over a non-encrypted socket, are + allowed. + * ``password`` (``str``), the authentication secret. Ignored without ``user`` + kwarg also being present. + * ``user`` (``str``), the user to authenticate. + * ``virtual_host`` (``str``), the hostname to set in the Open performative + used by peer to determine the correct back-end service for + the client; if ``virtual_host`` is not supplied the host field + from the URL is used instead. + * ``offered_capabilities``, a list of capabilities being offered to the + peer. The list must contain symbols (or strings, which will be converted + to symbols). + * ``desired_capabilities``, a list of capabilities desired from the peer. + The list must contain symbols (or strings, which will be converted + to symbols). + * ``properties``, a list of connection properties. This must be a map + with symbol keys (or string keys, which will be converted to symbol keys). + * ``sni`` (``str``), a hostname to use with SSL/TLS Server Name Indication (SNI) + * ``max_frame_size`` (``int``), the maximum allowable TCP packet size between the + peers. + + :return: A new connection object. + + .. note:: Only one of ``url`` or ``urls`` should be specified. + + .. note:: The following kwargs will be overridden by the values found + in the JSON configuration file (if they exist there): + + * ``password`` + * ``user`` + + and the following kwargs will be overridden by the values found in the ``sasl`` + sub-map of the above configuration file (if they exist there): + + * ``sasl_enabled`` + * ``allowed_mechs`` + """ + if not url and not urls and not address: + config = _get_default_config() + scheme = config.get("scheme", "amqps") + _url = "%s://%s:%s" % ( + scheme, + config.get("host", "localhost"), + config.get("port", _get_default_port_for_scheme(scheme)), + ) + _ssl_domain = None + _kwargs = kwargs + if config.get("user"): + _kwargs["user"] = config.get("user") + if config.get("password"): + _kwargs["password"] = config.get("password") + sasl_config = config.get("sasl", {}) + _kwargs["sasl_enabled"] = sasl_config.get("enabled", True) + if sasl_config.get("mechanisms"): + _kwargs["allowed_mechs"] = sasl_config.get("mechanisms") + tls_config = config.get("tls", {}) + if scheme == "amqps": + _ssl_domain = SSLDomain(SSLDomain.MODE_CLIENT) + ca = tls_config.get("ca") + cert = tls_config.get("cert") + key = tls_config.get("key") + verify = tls_config.get("verify", True) + if ca: + _ssl_domain.set_trusted_ca_db(str(ca)) + if not verify: + _ssl_domain.set_peer_authentication(SSLDomain.ANONYMOUS_PEER, None) + if cert and key: + _ssl_domain.set_credentials(str(cert), str(key), None) + + return self._connect( + _url, + handler=handler, + reconnect=reconnect, + heartbeat=heartbeat, + ssl_domain=_ssl_domain, + **_kwargs + ) + else: + return self._connect( + url=url, + urls=urls, + handler=handler, + reconnect=reconnect, + heartbeat=heartbeat, + ssl_domain=ssl_domain, + **kwargs + ) + + def _connect( + self, + url: Optional[Union[str, Url]] = None, + urls: Optional[List[str]] = None, + handler: Optional["Handler"] = None, + reconnect: Optional[Union[List[Union[float, int]], bool, Backoff]] = None, + heartbeat: None = None, + ssl_domain: Optional[SSLDomain] = None, + **kwargs + ) -> Connection: + conn = self.connection(handler) + conn.container = kwargs.get("container_id", self.container_id) or str( + _generate_uuid() + ) + conn.offered_capabilities = kwargs.get("offered_capabilities") + conn.desired_capabilities = kwargs.get("desired_capabilities") + conn.properties = kwargs.get("properties") + + connector = _Connector(conn) + connector.allow_insecure_mechs = kwargs.get( + "allow_insecure_mechs", self.allow_insecure_mechs + ) + connector.allowed_mechs = kwargs.get("allowed_mechs", self.allowed_mechs) + connector.sasl_enabled = kwargs.get("sasl_enabled", self.sasl_enabled) + connector.user = kwargs.get("user", self.user) + connector.password = kwargs.get("password", self.password) + connector.virtual_host = kwargs.get("virtual_host") + if connector.virtual_host: + # only set hostname if virtual-host is a non-empty string + conn.hostname = connector.virtual_host + connector.ssl_sni = kwargs.get("sni") + connector.max_frame_size = kwargs.get("max_frame_size") + + conn._overrides = connector + if url: + connector.address = Urls([url]) + elif urls: + connector.address = Urls(urls) + else: + raise ValueError("One of url or urls required") + if heartbeat: + connector.heartbeat = heartbeat + + connector.reconnect = make_backoff_wrapper(reconnect) + + # use container's default client domain if none specified. This is + # only necessary of the URL specifies the "amqps:" scheme + connector.ssl_domain = ssl_domain or (self.ssl and self.ssl.client) + conn._session_policy = SessionPerConnection() # todo: make configurable + conn.open() + return conn + + def _get_id( + self, container: str, remote: Optional[str], local: Optional[str] + ) -> str: + if local and remote: + return "%s-%s-%s" % (container, remote, local) + elif local: + return "%s-%s" % (container, local) + elif remote: + return "%s-%s" % (container, remote) + else: + return "%s-%s" % (container, str(_generate_uuid())) + + def _get_session(self, context: Connection) -> Session: + if isinstance(context, Url): + return self._get_session(self.connect(url=context)) + elif isinstance(context, Session): + return context + elif isinstance(context, Connection): + if hasattr(context, "_session_policy"): + return context._session_policy.session(context) + else: + return _create_session(context) + else: + return context.session() + + def create_sender( + self, + context: Union[str, Url, Connection], + target: Optional[str] = None, + source: Optional[str] = None, + name: Optional[str] = None, + handler: Optional[Handler] = None, + tags: Optional[Callable[[], bytes]] = None, + options: Optional[ + Union[ + "SenderOption", List["SenderOption"], "LinkOption", List["LinkOption"] + ] + ] = None, + ) -> "Sender": + """ + Initiates the establishment of a link over which messages can + be sent. + + There are two patterns of use: + + 1. A connection can be passed as the first argument, in which + case the link is established on that connection. In this case + the target address can be specified as the second argument (or + as a keyword argument). The source address can also be specified + if desired. + + 2. Alternatively a URL can be passed as the first argument. In + this case a new connection will be established on which the link + will be attached. If a path is specified and the target is not, + then the path of the URL is used as the target address. + + The name of the link may be specified if desired, otherwise a + unique name will be generated. + + Various :class:`LinkOption` s can be specified to further control the + attachment. + + :param context: A connection object or a URL. + :param target: Address of target node. + :param source: Address of source node. + :param name: Sender name. + :param handler: Event handler for this sender. + :param tags: Function to generate tags for this sender of the form ``def simple_tags():`` + and returns a ``bytes`` type + :param options: A single option, or a list of sender options + + :return: New sender instance. + """ + if isinstance(context, str): + context = Url(context) + if isinstance(context, Url) and not target: + target = context.path + session = self._get_session(context) + snd = session.sender( + name or self._get_id(session.connection.container, target, source) + ) + if source: + snd.source.address = source + if target: + snd.target.address = target + if handler is not None: + snd.handler = handler + if tags: + snd.tag_generator = tags + _apply_link_options(options, snd) + snd.open() + return snd + + def create_receiver( + self, + context: Union[Connection, Url, str], + source: Optional[str] = None, + target: Optional[str] = None, + name: Optional[str] = None, + dynamic: bool = False, + handler: Optional[Handler] = None, + options: Optional[ + Union[ReceiverOption, List[ReceiverOption], LinkOption, List[LinkOption]] + ] = None, + ) -> "Receiver": + """ + Initiates the establishment of a link over which messages can + be received (aka a subscription). + + There are two patterns of use: + + (1) A connection can be passed as the first argument, in which + case the link is established on that connection. In this case + the source address can be specified as the second argument (or + as a keyword argument). The target address can also be specified + if desired. + + (2) Alternatively a URL can be passed as the first argument. In + this case a new connection will be established on which the link + will be attached. If a path is specified and the source is not, + then the path of the URL is used as the target address. + + The name of the link may be specified if desired, otherwise a + unique name will be generated. + + Various :class:`LinkOption` s can be specified to further control the + attachment. + + :param context: A connection object or a URL. + :param source: Address of source node. + :param target: Address of target node. + :param name: Receiver name. + :param dynamic: If ``True``, indicates dynamic creation of the receiver. + :param handler: Event handler for this receiver. + :param options: A single option, or a list of receiver options + + :return: New receiver instance. + """ + if isinstance(context, str): + context = Url(context) + if isinstance(context, Url) and not source: + source = context.path + session = self._get_session(context) + rcv = session.receiver( + name or self._get_id(session.connection.container, source, target) + ) + if source: + rcv.source.address = source + if dynamic: + rcv.source.dynamic = True + if target: + rcv.target.address = target + if handler is not None: + rcv.handler = handler + _apply_link_options(options, rcv) + rcv.open() + return rcv + + def declare_transaction( + self, + context: Connection, + handler: Optional["TransactionHandler"] = None, + settle_before_discharge: bool = False, + ) -> Transaction: + """ + Declare a local transaction. + + :param context: Context for the transaction, usually the connection. + :param handler: Handler for transactional events. + :param settle_before_discharge: Settle all transaction control messages before + the transaction is discharged. + """ + if not _get_attr(context, "_txn_ctrl"): + + class InternalTransactionHandler(OutgoingMessageHandler): + def __init__(self): + super(InternalTransactionHandler, self).__init__(auto_settle=True) + + def on_settled(self, event): + if hasattr(event.delivery, "transaction"): + event.transaction = event.delivery.transaction + event.delivery.transaction.handle_outcome(event) + + def on_unhandled(self, method, event): + if handler: + event.dispatch(handler) + + context._txn_ctrl = self.create_sender( + context, None, name="txn-ctrl", handler=InternalTransactionHandler() + ) + context._txn_ctrl.target.type = Terminus.COORDINATOR + context._txn_ctrl.target.capabilities.put_object( + symbol("amqp:local-transactions") + ) + return Transaction(context._txn_ctrl, handler, settle_before_discharge) + + def listen( + self, url: Union[str, Url], ssl_domain: Optional[SSLDomain] = None + ) -> Acceptor: + """ + Initiates a server socket, accepting incoming AMQP connections + on the interface and port specified. + + :param url: URL on which to listen for incoming AMQP connections. + :param ssl_domain: SSL configuration object if SSL is to be used, ``None`` otherwise. + """ + url = Url(url) + acceptor = self.acceptor(url.host, url.port) + ssl_config = ssl_domain + if not ssl_config and url.scheme == "amqps": + # use container's default server domain + if self.ssl: + ssl_config = self.ssl.server + else: + raise SSLUnavailable("amqps: SSL libraries not found") + if ssl_config: + acceptor.set_ssl_domain(ssl_config) + return acceptor + + def do_work(self, timeout: Optional[float] = None) -> bool: + if timeout: + self.timeout = timeout + return self.process() diff --git a/rabbitmq_amqp_python_client/qpid/proton/_selectable.py b/rabbitmq_amqp_python_client/qpid/proton/_selectable.py new file mode 100644 index 0000000..85ee6b7 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_selectable.py @@ -0,0 +1,108 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 TYPE_CHECKING, Any, Optional, Union + +from ._events import Event +from ._io import PN_INVALID_SOCKET + +if TYPE_CHECKING: + from socket import socket + + from ._events import EventType + from ._reactor import Container, EventInjector + + +class Selectable(object): + def __init__( + self, + delegate: Optional[Union["EventInjector", "socket"]], + reactor: "Container", + ) -> None: + self._delegate = delegate + self.reading = False + self.writing = False + self._deadline = 0 + self._terminal = False + self._released = False + self._terminated = False + self._reactor = reactor + self.push_event(self, Event.SELECTABLE_INIT) + + def close(self) -> None: + if self._delegate and not self._released: + self._delegate.close() + + def fileno(self) -> int: + if self._delegate: + return self._delegate.fileno() + else: + return PN_INVALID_SOCKET + + def __getattr__(self, name: str) -> Any: + return getattr(self._delegate, name) + + @property + def deadline(self) -> Optional[float]: + tstamp = self._deadline + if tstamp: + return tstamp + else: + return None + + @deadline.setter + def deadline(self, deadline: Optional[float]) -> None: + if not deadline: + self._deadline = 0 + else: + self._deadline = deadline + + def push_event( + self, + context: "Selectable", + etype: "EventType", + ) -> None: + self._reactor.push_event(context, etype) + + def update(self) -> None: + if not self._terminated: + if self._terminal: + self._terminated = True + self.push_event(self, Event.SELECTABLE_FINAL) + else: + self.push_event(self, Event.SELECTABLE_UPDATED) + + def readable(self) -> None: + self.push_event(self, Event.SELECTABLE_READABLE) + + def writable(self) -> None: + self.push_event(self, Event.SELECTABLE_WRITABLE) + + def expired(self) -> None: + self.push_event(self, Event.SELECTABLE_EXPIRED) + + @property + def is_terminal(self) -> bool: + return self._terminal + + def terminate(self) -> None: + self._terminal = True + + def release(self) -> None: + self._released = True diff --git a/rabbitmq_amqp_python_client/qpid/proton/_tracing.py b/rabbitmq_amqp_python_client/qpid/proton/_tracing.py new file mode 100644 index 0000000..0c02d12 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_tracing.py @@ -0,0 +1,140 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 atexit +import os +import sys +import time + +try: + import jaeger_client + import opentracing + from opentracing.ext import tags + from opentracing.propagation import Format +except ImportError: + raise ImportError("proton tracing requires opentracing and jaeger_client modules") + +import proton +from proton import Sender as ProtonSender +from proton.handlers import IncomingMessageHandler as ProtonIncomingMessageHandler +from proton.handlers import OutgoingMessageHandler as ProtonOutgoingMessageHandler + +_tracer = None +_trace_key = proton.symbol("x-opt-qpid-tracestate") + + +def get_tracer(): + global _tracer + if _tracer is not None: + return _tracer + exe = sys.argv[0] if sys.argv[0] else "interactive-session" + return init_tracer(os.path.basename(exe)) + + +def _fini_tracer(): + time.sleep(1) + c = opentracing.global_tracer().close() + while not c.done(): + time.sleep(0.5) + + +def init_tracer(service_name): + global _tracer + if _tracer is not None: + return _tracer + + config = jaeger_client.Config(config={}, service_name=service_name, validate=True) + config.initialize_tracer() + _tracer = opentracing.global_tracer() + # A nasty hack to ensure enough time for the tracing data to be flushed + atexit.register(_fini_tracer) + return _tracer + + +class IncomingMessageHandler(ProtonIncomingMessageHandler): + def on_message(self, event): + if self.delegate is not None: + tracer = get_tracer() + message = event.message + receiver = event.receiver + connection = event.connection + span_tags = { + tags.SPAN_KIND: tags.SPAN_KIND_CONSUMER, + tags.MESSAGE_BUS_DESTINATION: receiver.source.address, + tags.PEER_ADDRESS: connection.connected_address, + tags.PEER_HOSTNAME: connection.hostname, + tags.COMPONENT: "proton-message-tracing", + } + if message.annotations is not None and _trace_key in message.annotations: + headers = message.annotations[_trace_key] + span_ctx = tracer.extract(Format.TEXT_MAP, headers) + with tracer.start_active_span( + "amqp-delivery-receive", child_of=span_ctx, tags=span_tags + ): + proton._events._dispatch(self.delegate, "on_message", event) + else: + with tracer.start_active_span( + "amqp-delivery-receive", ignore_active_span=True, tags=span_tags + ): + proton._events._dispatch(self.delegate, "on_message", event) + + +class OutgoingMessageHandler(ProtonOutgoingMessageHandler): + def on_settled(self, event): + if self.delegate is not None: + delivery = event.delivery + state = delivery.remote_state + span = delivery.span + span.set_tag("delivery-terminal-state", state.name) + span.log_kv({"event": "delivery settled", "state": state.name}) + span.finish() + proton._events._dispatch(self.delegate, "on_settled", event) + + +class Sender(ProtonSender): + def send(self, msg): + tracer = get_tracer() + connection = self.connection + span_tags = { + tags.SPAN_KIND: tags.SPAN_KIND_PRODUCER, + tags.MESSAGE_BUS_DESTINATION: self.target.address, + tags.PEER_ADDRESS: connection.connected_address, + tags.PEER_HOSTNAME: connection.hostname, + tags.COMPONENT: "proton-message-tracing", + } + span = tracer.start_span("amqp-delivery-send", tags=span_tags) + headers = {} + tracer.inject(span, Format.TEXT_MAP, headers) + if msg.annotations is None: + msg.annotations = {_trace_key: headers} + else: + msg.annotations[_trace_key] = headers + delivery = ProtonSender.send(self, msg) + delivery.span = span + span.set_tag("delivery-tag", delivery.tag) + return delivery + + +# Monkey patch proton for tracing (need to patch both internal and external names) +proton._handlers.IncomingMessageHandler = IncomingMessageHandler +proton._handlers.OutgoingMessageHandler = OutgoingMessageHandler +proton._endpoints.Sender = Sender +proton.handlers.IncomingMessageHandler = IncomingMessageHandler +proton.handlers.OutgoingMessageHandler = OutgoingMessageHandler +proton.Sender = Sender diff --git a/rabbitmq_amqp_python_client/qpid/proton/_transport.py b/rabbitmq_amqp_python_client/qpid/proton/_transport.py new file mode 100644 index 0000000..aeefb1d --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_transport.py @@ -0,0 +1,1278 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 ( + TYPE_CHECKING, + Callable, + List, + Optional, + Type, + Union, +) + +from cproton import ( + PN_EOS, + PN_SASL_AUTH, + PN_SASL_NONE, + PN_SASL_OK, + PN_SASL_PERM, + PN_SASL_SYS, + PN_SASL_TEMP, + PN_SSL_ANONYMOUS_PEER, + PN_SSL_CERT_SUBJECT_CITY_OR_LOCALITY, + PN_SSL_CERT_SUBJECT_COMMON_NAME, + PN_SSL_CERT_SUBJECT_COUNTRY_NAME, + PN_SSL_CERT_SUBJECT_ORGANIZATION_NAME, + PN_SSL_CERT_SUBJECT_ORGANIZATION_UNIT, + PN_SSL_CERT_SUBJECT_STATE_OR_PROVINCE, + PN_SSL_MD5, + PN_SSL_MODE_CLIENT, + PN_SSL_MODE_SERVER, + PN_SSL_RESUME_NEW, + PN_SSL_RESUME_REUSED, + PN_SSL_RESUME_UNKNOWN, + PN_SSL_SHA1, + PN_SSL_SHA256, + PN_SSL_SHA512, + PN_SSL_VERIFY_PEER, + PN_SSL_VERIFY_PEER_NAME, + PN_TRACE_DRV, + PN_TRACE_FRM, + PN_TRACE_OFF, + PN_TRACE_RAW, + isnull, + pn_error_text, + pn_sasl, + pn_sasl_allowed_mechs, + pn_sasl_config_name, + pn_sasl_config_path, + pn_sasl_done, + pn_sasl_extended, + pn_sasl_get_allow_insecure_mechs, + pn_sasl_get_authorization, + pn_sasl_get_mech, + pn_sasl_get_user, + pn_sasl_outcome, + pn_sasl_set_allow_insecure_mechs, + pn_ssl, + pn_ssl_domain, + pn_ssl_domain_allow_unsecured_client, + pn_ssl_domain_free, + pn_ssl_domain_set_credentials, + pn_ssl_domain_set_peer_authentication, + pn_ssl_domain_set_trusted_ca_db, + pn_ssl_get_cert_fingerprint, + pn_ssl_get_cipher_name, + pn_ssl_get_peer_hostname, + pn_ssl_get_protocol_name, + pn_ssl_get_remote_subject, + pn_ssl_get_remote_subject_subfield, + pn_ssl_init, + pn_ssl_present, + pn_ssl_resume_status, + pn_ssl_set_peer_hostname, + pn_transport, + pn_transport_attachments, + pn_transport_bind, + pn_transport_capacity, + pn_transport_close_head, + pn_transport_close_tail, + pn_transport_closed, + pn_transport_condition, + pn_transport_connection, + pn_transport_error, + pn_transport_get_channel_max, + pn_transport_get_frames_input, + pn_transport_get_frames_output, + pn_transport_get_idle_timeout, + pn_transport_get_max_frame, + pn_transport_get_pytracer, + pn_transport_get_remote_idle_timeout, + pn_transport_get_remote_max_frame, + pn_transport_get_user, + pn_transport_is_authenticated, + pn_transport_is_encrypted, + pn_transport_log, + pn_transport_peek, + pn_transport_pending, + pn_transport_pop, + pn_transport_push, + pn_transport_remote_channel_max, + pn_transport_require_auth, + pn_transport_require_encryption, + pn_transport_set_channel_max, + pn_transport_set_idle_timeout, + pn_transport_set_max_frame, + pn_transport_set_pytracer, + pn_transport_set_server, + pn_transport_tick, + pn_transport_trace, + pn_transport_unbind, +) + +from ._common import millis2secs, secs2millis +from ._condition import cond2obj, obj2cond +from ._exceptions import ( + EXCEPTIONS, + SessionException, + SSLException, + SSLUnavailable, + TransportException, +) +from ._wrapper import Wrapper + +if TYPE_CHECKING: + from ._condition import Condition + from ._endpoints import Connection # would produce circular import + + +class TraceAdapter: + def __init__(self, tracer: Callable[["Transport", str], None]) -> None: + self.tracer = tracer + + def __call__(self, trans_impl, message): + self.tracer(Transport.wrap(trans_impl), message) + + +class Transport(Wrapper): + """ + A network channel supporting an AMQP connection. + """ + + TRACE_OFF = PN_TRACE_OFF + """ Turn logging off entirely. """ + + TRACE_DRV = PN_TRACE_DRV + """ Log driver-related events. """ + + TRACE_FRM = PN_TRACE_FRM + """ Log protocol frames going in and out of the transport. """ + + TRACE_RAW = PN_TRACE_RAW + """ Log raw binary data going in and out of the transport. """ + + CLIENT = 1 + """ Transport mode is as a client. """ + + SERVER = 2 + """ Transport mode is as a server. """ + + @staticmethod + def wrap(impl: Optional[Callable]) -> Optional["Transport"]: + if isnull(impl): + return None + else: + return Transport(impl=impl) + + def __init__( + self, + mode: "Optional[int]" = None, + impl: "Callable" = None, + ) -> None: + if impl is None: + Wrapper.__init__( + self, constructor=pn_transport, get_context=pn_transport_attachments + ) + else: + Wrapper.__init__(self, impl, pn_transport_attachments) + if mode == Transport.SERVER: + pn_transport_set_server(self._impl) + elif mode is None or mode == Transport.CLIENT: + pass + else: + raise TransportException( + "Cannot initialise Transport from mode: %s" % str(mode) + ) + + def _init(self) -> None: + self._sasl = None + self._ssl = None + self._reactor = None + self._connect_selectable = None + + def _check(self, err: int) -> int: + if err < 0: + exc = EXCEPTIONS.get(err, TransportException) + raise exc("[%s]: %s" % (err, pn_error_text(pn_transport_error(self._impl)))) + else: + return err + + @property + def tracer(self) -> Optional[Callable[["Transport", str], None]]: + """A callback for trace logging. The callback is passed the transport + and log message. For no tracer callback, value is ``None``. + """ + adapter = pn_transport_get_pytracer(self._impl) + if adapter: + return adapter.tracer + else: + return None + + @tracer.setter + def tracer(self, tracer: Callable[["Transport", str], None]) -> None: + pn_transport_set_pytracer(self._impl, TraceAdapter(tracer)) + + def log(self, message: str) -> None: + """ + Log a message using a transport's logging mechanism. + + This can be useful in a debugging context as the log message will + be prefixed with the transport's identifier. + + :param message: The message to be logged. + :type message: ``str`` + """ + pn_transport_log(self._impl, message) + + def require_auth(self, bool: bool) -> None: + """ + Set whether a non-authenticated transport connection is allowed. + + There are several ways within the AMQP protocol suite to get + unauthenticated connections: + + - Use no SASL layer (with either no TLS or TLS without client certificates) + - Use a SASL layer but the ANONYMOUS mechanism + + The default if this option is not set is to allow unauthenticated connections. + + :param bool: ``True`` when authenticated connections are required. + """ + pn_transport_require_auth(self._impl, bool) + + @property + def authenticated(self) -> bool: + """ + Indicate whether the transport connection is authenticated. + + .. note:: This property may not be stable until the :const:`Event.CONNECTION_REMOTE_OPEN` + event is received. + + :type: ``bool`` + """ + return pn_transport_is_authenticated(self._impl) + + def require_encryption(self, bool): + """ + Set whether a non encrypted transport connection is allowed + + There are several ways within the AMQP protocol suite to get encrypted connections: + + - Use TLS + - Use a SASL with a mechanism that supports security layers + + The default if this option is not set is to allow unencrypted connections. + + :param bool: ``True`` if encryption is required on this transport, ``False`` otherwise. + :type bool: ``bool`` + """ + pn_transport_require_encryption(self._impl, bool) + + @property + def encrypted(self) -> bool: + """ + Indicate whether the transport connection is encrypted. + + .. note:: This property may not be stable until the :const:`Event.CONNECTION_REMOTE_OPEN` + event is received. + """ + return pn_transport_is_encrypted(self._impl) + + @property + def user(self) -> Optional[str]: + """ + The authenticated user. + + On the client it will return whatever user was passed in to the + :attr:`Connection.user` attribute of the bound connection. + + The returned value is only reliable after the ``PN_TRANSPORT_AUTHENTICATED`` + event has been received. + """ + return pn_transport_get_user(self._impl) + + def bind(self, connection: "Connection") -> None: + """ + Assign a connection to the transport. + + :param connection: Connection to which to bind. + :raise: :exc:`TransportException` if there is any Proton error. + """ + self._check(pn_transport_bind(self._impl, connection._impl)) + + def bind_nothrow(self, connection: "Connection") -> None: + """ + Assign a connection to the transport. Any failure is + ignored rather than thrown. + + :param connection: Connection to which to bind. + """ + pn_transport_bind(self._impl, connection._impl) + + def unbind(self) -> None: + """ + Unbinds a transport from its AMQP connection. + + :raise: :exc:`TransportException` if there is any Proton error. + """ + self._check(pn_transport_unbind(self._impl)) + + def trace(self, n: int) -> None: + """ + Update a transports trace flags. + + The trace flags for a transport control what sort of information is + logged. The value may be :const:`TRACE_OFF` or any combination of + :const:`TRACE_DRV`, :const:`TRACE_FRM`, :const:`TRACE_RAW` using + a bitwise or operation. + + :param n: Trace flags + """ + pn_transport_trace(self._impl, n) + + def tick(self, now: float) -> float: + """ + Process any pending transport timer events (like heartbeat generation). + + This method should be called after all pending input has been + processed by the transport and before generating output. It returns + the deadline for the next pending timer event, if any are present. + + .. note:: This function does nothing until the first data is read + from or written to the transport. + + :param now: seconds since epoch. + :return: If non-zero, then the monotonic expiration time of the next + pending timer event for the transport. The caller must invoke + :meth:`tick` again at least once at or before this deadline + occurs. If ``0.0``, then there are no pending events. + """ + return millis2secs(pn_transport_tick(self._impl, secs2millis(now))) + + def capacity(self) -> int: + """ + Get the amount of free space for input following the transport's + tail pointer. + + :return: Available space for input in bytes. + :raise: :exc:`TransportException` if there is any Proton error. + """ + c = pn_transport_capacity(self._impl) + if c >= PN_EOS: + return c + else: + return self._check(c) + + def push(self, binary: bytes) -> None: + """ + Pushes the supplied bytes into the tail of the transport. + Only some of the bytes will be copied if there is insufficient + capacity available. Use :meth:`capacity` to determine how much + capacity the transport has. + + :param binary: Data to be pushed onto the transport tail. + :raise: - :exc:`TransportException` if there is any Proton error. + - ``OverflowError`` if the size of the data exceeds the + transport capacity. + """ + n = self._check(pn_transport_push(self._impl, binary)) + if n != len(binary): + raise OverflowError( + "unable to process all bytes: %s, %s" % (n, len(binary)) + ) + + def close_tail(self) -> None: + """ + Indicate that the input has reached End Of Stream (EOS). + + This tells the transport that no more input will be forthcoming. + + :raise: :exc:`TransportException` if there is any Proton error. + """ + self._check(pn_transport_close_tail(self._impl)) + + def pending(self) -> int: + """ + Get the number of pending output bytes following the transport's + head pointer. + + :return: The number of pending output bytes. + :raise: :exc:`TransportException` if there is any Proton error. + """ + p = pn_transport_pending(self._impl) + if p >= PN_EOS: + return p + else: + return self._check(p) + + def peek(self, size: int) -> Optional[bytes]: + """ + Returns ``size`` bytes from the head of the transport. + + It is an error to call this with a value of ``size`` that + is greater than the value reported by :meth:`pending`. + + :param size: Number of bytes to return. + :return: ``size`` bytes from the head of the transport, or ``None`` + if none are available. + :raise: :exc:`TransportException` if there is any Proton error. + """ + cd, out = pn_transport_peek(self._impl, size) + if cd == PN_EOS: + return None + else: + self._check(cd) + return out + + def pop(self, size: int) -> None: + """ + Removes ``size`` bytes of output from the pending output queue + following the transport's head pointer. + + Calls to this function may alter the transport's head pointer as + well as the number of pending bytes reported by + :meth:`pending`. + + :param size: Number of bytes to remove. + """ + pn_transport_pop(self._impl, size) + + def close_head(self) -> None: + """ + Indicate that the output has closed. + + This tells the transport that no more output will be popped. + + :raise: :exc:`TransportException` if there is any Proton error. + """ + self._check(pn_transport_close_head(self._impl)) + + @property + def closed(self) -> bool: + """ + ``True`` iff both the transport head and transport tail are closed + using :meth:`close_head` and :meth:`close_tail` respectively. + """ + return pn_transport_closed(self._impl) + + # AMQP 1.0 max-frame-size + @property + def max_frame_size(self) -> int: + """The maximum size for transport frames (in bytes).""" + return pn_transport_get_max_frame(self._impl) + + @max_frame_size.setter + def max_frame_size(self, value: int) -> None: + pn_transport_set_max_frame(self._impl, value) + + @property + def remote_max_frame_size(self) -> int: + """ + The maximum frame size of a transport's remote peer (in bytes). + """ + return pn_transport_get_remote_max_frame(self._impl) + + @property + def channel_max(self) -> int: + """The maximum channel number that may be used on this transport. + + .. note:: This is the maximum channel number allowed, giving a + valid channel number range of ``[0 .. channel_max]``. Therefore the + maximum number of simultaneously active channels will be + channel_max plus 1. + + You can set this more than once to raise and lower + the limit your application imposes on max channels for this + transport. However, smaller limits may be imposed by Proton, + or by the remote peer. + + After the ``OPEN`` frame has been sent to the remote peer, + further calls to this function will have no effect. + + :raise: :exc:`SessionException` if the ``OPEN`` frame has already + been sent. + """ + return pn_transport_get_channel_max(self._impl) + + @channel_max.setter + def channel_max(self, value: int) -> None: + if pn_transport_set_channel_max(self._impl, value): + raise SessionException("Too late to change channel max.") + + @property + def remote_channel_max(self) -> int: + """ + The maximum allowed channel number of a transport's remote peer. + """ + return pn_transport_remote_channel_max(self._impl) + + # AMQP 1.0 idle-time-out + @property + def idle_timeout(self) -> float: + """The idle timeout of the connection in seconds. A zero idle + timeout means heartbeats are disabled. + """ + return millis2secs(pn_transport_get_idle_timeout(self._impl)) + + @idle_timeout.setter + def idle_timeout(self, sec: Union[float, int]) -> None: + pn_transport_set_idle_timeout(self._impl, secs2millis(sec)) + + @property + def remote_idle_timeout(self) -> float: + """ + Get the idle timeout for a transport's remote peer in + seconds. A zero idle timeout means heartbeats are disabled. + """ + return millis2secs(pn_transport_get_remote_idle_timeout(self._impl)) + + @property + def frames_output(self) -> int: + """ + Get the number of frames output by a transport. + """ + return pn_transport_get_frames_output(self._impl) + + @property + def frames_input(self) -> int: + """ + Get the number of frames input by a transport. + """ + return pn_transport_get_frames_input(self._impl) + + def sasl(self) -> "SASL": + """ + Get the :class:`SASL` object associated with this transport. + + :return: SASL object associated with this transport. + """ + return SASL(self) + + def ssl( + self, + domain: Optional["SSLDomain"] = None, + session_details: Optional["SSLSessionDetails"] = None, + ) -> "SSL": + """ + Get the :class:`SSL` session associated with this transport. If + not set, then a new session will be created using ``domain`` and + ``session_details``. + + :param domain: An SSL domain configuration object + :param session_details: A unique identifier for the SSL session. + :return: SSL session associated with this transport. + """ + # SSL factory (singleton for this transport) + if not self._ssl: + self._ssl = SSL(self, domain, session_details) + return self._ssl + + @property + def condition(self) -> Optional["Condition"]: + """Get additional information about the condition of the transport. + + When a :const:`Event.TRANSPORT_ERROR` event occurs, this operation + can be used to access the details of the error condition. + + See :class:`Condition` for more information. + """ + return cond2obj(pn_transport_condition(self._impl)) + + @condition.setter + def condition(self, cond: "Condition") -> None: + pn_cond = pn_transport_condition(self._impl) + obj2cond(cond, pn_cond) + + @property + def connection(self) -> "Connection": + """The connection bound to this transport.""" + from . import _endpoints + + return _endpoints.Connection.wrap(pn_transport_connection(self._impl)) + + +class SASLException(TransportException): + pass + + +class SASL(Wrapper): + """ + The SASL layer is responsible for establishing an authenticated + and/or encrypted tunnel over which AMQP frames are passed between + peers. The peer acting as the SASL Client must provide + authentication credentials. The peer acting as the SASL Server must + provide authentication against the received credentials. + """ + + OK = PN_SASL_OK + AUTH = PN_SASL_AUTH + SYS = PN_SASL_SYS + PERM = PN_SASL_PERM + TEMP = PN_SASL_TEMP + + @staticmethod + def extended() -> bool: + """ + Check for support of extended SASL negotiation. + + All implementations of Proton support ``ANONYMOUS`` and ``EXTERNAL`` on both + client and server sides and ``PLAIN`` on the client side. + + Extended SASL implementations use an external library (Cyrus SASL) + to support other mechanisms beyond these basic ones. + + :rtype: ``True`` if we support extended SASL negotiation, ``False`` if + we only support basic negotiation. + """ + return pn_sasl_extended() + + def __init__(self, transport: Transport) -> None: + Wrapper.__init__(self, transport._impl, pn_transport_attachments) + self._sasl = pn_sasl(transport._impl) + + def _check(self, err): + if err < 0: + exc = EXCEPTIONS.get(err, SASLException) + raise exc("[%s]" % (err)) + else: + return err + + @property + def user(self) -> Optional[str]: + """ + Retrieve the authenticated user. This is usually used at the the + server end to find the name of the authenticated user. + + If :meth:`outcome` returns a value other than :const:`OK`, then + there will be no user to return. The returned value is only reliable + after the ``PN_TRANSPORT_AUTHENTICATED`` event has been received. + + :rtype: * If the SASL layer was not negotiated then ``None`` is returned. + * If the ``ANONYMOUS`` mechanism is used then the user will be + ``"anonymous"``. + * Otherwise a string containing the user is + returned. + """ + return pn_sasl_get_user(self._sasl) + + @property + def authorization(self) -> Optional[str]: + """ + Retrieve the requested authorization user. This is usually used at the the + server end to find the name of any requested authorization user. + + If the peer has not requested an authorization user or the SASL mechanism has + no capability to transport an authorization id this will be the same as the + authenticated user. + + Note that it is the role of the server to ensure that the authenticated user is + actually allowed to act as the requested authorization user. + + If :meth:`outcome` returns a value other than :const:`OK`, then + there will be no user to return. The returned value is only reliable + after the ``PN_TRANSPORT_AUTHENTICATED`` event has been received. + + :rtype: * If the SASL layer was not negotiated then ``None`` is returned. + * If the ``ANONYMOUS`` mechanism is used then the user will be + ``"anonymous"``. + * Otherwise a string containing the user is + returned. + """ + return pn_sasl_get_authorization(self._sasl) + + @property + def mech(self) -> str: + """ + Return the selected SASL mechanism. + + The returned value is only reliable after the ``PN_TRANSPORT_AUTHENTICATED`` + event has been received. + + :rtype: The authentication mechanism selected by the SASL layer. + """ + return pn_sasl_get_mech(self._sasl) + + @property + def outcome(self) -> Optional[int]: + """ + Retrieve the outcome of SASL negotiation. + + :rtype: * ``None`` if no negotiation has taken place. + * Otherwise the outcome of the negotiation. + """ + outcome = pn_sasl_outcome(self._sasl) + if outcome == PN_SASL_NONE: + return None + else: + return outcome + + def allowed_mechs(self, mechs: Union[str, List[str]]) -> None: + """ + SASL mechanisms that are to be considered for authentication. + + This can be used on either the client or the server to restrict + the SASL mechanisms that may be used to the mechanisms on the list. + + **NOTE:** By default the ``GSSAPI`` and ``GSS-SPNEGO`` mechanisms + are not enabled for clients. This is because these mechanisms have + the problematic behaviour of 'capturing' the client whenever they + are installed so that they will be used by the client if offered by + the server even if the client can't successfully authenticate this + way. This can lead to some very hard to debug failures. + + **NOTE:** The ``GSSAPI`` or ``GSS-SPNEGO`` mechanisms need to be + explicitly enabled if they are required (together with any other + required mechanisms). + + :param mechs: A list of mechanisms that are allowed for authentication, + either a string containing a space-separated list of mechs + ``"mech1 mech2 ..."``, or a Python list of strings + ``["mech1", "mech2", ...]``. + """ + if isinstance(mechs, list): + mechs = " ".join(mechs) + pn_sasl_allowed_mechs(self._sasl, mechs) + + @property + def allow_insecure_mechs(self) -> bool: + """Allow unencrypted cleartext passwords (PLAIN mech)""" + return pn_sasl_get_allow_insecure_mechs(self._sasl) + + @allow_insecure_mechs.setter + def allow_insecure_mechs(self, insecure: bool) -> None: + pn_sasl_set_allow_insecure_mechs(self._sasl, insecure) + + def done(self, outcome): + """ + Set the outcome of SASL negotiation. Used by the server to set the + result of the negotiation process. + """ + pn_sasl_done(self._sasl, outcome) + + def config_name(self, name: str): + """ + Set the SASL configuration name. This is used to construct the SASL + configuration filename. In the current implementation ``".conf"`` is + added to the name and the file is looked for in the configuration + directory. + + If not set it will default to ``"proton-server"`` for a sasl server + and ``"proton-client"`` for a client. + + :param name: The configuration name. + """ + pn_sasl_config_name(self._sasl, name) + + def config_path(self, path: str): + """ + Set the SASL configuration path. This is used to tell SASL where + to look for the configuration file. In the current implementation + it can be a colon separated list of directories. + + The environment variable ``PN_SASL_CONFIG_PATH`` can also be used + to set this path, but if both methods are used then this + :meth:`config_path` will take precedence. + + If not set, the underlying implementation default will be used. + + :param path: The configuration path, may contain colon-separated list + if more than one path is specified. + """ + pn_sasl_config_path(self._sasl, path) + + +class SSLDomain(object): + """ + An SSL configuration domain, used to hold the SSL configuration + for one or more SSL sessions. + """ + + MODE_CLIENT = PN_SSL_MODE_CLIENT + """Local connection endpoint is an SSL client.""" + + MODE_SERVER = PN_SSL_MODE_SERVER + """Local connection endpoint is an SSL server.""" + + VERIFY_PEER = PN_SSL_VERIFY_PEER + """Require peer to provide a valid identifying certificate.""" + + VERIFY_PEER_NAME = PN_SSL_VERIFY_PEER_NAME + """Require valid certificate and matching name.""" + + ANONYMOUS_PEER = PN_SSL_ANONYMOUS_PEER + """Do not require a certificate nor cipher authorization.""" + + def __init__(self, mode: int) -> None: + self._domain = pn_ssl_domain(mode) + if self._domain is None: + raise SSLUnavailable() + + def _check(self, err: int) -> int: + if err < 0: + exc = EXCEPTIONS.get(err, SSLException) + raise exc("SSL failure.") + else: + return err + + def set_credentials( + self, cert_file: str, key_file: str, password: Optional[str] + ) -> int: + """ + Set the certificate that identifies the local node to the remote. + + This certificate establishes the identity for the local node for all :class:`SSL` sessions + created from this domain. It will be sent to the remote if the remote needs to verify + the identity of this node. This may be used for both SSL servers and SSL clients (if + client authentication is required by the server). + + .. note:: This setting effects only those :class:`SSL` objects created after this call + returns. :class:`SSL` objects created before invoking this method will use the domain's + previous setting. + + :param cert_file: Specifier for the file/database containing the identifying + certificate. For Openssl users, this is a PEM file. For Windows SChannel + users, this is the PKCS#12 file or system store. + :param key_file: An optional key to access the identifying certificate. For + Openssl users, this is an optional PEM file containing the private key + used to sign the certificate. For Windows SChannel users, this is the + friendly name of the self-identifying certificate if there are multiple + certificates in the store. + :param password: The password used to sign the key, else ``None`` if key is not + protected. + :return: 0 on success + :raise: :exc:`SSLException` if there is any Proton error + """ + return self._check( + pn_ssl_domain_set_credentials(self._domain, cert_file, key_file, password) + ) + + def set_trusted_ca_db(self, certificate_db: str) -> int: + """ + Configure the set of trusted CA certificates used by this domain to verify peers. + + If the local SSL client/server needs to verify the identity of the remote, it must + validate the signature of the remote's certificate. This function sets the database of + trusted CAs that will be used to verify the signature of the remote's certificate. + + .. note:: This setting effects only those :class:`SSL` objects created after this call + returns. :class:`SSL` objects created before invoking this method will use the domain's + previous setting. + + .. note:: By default the list of trusted CA certificates will be set to the system default. + What this is is depends on the OS and the SSL implementation used: For OpenSSL the default + will depend on how the OS is set up. When using the Windows SChannel implementation the default + will be the users default trusted certificate store. + + :param certificate_db: Database of trusted CAs, used to authenticate the peer. + :return: 0 on success + :raise: :exc:`SSLException` if there is any Proton error + """ + return self._check( + pn_ssl_domain_set_trusted_ca_db(self._domain, certificate_db) + ) + + def set_peer_authentication( + self, verify_mode: int, trusted_CAs: Optional[str] = None + ) -> int: + """ + This method controls how the peer's certificate is validated, if at all. By default, + servers do not attempt to verify their peers (PN_SSL_ANONYMOUS_PEER) but + clients attempt to verify both the certificate and peer name (PN_SSL_VERIFY_PEER_NAME). + Once certificates and trusted CAs are configured, peer verification can be enabled. + + .. note:: In order to verify a peer, a trusted CA must be configured. See + :meth:`set_trusted_ca_db`. + + .. note:: Servers must provide their own certificate when verifying a peer. See + :meth:`set_credentials`. + + .. note:: This setting effects only those :class:`SSL` objects created after this call + returns. :class:`SSL` objects created before invoking this method will use the domain's + previous setting. + + :param verify_mode: The level of validation to apply to the peer, one of :const:`VERIFY_PEER`, + :const:`VERIFY_PEER_NAME`, :const:`ANONYMOUS_PEER`, + :param trusted_CAs: Path to a database of trusted CAs that the server will advertise. + :return: 0 on success + :raise: :exc:`SSLException` if there is any Proton error + """ + return self._check( + pn_ssl_domain_set_peer_authentication( + self._domain, verify_mode, trusted_CAs + ) + ) + + def allow_unsecured_client(self) -> int: + """ + Permit a server to accept connection requests from non-SSL clients. + + This configures the server to "sniff" the incoming client data stream, + and dynamically determine whether SSL/TLS is being used. This option + is disabled by default: only clients using SSL/TLS are accepted. + + :raise: :exc:`SSLException` if there is any Proton error + """ + return self._check(pn_ssl_domain_allow_unsecured_client(self._domain)) + + def __del__(self) -> None: + pn_ssl_domain_free(self._domain) + + +class SSL(object): + """ + An SSL session associated with a transport. A transport must have + an SSL object in order to "speak" SSL over its connection. + """ + + @staticmethod + def present() -> bool: + """ + Tests for an SSL implementation being present. + + :return: ``True`` if we support SSL, ``False`` if not. + """ + return pn_ssl_present() + + def _check(self, err: int) -> int: + if err < 0: + exc = EXCEPTIONS.get(err, SSLException) + raise exc("SSL failure.") + else: + return err + + def __new__( + cls: Type["SSL"], + transport: Transport, + domain: SSLDomain, + session_details: Optional["SSLSessionDetails"] = None, + ) -> "SSL": + """Enforce a singleton SSL object per Transport""" + if transport._ssl: + # unfortunately, we've combined the allocation and the configuration in a + # single step. So catch any attempt by the application to provide what + # may be a different configuration than the original (hack) + ssl = transport._ssl + different_domain = domain and (ssl._domain is not domain) + different_session_details = session_details and ( + ssl._session_details is not session_details + ) + if different_domain or different_session_details: + raise SSLException("Cannot re-configure existing SSL object!") + else: + obj = super(SSL, cls).__new__(cls) + obj._domain = domain + obj._session_details = session_details + session_id = None + if session_details: + session_id = session_details.get_session_id() + obj._ssl = pn_ssl(transport._impl) + if obj._ssl is None: + raise SSLUnavailable() + if domain: + pn_ssl_init(obj._ssl, domain._domain, session_id) + transport._ssl = obj + return transport._ssl + + def cipher_name(self) -> Optional[str]: + """ + Get the name of the Cipher that is currently in use. + + Gets a text description of the cipher that is currently active, or + returns ``None`` if SSL is not active (no cipher). + + .. note:: The cipher in use may change over time due to renegotiation + or other changes to the SSL state. + + :return: The cypher name, or ``None`` if no cipher in use. + """ + return pn_ssl_get_cipher_name(self._ssl, 128) + + def protocol_name(self) -> Optional[str]: + """ + Get the name of the SSL protocol that is currently in use. + + Gets a text description of the SSL protocol that is currently active, + or returns ``None`` if SSL is not active. + + .. note:: The protocol may change over time due to renegotiation. + + :return: The protocol name if SSL is active, or ``None`` if SSL connection + is not ready or active. + """ + return pn_ssl_get_protocol_name(self._ssl, 128) + + SHA1 = PN_SSL_SHA1 + """Produces hash that is 20 bytes long using SHA-1""" + + SHA256 = PN_SSL_SHA256 + """Produces hash that is 32 bytes long using SHA-256""" + + SHA512 = PN_SSL_SHA512 + """Produces hash that is 64 bytes long using SHA-512""" + + MD5 = PN_SSL_MD5 + """Produces hash that is 16 bytes long using MD5""" + + CERT_COUNTRY_NAME = PN_SSL_CERT_SUBJECT_COUNTRY_NAME + """Certificate country name 2-char ISO code""" + + CERT_STATE_OR_PROVINCE = PN_SSL_CERT_SUBJECT_STATE_OR_PROVINCE + """Certificate state or province, not abbreviated""" + + CERT_CITY_OR_LOCALITY = PN_SSL_CERT_SUBJECT_CITY_OR_LOCALITY + """Certificate city or place name, not abbreviated""" + + CERT_ORGANIZATION_NAME = PN_SSL_CERT_SUBJECT_ORGANIZATION_NAME + """Certificate organization name""" + + CERT_ORGANIZATION_UNIT = PN_SSL_CERT_SUBJECT_ORGANIZATION_UNIT + """Certificate organization unit or division within organization""" + + CERT_COMMON_NAME = PN_SSL_CERT_SUBJECT_COMMON_NAME + """Certificate common name or URL""" + + def get_cert_subject_subfield(self, subfield_name: int) -> Optional[str]: + """ + Returns a string that contains the value of the sub field of + the subject field in the ssl certificate. The subject field + usually contains the following values: + + * :const:`CERT_COUNTRY_NAME` + * :const:`CERT_STATE_OR_PROVINCE` + * :const:`CERT_CITY_OR_LOCALITY` + * :const:`CERT_ORGANIZATION_NAME` + * :const:`CERT_ORGANIZATION_UNIT` + * :const:`CERT_COMMON_NAME` + + :param subfield_name: The enumeration representing the required + sub field listed above + :return: A string which contains the requested sub field value which + is valid until the ssl object is destroyed. + """ + subfield_value = pn_ssl_get_remote_subject_subfield(self._ssl, subfield_name) + return subfield_value + + def get_cert_subject(self) -> str: + """ + Get the subject from the peer's certificate. + + :return: A string containing the full subject. + """ + subject = pn_ssl_get_remote_subject(self._ssl) + return subject + + def _get_cert_subject_unknown_subfield(self) -> None: + # Pass in an unhandled enum + return self.get_cert_subject_subfield(10) + + # Convenience functions for obtaining the subfields of the subject field. + def get_cert_common_name(self) -> str: + """ + A convenience method to get a string that contains the :const:`CERT_COMMON_NAME` + sub field of the subject field in the ssl certificate. + + :return: A string containing the :const:`CERT_COMMON_NAME` sub field. + """ + return self.get_cert_subject_subfield(SSL.CERT_COMMON_NAME) + + def get_cert_organization(self) -> str: + """ + A convenience method to get a string that contains the :const:`CERT_ORGANIZATION_NAME` + sub field of the subject field in the ssl certificate. + + :return: A string containing the :const:`CERT_ORGANIZATION_NAME` sub field. + """ + return self.get_cert_subject_subfield(SSL.CERT_ORGANIZATION_NAME) + + def get_cert_organization_unit(self) -> str: + """ + A convenience method to get a string that contains the :const:`CERT_ORGANIZATION_UNIT` + sub field of the subject field in the ssl certificate. + + :return: A string containing the :const:`CERT_ORGANIZATION_UNIT` sub field. + """ + return self.get_cert_subject_subfield(SSL.CERT_ORGANIZATION_UNIT) + + def get_cert_locality_or_city(self) -> str: + """ + A convenience method to get a string that contains the :const:`CERT_CITY_OR_LOCALITY` + sub field of the subject field in the ssl certificate. + + :return: A string containing the :const:`CERT_CITY_OR_LOCALITY` sub field. + """ + return self.get_cert_subject_subfield(SSL.CERT_CITY_OR_LOCALITY) + + def get_cert_country(self) -> str: + """ + A convenience method to get a string that contains the :const:`CERT_COUNTRY_NAME` + sub field of the subject field in the ssl certificate. + + :return: A string containing the :const:`CERT_COUNTRY_NAME` sub field. + """ + return self.get_cert_subject_subfield(SSL.CERT_COUNTRY_NAME) + + def get_cert_state_or_province(self) -> str: + """ + A convenience method to get a string that contains the :const:`CERT_STATE_OR_PROVINCE` + sub field of the subject field in the ssl certificate. + + :return: A string containing the :const:`CERT_STATE_OR_PROVINCE` sub field. + """ + return self.get_cert_subject_subfield(SSL.CERT_STATE_OR_PROVINCE) + + def get_cert_fingerprint( + self, fingerprint_length: int, digest_name: int + ) -> Optional[str]: + """ + Get the fingerprint of the certificate. The certificate fingerprint + (as displayed in the Fingerprints section when looking at a certificate + with say the Firefox browser) is the hexadecimal hash of the entire + certificate. The fingerprint is not part of the certificate, rather + it is computed from the certificate and can be used to uniquely identify + a certificate. + + :param fingerprint_length: Must be :math:`>= 33` for md5, :math:`>= 41` + for sha1, :math:`>= 65` for sha256 and :math:`>= 129` + for sha512. + :param digest_name: The hash algorithm to use. Must be one of :const:`SHA1`, + :const:`SHA256`, :const:`SHA512`, :const:`MD5`. + :return: Hex fingerprint in a string, or ``None`` if an error occurred. + """ + return pn_ssl_get_cert_fingerprint(self._ssl, fingerprint_length, digest_name) + + # Convenience functions for obtaining fingerprint for specific hashing algorithms + def _get_cert_fingerprint_unknown_hash_alg(self) -> None: + return self.get_cert_fingerprint(41, 10) + + def get_cert_fingerprint_sha1(self) -> Optional[str]: + """ + A convenience method to get the :const:`SHA1` fingerprint of the + certificate. + + :return: Hex fingerprint in a string, or ``None`` if an error occurred. + """ + return self.get_cert_fingerprint(41, SSL.SHA1) + + def get_cert_fingerprint_sha256(self) -> Optional[str]: + """ + A convenience method to get the :const:`SHA256` fingerprint of the + certificate. + + :return: Hex fingerprint in a string, or ``None`` if an error occurred. + """ + # sha256 produces a fingerprint that is 64 characters long + return self.get_cert_fingerprint(65, SSL.SHA256) + + def get_cert_fingerprint_sha512(self) -> Optional[str]: + """ + A convenience method to get the :const:`SHA512` fingerprint of the + certificate. + + :return: Hex fingerprint in a string, or ``None`` if an error occurred. + """ + # sha512 produces a fingerprint that is 128 characters long + return self.get_cert_fingerprint(129, SSL.SHA512) + + def get_cert_fingerprint_md5(self) -> Optional[str]: + """ + A convenience method to get the :const:`MD5` fingerprint of the + certificate. + + :return: Hex fingerprint in a string, or ``None`` if an error occurred. + """ + return self.get_cert_fingerprint(33, SSL.MD5) + + @property + def remote_subject(self) -> str: + """ + The subject from the peers certificate. + """ + return pn_ssl_get_remote_subject(self._ssl) + + RESUME_UNKNOWN = PN_SSL_RESUME_UNKNOWN + """Session resume state unknown/not supported.""" + + RESUME_NEW = PN_SSL_RESUME_NEW + """Session renegotiated - not resumed.""" + + RESUME_REUSED = PN_SSL_RESUME_REUSED + """Session resumed from previous session.""" + + def resume_status(self) -> int: + """ + Check whether the state has been resumed. + + Used for client session resume. When called on an active session, + indicates whether the state has been resumed from a previous session. + + .. note:: This is a best-effort service - there is no guarantee that + the remote server will accept the resumed parameters. The remote + server may choose to ignore these parameters, and request a + re-negotiation instead. + + :return: Status code indicating whether or not the session has been + resumed. One of: + * :const:`RESUME_UNKNOWN` + * :const:`RESUME_NEW` + * :const:`RESUME_REUSED` + """ + return pn_ssl_resume_status(self._ssl) + + @property + def peer_hostname(self) -> str: + """Manage the expected name of the remote peer. + + The hostname is used for two purposes: + + 1. when set on an SSL client, it is sent to the server during the + handshake (if Server Name Indication is supported) + 2. it is used to check against the identifying name provided in the + peer's certificate. If the supplied name does not exactly match a + SubjectAltName (type DNS name), or the CommonName entry in the + peer's certificate, the peer is considered unauthenticated + (potential imposter), and the SSL connection is aborted. + + .. note:: Verification of the hostname is only done if + :const:`SSLDomain.VERIFY_PEER_NAME` is set using + :meth:`SSLDomain.set_peer_authentication`.""" + err, name = pn_ssl_get_peer_hostname(self._ssl, 1024) + self._check(err) + return name + + @peer_hostname.setter + def peer_hostname(self, hostname: Optional[str]) -> None: + self._check(pn_ssl_set_peer_hostname(self._ssl, hostname)) + + +class SSLSessionDetails(object): + """ + Unique identifier for the SSL session. Used to resume previous + session on a new SSL connection. + """ + + def __init__(self, session_id: str) -> None: + self._session_id = session_id + + def get_session_id(self) -> str: + """ + Get the unique identifier for this SSL session + + :return: Session identifier + """ + return self._session_id diff --git a/rabbitmq_amqp_python_client/qpid/proton/_url.py b/rabbitmq_amqp_python_client/qpid/proton/_url.py new file mode 100644 index 0000000..4feffa3 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_url.py @@ -0,0 +1,282 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 socket +from urllib.parse import ( + quote, + unquote, + urlparse, + urlunparse, +) + + +class Url(object): + """ + **DEPRECATED** Simple URL parser/constructor. + + .. deprecated:: 0.27 + Use a ``str`` containing the URL instead. + + Handles URLs of the form: + + ``://:@:/`` + + All components can be ``None`` if not specified in the URL string. + + The port can be specified as a service name, e.g. 'amqp' in the + URL string but :class:`Url.Port` always gives the integer value. + + .. warning:: The placement of user and password in URLs is not + recommended. It can result in credentials leaking out in program + logs. Use connection configuration attributes instead. + + :ivar scheme: Url scheme e.g. 'amqp' or 'amqps' + :ivar username: Username + :ivar ~.password: Password + :ivar ~.host: Host name, ipv6 literal or ipv4 dotted quad. + :ivar ~.port: Integer port. + :ivar host_port: Returns host:port + + :param url: URL string to parse. + :type url: ``str`` + :param defaults: If ``True``, fill in missing default values in the URL. + If ``False``, you can fill them in later by calling self.defaults() + :type defaults: ``bool`` + :param kwargs: scheme, user, password, host, port, path. + If specified, replaces corresponding part in url string. + """ + + AMQPS = "amqps" + """URL scheme for the AMQP protocol secured with SSL.""" + + AMQP = "amqp" + """URL scheme for the AMQP protocol.""" + + class Port(int): + """An integer port number that can be constructed from a service name string""" + + def __new__(cls, value): + """ + :param value: integer port number or string service name. + """ + port = super(Url.Port, cls).__new__(cls, cls._port_int(value)) + setattr(port, "name", str(value)) + return port + + def __eq__(self, x): + return str(self) == x or int(self) == x + + def __ne__(self, x): + return not self == x + + def __str__(self): + return str(self.name) + + @staticmethod + def _port_int(value): + """Convert service, an integer or a service name, into an integer port number.""" + try: + return int(value) + except ValueError: + try: + return socket.getservbyname(value) + except socket.error: + # Not every system has amqp/amqps defined as a service + if value == Url.AMQPS: + return 5671 + elif value == Url.AMQP: + return 5672 + else: + raise ValueError( + "Not a valid port number or service name: '%s'" % value + ) + + def __init__(self, url=None, defaults=True, **kwargs): + if isinstance(url, Url): + self.scheme = url.scheme + self.username = url.username + self.password = url.password + self._host = url._host + self._port = url._port + self._path = url._path + self._params = url._params + self._query = url._query + self._fragment = url._fragment + elif url: + if not url.startswith("//"): + p = url.partition(":") + if "/" in p[0] or not p[2].startswith("//"): + url = "//" + url + u = urlparse(url) + if not u: + raise ValueError("Invalid URL '%s'" % url) + self.scheme = None if not u.scheme else u.scheme + self.username = u.username and unquote(u.username) + self.password = u.password and unquote(u.password) + (self._host, self._port) = self._parse_host_port(u.netloc) + self._path = None if not u.path else u.path + self._params = u.params + self._query = u.query + self._fragment = u.fragment + else: + self.scheme = None + self.username = None + self.password = None + self._host = None + self._port = None + self._path = None + self._params = None + self._query = None + self._fragment = None + for k in kwargs: # Let kwargs override values parsed from url + getattr(self, k) # Check for invalid kwargs + setattr(self, k, kwargs[k]) + if defaults: + self.defaults() + + @staticmethod + def _parse_host_port(nl): + hostport = nl.split("@")[-1] + hostportsplit = hostport.split("]") + beforebrace = hostportsplit[0] + afterbrace = hostportsplit[-1] + + if len(hostportsplit) == 1: + beforebrace = "" + else: + beforebrace += "]" + if ":" in afterbrace: + afterbracesplit = afterbrace.split(":") + port = afterbracesplit[1] + host = (beforebrace + afterbracesplit[0]).lower() + if not port: + port = None + else: + host = (beforebrace + afterbrace).lower() + port = None + if not host: + host = None + return host, port + + @property + def path(self): + """ + The path segment of a URL + + :type: ``str`` + """ + return self._path if not self._path or self._path[0] != "/" else self._path[1:] + + @path.setter + def path(self, p): + self._path = p if p[0] == "/" else "/" + p + + @staticmethod + def _ipv6literal(s): + return s.startswith("[") and s.endswith("]") + + @property + def host(self): + """ + The host segment of a URL + + :type: ``str`` + """ + if self._host and self._ipv6literal(self._host): + return self._host[1:-1] + else: + return self._host + + @host.setter + def host(self, h): + if ":" in h and not self._ipv6literal(h): + self._host = "[" + h + "]" + else: + self._host = h + + @property + def port(self): + """ + The port number segment of a URL. + + :type: :class:`Url.Port` + """ + return self._port and Url.Port(self._port) + + @port.setter + def port(self, p): + self._port = p + + @property + def _netloc(self): + hostport = "" + if self._host: + hostport = self._host + if self._port: + hostport += ":" + hostport += str(self._port) + userpart = "" + if self.username: + userpart += quote(self.username) + if self.password: + userpart += ":" + userpart += quote(self.password) + if self.username or self.password: + userpart += "@" + return userpart + hostport + + def __str__(self): + if ( + self.scheme + and not self._netloc + and not self._path + and not self._params + and not self._query + and not self._fragment + ): + return self.scheme + "://" + return urlunparse( + ( + self.scheme or "", + self._netloc or "", + self._path or "", + self._params or "", + self._query or "", + self._fragment or "", + ) + ) + + def __repr__(self): + return "Url('%s')" % self + + def __eq__(self, x): + return str(self) == str(x) + + def __ne__(self, x): + return not self == x + + def defaults(self): + """ + Fill in missing values (scheme, host or port) with defaults + :return: self + """ + self.scheme = self.scheme or self.AMQP + self._host = self._host or "0.0.0.0" + self._port = self._port or self.Port(self.scheme) + return self diff --git a/rabbitmq_amqp_python_client/qpid/proton/_utils.py b/rabbitmq_amqp_python_client/qpid/proton/_utils.py new file mode 100644 index 0000000..38da406 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_utils.py @@ -0,0 +1,728 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 collections +import threading +import time +from typing import ( + TYPE_CHECKING, + Any, + Callable, + List, + Optional, + Union, +) + +from ._delivery import Delivery +from ._endpoints import Endpoint, Link +from ._events import Handler +from ._exceptions import ( + ConnectionException, + LinkException, + ProtonException, + Timeout, +) +from ._handlers import ( + IncomingMessageHandler, + MessagingHandler, +) +from ._reactor import Container +from ._url import Url + +try: + from typing import Literal +except ImportError: + # https://www.python.org/dev/peps/pep-0560/#class-getitem + class GenericMeta(type): + def __getitem__(self, item): + pass + + class Literal(metaclass=GenericMeta): + pass + + +if TYPE_CHECKING: + from ._delivery import DispositionType + from ._endpoints import Receiver, Sender + from ._events import Event + from ._message import Message + from ._reactor import ( + Backoff, + Connection, + LinkOption, + ReceiverOption, + SenderOption, + ) + from ._transport import SSLDomain + + +class BlockingLink: + def __init__( + self, connection: "BlockingConnection", link: Union["Sender", "Receiver"] + ) -> None: + self.connection = connection + self.link = link + self.connection.wait( + lambda: not (self.link.state & Endpoint.REMOTE_UNINIT), + msg="Opening link %s" % link.name, + ) + self._checkClosed() + + def _waitForClose(self, timeout=1): + try: + self.connection.wait( + lambda: self.link.state & Endpoint.REMOTE_CLOSED, + timeout=timeout, + msg="Opening link %s" % self.link.name, + ) + except Timeout: + pass + self._checkClosed() + + def _checkClosed(self) -> None: + if self.link.state & Endpoint.REMOTE_CLOSED: + self.link.close() + if not self.connection.closing: + raise LinkDetached(self.link) + + def close(self): + """ + Close the link. + """ + self.link.close() + self.connection.wait( + lambda: not (self.link.state & Endpoint.REMOTE_ACTIVE), + msg="Closing link %s" % self.link.name, + ) + + # Access to other link attributes. + def __getattr__(self, name: str) -> Any: + return getattr(self.link, name) + + +class SendException(ProtonException): + """ + Exception used to indicate an exceptional state/condition on a send request. + + :param state: The delivery state which caused the exception. + """ + + def __init__(self, state: int) -> None: + self.state = state + + +def _is_settled(delivery: Delivery) -> bool: + return delivery.settled or delivery.link.snd_settle_mode == Link.SND_SETTLED + + +class BlockingSender(BlockingLink): + """ + A synchronous sender wrapper. This is typically created by calling + :meth:`BlockingConnection.create_sender`. + """ + + def __init__(self, connection: "BlockingConnection", sender: "Sender") -> None: + super(BlockingSender, self).__init__(connection, sender) + if ( + self.link.target + and self.link.target.address + and self.link.target.address != self.link.remote_target.address + ): + # this may be followed by a detach, which may contain an error condition, so wait a little... + self._waitForClose() + # ...but close ourselves if peer does not + self.link.close() + raise LinkException( + "Failed to open sender %s, target does not match" % self.link.name + ) + + def send( + self, + msg: "Message", + timeout: Union[None, Literal[False], float] = False, + error_states: Optional[List["DispositionType"]] = None, + ) -> Delivery: + """ + Blocking send which will return only when the send is complete + and the message settled. + + :param timeout: Timeout in seconds. If ``False``, the value of ``timeout`` used in the + constructor of the :class:`BlockingConnection` object used in the constructor will be used. + If ``None``, there is no timeout. Any other value is treated as a timeout in seconds. + :param error_states: List of delivery flags which when present in Delivery object + will cause a :class:`SendException` exception to be raised. If ``None``, these + will default to a list containing :const:`proton.Delivery.REJECTED` and :const:`proton.Delivery.RELEASED`. + :return: Delivery object for this message. + """ + + delivery = self.link.send(msg) + self.connection.wait( + lambda: _is_settled(delivery), + msg="Sending on sender %s" % self.link.name, + timeout=timeout, + ) + if delivery.link.snd_settle_mode != Link.SND_SETTLED: + delivery.settle() + bad = error_states + if bad is None: + bad = [Delivery.REJECTED, Delivery.RELEASED] + if delivery.remote_state in bad: + raise SendException(delivery.remote_state) + return delivery + + +class Fetcher(MessagingHandler): + """ + A message handler for blocking receivers. + """ + + def __init__(self, connection: "Connection", prefetch: int): + super(Fetcher, self).__init__(prefetch=prefetch, auto_accept=False) + self.connection = connection + self.incoming = collections.deque([]) + self.unsettled = collections.deque([]) + + def on_message(self, event: "Event") -> None: + self.incoming.append((event.message, event.delivery)) + self.connection.container.yield_() # Wake up the wait() loop to handle the message. + + def on_link_error(self, event: "Event") -> None: + if event.link.state & Endpoint.LOCAL_ACTIVE: + event.link.close() + if not self.connection.closing: + raise LinkDetached(event.link) + + def on_connection_error(self, event: "Event") -> None: + if not self.connection.closing: + raise ConnectionClosed(event.connection) + + @property + def has_message(self) -> int: + """ + The number of messages that have been received and are waiting to be + retrieved with :meth:`pop`. + """ + return len(self.incoming) + + def pop(self) -> "Message": + """ + Get the next available incoming message. If the message is unsettled, its + delivery object is moved onto the unsettled queue, and can be settled with + a call to :meth:`settle`. + """ + message, delivery = self.incoming.popleft() + if not delivery.settled: + self.unsettled.append(delivery) + return message + + def settle(self, state: Optional[int] = None) -> None: + """ + Settle the next message previously taken with :meth:`pop`. + + :param state: + :type state: + """ + delivery = self.unsettled.popleft() + if state: + delivery.update(state) + delivery.settle() + + +class BlockingReceiver(BlockingLink): + """ + A synchronous receiver wrapper. This is typically created by calling + :meth:`BlockingConnection.create_receiver`. + """ + + def __init__( + self, + connection: "BlockingConnection", + receiver: "Receiver", + fetcher: Optional[Fetcher], + credit: int = 1, + ) -> None: + super(BlockingReceiver, self).__init__(connection, receiver) + if ( + self.link.source + and self.link.source.address + and self.link.source.address != self.link.remote_source.address + ): + # this may be followed by a detach, which may contain an error condition, so wait a little... + self._waitForClose() + # ...but close ourselves if peer does not + self.link.close() + raise LinkException( + "Failed to open receiver %s, source does not match" % self.link.name + ) + if credit: + receiver.flow(credit) + self.fetcher = fetcher + self.container = connection.container + + def __del__(self): + self.fetcher = None + # The next line causes a core dump if the Proton-C reactor finalizes + # first. The self.container reference prevents out of order reactor + # finalization. It may not be set if exception in BlockingLink.__init__ + if hasattr(self, "container"): + self.link.handler = None # implicit call to reactor + + def receive(self, timeout: Union[None, Literal[False], float] = False) -> "Message": + """ + Blocking receive call which will return only when a message is received or + a timeout (if supplied) occurs. + + :param timeout: Timeout in seconds. If ``False``, the value of ``timeout`` used in the + constructor of the :class:`BlockingConnection` object used in the constructor will be used. + If ``None``, there is no timeout. Any other value is treated as a timeout in seconds. + """ + if not self.fetcher: + raise Exception( + "Can't call receive on this receiver as a handler was not provided" + ) + if not self.link.credit: + self.link.flow(1) + self.connection.wait( + lambda: self.fetcher.has_message, + msg="Receiving on receiver %s" % self.link.name, + timeout=timeout, + ) + return self.fetcher.pop() + + def accept(self) -> None: + """ + Accept and settle the received message. The delivery is set to + :const:`proton.Delivery.ACCEPTED`. + """ + self.settle(Delivery.ACCEPTED) + + def reject(self) -> None: + """ + Reject the received message. The delivery is set to + :const:`proton.Delivery.REJECTED`. + """ + self.settle(Delivery.REJECTED) + + def release(self, delivered: bool = True) -> None: + """ + Release the received message. + + :param delivered: If ``True``, the message delivery is being set to + :const:`proton.Delivery.MODIFIED`, ie being returned to the sender + and annotated. If ``False``, the message is returned without + annotations and the delivery set to :const:`proton.Delivery.RELEASED`. + """ + if delivered: + self.settle(Delivery.MODIFIED) + else: + self.settle(Delivery.RELEASED) + + def settle(self, state: Optional["DispositionType"] = None): + """ + Settle any received messages. + + :param state: Update the delivery of all unsettled messages with the + supplied state, then settle them. + :type state: ``None`` or a valid delivery state (see + :class:`proton.Delivery`. + """ + if not self.fetcher: + raise Exception( + "Can't call accept/reject etc on this receiver as a handler was not provided" + ) + self.fetcher.settle(state) + + +class LinkDetached(LinkException): + """ + The exception raised when the remote peer unexpectedly closes a link in a blocking + context, or an unexpected link error occurs. + + :param link: The link which closed unexpectedly. + """ + + def __init__(self, link: Link) -> None: + self.link = link + if link.is_sender: + txt = "sender %s to %s closed" % (link.name, link.target.address) + else: + txt = "receiver %s from %s closed" % (link.name, link.source.address) + if link.remote_condition: + txt += " due to: %s" % link.remote_condition + self.condition = link.remote_condition.name + else: + txt += " by peer" + self.condition = None + super(LinkDetached, self).__init__(txt) + + +class ConnectionClosed(ConnectionException): + """ + The exception raised when the remote peer unexpectedly closes a connection in a blocking + context, or an unexpected connection error occurs. + + :param connection: The connection which closed unexpectedly. + """ + + def __init__(self, connection: "Connection") -> None: + self.connection = connection + txt = "Connection %s closed" % connection.hostname + if connection.remote_condition: + txt += " due to: %s" % connection.remote_condition + self.condition = connection.remote_condition.name + else: + txt += " by peer" + self.condition = None + super(ConnectionClosed, self).__init__(txt) + + +class BlockingConnection(Handler): + """ + A synchronous style connection wrapper. + + This object's implementation uses OS resources. To ensure they + are released when the object is no longer in use, make sure that + object operations are enclosed in a try block and that close() is + always executed on exit. + + :param url: The connection URL. + :param timeout: Connection timeout in seconds. If ``None``, defaults to 60 seconds. + :param container: Container to process the events on the connection. If ``None``, + a new :class:`proton.Container` will be created. + :param ssl_domain: + :param heartbeat: A value in seconds indicating the desired frequency of + heartbeats used to test the underlying socket is alive. + :param urls: A list of connection URLs to try to connect to. + :param kwargs: Container keyword arguments. See :class:`proton.reactor.Container` + for a list of the valid kwargs. + """ + + def __init__( + self, + url: Optional[Union[str, Url]] = None, + timeout: Optional[float] = None, + container: Optional[Container] = None, + ssl_domain: Optional["SSLDomain"] = None, + heartbeat: Optional[float] = None, + urls: Optional[List[str]] = None, + reconnect: Union[None, Literal[False], "Backoff"] = None, + **kwargs + ) -> None: + self.disconnected = False + self.timeout = timeout or 60 + self.container = container or Container() + self.container.timeout = self.timeout + self.container.start() + self.conn = None + self.closing = False + # Preserve previous behaviour if neither reconnect nor urls are supplied + if url is not None and urls is None and reconnect is None: + reconnect = False + url = Url(url).defaults() + failed = True + try: + self.conn = self.container.connect( + url=url, + handler=self, + ssl_domain=ssl_domain, + reconnect=reconnect, + heartbeat=heartbeat, + urls=urls, + **kwargs + ) + self.wait( + lambda: not (self.conn.state & Endpoint.REMOTE_UNINIT), + msg="Opening connection", + ) + failed = False + finally: + if failed and self.conn: + self.close() + + def create_sender( + self, + address: Optional[str], + handler: Optional[Handler] = None, + name: Optional[str] = None, + options: Optional[ + Union[ + "SenderOption", List["SenderOption"], "LinkOption", List["LinkOption"] + ] + ] = None, + ) -> BlockingSender: + """ + Create a blocking sender. + + :param address: Address of target node. + :param handler: Event handler for this sender. + :param name: Sender name. + :param options: A single option, or a list of sender options + :return: New blocking sender instance. + """ + return BlockingSender( + self, + self.container.create_sender( + self.conn, address, name=name, handler=handler, options=options + ), + ) + + def create_receiver( + self, + address: Optional[str] = None, + credit: Optional[int] = None, + dynamic: bool = False, + handler: Optional[Handler] = None, + name: Optional[str] = None, + options: Optional[ + Union[ + "ReceiverOption", + List["ReceiverOption"], + "LinkOption", + List["LinkOption"], + ] + ] = None, + ) -> BlockingReceiver: + """ + Create a blocking receiver. + + :param address: Address of source node. + :param credit: Initial link flow credit. If not set, will default to 1. + :param dynamic: If ``True``, indicates dynamic creation of the receiver. + :param handler: Event handler for this receiver. + :param name: Receiver name. + :param options: A single option, or a list of receiver options + :return: New blocking receiver instance. + """ + prefetch = credit + if handler: + fetcher = None + if prefetch is None: + prefetch = 1 + else: + fetcher = Fetcher(self, credit) + return BlockingReceiver( + self, + self.container.create_receiver( + self.conn, + address, + name=name, + dynamic=dynamic, + handler=handler or fetcher, + options=options, + ), + fetcher, + credit=prefetch, + ) + + def close(self) -> None: + """ + Close the connection. + """ + # TODO: provide stronger interrupt protection on cleanup. See PEP 419 + if self.closing: + return + self.closing = True + self.container.errors = [] + try: + if self.conn: + self.conn.close() + self.wait( + lambda: not (self.conn.state & Endpoint.REMOTE_ACTIVE), + msg="Closing connection", + ) + if self.conn.transport: + # Close tail to force transport cleanup without waiting/hanging for peer close frame. + self.conn.transport.close_tail() + finally: + self.conn.free() + # Nothing left to block on. Allow reactor to clean up. + self.run() + if self.conn: + self.conn.handler = None # break cyclical reference + self.conn = None + self.container.stop_events() + self.container = None + + @property + def url(self) -> str: + """ + The address for this connection. + """ + return self.conn and self.conn.connected_address + + def _is_closed(self) -> int: + return self.conn.state & (Endpoint.LOCAL_CLOSED | Endpoint.REMOTE_CLOSED) + + def run(self) -> None: + """ + Hand control over to the event loop (e.g. if waiting indefinitely for incoming messages) + """ + while self.container.process(): + pass + self.container.stop() + self.container.process() + + def wait( + self, + condition: Callable[[], bool], + timeout: Union[None, Literal[False], float] = False, + msg: Optional[str] = None, + ) -> None: + """ + Process events until ``condition()`` returns ``True``. + + :param condition: Condition which determines when the wait will end. + :param timeout: Timeout in seconds. If ``False``, the value of ``timeout`` used in the + constructor of this object will be used. If ``None``, there is no timeout. Any other + value is treated as a timeout in seconds. + :param msg: Context message for :class:`proton.Timeout` exception + """ + if timeout is False: + timeout = self.timeout + if timeout is None: + while not condition() and not self.disconnected: + self.container.process() + else: + container_timeout = self.container.timeout + self.container.timeout = timeout + try: + deadline = time.time() + timeout + while not condition() and not self.disconnected: + self.container.process() + if deadline < time.time(): + txt = "Connection %s timed out" % self.url + if msg: + txt += ": " + msg + raise Timeout(txt) + finally: + self.container.timeout = container_timeout + if self.disconnected and not self._is_closed(): + raise ConnectionException( + "Connection %s disconnected: %s" % (self.url, self.disconnected) + ) + + def on_link_remote_close(self, event: "Event") -> None: + """ + Event callback for when the remote terminus closes. + """ + if event.link.state & Endpoint.LOCAL_ACTIVE: + event.link.close() + if not self.closing: + raise LinkDetached(event.link) + + def on_connection_remote_close(self, event: "Event") -> None: + """ + Event callback for when the link peer closes the connection. + """ + if event.connection.state & Endpoint.LOCAL_ACTIVE: + event.connection.close() + if not self.closing: + raise ConnectionClosed(event.connection) + + def on_transport_tail_closed(self, event: "Event") -> None: + self.on_transport_closed(event) + + def on_transport_head_closed(self, event: "Event") -> None: + self.on_transport_closed(event) + + def on_transport_closed(self, event: "Event") -> None: + if not self.closing: + self.disconnected = event.transport.condition or "unknown" + + +class AtomicCount: + def __init__(self, start: int = 0, step: int = 1) -> None: + """Thread-safe atomic counter. Start at start, increment by step.""" + self.count, self.step = start, step + self.lock = threading.Lock() + + def next(self) -> int: + """Get the next value""" + self.lock.acquire() + self.count += self.step + result = self.count + self.lock.release() + return result + + +class SyncRequestResponse(IncomingMessageHandler): + """ + Implementation of the synchronous request-response (aka RPC) pattern. + A single instance can send many requests to the same or different + addresses. + + :param connection: Connection for requests and responses. + :param address: Address for all requests. If not specified, each request + must have the address property set. Successive messages may have + different addresses. + """ + + correlation_id = AtomicCount() + + def __init__( + self, connection: BlockingConnection, address: Optional[str] = None + ) -> None: + super(SyncRequestResponse, self).__init__() + self.connection = connection + self.address = address + self.sender = self.connection.create_sender(self.address) + # dynamic=true generates a unique address dynamically for this receiver. + # credit=1 because we want to receive 1 response message initially. + self.receiver = self.connection.create_receiver( + None, dynamic=True, credit=1, handler=self + ) + self.response = None + + def call(self, request: "Message") -> "Message": + """ + Send a request message, wait for and return the response message. + + :param request: Request message. If ``self.address`` is not set the + request message address must be set and will be used. + """ + if not self.address and not request.address: + raise ValueError("Request message has no address: %s" % request) + request.reply_to = self.reply_to + request.correlation_id = correlation_id = str(self.correlation_id.next()) + self.sender.send(request) + + def wakeup(): + return self.response and (self.response.correlation_id == correlation_id) + + self.connection.wait(wakeup, msg="Waiting for response") + response = self.response + self.response = None # Ready for next response. + self.receiver.flow(1) # Set up credit for the next response. + return response + + @property + def reply_to(self) -> str: + """ + The dynamic address of our receiver. + """ + return self.receiver.remote_source.address + + def on_message(self, event: "Event") -> None: + """ + Called when we receive a message for our receiver. + + :param event: The event which occurs when a message is received. + """ + self.response = event.message + self.connection.container.yield_() # Wake up the wait() loop to handle the message. diff --git a/rabbitmq_amqp_python_client/qpid/proton/_wrapper.py b/rabbitmq_amqp_python_client/qpid/proton/_wrapper.py new file mode 100644 index 0000000..4876faf --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/_wrapper.py @@ -0,0 +1,141 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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, Callable, Optional + +from cproton import ( + addressof, + pn_decref, + pn_incref, + pn_record_def_py, + pn_record_get_py, + pn_record_set_py, +) + +from ._exceptions import ProtonException + + +class EmptyAttrs: + def __contains__(self, name): + return False + + def __getitem__(self, name): + raise KeyError(name) + + def __setitem__(self, name, value): + raise TypeError("does not support item assignment") + + +EMPTY_ATTRS = EmptyAttrs() + + +class Wrapper(object): + """Wrapper for python objects that need to be stored in event contexts and be retrieved again from them + Quick note on how this works: + The actual *python* object has only 3 attributes which redirect into the wrapped C objects: + _impl The wrapped C object itself + _attrs This is a special pn_record_t holding a PYCTX which is a python dict + every attribute in the python object is actually looked up here + + Because the objects actual attributes are stored away they must be initialised *after* the wrapping + is set up. This is the purpose of the _init method in the wrapped object. Wrapper.__init__ will call + eht subclass _init to initialise attributes. So they *must not* be initialised in the subclass __init__ + before calling the superclass (Wrapper) __init__ or they will not be accessible from the wrapper at all. + + """ + + def __init__( + self, + impl: Any = None, + get_context: Optional[Callable[[Any], Any]] = None, + constructor: Optional[Callable[[], Any]] = None, + ) -> None: + init = False + if impl is None and constructor is not None: + # we are constructing a new object + impl = constructor() + if impl is None: + self.__dict__["_impl"] = impl + self.__dict__["_attrs"] = EMPTY_ATTRS + raise ProtonException( + "Wrapper failed to create wrapped object. Check for file descriptor or memory exhaustion." + ) + init = True + else: + # we are wrapping an existing object + pn_incref(impl) + + if get_context: + record = get_context(impl) + attrs = pn_record_get_py(record) + if attrs is None: + attrs = {} + pn_record_def_py(record) + pn_record_set_py(record, attrs) + init = True + else: + attrs = EMPTY_ATTRS + init = False + self.__dict__["_impl"] = impl + self.__dict__["_attrs"] = attrs + if init: + self._init() + + def __getattr__(self, name: str) -> Any: + attrs = self.__dict__["_attrs"] + if name in attrs: + return attrs[name] + else: + raise AttributeError(name + " not in _attrs") + + def __setattr__(self, name: str, value: Any) -> None: + if hasattr(self.__class__, name): + object.__setattr__(self, name, value) + else: + attrs = self.__dict__["_attrs"] + attrs[name] = value + + def __delattr__(self, name: str) -> None: + attrs = self.__dict__["_attrs"] + if attrs: + del attrs[name] + + def __hash__(self) -> int: + return hash(addressof(self._impl)) + + def __eq__(self, other: Any) -> bool: + if isinstance(other, Wrapper): + return addressof(self._impl) == addressof(other._impl) + return False + + def __ne__(self, other: Any) -> bool: + if isinstance(other, Wrapper): + return addressof(self._impl) != addressof(other._impl) + return True + + def __del__(self) -> None: + pn_decref(self._impl) + + def __repr__(self) -> str: + return "<%s.%s 0x%x ~ 0x%x>" % ( + self.__class__.__module__, + self.__class__.__name__, + id(self), + addressof(self._impl), + ) diff --git a/rabbitmq_amqp_python_client/qpid/proton/handlers.py b/rabbitmq_amqp_python_client/qpid/proton/handlers.py new file mode 100644 index 0000000..0b5ac06 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/handlers.py @@ -0,0 +1,48 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 ._handlers import ( + EndpointStateHandler, + FlowController, + Handshaker, + IncomingMessageHandler, + IOHandler, + MessagingHandler, + OutgoingMessageHandler, + PythonIO, + Reject, + Release, + TransactionalClientHandler, + TransactionHandler, +) + +__all__ = [ + "MessagingHandler", + "IncomingMessageHandler", + "OutgoingMessageHandler", + "EndpointStateHandler", + "TransactionHandler", + "TransactionalClientHandler", + "Reject", + "Release", + "Handshaker", + "FlowController", + "IOHandler", + "PythonIO", +] diff --git a/rabbitmq_amqp_python_client/qpid/proton/py.typed b/rabbitmq_amqp_python_client/qpid/proton/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/rabbitmq_amqp_python_client/qpid/proton/reactor.py b/rabbitmq_amqp_python_client/qpid/proton/reactor.py new file mode 100644 index 0000000..2abba36 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/reactor.py @@ -0,0 +1,58 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 ._reactor import ( + ApplicationEvent, + AtLeastOnce, + AtMostOnce, + Backoff, + Container, + Copy, + DurableSubscription, + DynamicNodeProperties, + EventInjector, + Filter, + Handler, + LinkOption, + Move, + ReceiverOption, + Selector, + SenderOption, + Transaction, +) + +__all__ = [ + "Container", + "ApplicationEvent", + "EventInjector", + "Handler", + "LinkOption", + "ReceiverOption", + "SenderOption", + "AtLeastOnce", + "AtMostOnce", + "DynamicNodeProperties", + "Filter", + "Selector", + "DurableSubscription", + "Copy", + "Move", + "Backoff", + "Transaction", +] diff --git a/rabbitmq_amqp_python_client/qpid/proton/tracing.py b/rabbitmq_amqp_python_client/qpid/proton/tracing.py new file mode 100644 index 0000000..57800e4 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/tracing.py @@ -0,0 +1,22 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 ._tracing import get_tracer, init_tracer + +__all__ = ["get_tracer", "init_tracer"] diff --git a/rabbitmq_amqp_python_client/qpid/proton/utils.py b/rabbitmq_amqp_python_client/qpid/proton/utils.py new file mode 100644 index 0000000..9562ac5 --- /dev/null +++ b/rabbitmq_amqp_python_client/qpid/proton/utils.py @@ -0,0 +1,38 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 ._utils import ( + BlockingConnection, + BlockingReceiver, + BlockingSender, + ConnectionClosed, + LinkDetached, + SendException, + SyncRequestResponse, +) + +__all__ = [ + "BlockingConnection", + "BlockingSender", + "BlockingReceiver", + "SyncRequestResponse", + "SendException", + "LinkDetached", + "ConnectionClosed", +] diff --git a/rabbitmq_amqp_python_client/queues.py b/rabbitmq_amqp_python_client/queues.py new file mode 100644 index 0000000..2b66377 --- /dev/null +++ b/rabbitmq_amqp_python_client/queues.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class QueueSpecification: + name: str + expires: Optional[int] = None + message_ttl: Optional[int] = None + overflow: Optional[str] = None + single_active_consumer: Optional[bool] = None + dead_letter_exchange: Optional[str] = None + dead_letter_routing_key: Optional[str] = None + max_len: Optional[int] = None + max_len_bytes: Optional[int] = None + leader_locator: Optional[str] = None + is_auto_delete: bool = False + is_durable: bool = True + + +@dataclass +class ClassicQueueSpecification(QueueSpecification): + maximum_priority: Optional[int] = None + + +@dataclass +class QuorumQueueSpecification(QueueSpecification): + deliver_limit: Optional[str] = None + dead_letter_strategy: Optional[str] = None + quorum_initial_group_size: Optional[int] = None + cluster_target_size: Optional[int] = None + + +@dataclass +class StreamSpecification: + name: str + max_len_bytes: Optional[int] = None + max_time_retention: Optional[int] = None + max_segment_size_in_bytes: Optional[int] = None + filter_size: Optional[int] = None + initial_group_size: Optional[int] = None + leader_locator: Optional[str] = None diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0929ca7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +import pytest + +from rabbitmq_amqp_python_client import Connection + + +@pytest.fixture() +def connection(pytestconfig): + connection = Connection("amqp://guest:guest@localhost:5672/") + connection.dial() + try: + yield connection + + finally: + connection.close() + + +@pytest.fixture() +def management(pytestconfig): + connection = Connection("amqp://guest:guest@localhost:5672/") + connection.dial() + try: + management = connection.management() + yield management + + finally: + management.close() + connection.close() diff --git a/tests/test_address_helper.py b/tests/test_address_helper.py new file mode 100644 index 0000000..5ef5c70 --- /dev/null +++ b/tests/test_address_helper.py @@ -0,0 +1,28 @@ +from rabbitmq_amqp_python_client import ( + exchange_address, + queue_address, +) + + +def test_encoding_queue_simple() -> None: + queue = "my_queue" + + address = queue_address(queue) + + assert address == "/queues/my_queue" + + +def test_encoding_queue_hex() -> None: + queue = "my_queue>" + + address = queue_address(queue) + + assert address == "/queues/my_queue%3E" + + +def test_encoding_exchange_hex() -> None: + queue = "my_exchange/()" + + address = exchange_address(queue) + + assert address == "/exchanges/my_exchange%2F%28%29" diff --git a/tests/test_connection.py b/tests/test_connection.py index ff42441..1d2aa4d 100644 --- a/tests/test_connection.py +++ b/tests/test_connection.py @@ -1,7 +1,7 @@ -from proton.utils import BlockingConnection +from rabbitmq_amqp_python_client import Connection -# Temporary this will be replaced by our connection Deal when we start the implementation -# For the moment we just need a test to run poetry run pytest without failing def test_connection() -> None: - BlockingConnection("amqp://guest:guest@localhost:5672/") + connection = Connection("amqp://guest:guest@localhost:5672/") + connection.dial() + connection.close() diff --git a/tests/test_management.py b/tests/test_management.py new file mode 100644 index 0000000..a810368 --- /dev/null +++ b/tests/test_management.py @@ -0,0 +1,236 @@ +from rabbitmq_amqp_python_client import ( + BindingSpecification, + ClassicQueueSpecification, + ExchangeSpecification, + Management, + QueueType, + QuorumQueueSpecification, + StreamSpecification, +) +from rabbitmq_amqp_python_client.exceptions import ( + ValidationCodeException, +) + + +def test_declare_delete_exchange(management: Management) -> None: + + exchange_name = "test-exchange" + + exchange_info = management.declare_exchange( + ExchangeSpecification(name=exchange_name, arguments={}) + ) + + assert exchange_info.name == exchange_name + + management.delete_exchange(exchange_name) + + +def test_declare_purge_delete_queue(management: Management) -> None: + queue_name = "my_queue" + + queue_info = management.declare_queue(QuorumQueueSpecification(name=queue_name)) + + assert queue_info.name == queue_name + + management.purge_queue(queue_name) + + management.delete_queue(queue_name) + + +def test_bind_exchange_to_queue(management: Management) -> None: + + exchange_name = "test-bind-exchange-to-queue-exchange" + queue_name = "test-bind-exchange-to-queue-queue" + routing_key = "routing-key" + + management.declare_exchange(ExchangeSpecification(name=exchange_name, arguments={})) + + management.declare_queue(QuorumQueueSpecification(name=queue_name)) + + binding_exchange_queue_path = management.bind( + BindingSpecification( + source_exchange=exchange_name, + destination_queue=queue_name, + binding_key=routing_key, + ) + ) + + print(binding_exchange_queue_path) + + assert ( + binding_exchange_queue_path + == "/bindings/src=" + + exchange_name + + ";dstq=" + + queue_name + + ";key=" + + routing_key + + ";args=" + ) + + management.delete_exchange(exchange_name) + + management.delete_queue(queue_name) + + management.unbind(binding_exchange_queue_path) + + +def test_queue_info_with_validations(management: Management) -> None: + + queue_name = "test_queue_info_with_validation" + + queue_specification = QuorumQueueSpecification( + name=queue_name, + ) + management.declare_queue(queue_specification) + + queue_info = management.queue_info(queue_name=queue_name) + + management.delete_queue(queue_name) + + assert queue_info.name == queue_name + assert queue_info.queue_type == QueueType.quorum + assert queue_info.is_durable == queue_specification.is_durable + assert queue_info.message_count == 0 + + +def test_queue_info_for_stream_with_validations(management: Management) -> None: + + stream_name = "test_stream_info_with_validation" + + queue_specification = StreamSpecification( + name=stream_name, + ) + management.declare_queue(queue_specification) + + stream_info = management.queue_info(queue_name=stream_name) + + management.delete_queue(stream_name) + + assert stream_info.name == stream_name + assert stream_info.queue_type == QueueType.stream + assert stream_info.message_count == 0 + + +def test_queue_precondition_fail(management: Management) -> None: + test_failure = True + + queue_name = "test-queue_precondition_fail" + + queue_specification = QuorumQueueSpecification( + name=queue_name, is_auto_delete=False + ) + management.declare_queue(queue_specification) + + management.declare_queue(queue_specification) + + queue_specification = QuorumQueueSpecification( + name=queue_name, + is_auto_delete=True, + ) + + management.delete_queue(queue_name) + + try: + management.declare_queue(queue_specification) + except ValidationCodeException: + test_failure = False + + assert test_failure is False + + +def test_declare_classic_queue(management: Management) -> None: + + queue_name = "test-declare_classic_queue" + + queue_specification = QuorumQueueSpecification( + name=queue_name, + is_auto_delete=False, + ) + queue_info = management.declare_queue(queue_specification) + + assert queue_info.name == queue_specification.name + + management.delete_queue(queue_name) + + +def test_declare_classic_queue_with_args(management: Management) -> None: + + queue_name = "test-queue_with_args" + + queue_specification = ClassicQueueSpecification( + name=queue_name, + is_auto_delete=False, + dead_letter_exchange="my_exchange", + dead_letter_routing_key="my_key", + max_len=50000000, + max_len_bytes=1000000000, + expires=2000, + single_active_consumer=True, + ) + + queue_info = management.declare_queue(queue_specification) + + assert queue_specification.name == queue_info.name + assert queue_specification.is_auto_delete == queue_info.is_auto_delete + assert queue_specification.dead_letter_exchange == queue_info.dead_letter_exchange + assert ( + queue_specification.dead_letter_routing_key + == queue_info.dead_letter_routing_key + ) + assert queue_specification.max_len == queue_info.max_len + assert queue_specification.max_len_bytes == queue_info.max_len_bytes + assert queue_specification.expires == queue_info.expires + assert ( + queue_specification.single_active_consumer == queue_info.single_active_consumer + ) + + management.delete_queue(queue_name) + + +def test_declare_classic_queue_with_invalid_args(management: Management) -> None: + queue_name = "test-queue_with_args" + test_failure = True + + queue_specification = ClassicQueueSpecification( + name=queue_name, + max_len=-5, + ) + + try: + management.declare_queue(queue_specification) + except ValidationCodeException: + test_failure = False + + management.delete_queue(queue_name) + + assert test_failure is False + + +def test_declare_stream_with_args(management: Management) -> None: + stream_name = "test-stream_with_args" + + stream_specification = StreamSpecification( + name=stream_name, + max_len_bytes=1000000000, + max_time_retention=10000000, + max_segment_size_in_bytes=100000000, + filter_size=1000, + initial_group_size=3, + leader_locator="node1", + ) + + stream_info = management.declare_queue(stream_specification) + + assert stream_specification.name == stream_info.name + assert stream_specification.max_len_bytes == stream_info.max_len_bytes + assert stream_specification.max_time_retention == stream_info.max_time_retention + assert ( + stream_specification.max_segment_size_in_bytes + == stream_info.max_segment_size_in_bytes + ) + assert stream_specification.filter_size == stream_info.filter_size + assert stream_specification.initial_group_size == stream_info.initial_group_size + assert stream_specification.leader_locator == stream_info.leader_locator + + management.delete_queue(stream_name) diff --git a/tests/test_publisher.py b/tests/test_publisher.py new file mode 100644 index 0000000..048d7a9 --- /dev/null +++ b/tests/test_publisher.py @@ -0,0 +1,102 @@ +import time + +from rabbitmq_amqp_python_client import ( + BindingSpecification, + Connection, + ExchangeSpecification, + Message, + QuorumQueueSpecification, + exchange_address, +) + + +def test_publish_queue(connection: Connection) -> None: + + queue_name = "test-queue" + management = connection.management() + + management.declare_queue(QuorumQueueSpecification(name=queue_name)) + + raised = False + + try: + publisher = connection.publisher("/queues/" + queue_name) + publisher.publish(Message(body="test")) + except Exception: + raised = True + + assert raised is False + + publisher.close() + + management.delete_queue(queue_name) + management.close() + + +def test_publish_exchange(connection: Connection) -> None: + + exchange_name = "test-exchange" + queue_name = "test-queue" + management = connection.management() + routing_key = "routing-key" + + management.declare_exchange(ExchangeSpecification(name=exchange_name, arguments={})) + + management.declare_queue(QuorumQueueSpecification(name=queue_name)) + + management.bind( + BindingSpecification( + source_exchange=exchange_name, + destination_queue=queue_name, + binding_key=routing_key, + ) + ) + + addr = exchange_address(exchange_name, routing_key) + + raised = False + + try: + publisher = connection.publisher(addr) + publisher.publish(Message(body="test")) + except Exception: + raised = True + + assert raised is False + + publisher.close() + + management.delete_exchange(exchange_name) + management.delete_queue(queue_name) + management.close() + + +def test_publish_purge(connection: Connection) -> None: + connection = Connection("amqp://guest:guest@localhost:5672/") + connection.dial() + + queue_name = "test-queue" + management = connection.management() + + management.declare_queue(QuorumQueueSpecification(name=queue_name)) + + raised = False + + try: + publisher = connection.publisher("/queues/" + queue_name) + for i in range(20): + publisher.publish(Message(body="test")) + except Exception: + raised = True + + time.sleep(4) + + message_purged = management.purge_queue(queue_name) + + assert raised is False + assert message_purged == 20 + + publisher.close() + + management.delete_queue(queue_name) + management.close()