diff --git a/pyproject.toml b/pyproject.toml index f449cc9c..5e6dbb71 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,16 +21,18 @@ classifiers = [ ] dependencies = [ "pydantic==2.9.2", - "aiochannel>=1.2.1", - "black>=23.11,<25.0", - "grpcio-tools>=1.59.3", - "grpcio>=1.59.3", - "msgpack-types>=0.3.0", - "msgpack>=1.0.7", - "nanoid>=2.0.0", - "protobuf>=4.24.4", - "pydantic-core>=2.20.1", - "websockets>=12.0", + "aiochannel>=1.2.1", + "black>=23.11,<25.0", + "grpcio-tools>=1.59.3", + "grpcio>=1.59.3", + "msgpack-types>=0.3.0", + "msgpack>=1.0.7", + "nanoid>=2.0.0", + "protobuf>=4.24.4", + "pydantic-core>=2.20.1", + "websockets>=12.0", + "opentelemetry-sdk>=1.27.0,<1.28.0", + "opentelemetry-api>=1.27.0,<1.28.0", ] [tool.uv] diff --git a/replit_river/client.py b/replit_river/client.py index fab2dc62..ead91f01 100644 --- a/replit_river/client.py +++ b/replit_river/client.py @@ -2,6 +2,13 @@ from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable from typing import Any, Generic, Optional, Union +from replit_river.client_interceptor import ( + ClientInterceptor, + ClientRpcDetails, + ClientStreamDetails, + ClientSubscriptionDetails, + ClientUploadDetails, +) from replit_river.client_transport import ClientTransport from replit_river.transport_options import ( HandshakeMetadataType, @@ -28,6 +35,7 @@ def __init__( client_id: str, server_id: str, transport_options: TransportOptions, + interceptors: list[ClientInterceptor] = [], ) -> None: self._client_id = client_id self._server_id = server_id @@ -37,6 +45,7 @@ def __init__( server_id=server_id, transport_options=transport_options, ) + self._interceptors = interceptors async def close(self) -> None: logger.info(f"river client {self._client_id} start closing") @@ -56,13 +65,34 @@ async def send_rpc( error_deserializer: Callable[[Any], ErrorType], ) -> ResponseType: session = await self._transport.get_or_create_session() - return await session.send_rpc( - service_name, - procedure_name, - request, - request_serializer, - response_deserializer, - error_deserializer, + + async def _run_interceptor( + details: ClientRpcDetails, + interceptors: list[ClientInterceptor], + ) -> ResponseType: + if interceptors: + head, tail = interceptors[0], interceptors[1:] + return await head.intercept_rpc( # type: ignore + details, + lambda details: _run_interceptor(details, tail), + ) + else: + return await session.send_rpc( + details.service_name, + details.procedure_name, + details.request, + request_serializer, + response_deserializer, + error_deserializer, + ) + + return await _run_interceptor( + ClientRpcDetails( + service_name=service_name, + procedure_name=procedure_name, + request=request, + ), + self._interceptors, ) async def send_upload( @@ -77,15 +107,35 @@ async def send_upload( error_deserializer: Callable[[Any], ErrorType], ) -> ResponseType: session = await self._transport.get_or_create_session() - return await session.send_upload( - service_name, - procedure_name, - init, - request, - init_serializer, - request_serializer, - response_deserializer, - error_deserializer, + + async def _run_interceptor( + details: ClientUploadDetails, + interceptors: list[ClientInterceptor], + ) -> ResponseType: + if interceptors: + head, tail = interceptors[0], interceptors[1:] + return await head.intercept_upload( # type: ignore + details, lambda details: _run_interceptor(details, tail) + ) + else: + return await session.send_upload( + service_name, + procedure_name, + init, + request, + init_serializer, + request_serializer, + response_deserializer, + error_deserializer, + ) + + return await _run_interceptor( + ClientUploadDetails( + service_name=service_name, + procedure_name=procedure_name, + init=init, + ), + self._interceptors, ) async def send_subscription( @@ -98,13 +148,33 @@ async def send_subscription( error_deserializer: Callable[[Any], ErrorType], ) -> AsyncIterator[Union[ResponseType, ErrorType]]: session = await self._transport.get_or_create_session() - return session.send_subscription( - service_name, - procedure_name, - request, - request_serializer, - response_deserializer, - error_deserializer, + + async def _run_interceptor( + details: ClientSubscriptionDetails, + interceptors: list[ClientInterceptor], + ) -> AsyncIterator[Union[ResponseType, ErrorType]]: + if interceptors: + head, tail = interceptors[0], interceptors[1:] + return await head.intercept_subscription( # type: ignore + details, lambda details: _run_interceptor(details, tail) + ) + else: + return session.send_subscription( + service_name, + procedure_name, + request, + request_serializer, + response_deserializer, + error_deserializer, + ) + + return await _run_interceptor( + ClientSubscriptionDetails( + service_name=service_name, + procedure_name=procedure_name, + request=request, + ), + self._interceptors, ) async def send_stream( @@ -119,13 +189,33 @@ async def send_stream( error_deserializer: Callable[[Any], ErrorType], ) -> AsyncIterator[Union[ResponseType, ErrorType]]: session = await self._transport.get_or_create_session() - return session.send_stream( - service_name, - procedure_name, - init, - request, - init_serializer, - request_serializer, - response_deserializer, - error_deserializer, + + async def _run_interceptor( + details: ClientStreamDetails, + interceptors: list[ClientInterceptor], + ) -> AsyncIterator[Union[ResponseType, ErrorType]]: + if interceptors: + head, tail = interceptors[0], interceptors[1:] + return await head.intercept_stream( # type: ignore + details, lambda details: _run_interceptor(details, tail) + ) + else: + return session.send_stream( + service_name, + procedure_name, + init, + request, + init_serializer, + request_serializer, + response_deserializer, + error_deserializer, + ) + + return await _run_interceptor( + ClientStreamDetails( + service_name=service_name, + procedure_name=procedure_name, + init=init, + ), + self._interceptors, ) diff --git a/replit_river/client_interceptor.py b/replit_river/client_interceptor.py new file mode 100644 index 00000000..3c103a5c --- /dev/null +++ b/replit_river/client_interceptor.py @@ -0,0 +1,74 @@ +from abc import ABC, abstractmethod +from typing import Any, AsyncIterator, Awaitable, Callable, NamedTuple, Optional + + +class ClientRpcDetails(NamedTuple): + service_name: str + procedure_name: str + request: Any + + +class ClientUploadDetails(NamedTuple): + service_name: str + procedure_name: str + init: Optional[Any] + + +class ClientSubscriptionDetails(NamedTuple): + service_name: str + procedure_name: str + request: Any + + +class ClientStreamDetails(NamedTuple): + service_name: str + procedure_name: str + init: Optional[Any] + + +class ClientInterceptor(ABC): + @abstractmethod + async def intercept_rpc( + self, + details: ClientRpcDetails, + continuation: Callable[[ClientRpcDetails], Awaitable[Any]], + ) -> Any: + """ + TODO: docs + """ + + @abstractmethod + async def intercept_upload( + self, + details: ClientUploadDetails, + continuation: Callable[[ClientUploadDetails], Awaitable[Any]], + ) -> Any: + """ + TODO: docs + """ + + @abstractmethod + async def intercept_subscription( + self, + details: ClientSubscriptionDetails, + continuation: Callable[ + [ClientSubscriptionDetails], + Awaitable[AsyncIterator[Any]], + ], + ) -> AsyncIterator[Any]: + """ + TODO: docs + """ + + @abstractmethod + async def intercept_stream( + self, + details: ClientStreamDetails, + continuation: Callable[ + [ClientStreamDetails], + Awaitable[AsyncIterator[Any]], + ], + ) -> AsyncIterator[Any]: + """ + TODO: docs + """ diff --git a/replit_river/contrib/opentelemetry.py b/replit_river/contrib/opentelemetry.py new file mode 100644 index 00000000..77f520d1 --- /dev/null +++ b/replit_river/contrib/opentelemetry.py @@ -0,0 +1,86 @@ +from typing import Any, AsyncIterator, Awaitable, Callable + +from opentelemetry import trace + +from replit_river.client_interceptor import ( + ClientInterceptor, + ClientRpcDetails, + ClientStreamDetails, + ClientSubscriptionDetails, + ClientUploadDetails, +) +from replit_river.error_schema import RiverException + +tracer = trace.get_tracer(__name__) + + +class OpenTelemetryClientInterceptor(ClientInterceptor): + async def intercept_rpc( + self, + details: ClientRpcDetails, + continuation: Callable[[ClientRpcDetails], Awaitable[Any]], + ) -> Any: + with tracer.start_as_current_span( + f"river.rpc.{details.service_name}.{details.procedure_name}", + kind=trace.SpanKind.CLIENT, + ) as span: + try: + return await continuation(details) + except RiverException as e: + span.set_attribute("river.error_code", e.code) + span.set_attribute("river.error_message", e.message) + return e + + async def intercept_upload( + self, + details: ClientUploadDetails, + continuation: Callable[[ClientUploadDetails], Awaitable[Any]], + ) -> Any: + with tracer.start_as_current_span( + f"river.upload.{details.service_name}.{details.procedure_name}", + kind=trace.SpanKind.CLIENT, + ) as span: + try: + return await continuation(details) + except RiverException as e: + span.set_attribute("river.error_code", e.code) + span.set_attribute("river.error_message", e.message) + return e + + async def intercept_subscription( + self, + details: ClientSubscriptionDetails, + continuation: Callable[ + [ClientSubscriptionDetails], + Awaitable[AsyncIterator[Any]], + ], + ) -> Any: + with tracer.start_as_current_span( + f"river.subscription.{details.service_name}.{details.procedure_name}", + kind=trace.SpanKind.CLIENT, + ) as span: + try: + return await continuation(details) + except RiverException as e: + span.set_attribute("river.error_code", e.code) + span.set_attribute("river.error_message", e.message) + return e + + async def intercept_stream( + self, + details: ClientStreamDetails, + continuation: Callable[ + [ClientStreamDetails], + Awaitable[AsyncIterator[Any]], + ], + ) -> Any: + with tracer.start_as_current_span( + f"river.stream.{details.service_name}.{details.procedure_name}", + kind=trace.SpanKind.CLIENT, + ) as span: + try: + return await continuation(details) + except RiverException as e: + span.set_attribute("river.error_code", e.code) + span.set_attribute("river.error_message", e.message) + return e diff --git a/replit_river/rpc.py b/replit_river/rpc.py index f91523db..eb6844f0 100644 --- a/replit_river/rpc.py +++ b/replit_river/rpc.py @@ -85,6 +85,11 @@ class ControlMessageHandshakeResponse(BaseModel): status: HandShakeStatus +class PropagationContext: + traceparent: str + tracestate: str + + class TransportMessage(BaseModel): id: str # from_ is used instead of from because from is a reserved keyword in Python @@ -96,6 +101,7 @@ class TransportMessage(BaseModel): procedureName: Optional[str] = None streamId: str controlFlags: int + tracing: Optional[PropagationContext] = None payload: Any model_config = ConfigDict(populate_by_name=True) # need this because we create TransportMessage objects with destructuring diff --git a/tests/conftest.py b/tests/conftest.py index ae764913..72f10c96 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -138,7 +138,6 @@ async def client( transport_options: TransportOptions, no_logging_error: NoErrors, ) -> AsyncGenerator[Client, None]: - async def websocket_uri_factory() -> UriAndMetadata[None]: return { "uri": "ws://localhost:8765", diff --git a/uv.lock b/uv.lock index e884e3bb..eca454c4 100644 --- a/uv.lock +++ b/uv.lock @@ -125,6 +125,18 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "deprecated" +version = "1.2.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/a3/53e7d78a6850ffdd394d7048a31a6f14e44900adedf190f9a165f6b69439/deprecated-1.2.15.tar.gz", hash = "sha256:683e561a90de76239796e6b6feac66b99030d2dd3fcf61ef996330f14bbb9b0d", size = 2977612 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/8f/c7f227eb42cfeaddce3eb0c96c60cbca37797fa7b34f8e1aeadf6c5c0983/Deprecated-1.2.15-py2.py3-none-any.whl", hash = "sha256:353bc4a8ac4bfc96800ddab349d89c25dec1079f65fd53acdcc1e0b975b21320", size = 9941 }, +] + [[package]] name = "deptry" version = "0.20.0" @@ -218,6 +230,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/d0/f0855a0ccb26ffeb41e6db68b5cbb25d7e9ba1f8f19151eef36210e64efc/grpcio_tools-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:6961da86e9856b4ddee0bf51ef6636b4bf9c29c0715aa71f3c8f027c45d42654", size = 1089819 }, ] +[[package]] +name = "importlib-metadata" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/bd/fa8ce65b0a7d4b6d143ec23b0f5fd3f7ab80121078c465bc02baeaab22dc/importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5", size = 54320 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/14/362d31bf1076b21e1bcdcb0dc61944822ff263937b804a79231df2774d28/importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1", size = 26269 }, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -340,6 +364,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/0d/8630f13998638dc01e187fadd2e5c6d42d127d08aeb4943d231664d6e539/nanoid-2.0.0-py3-none-any.whl", hash = "sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb", size = 5844 }, ] +[[package]] +name = "opentelemetry-api" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "importlib-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/83/93114b6de85a98963aec218a51509a52ed3f8de918fe91eb0f7299805c3f/opentelemetry_api-1.27.0.tar.gz", hash = "sha256:ed673583eaa5f81b5ce5e86ef7cdaf622f88ef65f0b9aab40b843dcae5bef342", size = 62693 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/1f/737dcdbc9fea2fa96c1b392ae47275165a7c641663fbb08a8d252968eed2/opentelemetry_api-1.27.0-py3-none-any.whl", hash = "sha256:953d5871815e7c30c81b56d910c707588000fff7a3ca1c73e6531911d53065e7", size = 63970 }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/9a/82a6ac0f06590f3d72241a587cb8b0b751bd98728e896cc4cbd4847248e6/opentelemetry_sdk-1.27.0.tar.gz", hash = "sha256:d525017dea0ccce9ba4e0245100ec46ecdc043f2d7b8315d56b19aff0904fa6f", size = 145019 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/bd/a6602e71e315055d63b2ff07172bd2d012b4cba2d4e00735d74ba42fc4d6/opentelemetry_sdk-1.27.0-py3-none-any.whl", hash = "sha256:365f5e32f920faf0fd9e14fdfd92c086e317eaa5f860edba9cdc17a380d9197d", size = 110505 }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.48b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0a/89/1724ad69f7411772446067cdfa73b598694c8c91f7f8c922e344d96d81f9/opentelemetry_semantic_conventions-0.48b0.tar.gz", hash = "sha256:12d74983783b6878162208be57c9effcb89dc88691c64992d70bb89dc00daa1a", size = 89445 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/7a/4f0063dbb0b6c971568291a8bc19a4ca70d3c185db2d956230dd67429dfc/opentelemetry_semantic_conventions-0.48b0-py3-none-any.whl", hash = "sha256:a0de9f45c413a8669788a38569c7e0a11ce6ce97861a628cca785deecdc32a1f", size = 149685 }, +] + [[package]] name = "packaging" version = "24.1" @@ -515,6 +579,8 @@ dependencies = [ { name = "msgpack" }, { name = "msgpack-types" }, { name = "nanoid" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, { name = "protobuf" }, { name = "pydantic" }, { name = "pydantic-core" }, @@ -543,6 +609,8 @@ requires-dist = [ { name = "msgpack", specifier = ">=1.0.7" }, { name = "msgpack-types", specifier = ">=0.3.0" }, { name = "nanoid", specifier = ">=2.0.0" }, + { name = "opentelemetry-api", specifier = ">=1.27.0,<1.28.0" }, + { name = "opentelemetry-sdk", specifier = ">=1.27.0,<1.28.0" }, { name = "protobuf", specifier = ">=4.24.4" }, { name = "pydantic", specifier = "==2.9.2" }, { name = "pydantic-core", specifier = ">=2.20.1" }, @@ -664,3 +732,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/d8/63d6194aae711d7263df4498200c690a9c39fb437ede10f3e157a6343e0d/websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2", size = 159144 }, { url = "https://files.pythonhosted.org/packages/56/27/96a5cd2626d11c8280656c6c71d8ab50fe006490ef9971ccd154e0c42cd2/websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f", size = 152134 }, ] + +[[package]] +name = "wrapt" +version = "1.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/4c/063a912e20bcef7124e0df97282a8af3ff3e4b603ce84c481d6d7346be0a/wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d", size = 53972 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/03/c188ac517f402775b90d6f312955a5e53b866c964b32119f2ed76315697e/wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09", size = 37313 }, + { url = "https://files.pythonhosted.org/packages/0f/16/ea627d7817394db04518f62934a5de59874b587b792300991b3c347ff5e0/wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d", size = 38164 }, + { url = "https://files.pythonhosted.org/packages/7f/a7/f1212ba098f3de0fd244e2de0f8791ad2539c03bef6c05a9fcb03e45b089/wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389", size = 80890 }, + { url = "https://files.pythonhosted.org/packages/b7/96/bb5e08b3d6db003c9ab219c487714c13a237ee7dcc572a555eaf1ce7dc82/wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060", size = 73118 }, + { url = "https://files.pythonhosted.org/packages/6e/52/2da48b35193e39ac53cfb141467d9f259851522d0e8c87153f0ba4205fb1/wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1", size = 80746 }, + { url = "https://files.pythonhosted.org/packages/11/fb/18ec40265ab81c0e82a934de04596b6ce972c27ba2592c8b53d5585e6bcd/wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3", size = 85668 }, + { url = "https://files.pythonhosted.org/packages/0f/ef/0ecb1fa23145560431b970418dce575cfaec555ab08617d82eb92afc7ccf/wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956", size = 78556 }, + { url = "https://files.pythonhosted.org/packages/25/62/cd284b2b747f175b5a96cbd8092b32e7369edab0644c45784871528eb852/wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d", size = 85712 }, + { url = "https://files.pythonhosted.org/packages/e5/a7/47b7ff74fbadf81b696872d5ba504966591a3468f1bc86bca2f407baef68/wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362", size = 35327 }, + { url = "https://files.pythonhosted.org/packages/cf/c3/0084351951d9579ae83a3d9e38c140371e4c6b038136909235079f2e6e78/wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89", size = 37523 }, + { url = "https://files.pythonhosted.org/packages/92/17/224132494c1e23521868cdd57cd1e903f3b6a7ba6996b7b8f077ff8ac7fe/wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b", size = 37614 }, + { url = "https://files.pythonhosted.org/packages/6a/d7/cfcd73e8f4858079ac59d9db1ec5a1349bc486ae8e9ba55698cc1f4a1dff/wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36", size = 38316 }, + { url = "https://files.pythonhosted.org/packages/7e/79/5ff0a5c54bda5aec75b36453d06be4f83d5cd4932cc84b7cb2b52cee23e2/wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73", size = 86322 }, + { url = "https://files.pythonhosted.org/packages/c4/81/e799bf5d419f422d8712108837c1d9bf6ebe3cb2a81ad94413449543a923/wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809", size = 79055 }, + { url = "https://files.pythonhosted.org/packages/62/62/30ca2405de6a20448ee557ab2cd61ab9c5900be7cbd18a2639db595f0b98/wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b", size = 87291 }, + { url = "https://files.pythonhosted.org/packages/49/4e/5d2f6d7b57fc9956bf06e944eb00463551f7d52fc73ca35cfc4c2cdb7aed/wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81", size = 90374 }, + { url = "https://files.pythonhosted.org/packages/a6/9b/c2c21b44ff5b9bf14a83252a8b973fb84923764ff63db3e6dfc3895cf2e0/wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9", size = 83896 }, + { url = "https://files.pythonhosted.org/packages/14/26/93a9fa02c6f257df54d7570dfe8011995138118d11939a4ecd82cb849613/wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c", size = 91738 }, + { url = "https://files.pythonhosted.org/packages/a2/5b/4660897233eb2c8c4de3dc7cefed114c61bacb3c28327e64150dc44ee2f6/wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc", size = 35568 }, + { url = "https://files.pythonhosted.org/packages/5c/cc/8297f9658506b224aa4bd71906447dea6bb0ba629861a758c28f67428b91/wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8", size = 37653 }, + { url = "https://files.pythonhosted.org/packages/ff/21/abdedb4cdf6ff41ebf01a74087740a709e2edb146490e4d9beea054b0b7a/wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1", size = 23362 }, +] + +[[package]] +name = "zipp" +version = "3.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/50/bad581df71744867e9468ebd0bcd6505de3b275e06f202c2cb016e3ff56f/zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4", size = 24545 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/1a/7e4798e9339adc931158c9d69ecc34f5e6791489d469f5e50ec15e35f458/zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931", size = 9630 }, +]