asyncapi-python 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. asyncapi_python-0.1.2/PKG-INFO +97 -0
  2. asyncapi_python-0.1.2/README.md +70 -0
  3. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/pyproject.toml +15 -12
  4. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python/amqp/base_application.py +4 -5
  5. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python/amqp/consumer.py +15 -11
  6. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python/amqp/message_handler.py +2 -2
  7. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python/amqp/message_handler_params.py +20 -16
  8. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python/amqp/producer.py +18 -16
  9. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python/amqp/utils.py +10 -2
  10. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/__init__.py +6 -7
  11. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/document/bindings/amqp.py +4 -4
  12. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/document/components.py +15 -29
  13. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/document/document.py +2 -2
  14. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/document/ref.py +40 -22
  15. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/generators/amqp/generate.py +100 -48
  16. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/generators/amqp/templates/application.py.j2 +9 -8
  17. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/generators/amqp/utils.py +9 -8
  18. asyncapi_python-0.1.0/PKG-INFO +0 -38
  19. asyncapi_python-0.1.0/README.md +0 -13
  20. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/LICENSE +0 -0
  21. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python/__init__.py +0 -0
  22. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python/amqp/__init__.py +0 -0
  23. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python/amqp/connection.py +0 -0
  24. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python/py.typed +0 -0
  25. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/document/__init__.py +0 -0
  26. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/document/base.py +0 -0
  27. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/document/bindings/__init__.py +0 -0
  28. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/document/document_context.py +0 -0
  29. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/generators/__init__.py +0 -0
  30. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/generators/amqp/__init__.py +0 -0
  31. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/generators/amqp/templates/__init__.py.j2 +0 -0
  32. {asyncapi_python-0.1.0 → asyncapi_python-0.1.2}/src/asyncapi_python_codegen/py.typed +0 -0
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.1
2
+ Name: asyncapi-python
3
+ Version: 0.1.2
4
+ Summary: Easily generate type-safe and async Python applications from AsyncAPI 3 specifications.
5
+ License: Apache-2.0
6
+ Author: Yaroslav Petrov
7
+ Author-email: yaroslav.v.petrov@gmail.com
8
+ Requires-Python: >=3.9,<3.13
9
+ Classifier: License :: OSI Approved :: Apache Software License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Provides-Extra: amqp
16
+ Provides-Extra: codegen
17
+ Requires-Dist: aio-pika ; extra == "amqp"
18
+ Requires-Dist: black ; extra == "codegen"
19
+ Requires-Dist: datamodel-code-generator[http] (>=0.26.1,<0.27.0) ; extra == "codegen"
20
+ Requires-Dist: jinja2 (>=3.1.4,<4.0.0) ; extra == "codegen"
21
+ Requires-Dist: pydantic (>=2)
22
+ Requires-Dist: pytz
23
+ Requires-Dist: pyyaml ; extra == "codegen"
24
+ Requires-Dist: typer[all] (>=0.12.5,<0.13.0) ; extra == "codegen"
25
+ Description-Content-Type: text/markdown
26
+
27
+ # AsyncAPI Python Code Generator
28
+
29
+ [Link to this github repository](https://github.com/G-USI/asyncapi-python)
30
+
31
+ Easily generate type-safe and async Python applications from AsyncAPI 3 specifications.
32
+
33
+ ## Features
34
+
35
+ - [x] Creates `Application` class from [AsyncAPI 3](https://asyncapi.com) specifications, implementing every operation in the file
36
+ - [x] Generates typed Python code (messages are generated using [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator))
37
+ - [x] Performs dynamic validation of messages with [Pydantic 2](https://docs.pydantic.dev/latest/)
38
+ - [x] Enforces the user code to implement all consumer methods as described by spec
39
+ - [x] Provides async code
40
+ - [x] Spec parser references other files through absolute or relative paths
41
+ - [ ] Spec parser references other files through url
42
+ - [x] Supports request-reply pattern
43
+ - [x] Supports publish-subscribe pattern
44
+ - [ ] AsyncAPI trait support
45
+ - [ ] Customizable message encoder/decoder
46
+
47
+ ## Requirements
48
+
49
+ - `python>=3.10`
50
+ - `pydantic>=2`
51
+ - `pytz`
52
+ - For `codegen` extra
53
+ - `jinja2`
54
+ - `typer`
55
+ - `datamodel-code-generator`
56
+ - `pyyaml`
57
+ - For `amqp` extra
58
+ - `aio-pika`
59
+
60
+ ## Installation
61
+
62
+ For code generation (development env), run:
63
+
64
+ ```bash
65
+ pip install asyncapi-python[codegen]
66
+ ```
67
+
68
+ For runtime, run:
69
+
70
+ ```bash
71
+ pip install asyncapi-python[amqp]
72
+ ```
73
+
74
+ You can replace `amqp` with any other supported protocols. For more info, see [Supported Protocols](#supported-protocols--use-cases) section.
75
+
76
+ ## Supported Protocols / Use Cases
77
+
78
+ Below, you may see the table of protocols and the supported use cases. The tick signs (✅) contain links to the examples for each implemented protocol-use case pair, while the hammer signs (🔨) contain links to the Issues tracking the progress for protocol-use case pairs. The list of protocols and use cases is expected to increase over the progress of development.
79
+
80
+ | Use Case | AMQP |
81
+ | ---------- | ------------------------------------------------ |
82
+ | Pub-Sub | [✅ amqp-pub-sub](./examples/amqp-pub-sub) |
83
+ | Work Queue | [✅ amqp-work-queue](./examples/amqp-work-queue) |
84
+ | RPC | [✅ amqp-rpc](./examples/amqp-rpc) |
85
+
86
+ ## Documentation
87
+
88
+ Although there's no documentation available at the moment, a set of comprehensive examples with comments for each implemented use-case is under the [examples](./examples/) directory.
89
+
90
+ ## Contributing
91
+
92
+ Contributions are welcome! Please feel free to open an Issue or submit a Pull Request.
93
+
94
+ ## License
95
+
96
+ This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
97
+
@@ -0,0 +1,70 @@
1
+ # AsyncAPI Python Code Generator
2
+
3
+ [Link to this github repository](https://github.com/G-USI/asyncapi-python)
4
+
5
+ Easily generate type-safe and async Python applications from AsyncAPI 3 specifications.
6
+
7
+ ## Features
8
+
9
+ - [x] Creates `Application` class from [AsyncAPI 3](https://asyncapi.com) specifications, implementing every operation in the file
10
+ - [x] Generates typed Python code (messages are generated using [datamodel-code-generator](https://github.com/koxudaxi/datamodel-code-generator))
11
+ - [x] Performs dynamic validation of messages with [Pydantic 2](https://docs.pydantic.dev/latest/)
12
+ - [x] Enforces the user code to implement all consumer methods as described by spec
13
+ - [x] Provides async code
14
+ - [x] Spec parser references other files through absolute or relative paths
15
+ - [ ] Spec parser references other files through url
16
+ - [x] Supports request-reply pattern
17
+ - [x] Supports publish-subscribe pattern
18
+ - [ ] AsyncAPI trait support
19
+ - [ ] Customizable message encoder/decoder
20
+
21
+ ## Requirements
22
+
23
+ - `python>=3.10`
24
+ - `pydantic>=2`
25
+ - `pytz`
26
+ - For `codegen` extra
27
+ - `jinja2`
28
+ - `typer`
29
+ - `datamodel-code-generator`
30
+ - `pyyaml`
31
+ - For `amqp` extra
32
+ - `aio-pika`
33
+
34
+ ## Installation
35
+
36
+ For code generation (development env), run:
37
+
38
+ ```bash
39
+ pip install asyncapi-python[codegen]
40
+ ```
41
+
42
+ For runtime, run:
43
+
44
+ ```bash
45
+ pip install asyncapi-python[amqp]
46
+ ```
47
+
48
+ You can replace `amqp` with any other supported protocols. For more info, see [Supported Protocols](#supported-protocols--use-cases) section.
49
+
50
+ ## Supported Protocols / Use Cases
51
+
52
+ Below, you may see the table of protocols and the supported use cases. The tick signs (✅) contain links to the examples for each implemented protocol-use case pair, while the hammer signs (🔨) contain links to the Issues tracking the progress for protocol-use case pairs. The list of protocols and use cases is expected to increase over the progress of development.
53
+
54
+ | Use Case | AMQP |
55
+ | ---------- | ------------------------------------------------ |
56
+ | Pub-Sub | [✅ amqp-pub-sub](./examples/amqp-pub-sub) |
57
+ | Work Queue | [✅ amqp-work-queue](./examples/amqp-work-queue) |
58
+ | RPC | [✅ amqp-rpc](./examples/amqp-rpc) |
59
+
60
+ ## Documentation
61
+
62
+ Although there's no documentation available at the moment, a set of comprehensive examples with comments for each implemented use-case is under the [examples](./examples/) directory.
63
+
64
+ ## Contributing
65
+
66
+ Contributions are welcome! Please feel free to open an Issue or submit a Pull Request.
67
+
68
+ ## License
69
+
70
+ This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
@@ -1,8 +1,8 @@
1
1
  [tool.poetry]
2
2
  name = "asyncapi-python"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  license = "Apache-2.0"
5
- description = ""
5
+ description = "Easily generate type-safe and async Python applications from AsyncAPI 3 specifications."
6
6
  authors = ["Yaroslav Petrov <yaroslav.v.petrov@gmail.com>"]
7
7
  readme = "README.md"
8
8
  packages = [
@@ -20,11 +20,12 @@ codegen = [
20
20
  "asyncapi_python_codegen",
21
21
  "pyyaml",
22
22
  "datamodel-code-generator",
23
+ "black",
23
24
  ]
24
25
  amqp = ["aio-pika"]
25
26
 
26
27
  [tool.poetry.dependencies]
27
- python = ">=3.10,<3.13"
28
+ python = ">=3.9,<3.13"
28
29
  pydantic = ">=2"
29
30
  pytz = "*"
30
31
  jinja2 = { version = "^3.1.4", optional = true }
@@ -32,17 +33,19 @@ typer = { extras = ["all"], version = "^0.12.5", optional = true }
32
33
  datamodel-code-generator = { extras = [
33
34
  "http",
34
35
  ], version = "^0.26.1", optional = true }
35
- aio-pika = { version = "^9.4.3", optional = true }
36
- pyyaml = { version = "^6.0.2", optional = true }
36
+ aio-pika = { version = "*", optional = true }
37
+ pyyaml = { version = "*", optional = true }
38
+ black = { version = "*", optional = true }
37
39
 
38
40
  [tool.poetry.group.dev.dependencies]
39
- black = "^24.10.0"
40
- mypy = "^1.11.2"
41
- isort = "^5.13.2"
42
- types-pyyaml = "^6.0.12.20240917"
43
- pytest = "^8.3.3"
44
- types-pytz = "^2024.2.0.20241003"
45
- pytest-asyncio = "^0.24.0"
41
+ black = "*"
42
+ mypy = "*"
43
+ isort = "*"
44
+ types-pyyaml = "*"
45
+ pytest = "*"
46
+ types-pytz = "*"
47
+ pytest-asyncio = "*"
48
+ pex = "*"
46
49
 
47
50
  [build-system]
48
51
  requires = ["poetry-core"]
@@ -16,25 +16,24 @@
16
16
  from .connection import channel_pool, AmqpPool
17
17
  from .consumer import Consumer
18
18
  from .producer import Producer
19
- from abc import ABC, abstractmethod
20
- from typing import Literal, TypedDict
19
+ from typing import Literal, Optional, TypedDict
21
20
 
22
21
 
23
22
  class Queue(TypedDict):
24
- name: str | None
23
+ name: Optional[str]
25
24
  durable: bool
26
25
  exclusive: bool
27
26
  auto_delete: bool
28
27
 
29
28
 
30
29
  class Exchange(TypedDict):
31
- name: str | None
30
+ name: Optional[str]
32
31
  type: Literal["topic", "direct", "fanout", "default", "headers"]
33
32
  durable: bool
34
33
  auto_delete: bool
35
34
 
36
35
 
37
- class BaseApplication(ABC):
36
+ class BaseApplication:
38
37
  def __init__(self, amqp_uri: str):
39
38
  self._uri = amqp_uri
40
39
  self._has_started = False
@@ -15,19 +15,19 @@
15
15
 
16
16
  from .message_handler import AbstractMessageHandler, MessageHandler, RpcMessageHandler
17
17
  from .message_handler_params import MessageHandlerParams
18
- from .utils import encode_message, decode_message
18
+ from .utils import encode_message, decode_message, union_model
19
19
 
20
20
  import asyncio
21
21
  from aio_pika import Message
22
22
  from aio_pika.pool import Pool
23
23
  from aio_pika.abc import AbstractRobustChannel
24
24
  from asyncio import Future
25
- from typing import Callable, TypeVar
26
- from pydantic import BaseModel
25
+ from typing import Callable, Optional, Sequence, TypeVar, Union
26
+ from pydantic import BaseModel, RootModel
27
27
  from logging import getLogger
28
28
 
29
- T = TypeVar("T", bound=BaseModel)
30
- U = TypeVar("U", bound=BaseModel)
29
+ T = TypeVar("T")
30
+ U = TypeVar("U")
31
31
 
32
32
 
33
33
  class Consumer:
@@ -36,7 +36,7 @@ class Consumer:
36
36
  self._logger = getLogger(__name__)
37
37
  self._pool = channel_pool
38
38
 
39
- async def run_blocking(self, timeout: int | float | None):
39
+ async def run_blocking(self, timeout: Union[int, float, None]):
40
40
  await self.run()
41
41
  if timeout is not None:
42
42
  await asyncio.sleep(timeout)
@@ -56,18 +56,20 @@ class Consumer:
56
56
  self,
57
57
  *,
58
58
  params: MessageHandlerParams,
59
- input_type: type[T],
60
- output_type: type[U] | None,
59
+ input_types: Sequence[type[T]],
60
+ output_types: Union[None, Sequence[type[U]]],
61
61
  callback: Callable,
62
62
  ):
63
63
  handler: AbstractMessageHandler
64
64
  if params in self._handlers:
65
65
  raise AssertionError(f"Only one handler for `{params}` is allowed")
66
- if output_type is None:
66
+ if output_types is None or len(output_types) == 0:
67
67
  handler = MessageHandler(
68
68
  name=params.root.name,
69
69
  callback=callback,
70
- decode_message=lambda x: decode_message(x, input_type),
70
+ decode_message=lambda x: decode_message(
71
+ x, union_model(input_types)
72
+ ).root,
71
73
  )
72
74
  else:
73
75
  handler = RpcMessageHandler(
@@ -75,6 +77,8 @@ class Consumer:
75
77
  callback=callback,
76
78
  reply_callback=self._reply_callback,
77
79
  encode_message=encode_message,
78
- decode_message=lambda x: decode_message(x, input_type),
80
+ decode_message=lambda x: decode_message(
81
+ x, union_model(input_types)
82
+ ).root,
79
83
  )
80
84
  self._handlers[params] = handler
@@ -15,13 +15,13 @@
15
15
 
16
16
  from abc import ABC, abstractmethod
17
17
  from aio_pika.message import AbstractIncomingMessage, Message
18
- from typing import Awaitable, Callable, Generic, TypeVar
18
+ from typing import Awaitable, Callable, Generic, TypeVar, Union
19
19
  from pydantic import BaseModel
20
20
  from logging import getLogger
21
21
 
22
22
 
23
23
  T = TypeVar("T", bound=BaseModel)
24
- U = TypeVar("U", bound=BaseModel | None)
24
+ U = TypeVar("U", bound=Union[BaseModel, None])
25
25
  V = TypeVar("V", bound=BaseModel)
26
26
 
27
27
 
@@ -14,7 +14,7 @@
14
14
 
15
15
 
16
16
  from pydantic import RootModel, BaseModel, ConfigDict, computed_field
17
- from typing import Literal
17
+ from typing import Literal, Optional, Union
18
18
  from aio_pika.abc import AbstractRobustChannel
19
19
  from .message_handler import AbstractMessageHandler
20
20
 
@@ -24,7 +24,7 @@ class ExchangeHandlerParams(BaseModel):
24
24
  kind: Literal["exchange"] = "exchange"
25
25
  type: Literal["direct", "fanout", "topic", "headers"]
26
26
  name: str
27
- routing_key: str | None
27
+ routing_key: Optional[str]
28
28
  auto_delete: bool = False
29
29
 
30
30
 
@@ -39,24 +39,28 @@ class QueueHandlerParams(BaseModel):
39
39
 
40
40
  class MessageHandlerParams(RootModel):
41
41
  model_config = ConfigDict(frozen=True)
42
- root: QueueHandlerParams | ExchangeHandlerParams
42
+ root: Union[QueueHandlerParams, ExchangeHandlerParams]
43
43
 
44
44
  async def setup_consume(
45
45
  self,
46
46
  handler: AbstractMessageHandler,
47
47
  channel: AbstractRobustChannel,
48
48
  ):
49
- match self.root:
50
- case ExchangeHandlerParams(
51
- name=name, routing_key=rk, type=et, auto_delete=ad
52
- ):
53
- exchange = await channel.declare_exchange(name, type=et, auto_delete=ad)
54
- queue = await channel.declare_queue(exclusive=True)
55
- await queue.bind(exchange, rk)
56
- case QueueHandlerParams(name=name, exclusive=ex, auto_delete=ad, durable=d):
57
- queue = await channel.declare_queue(
58
- name, exclusive=ex, auto_delete=ad, durable=d
59
- )
60
- case _:
61
- raise NotImplementedError
49
+ if isinstance(self.root, ExchangeHandlerParams):
50
+ exchange = await channel.declare_exchange(
51
+ name=self.root.name,
52
+ type=self.root.type,
53
+ auto_delete=self.root.auto_delete,
54
+ )
55
+ queue = await channel.declare_queue(exclusive=True)
56
+ await queue.bind(exchange, self.root.routing_key)
57
+ elif isinstance(self.root, QueueHandlerParams):
58
+ queue = await channel.declare_queue(
59
+ self.root.name,
60
+ exclusive=self.root.exclusive,
61
+ auto_delete=self.root.auto_delete,
62
+ durable=self.root.durable,
63
+ )
64
+ else:
65
+ raise NotImplementedError
62
66
  await queue.consume(handler)
@@ -21,14 +21,15 @@ from aio_pika.abc import (
21
21
  AbstractIncomingMessage,
22
22
  )
23
23
  from logging import getLogger
24
- from pydantic import BaseModel
25
- from typing import TypeVar
24
+ from pydantic import BaseModel, RootModel, create_model
25
+ from typing import Optional, Sequence, TypeVar, Union, Type
26
+ from collections.abc import Sequence as Seq
26
27
  from asyncio import Future
27
28
  from uuid import uuid4
28
- from .utils import encode_message, decode_message
29
+ from .utils import encode_message, decode_message, union_model
29
30
 
30
- T = TypeVar("T", bound=BaseModel)
31
- U = TypeVar("U", bound=BaseModel)
31
+ T = TypeVar("T")
32
+ U = TypeVar("U")
32
33
 
33
34
 
34
35
  class Producer:
@@ -44,7 +45,7 @@ class Producer:
44
45
  self._pool = channel_pool
45
46
  self._replies: dict[str, Future[AbstractIncomingMessage]] = {}
46
47
  self._reply_queue = reply_queue
47
- self._reply_consumer_tag: str | None = None
48
+ self._reply_consumer_tag: Optional[str] = None
48
49
 
49
50
  async def _on_reply(self, msg: AbstractIncomingMessage):
50
51
  await msg.ack()
@@ -59,12 +60,10 @@ class Producer:
59
60
  async def publish(
60
61
  self,
61
62
  message: T,
62
- exchange: str | None,
63
- routing_key: str | None,
63
+ exchange: Optional[str],
64
+ routing_key: Optional[str],
64
65
  ):
65
- outbound_message = Message(
66
- body=encode_message(message),
67
- )
66
+ outbound_message = Message(body=encode_message(RootModel[T](message)))
68
67
  async with self._pool.acquire() as channel:
69
68
  await (
70
69
  await channel.get_exchange(exchange)
@@ -75,9 +74,9 @@ class Producer:
75
74
  async def request(
76
75
  self,
77
76
  message: T,
78
- exchange: str | None,
79
- routing_key: str | None,
80
- output_type: type[U],
77
+ exchange: Optional[str],
78
+ routing_key: Optional[str],
79
+ output_types: Sequence[type[U]],
81
80
  ) -> U:
82
81
  if not self._reply_consumer_tag:
83
82
  raise AssertionError(
@@ -85,7 +84,7 @@ class Producer:
85
84
  )
86
85
  corr_id = str(uuid4())
87
86
  outbound_message = Message(
88
- body=encode_message(message),
87
+ body=encode_message(RootModel[T](message)),
89
88
  correlation_id=corr_id,
90
89
  reply_to=self._reply_queue.name,
91
90
  )
@@ -98,6 +97,9 @@ class Producer:
98
97
  ).publish(outbound_message, routing_key or "")
99
98
  self._logger.info(f"Sent request {message}")
100
99
  self._replies[corr_id] = reply_future
101
- res = decode_message((await reply_future).body, output_type)
100
+ res = decode_message(
101
+ (await reply_future).body,
102
+ union_model(tuple(output_types)),
103
+ ).root
102
104
  self._logger.info(f"Got response {res}")
103
105
  return res
@@ -13,10 +13,12 @@
13
13
  # limitations under the License.
14
14
 
15
15
 
16
- from pydantic import BaseModel
17
- from typing import TypeVar
16
+ from functools import cache
17
+ from pydantic import BaseModel, RootModel, create_model
18
+ from typing import TypeVar, Union
18
19
 
19
20
  T = TypeVar("T", bound=BaseModel)
21
+ U = TypeVar("U")
20
22
 
21
23
 
22
24
  def encode_message(message: T) -> bytes:
@@ -25,3 +27,9 @@ def encode_message(message: T) -> bytes:
25
27
 
26
28
  def decode_message(message: bytes, schema: type[T]) -> T:
27
29
  return schema.model_validate_json(message)
30
+
31
+
32
+ @cache
33
+ def union_model(types: tuple[type[U], ...]) -> type[RootModel[U]]:
34
+ UnionType = Union.__getitem__(types)
35
+ return RootModel[UnionType] # type: ignore
@@ -36,13 +36,12 @@ def generate(
36
36
 
37
37
  # Generate code
38
38
  generation_result: dict[Path, str]
39
- match protocol:
40
- case "amqp":
41
- generation_result = g.amqp.generate(
42
- input_path=input_file, output_path=output_dir
43
- )
44
- case _:
45
- raise NotImplementedError(f"Protocol {protocol} is not supported")
39
+ if protocol == "amqp":
40
+ generation_result = g.amqp.generate(
41
+ input_path=input_file, output_path=output_dir
42
+ )
43
+ else:
44
+ raise NotImplementedError(f"Protocol {protocol} is not supported")
46
45
 
47
46
  # Write files
48
47
  for path, code in generation_result.items():
@@ -13,13 +13,13 @@
13
13
  # limitations under the License.
14
14
 
15
15
 
16
- from typing import Literal
16
+ from typing import Literal, Optional, Union
17
17
 
18
18
  from pydantic import BaseModel, Field, RootModel
19
19
 
20
20
 
21
21
  class Exchange(BaseModel):
22
- name: str | None = None
22
+ name: Optional[str] = None
23
23
  type: Literal["topic", "direct", "fanout", "default", "headers"] = "default"
24
24
  durable: bool = False
25
25
  auto_delete: bool = Field(alias="autoDelete", default=False)
@@ -31,7 +31,7 @@ class ExchangeBinding(BaseModel):
31
31
 
32
32
 
33
33
  class Queue(BaseModel):
34
- name: str | None = None
34
+ name: Optional[str] = None
35
35
  durable: bool = False
36
36
  exclusive: bool = False
37
37
  auto_delete: bool = Field(alias="autoDelete", default=False)
@@ -43,4 +43,4 @@ class QueueBinding(BaseModel):
43
43
 
44
44
 
45
45
  class AmqpBinding(RootModel):
46
- root: ExchangeBinding | QueueBinding = QueueBinding()
46
+ root: Union[ExchangeBinding, QueueBinding] = QueueBinding()
@@ -15,10 +15,8 @@
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
- from pydantic import model_validator
19
-
20
- from .base import BaseModel
21
- from typing import Any, Literal
18
+ from .base import BaseModel, RootModel
19
+ from typing import Any, Literal, Optional
22
20
  from .ref import MaybeRef, Ref
23
21
  from .bindings import Bindings
24
22
 
@@ -28,56 +26,44 @@ class Components(BaseModel):
28
26
  channels: dict[str, MaybeRef[Channel]] = {}
29
27
  messages: dict[str, MaybeRef[Message]] = {}
30
28
  correlation_ids: dict[str, CorrelationId] = {}
29
+ schemas: dict[str, MaybeRef[JsonSchema]] = {}
31
30
 
32
31
 
33
- class JsonSchema(BaseModel):
32
+ class JsonSchema(RootModel):
34
33
  # TODO: Create a better parser for JsonSchema
35
- type: str
36
- properties: dict[str, Any]
37
- required: list[str] = []
34
+ root: Any
38
35
 
39
36
 
40
37
  class Message(BaseModel):
41
- title: str
42
- headers: MaybeRef[JsonSchema] | None = None
38
+ title: Optional[str] = None
39
+ headers: Optional[MaybeRef[JsonSchema]] = None
43
40
  payload: MaybeRef[JsonSchema]
44
41
 
45
- @model_validator(mode="before")
46
- @classmethod
47
- def has_title(cls, data: dict[str, Any]):
48
- if not "title" in data:
49
- raise AssertionError(
50
- "As of now, all Message objects require "
51
- + "`title` field to be present to uniquely identify data types. "
52
- + "This limitation will be removed in the future."
53
- )
54
- return data
55
-
56
42
 
57
43
  class CorrelationId(BaseModel):
58
- description: str | None = None
44
+ description: Optional[str] = None
59
45
  location: str
60
46
 
61
47
 
62
48
  class Operation(BaseModel):
63
49
  action: Literal["receive", "send"]
64
50
  channel: Ref[Channel]
65
- reply: OperationReply | None = None
51
+ reply: Optional[OperationReply] = None
66
52
 
67
53
 
68
54
  class OperationReply(BaseModel):
69
- address: ReplyAddress | None = None
55
+ address: Optional[ReplyAddress] = None
70
56
  channel: Ref[Channel]
71
57
 
72
58
 
73
59
  class ReplyAddress(BaseModel):
74
- description: str | None = None
60
+ description: Optional[str] = None
75
61
  location: str
76
62
 
77
63
 
78
64
  class Channel(BaseModel):
79
- address: str | None = None
80
- title: str | None = None
81
- description: str | None = None
82
- bindings: Bindings | None = None
65
+ address: Optional[str] = None
66
+ title: Optional[str] = None
67
+ description: Optional[str] = None
68
+ bindings: Optional[Bindings] = None
83
69
  messages: dict[str, MaybeRef[Message]]
@@ -18,7 +18,7 @@ from pathlib import Path
18
18
 
19
19
  from pydantic import Field
20
20
  from .base import BaseModel
21
- from typing import Annotated, Any, Literal
21
+ from typing import Annotated, Any, Literal, Optional
22
22
  import yaml
23
23
  from .components import Channel, Components, Message, Operation
24
24
  from .ref import MaybeRef, Ref
@@ -53,4 +53,4 @@ class Document(BaseModel):
53
53
  class Info(BaseModel):
54
54
  title: str
55
55
  version: str
56
- description: str | None = None
56
+ description: Optional[str] = None
@@ -23,7 +23,8 @@ from .document_context import (
23
23
  set_current_doc_path,
24
24
  DOCUMENT_CONTEXT_STACK,
25
25
  )
26
- from typing import Any, Callable, Generic, TypeVar, Annotated
26
+ from typing import Any, Callable, Generic, TypeVar, Annotated, Union
27
+ from typing_extensions import Self
27
28
 
28
29
 
29
30
  T = TypeVar("T", bound=BaseModel)
@@ -54,32 +55,49 @@ class Ref(BaseModel, Generic[T]):
54
55
  def get(self) -> T:
55
56
  from .document import Document
56
57
 
57
- doc = Document.load_yaml(self.filepath).model_dump(by_alias=True)
58
+ sub = self.flatten()
59
+ doc = Document.load_yaml(sub.filepath).model_dump(by_alias=True)
58
60
  for p in self.doc_path:
59
61
  doc = doc[p]
62
+ with set_current_doc_path(sub.filepath):
63
+ return sub.type().model_validate(doc)
60
64
 
61
- with set_current_doc_path(self.filepath):
62
- if "$ref" in doc:
63
- return self.__class__.model_validate(doc).get()
64
- return self.type().model_validate(doc)
65
+ @cache
66
+ def flatten(self, max_depth: int = 1000) -> Self:
67
+ from .document import Document
68
+
69
+ sub = self
70
+ for _ in range(max_depth):
71
+ doc = Document.load_yaml(sub.filepath).model_dump(by_alias=True)
72
+ try:
73
+ for p in sub.doc_path:
74
+ doc = doc[p]
75
+ except KeyError as e:
76
+ raise KeyError(
77
+ f"$ref `{sub.ref}` is invalid \n"
78
+ + f"The Error was raised when trying to get key {e.args}"
79
+ )
80
+ if not "$ref" in doc:
81
+ return sub
82
+ sub = self.__class__.model_validate(doc)
83
+ raise RecursionError(
84
+ f"Document Ref[{self.type().__class__}] flattening limit reached"
85
+ )
65
86
 
66
87
  @model_validator(mode="before")
67
88
  @classmethod
68
89
  def parse_ref(cls, data: Any) -> Any:
69
- fp: str | Path
70
-
71
- match data:
72
- case {"ref": ref} | {"$ref": ref} if isinstance(ref, str):
73
- match ref.split("#"):
74
- case "", dp:
75
- fp = current_doc_path()
76
- case fp, dp if not Path(fp).is_absolute():
77
- fp = current_doc_path().parent / fp
78
- case fp, dp:
79
- ...
80
-
81
- case x:
82
- raise ValueError(f"Requires {{$ref: ... }}, given {x} ")
90
+ fp: Union[str, Path]
91
+
92
+ if (ref := data.get("ref")) or (ref := data.get("$ref")):
93
+ fp, dp = ref.split("#")
94
+ if fp == "":
95
+ fp = current_doc_path()
96
+ elif not Path(fp).is_absolute():
97
+ fp = current_doc_path().parent / fp
98
+ else:
99
+ raise ValueError(f"Requires {{$ref: ... }}, given {data} ")
100
+
83
101
  return {
84
102
  **data,
85
103
  "$ref": ref,
@@ -88,8 +106,8 @@ class Ref(BaseModel, Generic[T]):
88
106
  }
89
107
 
90
108
 
91
- class MaybeRef(RootModel[Ref[T] | T], Generic[T]):
92
- root: Ref[T] | T
109
+ class MaybeRef(RootModel[Union[Ref[T], T]], Generic[T]):
110
+ root: Union[Ref[T], T]
93
111
 
94
112
  def get(self) -> T:
95
113
  return self.root.get() if isinstance(self.root, Ref) else self.root
@@ -17,11 +17,12 @@ from __future__ import annotations
17
17
  from contextlib import ExitStack
18
18
  import json
19
19
  from pathlib import Path
20
- import subprocess
20
+ import tempfile
21
21
  import jinja2 as j2
22
- from typing import Any, Literal, TypedDict
22
+ from typing import Literal, Optional, TypedDict
23
23
  from asyncapi_python_codegen import document as d
24
24
  from itertools import chain
25
+ from datamodel_code_generator.__main__ import main as datamodel_codegen
25
26
 
26
27
  from .utils import snake_case
27
28
 
@@ -46,13 +47,13 @@ def generate(
46
47
  doc.info.description,
47
48
  doc.info.version,
48
49
  ).items()
49
- } | {output_path / "models.py": generate_models(ops)}
50
+ } | {output_path / "models.py": generate_models(ops, doc.filepath.parent)}
50
51
 
51
52
 
52
53
  def generate_application(
53
54
  ops: list[Operation],
54
55
  title: str,
55
- description: str | None,
56
+ description: Optional[str],
56
57
  version: str,
57
58
  template_dir: Path = Path(__file__).parent / "templates",
58
59
  filenames: list[str] = ["__init__.py", "application.py"],
@@ -65,16 +66,11 @@ def generate_application(
65
66
  return {f: t.render(**render_args) for t, f in zip(templates, filenames)}
66
67
 
67
68
 
68
- def generate_models(schemas: list[Operation]) -> str:
69
- args = """datamodel-codegen
70
- --output-model-type pydantic_v2.BaseModel
71
- --input-file-type jsonschema
72
- """.split()
73
-
69
+ def generate_models(schemas: list[Operation], cwd: Path) -> str:
74
70
  inp = {
75
71
  "$schema": "http://json-schema.org/draft-07/schema#",
76
72
  "$defs": {
77
- type_name: type_schema
73
+ type_name: {"$ref": type_schema}
78
74
  for s in schemas
79
75
  for type_name, type_schema in chain(
80
76
  zip(s["input_types"], s["input_schemas"]),
@@ -82,31 +78,49 @@ def generate_models(schemas: list[Operation]) -> str:
82
78
  )
83
79
  },
84
80
  }
85
- return subprocess.run(
86
- args=args, capture_output=True, check=True, input=json.dumps(inp).encode()
87
- ).stdout.decode()
81
+
82
+ with tempfile.TemporaryDirectory() as dir:
83
+ schema_path = Path(dir) / "schema.json"
84
+ models_path = Path(dir) / "models.py"
85
+
86
+ args = f"""
87
+ --input { str(schema_path.absolute()) }
88
+ --output { str(models_path.absolute()) }
89
+ --output-model-type pydantic_v2.BaseModel
90
+ --input-file-type jsonschema
91
+ """.split()
92
+
93
+ with schema_path.open("w") as schema:
94
+ json.dump(inp, schema)
95
+
96
+ datamodel_codegen(args=args)
97
+
98
+ with models_path.open() as f:
99
+ models_code = f.read()
100
+
101
+ return models_code
88
102
 
89
103
 
90
104
  def get_operation(op_name: str, op: d.Operation) -> Operation:
91
- exchange: str | None
92
- routing_key: str | None
105
+ exchange: Optional[str]
106
+ routing_key: Optional[str]
93
107
 
94
108
  channel = op.channel.get()
95
109
  reply_channel = op.reply.channel.get() if op.reply else None
96
110
  addr = lambda x: x or channel.address or op_name
97
- match channel.bindings:
98
- case None:
99
- # Default exchange + named queues
100
- exchange = None
101
- routing_key = addr(None)
102
- case bind if bind.amqp.root.type == "queue":
103
- # Default exchange + named queues
104
- exchange = None
105
- routing_key = addr(bind.amqp.root.queue.name)
106
- case bind if bind.amqp.root.type == "routingKey":
107
- # Named exchange + exclusive queues
108
- exchange = addr(bind.amqp.root.exchange.name)
109
- routing_key = None
111
+
112
+ if channel.bindings is None:
113
+ # Default exchange + named queues
114
+ exchange = None
115
+ routing_key = addr(None)
116
+ elif (bind := channel.bindings).amqp.root.type == "queue":
117
+ # Default exchange + named queues
118
+ exchange = None
119
+ routing_key = addr(bind.amqp.root.queue.name)
120
+ elif bind.amqp.root.type == "routingKey":
121
+ # Named exchange + exclusive queues
122
+ exchange = addr(bind.amqp.root.exchange.name)
123
+ routing_key = None
110
124
 
111
125
  # Get reply channel properties
112
126
  if reply_channel is not None:
@@ -124,31 +138,69 @@ def get_operation(op_name: str, op: d.Operation) -> Operation:
124
138
  "As of now, reply channel must be a queue without name"
125
139
  )
126
140
 
141
+ input_types: list[str]
142
+ input_schemas: list[str]
143
+ output_types: list[str]
144
+ output_schemas: list[str]
145
+
146
+ input_types, input_schemas = get_channel_types(
147
+ channel, op.channel.filepath, op.channel.doc_path
148
+ )
149
+ output_types, output_schemas = (
150
+ get_channel_types(
151
+ op.reply.channel.get(),
152
+ op.reply.channel.filepath,
153
+ op.reply.channel.doc_path,
154
+ )
155
+ if op.reply
156
+ else ([], [])
157
+ )
158
+
127
159
  return {
128
160
  "field_name": snake_case(op_name),
129
161
  "action": op.action,
130
162
  "exchange": exchange,
131
163
  "routing_key": routing_key,
132
- "input_types": [msg.get().title for msg in channel.messages.values()],
133
- "input_schemas": [
134
- msg.get().payload.get().model_dump() for msg in channel.messages.values()
135
- ],
136
- "output_types": (
137
- [msg.get().title for msg in reply_channel.messages.values()]
138
- if reply_channel
139
- else []
140
- ),
141
- "output_schemas": (
142
- [
143
- msg.get().payload.get().model_dump()
144
- for msg in reply_channel.messages.values()
145
- ]
146
- if reply_channel
147
- else []
148
- ),
164
+ "input_types": input_types,
165
+ "input_schemas": input_schemas,
166
+ "output_types": output_types,
167
+ "output_schemas": output_schemas,
149
168
  }
150
169
 
151
170
 
171
+ def get_channel_types(
172
+ channel: d.Channel,
173
+ channel_filepath: Path,
174
+ channel_doc_path: tuple[str, ...],
175
+ ) -> tuple[list[str], list[str]]:
176
+ types, schemas = [], []
177
+ for message_key, message in channel.messages.items():
178
+
179
+ if isinstance(message.root, d.Ref):
180
+ msg_ref = message.root.flatten()
181
+ msg_filepath = msg_ref.filepath
182
+ msg_doc_path = msg_ref.doc_path
183
+ del msg_ref
184
+ else:
185
+ msg_filepath = channel_filepath
186
+ msg_doc_path = (*channel_doc_path, "messages", message_key)
187
+
188
+ message_payload = message.get().payload.root
189
+ if isinstance(message_payload, d.Ref):
190
+ payload_ref = message_payload.flatten()
191
+ pl_filepath = payload_ref.filepath
192
+ pl_doc_path = payload_ref.doc_path
193
+ del payload_ref
194
+ else:
195
+ pl_filepath = msg_filepath
196
+ pl_doc_path = (*msg_doc_path, "payload")
197
+
198
+ types.append(message.get().title or message_key)
199
+ schemas.append(str(pl_filepath) + "#/" + "/".join(pl_doc_path))
200
+
201
+ return types, schemas
202
+
203
+
152
204
  class Operation(TypedDict):
153
205
  field_name: str
154
206
  action: Literal["send", "receive"]
@@ -156,5 +208,5 @@ class Operation(TypedDict):
156
208
  routing_key: str | None
157
209
  input_types: list[str]
158
210
  output_types: list[str]
159
- input_schemas: list[Any]
160
- output_schemas: list[Any]
211
+ input_schemas: list[str]
212
+ output_schemas: list[str]
@@ -14,7 +14,8 @@
14
14
  from .models import *
15
15
  from asyncapi_python.amqp import BaseApplication, Consumer
16
16
  from asyncapi_python.amqp.message_handler_params import MessageHandlerParams
17
- from typing import Callable, Awaitable
17
+ from typing import Callable, Awaitable, Union
18
+ from pydantic import RootModel
18
19
 
19
20
 
20
21
  class Application(BaseApplication):
@@ -34,8 +35,8 @@ class Application(BaseApplication):
34
35
  await super().start(blocking=blocking)
35
36
 
36
37
  {% for op in ops if op.action == "send" -%}
37
- {%- set in_type = op.input_types | join(" | ") -%}
38
- {%- set out_type = op.output_types | join(" | ") if op.output_types else "None" -%}
38
+ {%- set in_type = "Union[" + (op.input_types | join(" | ")) + "]" -%}
39
+ {%- set out_type = "Union[" + (op.output_types | join(" | ") if op.output_types else "None") + "]" -%}
39
40
  {%- set callback_type = "Callable[["+ in_type + "], Awaitable[" + out_type + "]" + "]" -%}
40
41
  {%- set send_method = "publish" if out_type == "None" else "request" -%}
41
42
  {%- set routing_key_literal = '"' + op.routing_key + '"' if op.routing_key else "None" -%}
@@ -46,7 +47,7 @@ class Application(BaseApplication):
46
47
  exchange={{ '"' + op.exchange + '"' if op.exchange else "None" }},
47
48
  routing_key={{ routing_key_literal }},
48
49
  {%- if send_method == "request" %}
49
- output_type={{ out_type }},
50
+ output_types={{ "[" + op.output_types | join(", ") + "]" }},
50
51
  {%- endif %}
51
52
  )
52
53
  {% endfor %}
@@ -72,8 +73,8 @@ class ConsumerWrapper:
72
73
 
73
74
 
74
75
  {% for op in ops if op.action == "receive" %}
75
- {%- set in_type = op.input_types | join(" | ") -%}
76
- {%- set out_type = op.output_types | join(" | ") if op.output_types else "None" -%}
76
+ {%- set in_type = "Union[" + (op.input_types | join(", ")) + "]" -%}
77
+ {%- set out_type = "Union[" + (op.output_types | join(", ") if op.output_types else "None") + "]" -%}
77
78
  {%- set callback_type = "Callable[["+ in_type + "], Awaitable[" + out_type + "]" + "]" -%}
78
79
  {%- set routing_key_literal = '"' + op.routing_key + '"' if op.routing_key else "None" -%}
79
80
  def {{ op.field_name }}(self, callback: {{ callback_type }}) -> None:
@@ -93,8 +94,8 @@ class ConsumerWrapper:
93
94
  {"kind": "queue", "name": "{{op.routing_key}}"}
94
95
  {%- endif %}
95
96
  ),
96
- input_type={{in_type}},
97
- output_type={{out_type}},
97
+ input_types={{ "[" + op.input_types | join(", ") + "]" }},
98
+ output_types={{ "[" + op.output_types | join(", ") + "]" }},
98
99
  callback=callback,
99
100
  )
100
101
  {% endfor %}
@@ -31,12 +31,13 @@ def snake_case(s: str) -> str:
31
31
  ).lower()
32
32
 
33
33
 
34
- def camel_case(kind: Literal["upper", "lower"], string: str):
34
+ def camel_case(kind: Literal["upper", "lower"], string: str) -> str:
35
+ if not string:
36
+ return ""
37
+
35
38
  string = sub(r"(_|-)+", " ", string).title().replace(" ", "")
36
- match string, kind:
37
- case "", _:
38
- return ""
39
- case _, "lower":
40
- return string[0].lower() + string[1:]
41
- case _, "upper":
42
- return string[0].upper() + string[1:]
39
+
40
+ if kind == "lower":
41
+ return string[0].lower() + string[1:]
42
+ elif kind == "upper":
43
+ return string[0].upper() + string[1:]
@@ -1,38 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: asyncapi-python
3
- Version: 0.1.0
4
- Summary:
5
- License: Apache-2.0
6
- Author: Yaroslav Petrov
7
- Author-email: yaroslav.v.petrov@gmail.com
8
- Requires-Python: >=3.10,<3.13
9
- Classifier: License :: OSI Approved :: Apache Software License
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.10
12
- Classifier: Programming Language :: Python :: 3.11
13
- Classifier: Programming Language :: Python :: 3.12
14
- Provides-Extra: amqp
15
- Provides-Extra: codegen
16
- Requires-Dist: aio-pika (>=9.4.3,<10.0.0) ; extra == "amqp"
17
- Requires-Dist: datamodel-code-generator[http] (>=0.26.1,<0.27.0) ; extra == "codegen"
18
- Requires-Dist: jinja2 (>=3.1.4,<4.0.0) ; extra == "codegen"
19
- Requires-Dist: pydantic (>=2)
20
- Requires-Dist: pytz
21
- Requires-Dist: pyyaml (>=6.0.2,<7.0.0) ; extra == "codegen"
22
- Requires-Dist: typer[all] (>=0.12.5,<0.13.0) ; extra == "codegen"
23
- Description-Content-Type: text/markdown
24
-
25
- # AsyncAPI Python Code Generator
26
-
27
- > This README is a work in progress
28
-
29
- A command line interface to generate Python code from AsyncAPI specifications. This tool helps you automatically create Python implementations of AsyncAPI services, reducing boilerplate code and ensuring consistency with your API specifications.
30
-
31
- ## Features
32
-
33
- - Generates Python code from AsyncAPI specifications
34
- - Creates both consumer and producer implementations
35
- - Supports AMQP protocol
36
- - Includes connection pooling and management
37
- - Provides base application structure
38
-
@@ -1,13 +0,0 @@
1
- # AsyncAPI Python Code Generator
2
-
3
- > This README is a work in progress
4
-
5
- A command line interface to generate Python code from AsyncAPI specifications. This tool helps you automatically create Python implementations of AsyncAPI services, reducing boilerplate code and ensuring consistency with your API specifications.
6
-
7
- ## Features
8
-
9
- - Generates Python code from AsyncAPI specifications
10
- - Creates both consumer and producer implementations
11
- - Supports AMQP protocol
12
- - Includes connection pooling and management
13
- - Provides base application structure
File without changes