diff --git a/.gitignore b/.gitignore
index 5bf2443ccb..0c3c190c57 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,7 @@
.scannerwork/
.unasyncd_cache/
.venv/
+.venv*
.vscode/
__pycache__/
build/
diff --git a/docs/reference/channels/backends/asyncpg.rst b/docs/reference/channels/backends/asyncpg.rst
new file mode 100644
index 0000000000..91d44ecdf1
--- /dev/null
+++ b/docs/reference/channels/backends/asyncpg.rst
@@ -0,0 +1,5 @@
+asyncpg
+=======
+
+.. automodule:: litestar.channels.backends.asyncpg
+ :members:
diff --git a/docs/reference/channels/backends/index.rst b/docs/reference/channels/backends/index.rst
index 010ae7e509..02deff518a 100644
--- a/docs/reference/channels/backends/index.rst
+++ b/docs/reference/channels/backends/index.rst
@@ -6,3 +6,5 @@ backends
base
memory
redis
+ psycopg
+ asyncpg
diff --git a/docs/reference/channels/backends/psycopg.rst b/docs/reference/channels/backends/psycopg.rst
new file mode 100644
index 0000000000..4a8163db60
--- /dev/null
+++ b/docs/reference/channels/backends/psycopg.rst
@@ -0,0 +1,5 @@
+psycopg
+=======
+
+.. automodule:: litestar.channels.backends.psycopg
+ :members:
diff --git a/docs/usage/channels.rst b/docs/usage/channels.rst
index cbf0ef2721..6e2cd13c02 100644
--- a/docs/usage/channels.rst
+++ b/docs/usage/channels.rst
@@ -399,7 +399,7 @@ implemented are:
A basic in-memory backend, mostly useful for testing and local development, but
still fully capable. Since it stores all data in-process, it can achieve the highest
performance of all the backends, but at the same time is not suitable for
- applications running on multiple processes.
+ applications running on multiple processes
:class:`RedisChannelsPubSubBackend <.redis.RedisChannelsPubSubBackend>`
A Redis based backend, using `Pub/Sub `_ to
@@ -413,6 +413,17 @@ implemented are:
when history is needed
+:class:`AsyncPgChannelsBackend <.asyncpg.AsyncPgChannelsBackend>`
+ A postgres backend using the
+ `asyncpg `_ driver
+
+
+:class:`PsycoPgChannelsBackend <.psycopg.AsyncPgChannelsBackend>`
+ A postgres backend using the `psycopg3 `_
+ async driver
+
+
+
Integrating with websocket handlers
-----------------------------------
diff --git a/litestar/channels/backends/asyncpg.py b/litestar/channels/backends/asyncpg.py
new file mode 100644
index 0000000000..4b3948d179
--- /dev/null
+++ b/litestar/channels/backends/asyncpg.py
@@ -0,0 +1,82 @@
+from __future__ import annotations
+
+import asyncio
+from contextlib import AsyncExitStack
+from functools import partial
+from typing import AsyncGenerator, Awaitable, Callable, Iterable, overload
+
+import asyncpg
+
+from litestar.channels import ChannelsBackend
+from litestar.exceptions import ImproperlyConfiguredException
+
+
+class AsyncPgChannelsBackend(ChannelsBackend):
+ _listener_conn: asyncpg.Connection
+ _queue: asyncio.Queue[tuple[str, bytes]]
+
+ @overload
+ def __init__(self, dsn: str) -> None:
+ ...
+
+ @overload
+ def __init__(
+ self,
+ *,
+ make_connection: Callable[[], Awaitable[asyncpg.Connection]],
+ ) -> None:
+ ...
+
+ def __init__(
+ self,
+ dsn: str | None = None,
+ *,
+ make_connection: Callable[[], Awaitable[asyncpg.Connection]] | None = None,
+ ) -> None:
+ if not (dsn or make_connection):
+ raise ImproperlyConfiguredException("Need to specify dsn or make_connection")
+
+ self._subscribed_channels: set[str] = set()
+ self._exit_stack = AsyncExitStack()
+ self._connect = make_connection or partial(asyncpg.connect, dsn=dsn)
+
+ async def on_startup(self) -> None:
+ self._queue = asyncio.Queue()
+ self._listener_conn = await self._connect()
+
+ async def on_shutdown(self) -> None:
+ await self._listener_conn.close()
+ del self._queue
+
+ async def publish(self, data: bytes, channels: Iterable[str]) -> None:
+ dec_data = data.decode("utf-8")
+
+ conn = await self._connect()
+ try:
+ for channel in channels:
+ await conn.execute("SELECT pg_notify($1, $2);", channel, dec_data)
+ finally:
+ await conn.close()
+
+ async def subscribe(self, channels: Iterable[str]) -> None:
+ for channel in set(channels) - self._subscribed_channels:
+ await self._listener_conn.add_listener(channel, self._listener) # type: ignore[arg-type]
+ self._subscribed_channels.add(channel)
+
+ async def unsubscribe(self, channels: Iterable[str]) -> None:
+ for channel in channels:
+ await self._listener_conn.remove_listener(channel, self._listener) # type: ignore[arg-type]
+ self._subscribed_channels = self._subscribed_channels - set(channels)
+
+ async def stream_events(self) -> AsyncGenerator[tuple[str, bytes], None]:
+ while self._queue:
+ yield await self._queue.get()
+ self._queue.task_done()
+
+ async def get_history(self, channel: str, limit: int | None = None) -> list[bytes]:
+ raise NotImplementedError()
+
+ def _listener(self, /, connection: asyncpg.Connection, pid: int, channel: str, payload: object) -> None:
+ if not isinstance(payload, str):
+ raise RuntimeError("Invalid data received")
+ self._queue.put_nowait((channel, payload.encode("utf-8")))
diff --git a/litestar/channels/backends/psycopg.py b/litestar/channels/backends/psycopg.py
new file mode 100644
index 0000000000..14b53bcd1a
--- /dev/null
+++ b/litestar/channels/backends/psycopg.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from contextlib import AsyncExitStack
+from typing import AsyncGenerator, Iterable
+
+import psycopg
+
+from .base import ChannelsBackend
+
+
+def _safe_quote(ident: str) -> str:
+ return '"{}"'.format(ident.replace('"', '""')) # sourcery skip
+
+
+class PsycoPgChannelsBackend(ChannelsBackend):
+ _listener_conn: psycopg.AsyncConnection
+
+ def __init__(self, pg_dsn: str) -> None:
+ self._pg_dsn = pg_dsn
+ self._subscribed_channels: set[str] = set()
+ self._exit_stack = AsyncExitStack()
+
+ async def on_startup(self) -> None:
+ self._listener_conn = await psycopg.AsyncConnection.connect(self._pg_dsn, autocommit=True)
+ await self._exit_stack.enter_async_context(self._listener_conn)
+
+ async def on_shutdown(self) -> None:
+ await self._exit_stack.aclose()
+
+ async def publish(self, data: bytes, channels: Iterable[str]) -> None:
+ dec_data = data.decode("utf-8")
+ async with await psycopg.AsyncConnection.connect(self._pg_dsn) as conn:
+ for channel in channels:
+ await conn.execute("SELECT pg_notify(%s, %s);", (channel, dec_data))
+
+ async def subscribe(self, channels: Iterable[str]) -> None:
+ for channel in set(channels) - self._subscribed_channels:
+ # can't use placeholders in LISTEN
+ await self._listener_conn.execute(f"LISTEN {_safe_quote(channel)};") # pyright: ignore
+
+ self._subscribed_channels.add(channel)
+
+ async def unsubscribe(self, channels: Iterable[str]) -> None:
+ for channel in channels:
+ # can't use placeholders in UNLISTEN
+ await self._listener_conn.execute(f"UNLISTEN {_safe_quote(channel)};") # pyright: ignore
+ self._subscribed_channels = self._subscribed_channels - set(channels)
+
+ async def stream_events(self) -> AsyncGenerator[tuple[str, bytes], None]:
+ async for notify in self._listener_conn.notifies():
+ yield notify.channel, notify.payload.encode("utf-8")
+
+ async def get_history(self, channel: str, limit: int | None = None) -> list[bytes]:
+ raise NotImplementedError()
diff --git a/litestar/channels/plugin.py b/litestar/channels/plugin.py
index ae7dcc78b3..985c337847 100644
--- a/litestar/channels/plugin.py
+++ b/litestar/channels/plugin.py
@@ -311,10 +311,10 @@ async def _sub_worker(self) -> None:
subscriber.put_nowait(payload)
async def _on_startup(self) -> None:
+ await self._backend.on_startup()
self._pub_queue = Queue()
self._pub_task = create_task(self._pub_worker())
self._sub_task = create_task(self._sub_worker())
- await self._backend.on_startup()
if self._channels:
await self._backend.subscribe(list(self._channels))
diff --git a/litestar/contrib/pydantic/pydantic_dto_factory.py b/litestar/contrib/pydantic/pydantic_dto_factory.py
index d61f95d671..e7a7f901ee 100644
--- a/litestar/contrib/pydantic/pydantic_dto_factory.py
+++ b/litestar/contrib/pydantic/pydantic_dto_factory.py
@@ -8,7 +8,7 @@
from litestar.contrib.pydantic.utils import is_pydantic_undefined
from litestar.dto.base_dto import AbstractDTO
from litestar.dto.data_structures import DTOFieldDefinition
-from litestar.dto.field import DTO_FIELD_META_KEY, DTOField
+from litestar.dto.field import DTO_FIELD_META_KEY, extract_dto_field
from litestar.exceptions import MissingDependencyException, ValidationException
from litestar.types.empty import Empty
@@ -81,7 +81,8 @@ def generate_field_definitions(
for field_name, field_info in model_fields.items():
field_definition = model_field_definitions[field_name]
- dto_field = (field_definition.extra or {}).pop(DTO_FIELD_META_KEY, DTOField())
+ dto_field = extract_dto_field(field_definition, field_definition.extra)
+ field_definition.extra.pop(DTO_FIELD_META_KEY, None)
if not is_pydantic_undefined(field_info.default):
default = field_info.default
diff --git a/litestar/dto/dataclass_dto.py b/litestar/dto/dataclass_dto.py
index 554b0f3343..5301abac83 100644
--- a/litestar/dto/dataclass_dto.py
+++ b/litestar/dto/dataclass_dto.py
@@ -5,7 +5,7 @@
from litestar.dto.base_dto import AbstractDTO
from litestar.dto.data_structures import DTOFieldDefinition
-from litestar.dto.field import DTO_FIELD_META_KEY, DTOField
+from litestar.dto.field import extract_dto_field
from litestar.params import DependencyKwarg, KwargDefinition
from litestar.types.empty import Empty
@@ -40,7 +40,7 @@ def generate_field_definitions(
DTOFieldDefinition.from_field_definition(
field_definition=field_definition,
default_factory=default_factory,
- dto_field=dc_field.metadata.get(DTO_FIELD_META_KEY, DTOField()),
+ dto_field=extract_dto_field(field_definition, dc_field.metadata),
model_name=model_type.__name__,
),
name=key,
diff --git a/litestar/dto/field.py b/litestar/dto/field.py
index d5ed4b0a99..447b40a796 100644
--- a/litestar/dto/field.py
+++ b/litestar/dto/field.py
@@ -3,13 +3,19 @@
from dataclasses import dataclass
from enum import Enum
-from typing import Literal
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from typing import Any, Literal, Mapping
+
+ from litestar.typing import FieldDefinition
__all__ = (
"DTO_FIELD_META_KEY",
"DTOField",
"Mark",
"dto_field",
+ "extract_dto_field",
)
DTO_FIELD_META_KEY = "__dto__"
@@ -26,7 +32,7 @@ class Mark(str, Enum):
"""To mark a field that can neither be read or updated by clients."""
-@dataclass
+@dataclass(unsafe_hash=True)
class DTOField:
"""For configuring DTO behavior on model fields."""
@@ -47,3 +53,28 @@ def dto_field(mark: Literal["read-only", "write-only", "private"] | Mark) -> dic
Marking a field automates its inclusion/exclusion from DTO field definitions, depending on the DTO's purpose.
"""
return {DTO_FIELD_META_KEY: DTOField(mark=Mark(mark))}
+
+
+def extract_dto_field(field_definition: FieldDefinition, field_info_mapping: Mapping[str, Any]) -> DTOField:
+ """Extract ``DTOField`` instance for a model field.
+
+ Supports ``DTOField`` to bet set via ``Annotated`` or via a field info/metadata mapping.
+
+ E.g., ``Annotated[str, DTOField(mark="read-only")]`` or ``info=dto_field(mark="read-only")``.
+
+ If a value is found in ``field_info_mapping``, it is prioritized over the field definition's metadata.
+
+ Args:
+ field_definition: A field definition.
+ field_info_mapping: A field metadata/info attribute mapping, e.g., SQLAlchemy's ``info`` attribute,
+ or dataclasses ``metadata`` attribute.
+
+ Returns:
+ DTO field info, if any.
+ """
+ if inst := field_info_mapping.get(DTO_FIELD_META_KEY):
+ if not isinstance(inst, DTOField):
+ raise TypeError(f"DTO field info must be an instance of DTOField, got '{inst}'")
+ return inst
+
+ return next((f for f in field_definition.metadata if isinstance(f, DTOField)), DTOField())
diff --git a/litestar/dto/msgspec_dto.py b/litestar/dto/msgspec_dto.py
index 826a1d274f..9996319747 100644
--- a/litestar/dto/msgspec_dto.py
+++ b/litestar/dto/msgspec_dto.py
@@ -7,7 +7,7 @@
from litestar.dto.base_dto import AbstractDTO
from litestar.dto.data_structures import DTOFieldDefinition
-from litestar.dto.field import DTO_FIELD_META_KEY, DTOField
+from litestar.dto.field import DTO_FIELD_META_KEY, extract_dto_field
from litestar.types.empty import Empty
if TYPE_CHECKING:
@@ -36,7 +36,8 @@ def default_or_none(value: Any) -> Any:
for key, field_definition in cls.get_model_type_hints(model_type).items():
msgspec_field = msgspec_fields[key]
- dto_field = (field_definition.extra or {}).pop(DTO_FIELD_META_KEY, DTOField())
+ dto_field = extract_dto_field(field_definition, field_definition.extra)
+ field_definition.extra.pop(DTO_FIELD_META_KEY, None)
yield replace(
DTOFieldDefinition.from_field_definition(
diff --git a/pdm.lock b/pdm.lock
index 118c916546..21574c4f6d 100644
--- a/pdm.lock
+++ b/pdm.lock
@@ -5,7 +5,7 @@
groups = ["default", "standard", "jwt", "pydantic", "cli", "picologging", "dev-contrib", "piccolo", "prometheus", "dev", "mako", "test", "brotli", "cryptography", "linting", "attrs", "opentelemetry", "docs", "redis", "sqlalchemy", "full", "annotated-types", "jinja", "structlog", "minijinja"]
strategy = ["cross_platform"]
lock_version = "4.4"
-content_hash = "sha256:e3ef2b1bbcc753c48d8185f97fb73f909523db7b83d3c793e33c69f06ca5377c"
+content_hash = "sha256:41d1a0bec4e7f740ddcc225a576790c26341ac50eee37864a7a1486d04cb3687"
[[package]]
name = "accessible-pygments"
@@ -284,6 +284,20 @@ files = [
{file = "Babel-2.13.1.tar.gz", hash = "sha256:33e0952d7dd6374af8dbf6768cc4ddf3ccfefc244f9986d4074704f2fbd18900"},
]
+[[package]]
+name = "backports-zoneinfo"
+version = "0.2.1"
+requires_python = ">=3.6"
+summary = "Backport of the standard library zoneinfo module"
+files = [
+ {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"},
+ {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"},
+ {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"},
+ {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"},
+ {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"},
+ {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"},
+]
+
[[package]]
name = "beanie"
version = "1.23.6"
@@ -1409,7 +1423,7 @@ files = [
[[package]]
name = "litestar-sphinx-theme"
version = "0.2.0"
-requires_python = ">=3.8,<4.0"
+requires_python = "<4.0,>=3.8"
git = "https://github.com/litestar-org/litestar-sphinx-theme.git"
revision = "c5ce66aadc8f910c24f54bf0d172798c237a67eb"
summary = "A Sphinx theme for the Litestar organization"
@@ -2043,6 +2057,182 @@ files = [
{file = "prometheus_client-0.19.0.tar.gz", hash = "sha256:4585b0d1223148c27a225b10dbec5ae9bc4c81a99a3fa80774fa6209935324e1"},
]
+[[package]]
+name = "psycopg"
+version = "3.1.13"
+requires_python = ">=3.7"
+summary = "PostgreSQL database adapter for Python"
+dependencies = [
+ "backports-zoneinfo>=0.2.0; python_version < \"3.9\"",
+ "typing-extensions>=4.1",
+ "tzdata; sys_platform == \"win32\"",
+]
+files = [
+ {file = "psycopg-3.1.13-py3-none-any.whl", hash = "sha256:1253010894cfb64e2da4556d4eff5f05e45cafee641f64e02453be849c8f7687"},
+ {file = "psycopg-3.1.13.tar.gz", hash = "sha256:e6d047ce16950651d6e26c7c19ca57cc42e1d4841b58729f691244baeee46e30"},
+]
+
+[[package]]
+name = "psycopg-binary"
+version = "3.1.13"
+requires_python = ">=3.7"
+summary = "PostgreSQL database adapter for Python -- C optimisation distribution"
+files = [
+ {file = "psycopg_binary-3.1.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2cebf20e3c63e9fd5bb73a644b1327fed3f9496c394aec559a49f77ac0772fe2"},
+ {file = "psycopg_binary-3.1.13-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:323e6b2caedcb81a57e7b563d31b7cdb2b12aa29f641c3f4a8d071b96cdfafbe"},
+ {file = "psycopg_binary-3.1.13-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eac64d6b11e0ea9cadaaa3eda30ac3406c46561b1c482113bbdd7e64446a96e1"},
+ {file = "psycopg_binary-3.1.13-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae25d20847962f1800dc1d24e8b22876f736ce3076d923db9d902522c21498a8"},
+ {file = "psycopg_binary-3.1.13-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ed5c7ca4a0b241b4360c90cae961156f0c2a6c2822fb61f68076b928650b523"},
+ {file = "psycopg_binary-3.1.13-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7104f8e508d02532d2796563ed6c49a47d24935192f1c13a5b54f3cd78f5686a"},
+ {file = "psycopg_binary-3.1.13-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7b4e12ced8c8be2cd8d164d26c247c43713ba3e8c303a2b6334830bd081ace4b"},
+ {file = "psycopg_binary-3.1.13-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:31d00c5ad42ec6a7f5365dc2ada0ac1597741e49781aa49a9c0db708a68b07f5"},
+ {file = "psycopg_binary-3.1.13-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:c574e8e418fc98fcce054e24b3ea274e9ccbcf5310e47db8d5c07834e22bfa15"},
+ {file = "psycopg_binary-3.1.13-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8adf7af9a92d3b4cb849a79604735512d15fe51497a6b8a9accfe480b656a2a8"},
+ {file = "psycopg_binary-3.1.13-cp310-cp310-win_amd64.whl", hash = "sha256:c7cc4a583e279c6aa11aad41c99067e84477debd4501bac08c74539ddcebd4e3"},
+ {file = "psycopg_binary-3.1.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:770f16be9c0b542ae31c68204b4fb06e1484398a71ca9d9826cf37e6f6aa7973"},
+ {file = "psycopg_binary-3.1.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d5b82e82ed025ca09449a97323f82db10edf31dc2a96b021b42fcf351cb95b56"},
+ {file = "psycopg_binary-3.1.13-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95f62d0140b47a71aed55ff52eaae81a134ffc047cffdf73c564aebbc3259b9e"},
+ {file = "psycopg_binary-3.1.13-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4cccc2b2516ed00bfffd3d5eb8d29f0983ae57b1273c026c6a914125d2519be6"},
+ {file = "psycopg_binary-3.1.13-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbba364e29522d8e073c968f30e67e4018a596270244ebf64ed4e59b759492d6"},
+ {file = "psycopg_binary-3.1.13-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2aca6b55ef50911a0b05ec71b3ec749487813ac08085f260ad3caccbd7ecbb62"},
+ {file = "psycopg_binary-3.1.13-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:152e01605305aede07fe00eebd5f1f4792452efc13f017ae871e88b2fb8bf562"},
+ {file = "psycopg_binary-3.1.13-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d38b5b490e34e9b90e3734a84b546a33c39a69dbd3687d700ca2908c6389cb7f"},
+ {file = "psycopg_binary-3.1.13-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5a432c14c9f732345be2dcd94a1287affa562b98ffc6620863181ce15d3e5089"},
+ {file = "psycopg_binary-3.1.13-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:86919233d4293f01e0e00ec449746bd46803624e3bb52dd90831a4f3b2959bd8"},
+ {file = "psycopg_binary-3.1.13-cp311-cp311-win_amd64.whl", hash = "sha256:dea1c83d5f77651cb88d4726c1a4a4726d2712454081e016191d566de20def99"},
+ {file = "psycopg_binary-3.1.13-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:31db5f96438408a8b61ae487d29a11c4a7730b2e9e0857743ce99595fda1a148"},
+ {file = "psycopg_binary-3.1.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:da6e9b00c058557c5a0ba198f4fb7838863a0f88cafbf65c079c7c2e7d57d753"},
+ {file = "psycopg_binary-3.1.13-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18f207da55a5b2a5a828e4ea46ff3253bd641f79f45844a42ae981a94181b87c"},
+ {file = "psycopg_binary-3.1.13-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df292c11b66172b61ef16f6dffbf7363cbad873ff8c79a785c49fb237db4e720"},
+ {file = "psycopg_binary-3.1.13-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24866bd53f138d7aff9e88d2e52d6f205d974fe98788cc69d954cde60a76f2f1"},
+ {file = "psycopg_binary-3.1.13-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a875ec279b8cd34562963cc89f71b290cc0d65c7c1dd9f8ff53679ca52fbec2f"},
+ {file = "psycopg_binary-3.1.13-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:22aa5381db8e499b5a8489e1f6437c5e171eca476f975c138d6413ff15b66cfa"},
+ {file = "psycopg_binary-3.1.13-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ed05b5831146d648e43860670b2bc200eaa1bc0a8f744faeb8dd39d4de648bc5"},
+ {file = "psycopg_binary-3.1.13-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:51e4f98a48ddf41a0634a202e89b08448979b9cc88bd1a74207301b05db4c572"},
+ {file = "psycopg_binary-3.1.13-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:6bf92d298a5fff2477f8dd030fdaad84c8420e03fe76f175675ba3ac44e647f0"},
+ {file = "psycopg_binary-3.1.13-cp312-cp312-win_amd64.whl", hash = "sha256:eb49604d27dfe7ea8560f5fdbde667049f961273b64ec1d6c09df4b2c3e83256"},
+ {file = "psycopg_binary-3.1.13-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4d6f3bf6a6ac319c8660c44b22bc63e5bba9085ec7e04b171ceec9bb047ed20f"},
+ {file = "psycopg_binary-3.1.13-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44cb86654e0759de040dde495cd6bfe6b5f98b4f94c441f5fcbbdda371c62073"},
+ {file = "psycopg_binary-3.1.13-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ace2606984c0489a18e9dc455acceb8af1eed64c4afc7db4e7a46f4f77734db2"},
+ {file = "psycopg_binary-3.1.13-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c24927f5442eeb8ab42090c2882ec9208762bb8d7ae999c4916d55d36a68ee00"},
+ {file = "psycopg_binary-3.1.13-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84d01ad4594d05ffb374b56be0331bc3bd8525db07e4e18f70ed8a73e45e4a13"},
+ {file = "psycopg_binary-3.1.13-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a996e64c4cb61b49432ee81e7486ec94aa1fe369629b88e0dddbe03b76cdc7fb"},
+ {file = "psycopg_binary-3.1.13-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:af37704f0086da8ba3ab724ae770902fed09f25efc94979d38fc73857dfd2ea3"},
+ {file = "psycopg_binary-3.1.13-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:00e7c7bfa1d4bc9a31ed5da59bc402f29d14cc3156c08a5549998ef74fc54128"},
+ {file = "psycopg_binary-3.1.13-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52fa599635789d5a093525d7ac268f00910bae93bb6d896b5d9ad36e87c1b60e"},
+ {file = "psycopg_binary-3.1.13-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:da972ac7d62ee758e8da626d4b7626a13488ed9c0165574e745a02868793f636"},
+ {file = "psycopg_binary-3.1.13-cp38-cp38-win_amd64.whl", hash = "sha256:420314039fb004e3459d02430024673984bc098e9fccc17d4c9597cfb9b5ea84"},
+ {file = "psycopg_binary-3.1.13-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4c8cfc2ec46a732912acd9402666d52adcf97205dd7fda984602d46fa51b7199"},
+ {file = "psycopg_binary-3.1.13-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:535f6c77ef1a4f309fafd13fc910f2138befa0855a1e12979cd838ae54e27dda"},
+ {file = "psycopg_binary-3.1.13-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f500db8566f69ff81275640132ae96b7c4654c623de4ee29a0d0762b1d68086f"},
+ {file = "psycopg_binary-3.1.13-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5056e5025f6b2733f0ae913029743f40625bda354cd0b0bd00d4e5b04489bbf1"},
+ {file = "psycopg_binary-3.1.13-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a19b69b71e0dadf0a1ee3701eb580e01dd6bfc67c5f017fefb4b039035cd666e"},
+ {file = "psycopg_binary-3.1.13-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:29bbf26240eaf51bc950f3746c7924e9e0d181368c4967602aea75a5a091f8a6"},
+ {file = "psycopg_binary-3.1.13-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:722512f354453134c29df781ed717a79a953a1f2742e6388aa0cd0ba18e1c796"},
+ {file = "psycopg_binary-3.1.13-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0ab0b9118f5ba650d6aefd0d4dcbfbd36dfb415f0022a5f413d69b934a31a8a8"},
+ {file = "psycopg_binary-3.1.13-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ad4e691483fb7b88dde237c7c7e9691322e7ceccd35f23f4b27e6214b1ef22ae"},
+ {file = "psycopg_binary-3.1.13-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6a9545c5c9ccbb6ba0c45deb28f4626aade88a8ace36260324b7673965e7df64"},
+ {file = "psycopg_binary-3.1.13-cp39-cp39-win_amd64.whl", hash = "sha256:cd9550cfaf47db9eb44207278b9d418de0076df87cf3a2ea99bc123bd8d379c7"},
+]
+
+[[package]]
+name = "psycopg-pool"
+version = "3.2.0"
+requires_python = ">=3.8"
+summary = "Connection Pool for Psycopg"
+dependencies = [
+ "typing-extensions>=3.10",
+]
+files = [
+ {file = "psycopg-pool-3.2.0.tar.gz", hash = "sha256:2e857bb6c120d012dba240e30e5dff839d2d69daf3e962127ce6b8e40594170e"},
+ {file = "psycopg_pool-3.2.0-py3-none-any.whl", hash = "sha256:73371d4e795d9363c7b496cbb2dfce94ee8fbf2dcdc384d0a937d1d9d8bdd08d"},
+]
+
+[[package]]
+name = "psycopg2-binary"
+version = "2.9.9"
+requires_python = ">=3.7"
+summary = "psycopg2 - Python-PostgreSQL Database Adapter"
+files = [
+ {file = "psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c"},
+ {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202"},
+ {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7"},
+ {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b"},
+ {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9"},
+ {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84"},
+ {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e"},
+ {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98"},
+ {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245"},
+ {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e"},
+ {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f"},
+ {file = "psycopg2_binary-2.9.9-cp310-cp310-win32.whl", hash = "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682"},
+ {file = "psycopg2_binary-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0"},
+ {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26"},
+ {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f"},
+ {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2"},
+ {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0"},
+ {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53"},
+ {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be"},
+ {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27"},
+ {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359"},
+ {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2"},
+ {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc"},
+ {file = "psycopg2_binary-2.9.9-cp311-cp311-win32.whl", hash = "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d"},
+ {file = "psycopg2_binary-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417"},
+ {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf"},
+ {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d"},
+ {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212"},
+ {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493"},
+ {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996"},
+ {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119"},
+ {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba"},
+ {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07"},
+ {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb"},
+ {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe"},
+ {file = "psycopg2_binary-2.9.9-cp312-cp312-win32.whl", hash = "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93"},
+ {file = "psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab"},
+ {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980"},
+ {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6"},
+ {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94"},
+ {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152"},
+ {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55"},
+ {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972"},
+ {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd"},
+ {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59"},
+ {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3"},
+ {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716"},
+ {file = "psycopg2_binary-2.9.9-cp38-cp38-win32.whl", hash = "sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5"},
+ {file = "psycopg2_binary-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7"},
+ {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472"},
+ {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f"},
+ {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e"},
+ {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67"},
+ {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876"},
+ {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7"},
+ {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291"},
+ {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f"},
+ {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1"},
+ {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860"},
+ {file = "psycopg2_binary-2.9.9-cp39-cp39-win32.whl", hash = "sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90"},
+ {file = "psycopg2_binary-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957"},
+]
+
+[[package]]
+name = "psycopg"
+version = "3.1.13"
+extras = ["binary", "pool"]
+requires_python = ">=3.7"
+summary = "PostgreSQL database adapter for Python"
+dependencies = [
+ "psycopg-binary==3.1.13",
+ "psycopg-pool",
+ "psycopg==3.1.13",
+]
+files = [
+ {file = "psycopg-3.1.13-py3-none-any.whl", hash = "sha256:1253010894cfb64e2da4556d4eff5f05e45cafee641f64e02453be849c8f7687"},
+ {file = "psycopg-3.1.13.tar.gz", hash = "sha256:e6d047ce16950651d6e26c7c19ca57cc42e1d4841b58729f691244baeee46e30"},
+]
+
[[package]]
name = "pyasn1"
version = "0.5.0"
@@ -3353,6 +3543,16 @@ files = [
{file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"},
]
+[[package]]
+name = "tzdata"
+version = "2023.3"
+requires_python = ">=2"
+summary = "Provider of IANA time zone data"
+files = [
+ {file = "tzdata-2023.3-py2.py3-none-any.whl", hash = "sha256:7e65763eef3120314099b6939b5546db7adce1e7d6f2e179e3df563c70511eda"},
+ {file = "tzdata-2023.3.tar.gz", hash = "sha256:11ef1e08e54acb0d4f95bdb1be05da659673de4acbd21bf9c69e94cc5e907a3a"},
+]
+
[[package]]
name = "urllib3"
version = "2.0.7"
diff --git a/pyproject.toml b/pyproject.toml
index 528aa8948b..dbfb684a7d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -107,6 +107,9 @@ dev = [
"trio",
"aiosqlite",
"exceptiongroup; python_version < \"3.11\"",
+ "asyncpg>=0.29.0",
+ "psycopg[pool,binary]>=3.1.10",
+ "psycopg2-binary",
]
dev-contrib = ["opentelemetry-sdk", "httpx-sse"]
docs = [
diff --git a/tests/docker_service_fixtures.py b/tests/docker_service_fixtures.py
index 536a75c025..ee70f10761 100644
--- a/tests/docker_service_fixtures.py
+++ b/tests/docker_service_fixtures.py
@@ -139,3 +139,8 @@ async def postgres_responsive(host: str) -> bool:
return (await conn.fetchrow("SELECT 1"))[0] == 1 # type: ignore
finally:
await conn.close()
+
+
+@pytest.fixture()
+async def postgres_service(docker_services: DockerServiceRegistry) -> None:
+ await docker_services.start("postgres", check=postgres_responsive)
diff --git a/tests/unit/test_channels/conftest.py b/tests/unit/test_channels/conftest.py
index c95799143d..1f041ae901 100644
--- a/tests/unit/test_channels/conftest.py
+++ b/tests/unit/test_channels/conftest.py
@@ -3,7 +3,9 @@
import pytest
from redis.asyncio import Redis as AsyncRedis
+from litestar.channels.backends.asyncpg import AsyncPgChannelsBackend
from litestar.channels.backends.memory import MemoryChannelsBackend
+from litestar.channels.backends.psycopg import PsycoPgChannelsBackend
from litestar.channels.backends.redis import RedisChannelsPubSubBackend, RedisChannelsStreamBackend
@@ -37,3 +39,13 @@ def redis_pub_sub_backend(redis_client: AsyncRedis) -> RedisChannelsPubSubBacken
@pytest.fixture()
def memory_backend() -> MemoryChannelsBackend:
return MemoryChannelsBackend(history=10)
+
+
+@pytest.fixture()
+def postgres_asyncpg_backend(postgres_service: None, docker_ip: str) -> AsyncPgChannelsBackend:
+ return AsyncPgChannelsBackend(f"postgres://postgres:super-secret@{docker_ip}:5423")
+
+
+@pytest.fixture()
+def postgres_psycopg_backend(postgres_service: None, docker_ip: str) -> PsycoPgChannelsBackend:
+ return PsycoPgChannelsBackend(f"postgres://postgres:super-secret@{docker_ip}:5423")
diff --git a/tests/unit/test_channels/test_backends.py b/tests/unit/test_channels/test_backends.py
index 17eb0baf8f..871d355131 100644
--- a/tests/unit/test_channels/test_backends.py
+++ b/tests/unit/test_channels/test_backends.py
@@ -3,14 +3,18 @@
import asyncio
from datetime import timedelta
from typing import AsyncGenerator, cast
+from unittest.mock import AsyncMock, MagicMock
import pytest
from _pytest.fixtures import FixtureRequest
from redis.asyncio.client import Redis
from litestar.channels import ChannelsBackend
+from litestar.channels.backends.asyncpg import AsyncPgChannelsBackend
from litestar.channels.backends.memory import MemoryChannelsBackend
+from litestar.channels.backends.psycopg import PsycoPgChannelsBackend
from litestar.channels.backends.redis import RedisChannelsPubSubBackend, RedisChannelsStreamBackend
+from litestar.exceptions import ImproperlyConfiguredException
from litestar.utils.compat import async_next
@@ -18,6 +22,8 @@
params=[
pytest.param("redis_pub_sub_backend", id="redis:pubsub", marks=pytest.mark.xdist_group("redis")),
pytest.param("redis_stream_backend", id="redis:stream", marks=pytest.mark.xdist_group("redis")),
+ pytest.param("postgres_asyncpg_backend", id="postgres:asyncpg", marks=pytest.mark.xdist_group("postgres")),
+ pytest.param("postgres_psycopg_backend", id="postgres:psycopg", marks=pytest.mark.xdist_group("postgres")),
pytest.param("memory_backend", id="memory"),
]
)
@@ -82,7 +88,7 @@ async def test_unsubscribe_without_subscription(channels_backend: ChannelsBacken
async def test_get_history(
channels_backend: ChannelsBackend, history_limit: int | None, expected_history_length: int
) -> None:
- if isinstance(channels_backend, RedisChannelsPubSubBackend):
+ if isinstance(channels_backend, (RedisChannelsPubSubBackend, AsyncPgChannelsBackend, PsycoPgChannelsBackend)):
pytest.skip("Redis pub/sub backend does not support history")
messages = [str(i).encode() for i in range(100)]
@@ -97,7 +103,7 @@ async def test_get_history(
async def test_discards_history_entries(channels_backend: ChannelsBackend) -> None:
- if isinstance(channels_backend, RedisChannelsPubSubBackend):
+ if isinstance(channels_backend, (RedisChannelsPubSubBackend, AsyncPgChannelsBackend, PsycoPgChannelsBackend)):
pytest.skip("Redis pub/sub backend does not support history")
for _ in range(20):
@@ -133,3 +139,35 @@ async def test_memory_publish_not_initialized_raises() -> None:
with pytest.raises(RuntimeError):
await backend.publish(b"foo", ["something"])
+
+
+@pytest.mark.xdist_group("postgres")
+async def test_asyncpg_get_history(postgres_asyncpg_backend: AsyncPgChannelsBackend) -> None:
+ with pytest.raises(NotImplementedError):
+ await postgres_asyncpg_backend.get_history("something")
+
+
+@pytest.mark.xdist_group("postgres")
+async def test_psycopg_get_history(postgres_psycopg_backend: PsycoPgChannelsBackend) -> None:
+ with pytest.raises(NotImplementedError):
+ await postgres_psycopg_backend.get_history("something")
+
+
+async def test_asyncpg_make_connection() -> None:
+ make_connection = AsyncMock()
+
+ backend = AsyncPgChannelsBackend(make_connection=make_connection)
+ await backend.on_startup()
+
+ make_connection.assert_awaited_once()
+
+
+async def test_asyncpg_no_make_conn_or_dsn_passed_raises() -> None:
+ with pytest.raises(ImproperlyConfiguredException):
+ AsyncPgChannelsBackend() # type: ignore[call-overload]
+
+
+def test_asyncpg_listener_raises_on_non_string_payload() -> None:
+ backend = AsyncPgChannelsBackend(make_connection=AsyncMock())
+ with pytest.raises(RuntimeError):
+ backend._listener(connection=MagicMock(), pid=1, payload=b"abc", channel="foo")
diff --git a/tests/unit/test_channels/test_plugin.py b/tests/unit/test_channels/test_plugin.py
index c6743e6102..856c8bc9a8 100644
--- a/tests/unit/test_channels/test_plugin.py
+++ b/tests/unit/test_channels/test_plugin.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
+import time
from secrets import token_hex
from typing import cast
from unittest.mock import AsyncMock, MagicMock
@@ -24,6 +25,8 @@
params=[
pytest.param("redis_pub_sub_backend", id="redis:pubsub", marks=pytest.mark.xdist_group("redis")),
pytest.param("redis_stream_backend", id="redis:stream", marks=pytest.mark.xdist_group("redis")),
+ pytest.param("postgres_asyncpg_backend", id="postgres:asyncpg", marks=pytest.mark.xdist_group("postgres")),
+ pytest.param("postgres_psycopg_backend", id="postgres:psycopg", marks=pytest.mark.xdist_group("postgres")),
pytest.param("memory_backend", id="memory"),
]
)
@@ -119,7 +122,7 @@ def test_create_ws_route_handlers(
@pytest.mark.flaky(reruns=5)
-def test_ws_route_handlers_receive_arbitrary_message(channels_backend: ChannelsBackend) -> None:
+async def test_ws_route_handlers_receive_arbitrary_message(channels_backend: ChannelsBackend) -> None:
"""The websocket handlers await `WebSocket.receive()` to detect disconnection and stop the subscription.
This test ensures that the subscription is only stopped in the case of receiving a `websocket.disconnect` message.
@@ -140,7 +143,7 @@ def test_ws_route_handlers_receive_arbitrary_message(channels_backend: ChannelsB
@pytest.mark.flaky(reruns=5)
-async def test_create_ws_route_handlers_arbitrary_channels_allowed(channels_backend: ChannelsBackend) -> None:
+def test_create_ws_route_handlers_arbitrary_channels_allowed(channels_backend: ChannelsBackend) -> None:
channels_plugin = ChannelsPlugin(
backend=channels_backend,
arbitrary_channels_allowed=True,
@@ -155,6 +158,8 @@ async def test_create_ws_route_handlers_arbitrary_channels_allowed(channels_back
channels_plugin.publish("something", "foo")
assert ws.receive_text(timeout=2) == "something"
+ time.sleep(0.1)
+
with client.websocket_connect("/ws/bar") as ws:
channels_plugin.publish("something else", "bar")
assert ws.receive_text(timeout=2) == "something else"
diff --git a/tests/unit/test_contrib/test_msgspec.py b/tests/unit/test_contrib/test_msgspec.py
index 783f78f80f..9c28ed4ba9 100644
--- a/tests/unit/test_contrib/test_msgspec.py
+++ b/tests/unit/test_contrib/test_msgspec.py
@@ -5,7 +5,7 @@
from msgspec import Meta, Struct, field
from typing_extensions import Annotated
-from litestar.dto import MsgspecDTO, dto_field
+from litestar.dto import DTOField, MsgspecDTO, dto_field
from litestar.dto.data_structures import DTOFieldDefinition
from litestar.typing import FieldDefinition
@@ -38,3 +38,17 @@ class NotStruct:
assert MsgspecDTO.detect_nested_field(FieldDefinition.from_annotation(TestStruct)) is True
assert MsgspecDTO.detect_nested_field(FieldDefinition.from_annotation(NotStruct)) is False
+
+
+ReadOnlyInt = Annotated[int, DTOField("read-only")]
+
+
+def test_msgspec_dto_annotated_dto_field() -> None:
+ class Model(Struct):
+ a: Annotated[int, DTOField("read-only")]
+ b: ReadOnlyInt
+
+ dto_type = MsgspecDTO[Model]
+ fields = list(dto_type.generate_field_definitions(Model))
+ assert fields[0].dto_field == DTOField("read-only")
+ assert fields[1].dto_field == DTOField("read-only")
diff --git a/tests/unit/test_contrib/test_pydantic/test_pydantic_dto_factory.py b/tests/unit/test_contrib/test_pydantic/test_pydantic_dto_factory.py
index 562537f909..afe898eff9 100644
--- a/tests/unit/test_contrib/test_pydantic/test_pydantic_dto_factory.py
+++ b/tests/unit/test_contrib/test_pydantic/test_pydantic_dto_factory.py
@@ -7,7 +7,7 @@
from typing_extensions import Annotated
from litestar.contrib.pydantic import PydanticDTO
-from litestar.dto import dto_field
+from litestar.dto import DTOField, dto_field
from litestar.dto.data_structures import DTOFieldDefinition
from litestar.typing import FieldDefinition
@@ -58,3 +58,17 @@ class NotModel:
assert PydanticDTO.detect_nested_field(FieldDefinition.from_annotation(TestModel)) is True
assert PydanticDTO.detect_nested_field(FieldDefinition.from_annotation(NotModel)) is False
+
+
+ReadOnlyInt = Annotated[int, DTOField("read-only")]
+
+
+def test_pydantic_dto_annotated_dto_field() -> None:
+ class Model(BaseModel):
+ a: Annotated[int, DTOField("read-only")]
+ b: ReadOnlyInt
+
+ dto_type = PydanticDTO[Model]
+ fields = list(dto_type.generate_field_definitions(Model))
+ assert fields[0].dto_field == DTOField("read-only")
+ assert fields[1].dto_field == DTOField("read-only")
diff --git a/tests/unit/test_dto/test_factory/test_dataclass_dto.py b/tests/unit/test_dto/test_factory/test_dataclass_dto.py
index 42aea8cc91..7a334a2c39 100644
--- a/tests/unit/test_dto/test_factory/test_dataclass_dto.py
+++ b/tests/unit/test_dto/test_factory/test_dataclass_dto.py
@@ -6,6 +6,7 @@
from unittest.mock import ANY
import pytest
+from typing_extensions import Annotated
from litestar.dto import DataclassDTO, DTOField
from litestar.dto.data_structures import DTOFieldDefinition
@@ -121,3 +122,20 @@ def test_dataclass_field_definitions(dto_type: type[DataclassDTO[Model]]) -> Non
def test_dataclass_detect_nested(dto_type: type[DataclassDTO[Model]]) -> None:
assert dto_type.detect_nested_field(FieldDefinition.from_annotation(Model)) is True
assert dto_type.detect_nested_field(FieldDefinition.from_annotation(int)) is False
+
+
+ReadOnlyInt = Annotated[int, DTOField("read-only")]
+
+
+def test_dataclass_dto_annotated_dto_field() -> None:
+ Annotated[int, DTOField("read-only")]
+
+ @dataclass
+ class Model:
+ a: Annotated[int, DTOField("read-only")]
+ b: ReadOnlyInt
+
+ dto_type = DataclassDTO[Model]
+ fields = list(dto_type.generate_field_definitions(Model))
+ assert fields[0].dto_field == DTOField("read-only")
+ assert fields[1].dto_field == DTOField("read-only")