From 7db991ffa16a46fdc385a04b416bedfe40195c98 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Mon, 13 Apr 2026 16:11:11 -0700 Subject: [PATCH 1/7] Working on adding Nexus messaging sample code --- nexus_messaging/README.md | 16 ++ .../__init__.py | 0 nexus_messaging/callerpattern/README.md | 62 ++++++++ .../callerpattern}/__init__.py | 0 .../callerpattern/caller}/__init__.py | 0 .../callerpattern}/caller/app.py | 13 +- .../callerpattern/caller/workflows.py | 74 +++++++++ .../callerpattern/handler/__init__.py | 0 .../callerpattern/handler/activities.py | 22 +++ .../callerpattern/handler/service_handler.py | 80 ++++++++++ .../callerpattern/handler/worker.py | 62 ++++++++ .../callerpattern/handler/workflows.py | 88 +++++++++++ nexus_messaging/callerpattern/service.py | 67 ++++++++ nexus_messaging/endpoint_description.md | 14 ++ nexus_messaging/ondemandpattern/README.md | 66 ++++++++ nexus_messaging/ondemandpattern/__init__.py | 0 .../ondemandpattern/caller/__init__.py | 0 nexus_messaging/ondemandpattern/caller/app.py | 41 +++++ .../ondemandpattern/caller/workflows.py | 149 ++++++++++++++++++ .../ondemandpattern/handler/__init__.py | 0 .../ondemandpattern/handler/activities.py | 22 +++ .../handler/service_handler.py | 92 +++++++++++ .../ondemandpattern}/handler/worker.py | 22 ++- .../ondemandpattern/handler/workflows.py | 102 ++++++++++++ nexus_messaging/ondemandpattern/service.py | 72 +++++++++ nexus_sync_operations/README.md | 39 ----- nexus_sync_operations/caller/workflows.py | 46 ------ nexus_sync_operations/endpoint_description.md | 4 - .../handler/service_handler.py | 83 ---------- nexus_sync_operations/service.py | 20 --- tests/nexus_messaging/callerpattern_test.py | 122 ++++++++++++++ tests/nexus_messaging/ondemandpattern_test.py | 134 ++++++++++++++++ .../nexus_sync_operations_test.py | 16 +- 33 files changed, 1308 insertions(+), 220 deletions(-) create mode 100644 nexus_messaging/README.md rename {nexus_sync_operations => nexus_messaging}/__init__.py (100%) create mode 100644 nexus_messaging/callerpattern/README.md rename {nexus_sync_operations/caller => nexus_messaging/callerpattern}/__init__.py (100%) rename {nexus_sync_operations/handler => nexus_messaging/callerpattern/caller}/__init__.py (100%) rename {nexus_sync_operations => nexus_messaging/callerpattern}/caller/app.py (73%) create mode 100644 nexus_messaging/callerpattern/caller/workflows.py create mode 100644 nexus_messaging/callerpattern/handler/__init__.py create mode 100644 nexus_messaging/callerpattern/handler/activities.py create mode 100644 nexus_messaging/callerpattern/handler/service_handler.py create mode 100644 nexus_messaging/callerpattern/handler/worker.py create mode 100644 nexus_messaging/callerpattern/handler/workflows.py create mode 100644 nexus_messaging/callerpattern/service.py create mode 100644 nexus_messaging/endpoint_description.md create mode 100644 nexus_messaging/ondemandpattern/README.md create mode 100644 nexus_messaging/ondemandpattern/__init__.py create mode 100644 nexus_messaging/ondemandpattern/caller/__init__.py create mode 100644 nexus_messaging/ondemandpattern/caller/app.py create mode 100644 nexus_messaging/ondemandpattern/caller/workflows.py create mode 100644 nexus_messaging/ondemandpattern/handler/__init__.py create mode 100644 nexus_messaging/ondemandpattern/handler/activities.py create mode 100644 nexus_messaging/ondemandpattern/handler/service_handler.py rename {nexus_sync_operations => nexus_messaging/ondemandpattern}/handler/worker.py (58%) create mode 100644 nexus_messaging/ondemandpattern/handler/workflows.py create mode 100644 nexus_messaging/ondemandpattern/service.py delete mode 100644 nexus_sync_operations/README.md delete mode 100644 nexus_sync_operations/caller/workflows.py delete mode 100644 nexus_sync_operations/endpoint_description.md delete mode 100644 nexus_sync_operations/handler/service_handler.py delete mode 100644 nexus_sync_operations/service.py create mode 100644 tests/nexus_messaging/callerpattern_test.py create mode 100644 tests/nexus_messaging/ondemandpattern_test.py diff --git a/nexus_messaging/README.md b/nexus_messaging/README.md new file mode 100644 index 00000000..22670572 --- /dev/null +++ b/nexus_messaging/README.md @@ -0,0 +1,16 @@ +This sample shows how to expose a long-running workflow's queries, updates, and signals as Nexus +operations. There are two self-contained examples, each in its own directory: + +| | `callerpattern/` | `ondemandpattern/` | +|---|---|---| +| **Pattern** | Signal an existing workflow | Create and run workflows on demand, and send signals to them | +| **Who creates the workflow?** | The handler worker starts it on boot | The caller starts it via a Nexus operation | +| **Who knows the workflow ID?** | Only the handler | The caller chooses and passes it in every operation | +| **Nexus service** | `NexusGreetingService` | `NexusRemoteGreetingService` | + +Each directory is fully self-contained for clarity. The `GreetingWorkflow`, activity, and +`Language` enum are **identical** between the two -- only the Nexus service definition and its +handler implementation differ. This highlights that the same workflow can be exposed through +Nexus in different ways depending on whether the caller needs lifecycle control. + +See each directory's README for running instructions. diff --git a/nexus_sync_operations/__init__.py b/nexus_messaging/__init__.py similarity index 100% rename from nexus_sync_operations/__init__.py rename to nexus_messaging/__init__.py diff --git a/nexus_messaging/callerpattern/README.md b/nexus_messaging/callerpattern/README.md new file mode 100644 index 00000000..085a4903 --- /dev/null +++ b/nexus_messaging/callerpattern/README.md @@ -0,0 +1,62 @@ +## Entity pattern + +The handler worker starts a `GreetingWorkflow` for a user ID. +`NexusGreetingServiceHandler` holds that ID and routes every Nexus operation to it. +The caller's input does not have that workflow ID as the caller doesn't know it -- but the caller +sends in the User ID, and `NexusGreetingServiceHandler` knows how to get the desired workflow ID +from that User ID (see the `get_workflow_id` call). + +The handler worker uses the same `get_workflow_id` call to generate a workflow ID from a user ID +when it launches the workflow. + +The caller workflow: +1. Queries for supported languages (`get_languages` -- backed by a `@workflow.query`) +2. Changes the language to Arabic (`set_language` -- backed by a `@workflow.update` that calls an activity) +3. Confirms the change via a second query (`get_language`) +4. Approves the workflow (`approve` -- backed by a `@workflow.signal`) + +### Sample directory structure + +- [service.py](./service.py) - shared Nexus service definition +- [caller](./caller) - a caller workflow that executes Nexus operations, together with a starter +- [handler](./handler) - Nexus operation handlers, together with a workflow used by the Nexus operations, and a worker that polls for workflow, activity, and Nexus tasks + +### Running + +Start a Temporal server: + +```bash +temporal server start-dev +``` + +Create the namespaces and Nexus endpoint: + +```bash +temporal operator namespace create --namespace nexus-messaging-handler-namespace +temporal operator namespace create --namespace nexus-messaging-caller-namespace + +temporal operator nexus endpoint create \ + --name nexus-messaging-nexus-endpoint \ + --target-namespace nexus-messaging-handler-namespace \ + --target-task-queue nexus-messaging-handler-task-queue +``` + +In one terminal, start the handler worker: + +```bash +uv run python -m nexus_messaging.callerpattern.handler.worker +``` + +In another terminal, run the caller workflow: + +```bash +uv run python -m nexus_messaging.callerpattern.caller.app +``` + +Expected output: + +``` +Supported languages: [, ] +Language changed: ENGLISH -> ARABIC +Workflow approved +``` diff --git a/nexus_sync_operations/caller/__init__.py b/nexus_messaging/callerpattern/__init__.py similarity index 100% rename from nexus_sync_operations/caller/__init__.py rename to nexus_messaging/callerpattern/__init__.py diff --git a/nexus_sync_operations/handler/__init__.py b/nexus_messaging/callerpattern/caller/__init__.py similarity index 100% rename from nexus_sync_operations/handler/__init__.py rename to nexus_messaging/callerpattern/caller/__init__.py diff --git a/nexus_sync_operations/caller/app.py b/nexus_messaging/callerpattern/caller/app.py similarity index 73% rename from nexus_sync_operations/caller/app.py rename to nexus_messaging/callerpattern/caller/app.py index 375628d2..933dcd5d 100644 --- a/nexus_sync_operations/caller/app.py +++ b/nexus_messaging/callerpattern/caller/app.py @@ -6,15 +6,13 @@ from temporalio.envconfig import ClientConfig from temporalio.worker import Worker -from nexus_sync_operations.caller.workflows import CallerWorkflow +from nexus_messaging.callerpattern.caller.workflows import CallerWorkflow -NAMESPACE = "nexus-sync-operations-caller-namespace" -TASK_QUEUE = "nexus-sync-operations-caller-task-queue" +NAMESPACE = "nexus-messaging-caller-namespace" +TASK_QUEUE = "nexus-messaging-caller-task-queue" -async def execute_caller_workflow( - client: Optional[Client] = None, -) -> None: +async def execute_caller_workflow(client: Optional[Client] = None) -> None: if client is None: config = ClientConfig.load_client_connect_config() config.setdefault("target_host", "localhost:7233") @@ -28,7 +26,8 @@ async def execute_caller_workflow( ): log = await client.execute_workflow( CallerWorkflow.run, - id=str(uuid.uuid4()), + arg="user-1", + id=f"nexus-messaging-caller-{uuid.uuid4()}", task_queue=TASK_QUEUE, ) for line in log: diff --git a/nexus_messaging/callerpattern/caller/workflows.py b/nexus_messaging/callerpattern/caller/workflows.py new file mode 100644 index 00000000..7418e90a --- /dev/null +++ b/nexus_messaging/callerpattern/caller/workflows.py @@ -0,0 +1,74 @@ +""" +A caller workflow that executes Nexus operations. The caller does not have information +about how these operations are implemented by the Nexus service. +""" + +from temporalio import workflow +from temporalio.exceptions import ApplicationError + +with workflow.unsafe.imports_passed_through(): + from nexus_messaging.callerpattern.service import ( + ApproveInput, + GetLanguageInput, + GetLanguagesInput, + Language, + NexusGreetingService, + SetLanguageInput, + ) + +NEXUS_ENDPOINT = "nexus-messaging-nexus-endpoint" + + +@workflow.defn +class CallerWorkflow: + @workflow.run + async def run(self, user_id: str) -> list[str]: + log: list[str] = [] + nexus_client = workflow.create_nexus_client( + service=NexusGreetingService, + endpoint=NEXUS_ENDPOINT, + ) + + # Call a Nexus operation backed by a query against the entity workflow. + # The workflow must already be running on the handler, otherwise you will + # get an error saying the workflow has already terminated. + languages_output = await nexus_client.execute_operation( + NexusGreetingService.get_languages, + GetLanguagesInput(include_unsupported=False, user_id=user_id), + ) + log.append(f"Supported languages: {languages_output.languages}") + workflow.logger.info("Supported languages: %s", languages_output.languages) + + # Following are examples for each of the three messaging types - + # update, query, then signal. + + # Call a Nexus operation backed by an update against the entity workflow. + previous_language = await nexus_client.execute_operation( + NexusGreetingService.set_language, + SetLanguageInput(language=Language.ARABIC, user_id=user_id), + ) + + # Call a Nexus operation backed by a query to confirm the language change. + current_language = await nexus_client.execute_operation( + NexusGreetingService.get_language, + GetLanguageInput(user_id=user_id), + ) + if current_language != Language.ARABIC: + raise ApplicationError(f"Expected language ARABIC, got {current_language}") + + log.append( + f"Language changed: {previous_language.name} -> {Language.ARABIC.name}" + ) + workflow.logger.info( + "Language changed from %s to %s", previous_language, Language.ARABIC + ) + + # Call a Nexus operation backed by a signal against the entity workflow. + await nexus_client.execute_operation( + NexusGreetingService.approve, + ApproveInput(name="caller", user_id=user_id), + ) + log.append("Workflow approved") + workflow.logger.info("Workflow approved") + + return log diff --git a/nexus_messaging/callerpattern/handler/__init__.py b/nexus_messaging/callerpattern/handler/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nexus_messaging/callerpattern/handler/activities.py b/nexus_messaging/callerpattern/handler/activities.py new file mode 100644 index 00000000..4031b34f --- /dev/null +++ b/nexus_messaging/callerpattern/handler/activities.py @@ -0,0 +1,22 @@ +import asyncio +from typing import Optional + +from temporalio import activity + +from nexus_messaging.callerpattern.service import Language + + +@activity.defn +async def call_greeting_service(language: Language) -> Optional[str]: + """Simulates a call to a remote greeting service. Returns None if unsupported.""" + greetings = { + Language.ARABIC: "\u0645\u0631\u062d\u0628\u0627 \u0628\u0627\u0644\u0639\u0627\u0644\u0645", + Language.CHINESE: "\u4f60\u597d\uff0c\u4e16\u754c", + Language.ENGLISH: "Hello, world", + Language.FRENCH: "Bonjour, monde", + Language.HINDI: "\u0928\u092e\u0938\u094d\u0924\u0947 \u0926\u0941\u0928\u093f\u092f\u093e", + Language.PORTUGUESE: "Ol\u00e1 mundo", + Language.SPANISH: "Hola mundo", + } + await asyncio.sleep(0.2) + return greetings.get(language) diff --git a/nexus_messaging/callerpattern/handler/service_handler.py b/nexus_messaging/callerpattern/handler/service_handler.py new file mode 100644 index 00000000..cbc57ead --- /dev/null +++ b/nexus_messaging/callerpattern/handler/service_handler.py @@ -0,0 +1,80 @@ +""" +Nexus operation handler implementation for the entity pattern. Each operation receives a +user_id, which is mapped to a workflow ID. The operations are synchronous because queries +and updates against a running workflow complete quickly. +""" + +from __future__ import annotations + +import nexusrpc +from temporalio import nexus +from temporalio.client import WorkflowHandle + +from nexus_messaging.callerpattern.handler.workflows import GreetingWorkflow +from nexus_messaging.callerpattern.service import ( + ApproveInput, + ApproveOutput, + GetLanguageInput, + GetLanguagesInput, + GetLanguagesOutput, + Language, + NexusGreetingService, + SetLanguageInput, +) + +WORKFLOW_ID_PREFIX = "GreetingWorkflow_for_" + + +def get_workflow_id(user_id: str) -> str: + """Map a user ID to a workflow ID. + + This example assumes you might have multiple workflows, one for each user. + If you had a single workflow for all users, you could remove this function, + remove the user_id from each input, and just use a single workflow ID. + """ + return f"{WORKFLOW_ID_PREFIX}{user_id}" + + +@nexusrpc.handler.service_handler(service=NexusGreetingService) +class NexusGreetingServiceHandler: + def _get_workflow_handle( + self, user_id: str + ) -> WorkflowHandle[GreetingWorkflow, str]: + return nexus.client().get_workflow_handle_for( + GreetingWorkflow.run, get_workflow_id(user_id) + ) + + @nexusrpc.handler.sync_operation + async def get_languages( + self, ctx: nexusrpc.handler.StartOperationContext, input: GetLanguagesInput + ) -> GetLanguagesOutput: + return await self._get_workflow_handle(input.user_id).query( + GreetingWorkflow.get_languages, input + ) + + @nexusrpc.handler.sync_operation + async def get_language( + self, ctx: nexusrpc.handler.StartOperationContext, input: GetLanguageInput + ) -> Language: + return await self._get_workflow_handle(input.user_id).query( + GreetingWorkflow.get_language + ) + + # Routes to set_language_using_activity (not set_language) so that new languages not + # already in the greetings map can be fetched via an activity. + @nexusrpc.handler.sync_operation + async def set_language( + self, ctx: nexusrpc.handler.StartOperationContext, input: SetLanguageInput + ) -> Language: + return await self._get_workflow_handle(input.user_id).execute_update( + GreetingWorkflow.set_language_using_activity, input + ) + + @nexusrpc.handler.sync_operation + async def approve( + self, ctx: nexusrpc.handler.StartOperationContext, input: ApproveInput + ) -> ApproveOutput: + await self._get_workflow_handle(input.user_id).signal( + GreetingWorkflow.approve, input + ) + return ApproveOutput() diff --git a/nexus_messaging/callerpattern/handler/worker.py b/nexus_messaging/callerpattern/handler/worker.py new file mode 100644 index 00000000..fa8e2c0f --- /dev/null +++ b/nexus_messaging/callerpattern/handler/worker.py @@ -0,0 +1,62 @@ +import asyncio +import logging +from typing import Optional + +from temporalio.client import Client +from temporalio.common import WorkflowIDConflictPolicy +from temporalio.envconfig import ClientConfig +from temporalio.worker import Worker + +from nexus_messaging.callerpattern.handler.activities import call_greeting_service +from nexus_messaging.callerpattern.handler.service_handler import ( + NexusGreetingServiceHandler, + get_workflow_id, +) +from nexus_messaging.callerpattern.handler.workflows import GreetingWorkflow + +interrupt_event = asyncio.Event() + +NAMESPACE = "nexus-messaging-handler-namespace" +TASK_QUEUE = "nexus-messaging-handler-task-queue" +USER_ID = "user-1" + + +async def main(client: Optional[Client] = None): + logging.basicConfig(level=logging.INFO) + + if client is None: + config = ClientConfig.load_client_connect_config() + config.setdefault("target_host", "localhost:7233") + config.setdefault("namespace", NAMESPACE) + client = await Client.connect(**config) + + # Start the long-running entity workflow that backs the Nexus service, + # if not already running. + workflow_id = get_workflow_id(USER_ID) + await client.start_workflow( + GreetingWorkflow.run, + id=workflow_id, + task_queue=TASK_QUEUE, + id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING, + ) + logging.info("Started greeting workflow: %s", workflow_id) + + async with Worker( + client, + task_queue=TASK_QUEUE, + workflows=[GreetingWorkflow], + activities=[call_greeting_service], + nexus_service_handlers=[NexusGreetingServiceHandler()], + ): + logging.info("Handler worker started, ctrl+c to exit") + await interrupt_event.wait() + logging.info("Shutting down") + + +if __name__ == "__main__": + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(main()) + except KeyboardInterrupt: + interrupt_event.set() + loop.run_until_complete(loop.shutdown_asyncgens()) diff --git a/nexus_messaging/callerpattern/handler/workflows.py b/nexus_messaging/callerpattern/handler/workflows.py new file mode 100644 index 00000000..c3b9cbc3 --- /dev/null +++ b/nexus_messaging/callerpattern/handler/workflows.py @@ -0,0 +1,88 @@ +""" +A long-running "entity" workflow that backs the NexusGreetingService Nexus operations. +The workflow exposes queries, an update, and a signal. These are private implementation +details of the Nexus service: the caller only interacts via Nexus operations. +""" + +import asyncio +from datetime import timedelta + +from temporalio import workflow +from temporalio.exceptions import ApplicationError + +with workflow.unsafe.imports_passed_through(): + from nexus_messaging.callerpattern.handler.activities import call_greeting_service + from nexus_messaging.callerpattern.service import ( + ApproveInput, + GetLanguagesInput, + GetLanguagesOutput, + Language, + SetLanguageInput, + ) + + +@workflow.defn +class GreetingWorkflow: + def __init__(self) -> None: + self.approved_for_release = False + self.greetings: dict[Language, str] = { + Language.CHINESE: "\u4f60\u597d\uff0c\u4e16\u754c", + Language.ENGLISH: "Hello, world", + } + self.language = Language.ENGLISH + self.lock = asyncio.Lock() + + @workflow.run + async def run(self) -> str: + # Wait until approved and all in-flight update handlers have finished. + await workflow.wait_condition( + lambda: self.approved_for_release and workflow.all_handlers_finished() + ) + return self.greetings[self.language] + + @workflow.query + def get_languages(self, input: GetLanguagesInput) -> GetLanguagesOutput: + if input.include_unsupported: + languages = sorted(Language) + else: + languages = sorted(self.greetings) + return GetLanguagesOutput(languages=languages) + + @workflow.query + def get_language(self) -> Language: + return self.language + + @workflow.signal + def approve(self, input: ApproveInput) -> None: + workflow.logger.info("Approval signal received for user %s", input.user_id) + self.approved_for_release = True + + @workflow.update + def set_language(self, input: SetLanguageInput) -> Language: + workflow.logger.info("setLanguage update received for user %s", input.user_id) + previous_language, self.language = self.language, input.language + return previous_language + + @set_language.validator + def validate_set_language(self, input: SetLanguageInput) -> None: + if input.language not in self.greetings: + raise ValueError(f"{input.language.name} is not supported") + + # Changes the active language, calling an activity to fetch a greeting for new + # languages not already in the greetings map. + @workflow.update + async def set_language_using_activity(self, input: SetLanguageInput) -> Language: + if input.language not in self.greetings: + async with self.lock: + greeting = await workflow.execute_activity( + call_greeting_service, + input.language, + start_to_close_timeout=timedelta(seconds=10), + ) + if greeting is None: + raise ApplicationError( + f"Greeting service does not support {input.language.name}" + ) + self.greetings[input.language] = greeting + previous_language, self.language = self.language, input.language + return previous_language diff --git a/nexus_messaging/callerpattern/service.py b/nexus_messaging/callerpattern/service.py new file mode 100644 index 00000000..23a550fb --- /dev/null +++ b/nexus_messaging/callerpattern/service.py @@ -0,0 +1,67 @@ +""" +Nexus service definition for the caller (entity) pattern. Shared between the handler and +caller. The caller uses this to create a type-safe Nexus client; the handler implements +the operations. + +Every operation includes a user_id so the handler knows which entity workflow to target. +""" + +from dataclasses import dataclass +from enum import IntEnum + +import nexusrpc + + +class Language(IntEnum): + ARABIC = 1 + CHINESE = 2 + ENGLISH = 3 + FRENCH = 4 + HINDI = 5 + PORTUGUESE = 6 + SPANISH = 7 + + +@dataclass +class GetLanguagesInput: + include_unsupported: bool + user_id: str + + +@dataclass +class GetLanguagesOutput: + languages: list[Language] + + +@dataclass +class GetLanguageInput: + user_id: str + + +@dataclass +class SetLanguageInput: + language: Language + user_id: str + + +@dataclass +class ApproveInput: + name: str + user_id: str + + +@dataclass +class ApproveOutput: + pass + + +@nexusrpc.service +class NexusGreetingService: + # Returns the languages supported by the greeting workflow. + get_languages: nexusrpc.Operation[GetLanguagesInput, GetLanguagesOutput] + # Returns the currently active language. + get_language: nexusrpc.Operation[GetLanguageInput, Language] + # Changes the active language, returning the previous one. + set_language: nexusrpc.Operation[SetLanguageInput, Language] + # Approves the workflow, allowing it to complete. + approve: nexusrpc.Operation[ApproveInput, ApproveOutput] diff --git a/nexus_messaging/endpoint_description.md b/nexus_messaging/endpoint_description.md new file mode 100644 index 00000000..4184134b --- /dev/null +++ b/nexus_messaging/endpoint_description.md @@ -0,0 +1,14 @@ +## Services + +### [NexusGreetingService](https://github.com/temporalio/samples-python/blob/main/nexus_messaging/callerpattern/service.py) (callerpattern) +- operation: `get_languages` +- operation: `get_language` +- operation: `set_language` +- operation: `approve` + +### [NexusRemoteGreetingService](https://github.com/temporalio/samples-python/blob/main/nexus_messaging/ondemandpattern/service.py) (ondemandpattern) +- operation: `run_from_remote` +- operation: `get_languages` +- operation: `get_language` +- operation: `set_language` +- operation: `approve` diff --git a/nexus_messaging/ondemandpattern/README.md b/nexus_messaging/ondemandpattern/README.md new file mode 100644 index 00000000..260da9bd --- /dev/null +++ b/nexus_messaging/ondemandpattern/README.md @@ -0,0 +1,66 @@ +## On-demand pattern + +No workflow is pre-started. The caller creates and controls workflow instances through Nexus +operations. `NexusRemoteGreetingService` adds a `run_from_remote` operation that starts a new +`GreetingWorkflow`, and every other operation includes a `workflow_id` so the handler knows which +instance to target. + +The caller workflow: +1. Starts two remote `GreetingWorkflow` instances via `run_from_remote` (backed by `workflow_run_operation`) +2. Queries each for supported languages +3. Changes the language on each (Arabic and Hindi) +4. Confirms the changes via queries +5. Approves both workflows +6. Waits for each to complete and returns their results + +### Sample directory structure + +- [service.py](./service.py) - shared Nexus service definition +- [caller](./caller) - a caller workflow that creates remote workflows and executes Nexus operations, together with a starter +- [handler](./handler) - Nexus operation handlers, together with a workflow started on demand by the Nexus operations, and a worker that polls for workflow, activity, and Nexus tasks + +### Running + +Start a Temporal server: + +```bash +temporal server start-dev +``` + +Create the namespaces and Nexus endpoint: + +```bash +temporal operator namespace create --namespace nexus-messaging-handler-namespace +temporal operator namespace create --namespace nexus-messaging-caller-namespace + +temporal operator nexus endpoint create \ + --name nexus-messaging-nexus-endpoint \ + --target-namespace nexus-messaging-handler-namespace \ + --target-task-queue nexus-messaging-handler-task-queue +``` + +In one terminal, start the handler worker: + +```bash +uv run python -m nexus_messaging.ondemandpattern.handler.worker +``` + +In another terminal, run the caller workflow: + +```bash +uv run python -m nexus_messaging.ondemandpattern.caller.app +``` + +Expected output: + +``` +started remote greeting workflow: UserId One +started remote greeting workflow: UserId Two +Supported languages for UserId One: [, ] +Supported languages for UserId Two: [, ] +UserId One changed language: ENGLISH -> ARABIC +UserId Two changed language: ENGLISH -> HINDI +Workflows approved +Workflow one result: مرحبا بالعالم +Workflow two result: नमस्ते दुनिया +``` diff --git a/nexus_messaging/ondemandpattern/__init__.py b/nexus_messaging/ondemandpattern/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nexus_messaging/ondemandpattern/caller/__init__.py b/nexus_messaging/ondemandpattern/caller/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nexus_messaging/ondemandpattern/caller/app.py b/nexus_messaging/ondemandpattern/caller/app.py new file mode 100644 index 00000000..a1837bab --- /dev/null +++ b/nexus_messaging/ondemandpattern/caller/app.py @@ -0,0 +1,41 @@ +import asyncio +import uuid +from typing import Optional + +from temporalio.client import Client +from temporalio.envconfig import ClientConfig +from temporalio.worker import Worker + +from nexus_messaging.ondemandpattern.caller.workflows import CallerRemoteWorkflow + +NAMESPACE = "nexus-messaging-caller-namespace" +TASK_QUEUE = "nexus-messaging-caller-remote-task-queue" + + +async def execute_caller_workflow(client: Optional[Client] = None) -> None: + if client is None: + config = ClientConfig.load_client_connect_config() + config.setdefault("target_host", "localhost:7233") + config.setdefault("namespace", NAMESPACE) + client = await Client.connect(**config) + + async with Worker( + client, + task_queue=TASK_QUEUE, + workflows=[CallerRemoteWorkflow], + ): + log = await client.execute_workflow( + CallerRemoteWorkflow.run, + id=f"nexus-messaging-remote-caller-{uuid.uuid4()}", + task_queue=TASK_QUEUE, + ) + for line in log: + print(line) + + +if __name__ == "__main__": + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(execute_caller_workflow()) + except KeyboardInterrupt: + loop.run_until_complete(loop.shutdown_asyncgens()) diff --git a/nexus_messaging/ondemandpattern/caller/workflows.py b/nexus_messaging/ondemandpattern/caller/workflows.py new file mode 100644 index 00000000..fafb9925 --- /dev/null +++ b/nexus_messaging/ondemandpattern/caller/workflows.py @@ -0,0 +1,149 @@ +""" +A caller workflow that creates and controls workflow instances through Nexus operations. +Unlike the entity (callerpattern), no workflow is pre-started; the caller creates them +on demand via the run_from_remote operation. +""" + +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from nexus_messaging.ondemandpattern.service import ( + ApproveInput, + GetLanguageInput, + GetLanguagesInput, + Language, + NexusRemoteGreetingService, + RunFromRemoteInput, + SetLanguageInput, + ) + +NEXUS_ENDPOINT = "nexus-messaging-nexus-endpoint" + +REMOTE_WORKFLOW_ONE = "UserId One" +REMOTE_WORKFLOW_TWO = "UserId Two" + + +@workflow.defn +class CallerRemoteWorkflow: + def __init__(self) -> None: + self.nexus_client = workflow.create_nexus_client( + service=NexusRemoteGreetingService, + endpoint=NEXUS_ENDPOINT, + ) + + @workflow.run + async def run(self) -> list[str]: + log: list[str] = [] + + # Each call is performed twice in this example. This assumes there are two + # users we want to process. The first calls start two workflows, one for each + # user. Subsequent calls perform different actions between the two users. + + # This is an async Nexus operation -- starts a workflow on the handler and + # returns a handle. Unlike the sync operations below, this does not block + # until the workflow completes. It is backed by workflow_run_operation on the + # handler side. + handle_one = await self.nexus_client.start_operation( + NexusRemoteGreetingService.run_from_remote, + RunFromRemoteInput(user_id=REMOTE_WORKFLOW_ONE), + ) + log.append(f"started remote greeting workflow: {REMOTE_WORKFLOW_ONE}") + workflow.logger.info("started remote greeting workflow %s", REMOTE_WORKFLOW_ONE) + + handle_two = await self.nexus_client.start_operation( + NexusRemoteGreetingService.run_from_remote, + RunFromRemoteInput(user_id=REMOTE_WORKFLOW_TWO), + ) + log.append(f"started remote greeting workflow: {REMOTE_WORKFLOW_TWO}") + workflow.logger.info("started remote greeting workflow %s", REMOTE_WORKFLOW_TWO) + + # Query the remote workflows for supported languages. + languages_output = await self.nexus_client.execute_operation( + NexusRemoteGreetingService.get_languages, + GetLanguagesInput(include_unsupported=False, user_id=REMOTE_WORKFLOW_ONE), + ) + log.append( + f"Supported languages for {REMOTE_WORKFLOW_ONE}: " + f"{languages_output.languages}" + ) + workflow.logger.info( + "supported languages are %s for workflow %s", + languages_output.languages, + REMOTE_WORKFLOW_ONE, + ) + + languages_output = await self.nexus_client.execute_operation( + NexusRemoteGreetingService.get_languages, + GetLanguagesInput(include_unsupported=False, user_id=REMOTE_WORKFLOW_TWO), + ) + log.append( + f"Supported languages for {REMOTE_WORKFLOW_TWO}: " + f"{languages_output.languages}" + ) + workflow.logger.info( + "supported languages are %s for workflow %s", + languages_output.languages, + REMOTE_WORKFLOW_TWO, + ) + + # Update the language on each remote workflow. + previous_language_one = await self.nexus_client.execute_operation( + NexusRemoteGreetingService.set_language, + SetLanguageInput(language=Language.ARABIC, user_id=REMOTE_WORKFLOW_ONE), + ) + + previous_language_two = await self.nexus_client.execute_operation( + NexusRemoteGreetingService.set_language, + SetLanguageInput(language=Language.HINDI, user_id=REMOTE_WORKFLOW_TWO), + ) + + # Confirm the changes by querying. + current_language = await self.nexus_client.execute_operation( + NexusRemoteGreetingService.get_language, + GetLanguageInput(user_id=REMOTE_WORKFLOW_ONE), + ) + log.append( + f"{REMOTE_WORKFLOW_ONE} changed language: " + f"{previous_language_one.name} -> {current_language.name}" + ) + workflow.logger.info( + "Language changed from %s to %s for workflow %s", + previous_language_one, + current_language, + REMOTE_WORKFLOW_ONE, + ) + + current_language = await self.nexus_client.execute_operation( + NexusRemoteGreetingService.get_language, + GetLanguageInput(user_id=REMOTE_WORKFLOW_TWO), + ) + log.append( + f"{REMOTE_WORKFLOW_TWO} changed language: " + f"{previous_language_two.name} -> {current_language.name}" + ) + workflow.logger.info( + "Language changed from %s to %s for workflow %s", + previous_language_two, + current_language, + REMOTE_WORKFLOW_TWO, + ) + + # Approve both workflows so they can complete. + await self.nexus_client.execute_operation( + NexusRemoteGreetingService.approve, + ApproveInput(name="remote-caller", user_id=REMOTE_WORKFLOW_ONE), + ) + await self.nexus_client.execute_operation( + NexusRemoteGreetingService.approve, + ApproveInput(name="remote-caller", user_id=REMOTE_WORKFLOW_TWO), + ) + log.append("Workflows approved") + + # Wait for the remote workflows to finish and return their results. + result = await handle_one + log.append(f"Workflow one result: {result}") + + result = await handle_two + log.append(f"Workflow two result: {result}") + + return log diff --git a/nexus_messaging/ondemandpattern/handler/__init__.py b/nexus_messaging/ondemandpattern/handler/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nexus_messaging/ondemandpattern/handler/activities.py b/nexus_messaging/ondemandpattern/handler/activities.py new file mode 100644 index 00000000..ba028489 --- /dev/null +++ b/nexus_messaging/ondemandpattern/handler/activities.py @@ -0,0 +1,22 @@ +import asyncio +from typing import Optional + +from temporalio import activity + +from nexus_messaging.ondemandpattern.service import Language + + +@activity.defn +async def call_greeting_service(language: Language) -> Optional[str]: + """Simulates a call to a remote greeting service. Returns None if unsupported.""" + greetings = { + Language.ARABIC: "\u0645\u0631\u062d\u0628\u0627 \u0628\u0627\u0644\u0639\u0627\u0644\u0645", + Language.CHINESE: "\u4f60\u597d\uff0c\u4e16\u754c", + Language.ENGLISH: "Hello, world", + Language.FRENCH: "Bonjour, monde", + Language.HINDI: "\u0928\u092e\u0938\u094d\u0924\u0947 \u0926\u0941\u0928\u093f\u092f\u093e", + Language.PORTUGUESE: "Ol\u00e1 mundo", + Language.SPANISH: "Hola mundo", + } + await asyncio.sleep(0.2) + return greetings.get(language) diff --git a/nexus_messaging/ondemandpattern/handler/service_handler.py b/nexus_messaging/ondemandpattern/handler/service_handler.py new file mode 100644 index 00000000..2aeb2092 --- /dev/null +++ b/nexus_messaging/ondemandpattern/handler/service_handler.py @@ -0,0 +1,92 @@ +""" +Nexus operation handler for the on-demand pattern. Each operation receives the target +userId in its input, and run_from_remote starts a brand-new GreetingWorkflow. +""" + +from __future__ import annotations + +import nexusrpc +from temporalio import nexus +from temporalio.client import WorkflowHandle + +from nexus_messaging.ondemandpattern.handler.workflows import ( + ApproveInput as WorkflowApproveInput, + GetLanguagesInput as WorkflowGetLanguagesInput, + GreetingWorkflow, + SetLanguageInput as WorkflowSetLanguageInput, +) +from nexus_messaging.ondemandpattern.service import ( + ApproveInput, + ApproveOutput, + GetLanguageInput, + GetLanguagesInput, + GetLanguagesOutput, + Language, + NexusRemoteGreetingService, + RunFromRemoteInput, + SetLanguageInput, +) + +WORKFLOW_ID_PREFIX = "GreetingWorkflow_for_" + + +@nexusrpc.handler.service_handler(service=NexusRemoteGreetingService) +class NexusRemoteGreetingServiceHandler: + def _get_workflow_id(self, user_id: str) -> str: + return WORKFLOW_ID_PREFIX + user_id + + def _get_workflow_handle( + self, user_id: str + ) -> WorkflowHandle[GreetingWorkflow, str]: + return nexus.client().get_workflow_handle_for( + GreetingWorkflow.run, self._get_workflow_id(user_id) + ) + + # Starts a new GreetingWorkflow with the caller-specified user ID. + # This is an async Nexus operation backed by workflow_run_operation. + @nexus.workflow_run_operation + async def run_from_remote( + self, ctx: nexus.WorkflowRunOperationContext, input: RunFromRemoteInput + ) -> nexus.WorkflowHandle[str]: + return await ctx.start_workflow( + GreetingWorkflow.run, + id=self._get_workflow_id(input.user_id), + ) + + @nexusrpc.handler.sync_operation + async def get_languages( + self, ctx: nexusrpc.handler.StartOperationContext, input: GetLanguagesInput + ) -> GetLanguagesOutput: + return await self._get_workflow_handle(input.user_id).query( + GreetingWorkflow.get_languages, + WorkflowGetLanguagesInput(include_unsupported=input.include_unsupported), + ) + + @nexusrpc.handler.sync_operation + async def get_language( + self, ctx: nexusrpc.handler.StartOperationContext, input: GetLanguageInput + ) -> Language: + return await self._get_workflow_handle(input.user_id).query( + GreetingWorkflow.get_language, + ) + + # Routes to set_language_using_activity so that new languages not already in the + # greetings map can be fetched via an activity. + @nexusrpc.handler.sync_operation + async def set_language( + self, ctx: nexusrpc.handler.StartOperationContext, input: SetLanguageInput + ) -> Language: + return await self._get_workflow_handle(input.user_id).execute_update( + GreetingWorkflow.set_language_using_activity, + WorkflowSetLanguageInput(language=input.language), + ) + + @nexusrpc.handler.sync_operation + async def approve( + self, ctx: nexusrpc.handler.StartOperationContext, input: ApproveInput + ) -> ApproveOutput: + await self._get_workflow_handle(input.user_id).signal( + GreetingWorkflow.approve, + WorkflowApproveInput(name=input.name), + ) + return ApproveOutput() diff --git a/nexus_sync_operations/handler/worker.py b/nexus_messaging/ondemandpattern/handler/worker.py similarity index 58% rename from nexus_sync_operations/handler/worker.py rename to nexus_messaging/ondemandpattern/handler/worker.py index 97c8eb04..5eec9cfc 100644 --- a/nexus_sync_operations/handler/worker.py +++ b/nexus_messaging/ondemandpattern/handler/worker.py @@ -6,14 +6,16 @@ from temporalio.envconfig import ClientConfig from temporalio.worker import Worker -from message_passing.introduction.activities import call_greeting_service -from message_passing.introduction.workflows import GreetingWorkflow -from nexus_sync_operations.handler.service_handler import GreetingServiceHandler +from nexus_messaging.ondemandpattern.handler.activities import call_greeting_service +from nexus_messaging.ondemandpattern.handler.service_handler import ( + NexusRemoteGreetingServiceHandler, +) +from nexus_messaging.ondemandpattern.handler.workflows import GreetingWorkflow interrupt_event = asyncio.Event() -NAMESPACE = "nexus-sync-operations-handler-namespace" -TASK_QUEUE = "nexus-sync-operations-handler-task-queue" +NAMESPACE = "nexus-messaging-handler-namespace" +TASK_QUEUE = "nexus-messaging-handler-task-queue" async def main(client: Optional[Client] = None): @@ -25,20 +27,14 @@ async def main(client: Optional[Client] = None): config.setdefault("namespace", NAMESPACE) client = await Client.connect(**config) - # Create the nexus service handler instance, starting the long-running entity workflow that - # backs the Nexus service - greeting_service_handler = await GreetingServiceHandler.create( - "nexus-sync-operations-greeting-workflow", client, TASK_QUEUE - ) - async with Worker( client, task_queue=TASK_QUEUE, workflows=[GreetingWorkflow], activities=[call_greeting_service], - nexus_service_handlers=[greeting_service_handler], + nexus_service_handlers=[NexusRemoteGreetingServiceHandler()], ): - logging.info("Worker started, ctrl+c to exit") + logging.info("Handler worker started, ctrl+c to exit") await interrupt_event.wait() logging.info("Shutting down") diff --git a/nexus_messaging/ondemandpattern/handler/workflows.py b/nexus_messaging/ondemandpattern/handler/workflows.py new file mode 100644 index 00000000..ffaa6361 --- /dev/null +++ b/nexus_messaging/ondemandpattern/handler/workflows.py @@ -0,0 +1,102 @@ +""" +A long-running "entity" workflow that backs the NexusRemoteGreetingService Nexus +operations. The workflow exposes queries, an update, and a signal. These are private +implementation details of the Nexus service: the caller only interacts via Nexus +operations. + +Input types are defined locally (without workflow_id) because the handler strips the +workflow_id before dispatching to the workflow. +""" + +import asyncio +from dataclasses import dataclass +from datetime import timedelta + +from temporalio import workflow +from temporalio.exceptions import ApplicationError + +with workflow.unsafe.imports_passed_through(): + from nexus_messaging.ondemandpattern.handler.activities import call_greeting_service + from nexus_messaging.ondemandpattern.service import GetLanguagesOutput, Language + + +@dataclass +class GetLanguagesInput: + include_unsupported: bool + + +@dataclass +class SetLanguageInput: + language: Language + + +@dataclass +class ApproveInput: + name: str + + +@workflow.defn +class GreetingWorkflow: + def __init__(self) -> None: + self.approved_for_release = False + self.greetings: dict[Language, str] = { + Language.CHINESE: "\u4f60\u597d\uff0c\u4e16\u754c", + Language.ENGLISH: "Hello, world", + } + self.language = Language.ENGLISH + self.lock = asyncio.Lock() + + @workflow.run + async def run(self) -> str: + # Wait until approved and all in-flight update handlers have finished. + await workflow.wait_condition( + lambda: self.approved_for_release and workflow.all_handlers_finished() + ) + return self.greetings[self.language] + + @workflow.query + def get_languages(self, input: GetLanguagesInput) -> GetLanguagesOutput: + if input.include_unsupported: + languages = sorted(Language) + else: + languages = sorted(self.greetings) + return GetLanguagesOutput(languages=languages) + + @workflow.query + def get_language(self) -> Language: + return self.language + + @workflow.signal + def approve(self, input: ApproveInput) -> None: + workflow.logger.info("Approval signal received") + self.approved_for_release = True + + @workflow.update + def set_language(self, input: SetLanguageInput) -> Language: + workflow.logger.info("setLanguage update received") + previous_language, self.language = self.language, input.language + return previous_language + + @set_language.validator + def validate_set_language(self, input: SetLanguageInput) -> None: + if input.language not in self.greetings: + raise ValueError(f"{input.language.name} is not supported") + + # Changes the active language, calling an activity to fetch a greeting for new + # languages not already in the greetings map. + @workflow.update + async def set_language_using_activity(self, input: SetLanguageInput) -> Language: + if input.language not in self.greetings: + async with self.lock: + greeting = await workflow.execute_activity( + call_greeting_service, + input.language, + start_to_close_timeout=timedelta(seconds=10), + ) + if greeting is None: + raise ApplicationError( + f"Greeting service does not support {input.language.name}" + ) + self.greetings[input.language] = greeting + previous_language, self.language = self.language, input.language + return previous_language diff --git a/nexus_messaging/ondemandpattern/service.py b/nexus_messaging/ondemandpattern/service.py new file mode 100644 index 00000000..8f347d32 --- /dev/null +++ b/nexus_messaging/ondemandpattern/service.py @@ -0,0 +1,72 @@ +""" +Nexus service definition for the on-demand pattern. Every operation includes a userId +so the caller controls which workflow instance is targeted. This also exposes a +run_from_remote operation that starts a new GreetingWorkflow. +""" + +from dataclasses import dataclass +from enum import IntEnum + +import nexusrpc + + +class Language(IntEnum): + ARABIC = 1 + CHINESE = 2 + ENGLISH = 3 + FRENCH = 4 + HINDI = 5 + PORTUGUESE = 6 + SPANISH = 7 + + +@dataclass +class RunFromRemoteInput: + user_id: str + + +@dataclass +class GetLanguagesInput: + include_unsupported: bool + user_id: str + + +@dataclass +class GetLanguagesOutput: + languages: list[Language] + + +@dataclass +class GetLanguageInput: + user_id: str + + +@dataclass +class SetLanguageInput: + language: Language + user_id: str + + +@dataclass +class ApproveInput: + name: str + user_id: str + + +@dataclass +class ApproveOutput: + pass + + +@nexusrpc.service +class NexusRemoteGreetingService: + # Starts a new GreetingWorkflow with the given workflow ID (asynchronous). + run_from_remote: nexusrpc.Operation[RunFromRemoteInput, str] + # Returns the languages supported by the specified workflow. + get_languages: nexusrpc.Operation[GetLanguagesInput, GetLanguagesOutput] + # Returns the currently active language of the specified workflow. + get_language: nexusrpc.Operation[GetLanguageInput, Language] + # Changes the active language on the specified workflow, returning the previous one. + set_language: nexusrpc.Operation[SetLanguageInput, Language] + # Approves the specified workflow, allowing it to complete. + approve: nexusrpc.Operation[ApproveInput, ApproveOutput] diff --git a/nexus_sync_operations/README.md b/nexus_sync_operations/README.md deleted file mode 100644 index 10e266ec..00000000 --- a/nexus_sync_operations/README.md +++ /dev/null @@ -1,39 +0,0 @@ -This sample shows how to create a Nexus service that is backed by a long-running workflow and -exposes operations that execute updates and queries against that workflow. The long-running -workflow, and the updates/queries are private implementation detail of the nexus service: the caller -does not know how the operations are implemented. - -### Sample directory structure - -- [service.py](./service.py) - shared Nexus service definition -- [caller](./caller) - a caller workflow that executes Nexus operations, together with a worker and starter code -- [handler](./handler) - Nexus operation handlers, together with a workflow used by one of the Nexus operations, and a worker that polls for both workflow, activity, and Nexus tasks. - - -### Instructions - -Start a Temporal server. (See the main samples repo [README](../README.md)). - -Run the following to create the caller and handler namespaces, and the Nexus endpoint: - -``` -temporal operator namespace create --namespace nexus-sync-operations-handler-namespace -temporal operator namespace create --namespace nexus-sync-operations-caller-namespace - -temporal operator nexus endpoint create \ - --name nexus-sync-operations-nexus-endpoint \ - --target-namespace nexus-sync-operations-handler-namespace \ - --target-task-queue nexus-sync-operations-handler-task-queue \ - --description-file nexus_sync_operations/endpoint_description.md -``` - -In one terminal, run the Temporal worker in the handler namespace: -``` -uv run nexus_sync_operations/handler/worker.py -``` - -In another terminal, run the Temporal worker in the caller namespace and start the caller -workflow: -``` -uv run nexus_sync_operations/caller/app.py -``` diff --git a/nexus_sync_operations/caller/workflows.py b/nexus_sync_operations/caller/workflows.py deleted file mode 100644 index a358d764..00000000 --- a/nexus_sync_operations/caller/workflows.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -This is a workflow that calls nexus operations. The caller does not have information about how these -operations are implemented by the nexus service. -""" - -from temporalio import workflow - -from message_passing.introduction import Language -from message_passing.introduction.workflows import GetLanguagesInput, SetLanguageInput - -with workflow.unsafe.imports_passed_through(): - from nexus_sync_operations.service import GreetingService - -NEXUS_ENDPOINT = "nexus-sync-operations-nexus-endpoint" - - -@workflow.defn -class CallerWorkflow: - @workflow.run - async def run(self) -> list[str]: - log = [] - nexus_client = workflow.create_nexus_client( - service=GreetingService, - endpoint=NEXUS_ENDPOINT, - ) - - # Get supported languages - supported_languages = await nexus_client.execute_operation( - GreetingService.get_languages, GetLanguagesInput(include_unsupported=False) - ) - log.append(f"supported languages: {supported_languages}") - - # Set language - previous_language = await nexus_client.execute_operation( - GreetingService.set_language, - SetLanguageInput(language=Language.ARABIC), - ) - assert ( - await nexus_client.execute_operation(GreetingService.get_language, None) - == Language.ARABIC - ) - log.append( - f"language changed: {previous_language.name} -> {Language.ARABIC.name}" - ) - - return log diff --git a/nexus_sync_operations/endpoint_description.md b/nexus_sync_operations/endpoint_description.md deleted file mode 100644 index a33b60cf..00000000 --- a/nexus_sync_operations/endpoint_description.md +++ /dev/null @@ -1,4 +0,0 @@ -## Service: [GreetingService](https://github.com/temporalio/samples-python/blob/main/nexus_sync_operations/service.py) -- operation: `get_languages` -- operation: `get_language` -- operation: `set_language` diff --git a/nexus_sync_operations/handler/service_handler.py b/nexus_sync_operations/handler/service_handler.py deleted file mode 100644 index 626948f0..00000000 --- a/nexus_sync_operations/handler/service_handler.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -This file demonstrates how to implement a Nexus service that is backed by a long-running workflow -and exposes operations that perform updates and queries against that workflow. -""" - -from __future__ import annotations - -import nexusrpc -from temporalio import nexus -from temporalio.client import Client, WorkflowHandle -from temporalio.common import WorkflowIDConflictPolicy - -from message_passing.introduction import Language -from message_passing.introduction.workflows import ( - GetLanguagesInput, - GreetingWorkflow, - SetLanguageInput, -) -from nexus_sync_operations.service import GreetingService - - -@nexusrpc.handler.service_handler(service=GreetingService) -class GreetingServiceHandler: - def __init__(self, workflow_id: str): - self.workflow_id = workflow_id - - @classmethod - async def create( - cls, workflow_id: str, client: Client, task_queue: str - ) -> GreetingServiceHandler: - # Start the long-running "entity" workflow, if it is not already running. - await client.start_workflow( - GreetingWorkflow.run, - id=workflow_id, - task_queue=task_queue, - id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING, - ) - return cls(workflow_id) - - @property - def greeting_workflow_handle(self) -> WorkflowHandle[GreetingWorkflow, str]: - # In nexus operation handler code, nexus.client() is always available, returning a client - # connected to the handler namespace (it's the same client instance that your nexus worker - # is using to poll the server for nexus tasks). This client can be used to interact with the - # handler namespace, for example to send signals, queries, or updates. Remember however, - # that a sync_operation handler must return quickly (no more than a few seconds). To do - # long-running work in a nexus operation handler, use - # temporalio.nexus.workflow_run_operation (see the hello_nexus sample). - return nexus.client().get_workflow_handle_for( - GreetingWorkflow.run, self.workflow_id - ) - - # 👉 This is a handler for a nexus operation whose internal implementation involves executing a - # query against a long-running workflow that is private to the nexus service. - @nexusrpc.handler.sync_operation - async def get_languages( - self, ctx: nexusrpc.handler.StartOperationContext, input: GetLanguagesInput - ) -> list[Language]: - return await self.greeting_workflow_handle.query( - GreetingWorkflow.get_languages, input - ) - - # 👉 This is a handler for a nexus operation whose internal implementation involves executing a - # query against a long-running workflow that is private to the nexus service. - @nexusrpc.handler.sync_operation - async def get_language( - self, ctx: nexusrpc.handler.StartOperationContext, input: None - ) -> Language: - return await self.greeting_workflow_handle.query(GreetingWorkflow.get_language) - - # 👉 This is a handler for a nexus operation whose internal implementation involves executing an - # update against a long-running workflow that is private to the nexus service. Although updates - # can run for an arbitrarily long time, when exposing an update via a nexus sync operation the - # update should execute quickly (sync operations must complete in under 10s). - @nexusrpc.handler.sync_operation - async def set_language( - self, - ctx: nexusrpc.handler.StartOperationContext, - input: SetLanguageInput, - ) -> Language: - return await self.greeting_workflow_handle.execute_update( - GreetingWorkflow.set_language_using_activity, input - ) diff --git a/nexus_sync_operations/service.py b/nexus_sync_operations/service.py deleted file mode 100644 index 3436d5f3..00000000 --- a/nexus_sync_operations/service.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -This module defines a Nexus service that exposes three operations. - -It is used by the nexus service handler to validate that the operation handlers implement the -correct input and output types, and by the caller workflow to create a type-safe client. It does not -contain the implementation of the operations; see nexus_sync_operations.handler.service_handler for -that. -""" - -import nexusrpc - -from message_passing.introduction import Language -from message_passing.introduction.workflows import GetLanguagesInput, SetLanguageInput - - -@nexusrpc.service -class GreetingService: - get_languages: nexusrpc.Operation[GetLanguagesInput, list[Language]] - get_language: nexusrpc.Operation[None, Language] - set_language: nexusrpc.Operation[SetLanguageInput, Language] diff --git a/tests/nexus_messaging/callerpattern_test.py b/tests/nexus_messaging/callerpattern_test.py new file mode 100644 index 00000000..117f9370 --- /dev/null +++ b/tests/nexus_messaging/callerpattern_test.py @@ -0,0 +1,122 @@ +import asyncio +import uuid +from typing import Type + +import pytest +from temporalio import workflow +from temporalio.client import Client +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +import nexus_messaging.callerpattern.handler.worker +from nexus_messaging.callerpattern.caller.workflows import CallerWorkflow +from nexus_messaging.callerpattern.service import ( + GetLanguageInput, + GetLanguagesInput, + Language, + SetLanguageInput, +) +from tests.helpers.nexus import create_nexus_endpoint, delete_nexus_endpoint + +with workflow.unsafe.imports_passed_through(): + from nexus_messaging.callerpattern.service import NexusGreetingService + + +NEXUS_ENDPOINT = "nexus-messaging-nexus-endpoint" + + +@workflow.defn +class TestCallerWorkflow: + """Test workflow that calls Nexus operations and makes assertions.""" + + @workflow.run + async def run(self, user_id: str) -> None: + nexus_client = workflow.create_nexus_client( + service=NexusGreetingService, + endpoint=NEXUS_ENDPOINT, + ) + + supported_languages = await nexus_client.execute_operation( + NexusGreetingService.get_languages, + GetLanguagesInput(include_unsupported=False, user_id=user_id), + ) + assert supported_languages.languages == [Language.CHINESE, Language.ENGLISH] + + initial_language = await nexus_client.execute_operation( + NexusGreetingService.get_language, + GetLanguageInput(user_id=user_id), + ) + assert initial_language == Language.ENGLISH + + previous_language = await nexus_client.execute_operation( + NexusGreetingService.set_language, + SetLanguageInput(language=Language.CHINESE, user_id=user_id), + ) + assert previous_language == Language.ENGLISH + + current_language = await nexus_client.execute_operation( + NexusGreetingService.get_language, + GetLanguageInput(user_id=user_id), + ) + assert current_language == Language.CHINESE + + previous_language = await nexus_client.execute_operation( + NexusGreetingService.set_language, + SetLanguageInput(language=Language.ARABIC, user_id=user_id), + ) + assert previous_language == Language.CHINESE + + current_language = await nexus_client.execute_operation( + NexusGreetingService.get_language, + GetLanguageInput(user_id=user_id), + ) + assert current_language == Language.ARABIC + + +async def test_callerpattern(client: Client, env: WorkflowEnvironment): + if env.supports_time_skipping: + pytest.skip("Nexus tests don't work under the Java test server") + + await _run_caller_workflow(client, TestCallerWorkflow) + + +async def test_callerpattern_caller_workflow(client: Client, env: WorkflowEnvironment): + """Runs the CallerWorkflow from the sample to ensure it executes without errors.""" + if env.supports_time_skipping: + pytest.skip("Nexus tests don't work under the Java test server") + + await _run_caller_workflow(client, CallerWorkflow) + + +async def _run_caller_workflow(client: Client, wf: Type): + create_response = await create_nexus_endpoint( + name=NEXUS_ENDPOINT, + task_queue=nexus_messaging.callerpattern.handler.worker.TASK_QUEUE, + client=client, + ) + try: + handler_worker_task = asyncio.create_task( + nexus_messaging.callerpattern.handler.worker.main(client) + ) + try: + async with Worker( + client, + task_queue="test-caller-task-queue", + workflows=[wf], + ): + await client.execute_workflow( + wf.run, + arg="user-1", + id=str(uuid.uuid4()), + task_queue="test-caller-task-queue", + ) + finally: + nexus_messaging.callerpattern.handler.worker.interrupt_event.set() + await handler_worker_task + nexus_messaging.callerpattern.handler.worker.interrupt_event.clear() + finally: + await delete_nexus_endpoint( + id=create_response.endpoint.id, + version=create_response.endpoint.version, + client=client, + ) diff --git a/tests/nexus_messaging/ondemandpattern_test.py b/tests/nexus_messaging/ondemandpattern_test.py new file mode 100644 index 00000000..e43e3af3 --- /dev/null +++ b/tests/nexus_messaging/ondemandpattern_test.py @@ -0,0 +1,134 @@ +import asyncio +import uuid +from typing import Type + +import pytest +from temporalio import workflow +from temporalio.client import Client +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +import nexus_messaging.ondemandpattern.handler.worker +from nexus_messaging.ondemandpattern.caller.workflows import CallerRemoteWorkflow +from nexus_messaging.ondemandpattern.service import ( + ApproveInput, + GetLanguageInput, + GetLanguagesInput, + Language, + NexusRemoteGreetingService, + RunFromRemoteInput, + SetLanguageInput, +) +from tests.helpers.nexus import create_nexus_endpoint, delete_nexus_endpoint + +with workflow.unsafe.imports_passed_through(): + from nexus_messaging.ondemandpattern.service import NexusRemoteGreetingService + + +NEXUS_ENDPOINT = "nexus-messaging-nexus-endpoint" + + +@workflow.defn +class TestCallerRemoteWorkflow: + """Test workflow that creates remote workflows and makes assertions.""" + + @workflow.run + async def run(self) -> None: + nexus_client = workflow.create_nexus_client( + service=NexusRemoteGreetingService, + endpoint=NEXUS_ENDPOINT, + ) + + workflow_id = f"test-remote-{uuid.uuid4()}" + + # Start a remote workflow. + handle = await nexus_client.start_operation( + NexusRemoteGreetingService.run_from_remote, + RunFromRemoteInput(user_id=workflow_id), + ) + + # Query for supported languages. + languages_output = await nexus_client.execute_operation( + NexusRemoteGreetingService.get_languages, + GetLanguagesInput(include_unsupported=False, user_id=workflow_id), + ) + assert languages_output.languages == [Language.CHINESE, Language.ENGLISH] + + # Check initial language. + initial_language = await nexus_client.execute_operation( + NexusRemoteGreetingService.get_language, + GetLanguageInput(user_id=workflow_id), + ) + assert initial_language == Language.ENGLISH + + # Set language. + previous_language = await nexus_client.execute_operation( + NexusRemoteGreetingService.set_language, + SetLanguageInput(language=Language.ARABIC, user_id=workflow_id), + ) + assert previous_language == Language.ENGLISH + + current_language = await nexus_client.execute_operation( + NexusRemoteGreetingService.get_language, + GetLanguageInput(user_id=workflow_id), + ) + assert current_language == Language.ARABIC + + # Approve and wait for result. + await nexus_client.execute_operation( + NexusRemoteGreetingService.approve, + ApproveInput(name="test", user_id=workflow_id), + ) + + result = await handle + assert "\u0645\u0631\u062d\u0628\u0627" in result # Arabic greeting + + +async def test_ondemandpattern(client: Client, env: WorkflowEnvironment): + if env.supports_time_skipping: + pytest.skip("Nexus tests don't work under the Java test server") + + await _run_caller_workflow(client, TestCallerRemoteWorkflow) + + +async def test_ondemandpattern_caller_workflow( + client: Client, env: WorkflowEnvironment +): + """Runs the CallerRemoteWorkflow from the sample to ensure it executes without errors.""" + if env.supports_time_skipping: + pytest.skip("Nexus tests don't work under the Java test server") + + await _run_caller_workflow(client, CallerRemoteWorkflow) + + +async def _run_caller_workflow(client: Client, wf: Type): + create_response = await create_nexus_endpoint( + name=NEXUS_ENDPOINT, + task_queue=nexus_messaging.ondemandpattern.handler.worker.TASK_QUEUE, + client=client, + ) + try: + handler_worker_task = asyncio.create_task( + nexus_messaging.ondemandpattern.handler.worker.main(client) + ) + try: + async with Worker( + client, + task_queue="test-caller-remote-task-queue", + workflows=[wf], + ): + await client.execute_workflow( + wf.run, + id=str(uuid.uuid4()), + task_queue="test-caller-remote-task-queue", + ) + finally: + nexus_messaging.ondemandpattern.handler.worker.interrupt_event.set() + await handler_worker_task + nexus_messaging.ondemandpattern.handler.worker.interrupt_event.clear() + finally: + await delete_nexus_endpoint( + id=create_response.endpoint.id, + version=create_response.endpoint.version, + client=client, + ) diff --git a/tests/nexus_sync_operations/nexus_sync_operations_test.py b/tests/nexus_sync_operations/nexus_sync_operations_test.py index d74168cb..39914267 100644 --- a/tests/nexus_sync_operations/nexus_sync_operations_test.py +++ b/tests/nexus_sync_operations/nexus_sync_operations_test.py @@ -8,15 +8,15 @@ from temporalio.testing import WorkflowEnvironment from temporalio.worker import Worker -import nexus_sync_operations.handler.service_handler -import nexus_sync_operations.handler.worker +import nexus_sync_operations_DELETE_ME.handler.service_handler +import nexus_sync_operations_DELETE_ME.handler.worker from message_passing.introduction import Language from message_passing.introduction.workflows import GetLanguagesInput, SetLanguageInput -from nexus_sync_operations.caller.workflows import CallerWorkflow +from nexus_sync_operations_DELETE_ME.caller.workflows import CallerWorkflow from tests.helpers.nexus import create_nexus_endpoint, delete_nexus_endpoint with workflow.unsafe.imports_passed_through(): - from nexus_sync_operations.service import GreetingService + from nexus_sync_operations_DELETE_ME.service import GreetingService NEXUS_ENDPOINT = "nexus-sync-operations-nexus-endpoint" @@ -88,12 +88,12 @@ async def test_nexus_sync_operations_caller_workflow( async def _run_caller_workflow(client: Client, workflow: Type): create_response = await create_nexus_endpoint( name=NEXUS_ENDPOINT, - task_queue=nexus_sync_operations.handler.worker.TASK_QUEUE, + task_queue=nexus_sync_operations_DELETE_ME.handler.worker.TASK_QUEUE, client=client, ) try: handler_worker_task = asyncio.create_task( - nexus_sync_operations.handler.worker.main(client) + nexus_sync_operations_DELETE_ME.handler.worker.main(client) ) try: async with Worker( @@ -107,9 +107,9 @@ async def _run_caller_workflow(client: Client, workflow: Type): task_queue="test-caller-task-queue", ) finally: - nexus_sync_operations.handler.worker.interrupt_event.set() + nexus_sync_operations_DELETE_ME.handler.worker.interrupt_event.set() await handler_worker_task - nexus_sync_operations.handler.worker.interrupt_event.clear() + nexus_sync_operations_DELETE_ME.handler.worker.interrupt_event.clear() finally: await delete_nexus_endpoint( id=create_response.endpoint.id, From 41d3aaed3953a42dfc9e1a643c09b8cb4b03ca42 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Mon, 13 Apr 2026 16:23:30 -0700 Subject: [PATCH 2/7] Fix to the tests --- tests/nexus_messaging/ondemandpattern_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/nexus_messaging/ondemandpattern_test.py b/tests/nexus_messaging/ondemandpattern_test.py index e43e3af3..843df3b4 100644 --- a/tests/nexus_messaging/ondemandpattern_test.py +++ b/tests/nexus_messaging/ondemandpattern_test.py @@ -39,7 +39,7 @@ async def run(self) -> None: endpoint=NEXUS_ENDPOINT, ) - workflow_id = f"test-remote-{uuid.uuid4()}" + workflow_id = f"test-remote-{workflow.uuid4()}" # Start a remote workflow. handle = await nexus_client.start_operation( From 83386cf0cd33e78c4efdd46c9d92515d44ab74a4 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 14 Apr 2026 17:12:20 -0700 Subject: [PATCH 3/7] Updates from a code review --- nexus_messaging/callerpattern/README.md | 8 +- nexus_messaging/ondemandpattern/README.md | 8 +- .../handler/service_handler.py | 16 +-- .../ondemandpattern/handler/workflows.py | 31 ++--- tests/nexus_messaging/ondemandpattern_test.py | 1 - .../nexus_sync_operations_test.py | 118 ------------------ 6 files changed, 15 insertions(+), 167 deletions(-) delete mode 100644 tests/nexus_sync_operations/nexus_sync_operations_test.py diff --git a/nexus_messaging/callerpattern/README.md b/nexus_messaging/callerpattern/README.md index 085a4903..d4e73e04 100644 --- a/nexus_messaging/callerpattern/README.md +++ b/nexus_messaging/callerpattern/README.md @@ -1,4 +1,4 @@ -## Entity pattern +## Caller pattern The handler worker starts a `GreetingWorkflow` for a user ID. `NexusGreetingServiceHandler` holds that ID and routes every Nexus operation to it. @@ -15,12 +15,6 @@ The caller workflow: 3. Confirms the change via a second query (`get_language`) 4. Approves the workflow (`approve` -- backed by a `@workflow.signal`) -### Sample directory structure - -- [service.py](./service.py) - shared Nexus service definition -- [caller](./caller) - a caller workflow that executes Nexus operations, together with a starter -- [handler](./handler) - Nexus operation handlers, together with a workflow used by the Nexus operations, and a worker that polls for workflow, activity, and Nexus tasks - ### Running Start a Temporal server: diff --git a/nexus_messaging/ondemandpattern/README.md b/nexus_messaging/ondemandpattern/README.md index 260da9bd..53c3dae6 100644 --- a/nexus_messaging/ondemandpattern/README.md +++ b/nexus_messaging/ondemandpattern/README.md @@ -2,7 +2,7 @@ No workflow is pre-started. The caller creates and controls workflow instances through Nexus operations. `NexusRemoteGreetingService` adds a `run_from_remote` operation that starts a new -`GreetingWorkflow`, and every other operation includes a `workflow_id` so the handler knows which +`GreetingWorkflow`, and every other operation includes a `user_id` so the handler knows which instance to target. The caller workflow: @@ -13,12 +13,6 @@ The caller workflow: 5. Approves both workflows 6. Waits for each to complete and returns their results -### Sample directory structure - -- [service.py](./service.py) - shared Nexus service definition -- [caller](./caller) - a caller workflow that creates remote workflows and executes Nexus operations, together with a starter -- [handler](./handler) - Nexus operation handlers, together with a workflow started on demand by the Nexus operations, and a worker that polls for workflow, activity, and Nexus tasks - ### Running Start a Temporal server: diff --git a/nexus_messaging/ondemandpattern/handler/service_handler.py b/nexus_messaging/ondemandpattern/handler/service_handler.py index 2aeb2092..1351aae7 100644 --- a/nexus_messaging/ondemandpattern/handler/service_handler.py +++ b/nexus_messaging/ondemandpattern/handler/service_handler.py @@ -9,12 +9,7 @@ from temporalio import nexus from temporalio.client import WorkflowHandle -from nexus_messaging.ondemandpattern.handler.workflows import ( - ApproveInput as WorkflowApproveInput, - GetLanguagesInput as WorkflowGetLanguagesInput, - GreetingWorkflow, - SetLanguageInput as WorkflowSetLanguageInput, -) +from nexus_messaging.ondemandpattern.handler.workflows import GreetingWorkflow from nexus_messaging.ondemandpattern.service import ( ApproveInput, ApproveOutput, @@ -58,8 +53,7 @@ async def get_languages( self, ctx: nexusrpc.handler.StartOperationContext, input: GetLanguagesInput ) -> GetLanguagesOutput: return await self._get_workflow_handle(input.user_id).query( - GreetingWorkflow.get_languages, - WorkflowGetLanguagesInput(include_unsupported=input.include_unsupported), + GreetingWorkflow.get_languages, input ) @nexusrpc.handler.sync_operation @@ -77,8 +71,7 @@ async def set_language( self, ctx: nexusrpc.handler.StartOperationContext, input: SetLanguageInput ) -> Language: return await self._get_workflow_handle(input.user_id).execute_update( - GreetingWorkflow.set_language_using_activity, - WorkflowSetLanguageInput(language=input.language), + GreetingWorkflow.set_language_using_activity, input ) @nexusrpc.handler.sync_operation @@ -86,7 +79,6 @@ async def approve( self, ctx: nexusrpc.handler.StartOperationContext, input: ApproveInput ) -> ApproveOutput: await self._get_workflow_handle(input.user_id).signal( - GreetingWorkflow.approve, - WorkflowApproveInput(name=input.name), + GreetingWorkflow.approve, input ) return ApproveOutput() diff --git a/nexus_messaging/ondemandpattern/handler/workflows.py b/nexus_messaging/ondemandpattern/handler/workflows.py index ffaa6361..21227661 100644 --- a/nexus_messaging/ondemandpattern/handler/workflows.py +++ b/nexus_messaging/ondemandpattern/handler/workflows.py @@ -3,13 +3,9 @@ operations. The workflow exposes queries, an update, and a signal. These are private implementation details of the Nexus service: the caller only interacts via Nexus operations. - -Input types are defined locally (without workflow_id) because the handler strips the -workflow_id before dispatching to the workflow. """ import asyncio -from dataclasses import dataclass from datetime import timedelta from temporalio import workflow @@ -17,22 +13,13 @@ with workflow.unsafe.imports_passed_through(): from nexus_messaging.ondemandpattern.handler.activities import call_greeting_service - from nexus_messaging.ondemandpattern.service import GetLanguagesOutput, Language - - -@dataclass -class GetLanguagesInput: - include_unsupported: bool - - -@dataclass -class SetLanguageInput: - language: Language - - -@dataclass -class ApproveInput: - name: str + from nexus_messaging.ondemandpattern.service import ( + ApproveInput, + GetLanguagesInput, + GetLanguagesOutput, + Language, + SetLanguageInput, + ) @workflow.defn @@ -68,12 +55,12 @@ def get_language(self) -> Language: @workflow.signal def approve(self, input: ApproveInput) -> None: - workflow.logger.info("Approval signal received") + workflow.logger.info("Approval signal received for user %s", input.user_id) self.approved_for_release = True @workflow.update def set_language(self, input: SetLanguageInput) -> Language: - workflow.logger.info("setLanguage update received") + workflow.logger.info("setLanguage update received for user %s", input.user_id) previous_language, self.language = self.language, input.language return previous_language diff --git a/tests/nexus_messaging/ondemandpattern_test.py b/tests/nexus_messaging/ondemandpattern_test.py index 843df3b4..087285f1 100644 --- a/tests/nexus_messaging/ondemandpattern_test.py +++ b/tests/nexus_messaging/ondemandpattern_test.py @@ -15,7 +15,6 @@ GetLanguageInput, GetLanguagesInput, Language, - NexusRemoteGreetingService, RunFromRemoteInput, SetLanguageInput, ) diff --git a/tests/nexus_sync_operations/nexus_sync_operations_test.py b/tests/nexus_sync_operations/nexus_sync_operations_test.py deleted file mode 100644 index 39914267..00000000 --- a/tests/nexus_sync_operations/nexus_sync_operations_test.py +++ /dev/null @@ -1,118 +0,0 @@ -import asyncio -import uuid -from typing import Type - -import pytest -from temporalio import workflow -from temporalio.client import Client -from temporalio.testing import WorkflowEnvironment -from temporalio.worker import Worker - -import nexus_sync_operations_DELETE_ME.handler.service_handler -import nexus_sync_operations_DELETE_ME.handler.worker -from message_passing.introduction import Language -from message_passing.introduction.workflows import GetLanguagesInput, SetLanguageInput -from nexus_sync_operations_DELETE_ME.caller.workflows import CallerWorkflow -from tests.helpers.nexus import create_nexus_endpoint, delete_nexus_endpoint - -with workflow.unsafe.imports_passed_through(): - from nexus_sync_operations_DELETE_ME.service import GreetingService - - -NEXUS_ENDPOINT = "nexus-sync-operations-nexus-endpoint" - - -@workflow.defn -class TestCallerWorkflow: - """Test workflow that calls Nexus operations and makes assertions.""" - - @workflow.run - async def run(self) -> None: - nexus_client = workflow.create_nexus_client( - service=GreetingService, - endpoint=NEXUS_ENDPOINT, - ) - - supported_languages = await nexus_client.execute_operation( - GreetingService.get_languages, GetLanguagesInput(include_unsupported=False) - ) - assert supported_languages == [Language.CHINESE, Language.ENGLISH] - - initial_language = await nexus_client.execute_operation( - GreetingService.get_language, None - ) - assert initial_language == Language.ENGLISH - - previous_language = await nexus_client.execute_operation( - GreetingService.set_language, - SetLanguageInput(language=Language.CHINESE), - ) - assert previous_language == Language.ENGLISH - - current_language = await nexus_client.execute_operation( - GreetingService.get_language, None - ) - assert current_language == Language.CHINESE - - previous_language = await nexus_client.execute_operation( - GreetingService.set_language, - SetLanguageInput(language=Language.ARABIC), - ) - assert previous_language == Language.CHINESE - - current_language = await nexus_client.execute_operation( - GreetingService.get_language, None - ) - assert current_language == Language.ARABIC - - -async def test_nexus_sync_operations(client: Client, env: WorkflowEnvironment): - if env.supports_time_skipping: - pytest.skip("Nexus tests don't work under the Java test server") - - await _run_caller_workflow(client, TestCallerWorkflow) - - -async def test_nexus_sync_operations_caller_workflow( - client: Client, env: WorkflowEnvironment -): - """ - Runs the CallerWorkflow from the sample to ensure it executes without errors. - """ - if env.supports_time_skipping: - pytest.skip("Nexus tests don't work under the Java test server") - - await _run_caller_workflow(client, CallerWorkflow) - - -async def _run_caller_workflow(client: Client, workflow: Type): - create_response = await create_nexus_endpoint( - name=NEXUS_ENDPOINT, - task_queue=nexus_sync_operations_DELETE_ME.handler.worker.TASK_QUEUE, - client=client, - ) - try: - handler_worker_task = asyncio.create_task( - nexus_sync_operations_DELETE_ME.handler.worker.main(client) - ) - try: - async with Worker( - client, - task_queue="test-caller-task-queue", - workflows=[workflow], - ): - await client.execute_workflow( - workflow.run, - id=str(uuid.uuid4()), - task_queue="test-caller-task-queue", - ) - finally: - nexus_sync_operations_DELETE_ME.handler.worker.interrupt_event.set() - await handler_worker_task - nexus_sync_operations_DELETE_ME.handler.worker.interrupt_event.clear() - finally: - await delete_nexus_endpoint( - id=create_response.endpoint.id, - version=create_response.endpoint.version, - client=client, - ) From 274ea2ab449cfce6076a756b99f284076f50a3d7 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Mon, 13 Apr 2026 16:11:11 -0700 Subject: [PATCH 4/7] Working on adding Nexus messaging sample code --- nexus_messaging/README.md | 16 ++ .../__init__.py | 0 nexus_messaging/callerpattern/README.md | 62 ++++++++ .../callerpattern}/__init__.py | 0 .../callerpattern/caller}/__init__.py | 0 .../callerpattern}/caller/app.py | 13 +- .../callerpattern/caller/workflows.py | 74 +++++++++ .../callerpattern/handler/__init__.py | 0 .../callerpattern/handler/activities.py | 22 +++ .../callerpattern/handler/service_handler.py | 80 ++++++++++ .../callerpattern/handler/worker.py | 62 ++++++++ .../callerpattern/handler/workflows.py | 88 +++++++++++ nexus_messaging/callerpattern/service.py | 67 ++++++++ nexus_messaging/endpoint_description.md | 14 ++ nexus_messaging/ondemandpattern/README.md | 66 ++++++++ nexus_messaging/ondemandpattern/__init__.py | 0 .../ondemandpattern/caller/__init__.py | 0 nexus_messaging/ondemandpattern/caller/app.py | 41 +++++ .../ondemandpattern/caller/workflows.py | 149 ++++++++++++++++++ .../ondemandpattern/handler/__init__.py | 0 .../ondemandpattern/handler/activities.py | 22 +++ .../handler/service_handler.py | 92 +++++++++++ .../ondemandpattern}/handler/worker.py | 22 ++- .../ondemandpattern/handler/workflows.py | 102 ++++++++++++ nexus_messaging/ondemandpattern/service.py | 72 +++++++++ nexus_sync_operations/README.md | 39 ----- nexus_sync_operations/caller/workflows.py | 46 ------ nexus_sync_operations/endpoint_description.md | 4 - .../handler/service_handler.py | 83 ---------- nexus_sync_operations/service.py | 20 --- tests/nexus_messaging/callerpattern_test.py | 122 ++++++++++++++ tests/nexus_messaging/ondemandpattern_test.py | 134 ++++++++++++++++ .../nexus_sync_operations_test.py | 16 +- 33 files changed, 1308 insertions(+), 220 deletions(-) create mode 100644 nexus_messaging/README.md rename {nexus_sync_operations => nexus_messaging}/__init__.py (100%) create mode 100644 nexus_messaging/callerpattern/README.md rename {nexus_sync_operations/caller => nexus_messaging/callerpattern}/__init__.py (100%) rename {nexus_sync_operations/handler => nexus_messaging/callerpattern/caller}/__init__.py (100%) rename {nexus_sync_operations => nexus_messaging/callerpattern}/caller/app.py (73%) create mode 100644 nexus_messaging/callerpattern/caller/workflows.py create mode 100644 nexus_messaging/callerpattern/handler/__init__.py create mode 100644 nexus_messaging/callerpattern/handler/activities.py create mode 100644 nexus_messaging/callerpattern/handler/service_handler.py create mode 100644 nexus_messaging/callerpattern/handler/worker.py create mode 100644 nexus_messaging/callerpattern/handler/workflows.py create mode 100644 nexus_messaging/callerpattern/service.py create mode 100644 nexus_messaging/endpoint_description.md create mode 100644 nexus_messaging/ondemandpattern/README.md create mode 100644 nexus_messaging/ondemandpattern/__init__.py create mode 100644 nexus_messaging/ondemandpattern/caller/__init__.py create mode 100644 nexus_messaging/ondemandpattern/caller/app.py create mode 100644 nexus_messaging/ondemandpattern/caller/workflows.py create mode 100644 nexus_messaging/ondemandpattern/handler/__init__.py create mode 100644 nexus_messaging/ondemandpattern/handler/activities.py create mode 100644 nexus_messaging/ondemandpattern/handler/service_handler.py rename {nexus_sync_operations => nexus_messaging/ondemandpattern}/handler/worker.py (58%) create mode 100644 nexus_messaging/ondemandpattern/handler/workflows.py create mode 100644 nexus_messaging/ondemandpattern/service.py delete mode 100644 nexus_sync_operations/README.md delete mode 100644 nexus_sync_operations/caller/workflows.py delete mode 100644 nexus_sync_operations/endpoint_description.md delete mode 100644 nexus_sync_operations/handler/service_handler.py delete mode 100644 nexus_sync_operations/service.py create mode 100644 tests/nexus_messaging/callerpattern_test.py create mode 100644 tests/nexus_messaging/ondemandpattern_test.py diff --git a/nexus_messaging/README.md b/nexus_messaging/README.md new file mode 100644 index 00000000..22670572 --- /dev/null +++ b/nexus_messaging/README.md @@ -0,0 +1,16 @@ +This sample shows how to expose a long-running workflow's queries, updates, and signals as Nexus +operations. There are two self-contained examples, each in its own directory: + +| | `callerpattern/` | `ondemandpattern/` | +|---|---|---| +| **Pattern** | Signal an existing workflow | Create and run workflows on demand, and send signals to them | +| **Who creates the workflow?** | The handler worker starts it on boot | The caller starts it via a Nexus operation | +| **Who knows the workflow ID?** | Only the handler | The caller chooses and passes it in every operation | +| **Nexus service** | `NexusGreetingService` | `NexusRemoteGreetingService` | + +Each directory is fully self-contained for clarity. The `GreetingWorkflow`, activity, and +`Language` enum are **identical** between the two -- only the Nexus service definition and its +handler implementation differ. This highlights that the same workflow can be exposed through +Nexus in different ways depending on whether the caller needs lifecycle control. + +See each directory's README for running instructions. diff --git a/nexus_sync_operations/__init__.py b/nexus_messaging/__init__.py similarity index 100% rename from nexus_sync_operations/__init__.py rename to nexus_messaging/__init__.py diff --git a/nexus_messaging/callerpattern/README.md b/nexus_messaging/callerpattern/README.md new file mode 100644 index 00000000..085a4903 --- /dev/null +++ b/nexus_messaging/callerpattern/README.md @@ -0,0 +1,62 @@ +## Entity pattern + +The handler worker starts a `GreetingWorkflow` for a user ID. +`NexusGreetingServiceHandler` holds that ID and routes every Nexus operation to it. +The caller's input does not have that workflow ID as the caller doesn't know it -- but the caller +sends in the User ID, and `NexusGreetingServiceHandler` knows how to get the desired workflow ID +from that User ID (see the `get_workflow_id` call). + +The handler worker uses the same `get_workflow_id` call to generate a workflow ID from a user ID +when it launches the workflow. + +The caller workflow: +1. Queries for supported languages (`get_languages` -- backed by a `@workflow.query`) +2. Changes the language to Arabic (`set_language` -- backed by a `@workflow.update` that calls an activity) +3. Confirms the change via a second query (`get_language`) +4. Approves the workflow (`approve` -- backed by a `@workflow.signal`) + +### Sample directory structure + +- [service.py](./service.py) - shared Nexus service definition +- [caller](./caller) - a caller workflow that executes Nexus operations, together with a starter +- [handler](./handler) - Nexus operation handlers, together with a workflow used by the Nexus operations, and a worker that polls for workflow, activity, and Nexus tasks + +### Running + +Start a Temporal server: + +```bash +temporal server start-dev +``` + +Create the namespaces and Nexus endpoint: + +```bash +temporal operator namespace create --namespace nexus-messaging-handler-namespace +temporal operator namespace create --namespace nexus-messaging-caller-namespace + +temporal operator nexus endpoint create \ + --name nexus-messaging-nexus-endpoint \ + --target-namespace nexus-messaging-handler-namespace \ + --target-task-queue nexus-messaging-handler-task-queue +``` + +In one terminal, start the handler worker: + +```bash +uv run python -m nexus_messaging.callerpattern.handler.worker +``` + +In another terminal, run the caller workflow: + +```bash +uv run python -m nexus_messaging.callerpattern.caller.app +``` + +Expected output: + +``` +Supported languages: [, ] +Language changed: ENGLISH -> ARABIC +Workflow approved +``` diff --git a/nexus_sync_operations/caller/__init__.py b/nexus_messaging/callerpattern/__init__.py similarity index 100% rename from nexus_sync_operations/caller/__init__.py rename to nexus_messaging/callerpattern/__init__.py diff --git a/nexus_sync_operations/handler/__init__.py b/nexus_messaging/callerpattern/caller/__init__.py similarity index 100% rename from nexus_sync_operations/handler/__init__.py rename to nexus_messaging/callerpattern/caller/__init__.py diff --git a/nexus_sync_operations/caller/app.py b/nexus_messaging/callerpattern/caller/app.py similarity index 73% rename from nexus_sync_operations/caller/app.py rename to nexus_messaging/callerpattern/caller/app.py index 375628d2..933dcd5d 100644 --- a/nexus_sync_operations/caller/app.py +++ b/nexus_messaging/callerpattern/caller/app.py @@ -6,15 +6,13 @@ from temporalio.envconfig import ClientConfig from temporalio.worker import Worker -from nexus_sync_operations.caller.workflows import CallerWorkflow +from nexus_messaging.callerpattern.caller.workflows import CallerWorkflow -NAMESPACE = "nexus-sync-operations-caller-namespace" -TASK_QUEUE = "nexus-sync-operations-caller-task-queue" +NAMESPACE = "nexus-messaging-caller-namespace" +TASK_QUEUE = "nexus-messaging-caller-task-queue" -async def execute_caller_workflow( - client: Optional[Client] = None, -) -> None: +async def execute_caller_workflow(client: Optional[Client] = None) -> None: if client is None: config = ClientConfig.load_client_connect_config() config.setdefault("target_host", "localhost:7233") @@ -28,7 +26,8 @@ async def execute_caller_workflow( ): log = await client.execute_workflow( CallerWorkflow.run, - id=str(uuid.uuid4()), + arg="user-1", + id=f"nexus-messaging-caller-{uuid.uuid4()}", task_queue=TASK_QUEUE, ) for line in log: diff --git a/nexus_messaging/callerpattern/caller/workflows.py b/nexus_messaging/callerpattern/caller/workflows.py new file mode 100644 index 00000000..7418e90a --- /dev/null +++ b/nexus_messaging/callerpattern/caller/workflows.py @@ -0,0 +1,74 @@ +""" +A caller workflow that executes Nexus operations. The caller does not have information +about how these operations are implemented by the Nexus service. +""" + +from temporalio import workflow +from temporalio.exceptions import ApplicationError + +with workflow.unsafe.imports_passed_through(): + from nexus_messaging.callerpattern.service import ( + ApproveInput, + GetLanguageInput, + GetLanguagesInput, + Language, + NexusGreetingService, + SetLanguageInput, + ) + +NEXUS_ENDPOINT = "nexus-messaging-nexus-endpoint" + + +@workflow.defn +class CallerWorkflow: + @workflow.run + async def run(self, user_id: str) -> list[str]: + log: list[str] = [] + nexus_client = workflow.create_nexus_client( + service=NexusGreetingService, + endpoint=NEXUS_ENDPOINT, + ) + + # Call a Nexus operation backed by a query against the entity workflow. + # The workflow must already be running on the handler, otherwise you will + # get an error saying the workflow has already terminated. + languages_output = await nexus_client.execute_operation( + NexusGreetingService.get_languages, + GetLanguagesInput(include_unsupported=False, user_id=user_id), + ) + log.append(f"Supported languages: {languages_output.languages}") + workflow.logger.info("Supported languages: %s", languages_output.languages) + + # Following are examples for each of the three messaging types - + # update, query, then signal. + + # Call a Nexus operation backed by an update against the entity workflow. + previous_language = await nexus_client.execute_operation( + NexusGreetingService.set_language, + SetLanguageInput(language=Language.ARABIC, user_id=user_id), + ) + + # Call a Nexus operation backed by a query to confirm the language change. + current_language = await nexus_client.execute_operation( + NexusGreetingService.get_language, + GetLanguageInput(user_id=user_id), + ) + if current_language != Language.ARABIC: + raise ApplicationError(f"Expected language ARABIC, got {current_language}") + + log.append( + f"Language changed: {previous_language.name} -> {Language.ARABIC.name}" + ) + workflow.logger.info( + "Language changed from %s to %s", previous_language, Language.ARABIC + ) + + # Call a Nexus operation backed by a signal against the entity workflow. + await nexus_client.execute_operation( + NexusGreetingService.approve, + ApproveInput(name="caller", user_id=user_id), + ) + log.append("Workflow approved") + workflow.logger.info("Workflow approved") + + return log diff --git a/nexus_messaging/callerpattern/handler/__init__.py b/nexus_messaging/callerpattern/handler/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nexus_messaging/callerpattern/handler/activities.py b/nexus_messaging/callerpattern/handler/activities.py new file mode 100644 index 00000000..4031b34f --- /dev/null +++ b/nexus_messaging/callerpattern/handler/activities.py @@ -0,0 +1,22 @@ +import asyncio +from typing import Optional + +from temporalio import activity + +from nexus_messaging.callerpattern.service import Language + + +@activity.defn +async def call_greeting_service(language: Language) -> Optional[str]: + """Simulates a call to a remote greeting service. Returns None if unsupported.""" + greetings = { + Language.ARABIC: "\u0645\u0631\u062d\u0628\u0627 \u0628\u0627\u0644\u0639\u0627\u0644\u0645", + Language.CHINESE: "\u4f60\u597d\uff0c\u4e16\u754c", + Language.ENGLISH: "Hello, world", + Language.FRENCH: "Bonjour, monde", + Language.HINDI: "\u0928\u092e\u0938\u094d\u0924\u0947 \u0926\u0941\u0928\u093f\u092f\u093e", + Language.PORTUGUESE: "Ol\u00e1 mundo", + Language.SPANISH: "Hola mundo", + } + await asyncio.sleep(0.2) + return greetings.get(language) diff --git a/nexus_messaging/callerpattern/handler/service_handler.py b/nexus_messaging/callerpattern/handler/service_handler.py new file mode 100644 index 00000000..cbc57ead --- /dev/null +++ b/nexus_messaging/callerpattern/handler/service_handler.py @@ -0,0 +1,80 @@ +""" +Nexus operation handler implementation for the entity pattern. Each operation receives a +user_id, which is mapped to a workflow ID. The operations are synchronous because queries +and updates against a running workflow complete quickly. +""" + +from __future__ import annotations + +import nexusrpc +from temporalio import nexus +from temporalio.client import WorkflowHandle + +from nexus_messaging.callerpattern.handler.workflows import GreetingWorkflow +from nexus_messaging.callerpattern.service import ( + ApproveInput, + ApproveOutput, + GetLanguageInput, + GetLanguagesInput, + GetLanguagesOutput, + Language, + NexusGreetingService, + SetLanguageInput, +) + +WORKFLOW_ID_PREFIX = "GreetingWorkflow_for_" + + +def get_workflow_id(user_id: str) -> str: + """Map a user ID to a workflow ID. + + This example assumes you might have multiple workflows, one for each user. + If you had a single workflow for all users, you could remove this function, + remove the user_id from each input, and just use a single workflow ID. + """ + return f"{WORKFLOW_ID_PREFIX}{user_id}" + + +@nexusrpc.handler.service_handler(service=NexusGreetingService) +class NexusGreetingServiceHandler: + def _get_workflow_handle( + self, user_id: str + ) -> WorkflowHandle[GreetingWorkflow, str]: + return nexus.client().get_workflow_handle_for( + GreetingWorkflow.run, get_workflow_id(user_id) + ) + + @nexusrpc.handler.sync_operation + async def get_languages( + self, ctx: nexusrpc.handler.StartOperationContext, input: GetLanguagesInput + ) -> GetLanguagesOutput: + return await self._get_workflow_handle(input.user_id).query( + GreetingWorkflow.get_languages, input + ) + + @nexusrpc.handler.sync_operation + async def get_language( + self, ctx: nexusrpc.handler.StartOperationContext, input: GetLanguageInput + ) -> Language: + return await self._get_workflow_handle(input.user_id).query( + GreetingWorkflow.get_language + ) + + # Routes to set_language_using_activity (not set_language) so that new languages not + # already in the greetings map can be fetched via an activity. + @nexusrpc.handler.sync_operation + async def set_language( + self, ctx: nexusrpc.handler.StartOperationContext, input: SetLanguageInput + ) -> Language: + return await self._get_workflow_handle(input.user_id).execute_update( + GreetingWorkflow.set_language_using_activity, input + ) + + @nexusrpc.handler.sync_operation + async def approve( + self, ctx: nexusrpc.handler.StartOperationContext, input: ApproveInput + ) -> ApproveOutput: + await self._get_workflow_handle(input.user_id).signal( + GreetingWorkflow.approve, input + ) + return ApproveOutput() diff --git a/nexus_messaging/callerpattern/handler/worker.py b/nexus_messaging/callerpattern/handler/worker.py new file mode 100644 index 00000000..fa8e2c0f --- /dev/null +++ b/nexus_messaging/callerpattern/handler/worker.py @@ -0,0 +1,62 @@ +import asyncio +import logging +from typing import Optional + +from temporalio.client import Client +from temporalio.common import WorkflowIDConflictPolicy +from temporalio.envconfig import ClientConfig +from temporalio.worker import Worker + +from nexus_messaging.callerpattern.handler.activities import call_greeting_service +from nexus_messaging.callerpattern.handler.service_handler import ( + NexusGreetingServiceHandler, + get_workflow_id, +) +from nexus_messaging.callerpattern.handler.workflows import GreetingWorkflow + +interrupt_event = asyncio.Event() + +NAMESPACE = "nexus-messaging-handler-namespace" +TASK_QUEUE = "nexus-messaging-handler-task-queue" +USER_ID = "user-1" + + +async def main(client: Optional[Client] = None): + logging.basicConfig(level=logging.INFO) + + if client is None: + config = ClientConfig.load_client_connect_config() + config.setdefault("target_host", "localhost:7233") + config.setdefault("namespace", NAMESPACE) + client = await Client.connect(**config) + + # Start the long-running entity workflow that backs the Nexus service, + # if not already running. + workflow_id = get_workflow_id(USER_ID) + await client.start_workflow( + GreetingWorkflow.run, + id=workflow_id, + task_queue=TASK_QUEUE, + id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING, + ) + logging.info("Started greeting workflow: %s", workflow_id) + + async with Worker( + client, + task_queue=TASK_QUEUE, + workflows=[GreetingWorkflow], + activities=[call_greeting_service], + nexus_service_handlers=[NexusGreetingServiceHandler()], + ): + logging.info("Handler worker started, ctrl+c to exit") + await interrupt_event.wait() + logging.info("Shutting down") + + +if __name__ == "__main__": + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(main()) + except KeyboardInterrupt: + interrupt_event.set() + loop.run_until_complete(loop.shutdown_asyncgens()) diff --git a/nexus_messaging/callerpattern/handler/workflows.py b/nexus_messaging/callerpattern/handler/workflows.py new file mode 100644 index 00000000..c3b9cbc3 --- /dev/null +++ b/nexus_messaging/callerpattern/handler/workflows.py @@ -0,0 +1,88 @@ +""" +A long-running "entity" workflow that backs the NexusGreetingService Nexus operations. +The workflow exposes queries, an update, and a signal. These are private implementation +details of the Nexus service: the caller only interacts via Nexus operations. +""" + +import asyncio +from datetime import timedelta + +from temporalio import workflow +from temporalio.exceptions import ApplicationError + +with workflow.unsafe.imports_passed_through(): + from nexus_messaging.callerpattern.handler.activities import call_greeting_service + from nexus_messaging.callerpattern.service import ( + ApproveInput, + GetLanguagesInput, + GetLanguagesOutput, + Language, + SetLanguageInput, + ) + + +@workflow.defn +class GreetingWorkflow: + def __init__(self) -> None: + self.approved_for_release = False + self.greetings: dict[Language, str] = { + Language.CHINESE: "\u4f60\u597d\uff0c\u4e16\u754c", + Language.ENGLISH: "Hello, world", + } + self.language = Language.ENGLISH + self.lock = asyncio.Lock() + + @workflow.run + async def run(self) -> str: + # Wait until approved and all in-flight update handlers have finished. + await workflow.wait_condition( + lambda: self.approved_for_release and workflow.all_handlers_finished() + ) + return self.greetings[self.language] + + @workflow.query + def get_languages(self, input: GetLanguagesInput) -> GetLanguagesOutput: + if input.include_unsupported: + languages = sorted(Language) + else: + languages = sorted(self.greetings) + return GetLanguagesOutput(languages=languages) + + @workflow.query + def get_language(self) -> Language: + return self.language + + @workflow.signal + def approve(self, input: ApproveInput) -> None: + workflow.logger.info("Approval signal received for user %s", input.user_id) + self.approved_for_release = True + + @workflow.update + def set_language(self, input: SetLanguageInput) -> Language: + workflow.logger.info("setLanguage update received for user %s", input.user_id) + previous_language, self.language = self.language, input.language + return previous_language + + @set_language.validator + def validate_set_language(self, input: SetLanguageInput) -> None: + if input.language not in self.greetings: + raise ValueError(f"{input.language.name} is not supported") + + # Changes the active language, calling an activity to fetch a greeting for new + # languages not already in the greetings map. + @workflow.update + async def set_language_using_activity(self, input: SetLanguageInput) -> Language: + if input.language not in self.greetings: + async with self.lock: + greeting = await workflow.execute_activity( + call_greeting_service, + input.language, + start_to_close_timeout=timedelta(seconds=10), + ) + if greeting is None: + raise ApplicationError( + f"Greeting service does not support {input.language.name}" + ) + self.greetings[input.language] = greeting + previous_language, self.language = self.language, input.language + return previous_language diff --git a/nexus_messaging/callerpattern/service.py b/nexus_messaging/callerpattern/service.py new file mode 100644 index 00000000..23a550fb --- /dev/null +++ b/nexus_messaging/callerpattern/service.py @@ -0,0 +1,67 @@ +""" +Nexus service definition for the caller (entity) pattern. Shared between the handler and +caller. The caller uses this to create a type-safe Nexus client; the handler implements +the operations. + +Every operation includes a user_id so the handler knows which entity workflow to target. +""" + +from dataclasses import dataclass +from enum import IntEnum + +import nexusrpc + + +class Language(IntEnum): + ARABIC = 1 + CHINESE = 2 + ENGLISH = 3 + FRENCH = 4 + HINDI = 5 + PORTUGUESE = 6 + SPANISH = 7 + + +@dataclass +class GetLanguagesInput: + include_unsupported: bool + user_id: str + + +@dataclass +class GetLanguagesOutput: + languages: list[Language] + + +@dataclass +class GetLanguageInput: + user_id: str + + +@dataclass +class SetLanguageInput: + language: Language + user_id: str + + +@dataclass +class ApproveInput: + name: str + user_id: str + + +@dataclass +class ApproveOutput: + pass + + +@nexusrpc.service +class NexusGreetingService: + # Returns the languages supported by the greeting workflow. + get_languages: nexusrpc.Operation[GetLanguagesInput, GetLanguagesOutput] + # Returns the currently active language. + get_language: nexusrpc.Operation[GetLanguageInput, Language] + # Changes the active language, returning the previous one. + set_language: nexusrpc.Operation[SetLanguageInput, Language] + # Approves the workflow, allowing it to complete. + approve: nexusrpc.Operation[ApproveInput, ApproveOutput] diff --git a/nexus_messaging/endpoint_description.md b/nexus_messaging/endpoint_description.md new file mode 100644 index 00000000..4184134b --- /dev/null +++ b/nexus_messaging/endpoint_description.md @@ -0,0 +1,14 @@ +## Services + +### [NexusGreetingService](https://github.com/temporalio/samples-python/blob/main/nexus_messaging/callerpattern/service.py) (callerpattern) +- operation: `get_languages` +- operation: `get_language` +- operation: `set_language` +- operation: `approve` + +### [NexusRemoteGreetingService](https://github.com/temporalio/samples-python/blob/main/nexus_messaging/ondemandpattern/service.py) (ondemandpattern) +- operation: `run_from_remote` +- operation: `get_languages` +- operation: `get_language` +- operation: `set_language` +- operation: `approve` diff --git a/nexus_messaging/ondemandpattern/README.md b/nexus_messaging/ondemandpattern/README.md new file mode 100644 index 00000000..260da9bd --- /dev/null +++ b/nexus_messaging/ondemandpattern/README.md @@ -0,0 +1,66 @@ +## On-demand pattern + +No workflow is pre-started. The caller creates and controls workflow instances through Nexus +operations. `NexusRemoteGreetingService` adds a `run_from_remote` operation that starts a new +`GreetingWorkflow`, and every other operation includes a `workflow_id` so the handler knows which +instance to target. + +The caller workflow: +1. Starts two remote `GreetingWorkflow` instances via `run_from_remote` (backed by `workflow_run_operation`) +2. Queries each for supported languages +3. Changes the language on each (Arabic and Hindi) +4. Confirms the changes via queries +5. Approves both workflows +6. Waits for each to complete and returns their results + +### Sample directory structure + +- [service.py](./service.py) - shared Nexus service definition +- [caller](./caller) - a caller workflow that creates remote workflows and executes Nexus operations, together with a starter +- [handler](./handler) - Nexus operation handlers, together with a workflow started on demand by the Nexus operations, and a worker that polls for workflow, activity, and Nexus tasks + +### Running + +Start a Temporal server: + +```bash +temporal server start-dev +``` + +Create the namespaces and Nexus endpoint: + +```bash +temporal operator namespace create --namespace nexus-messaging-handler-namespace +temporal operator namespace create --namespace nexus-messaging-caller-namespace + +temporal operator nexus endpoint create \ + --name nexus-messaging-nexus-endpoint \ + --target-namespace nexus-messaging-handler-namespace \ + --target-task-queue nexus-messaging-handler-task-queue +``` + +In one terminal, start the handler worker: + +```bash +uv run python -m nexus_messaging.ondemandpattern.handler.worker +``` + +In another terminal, run the caller workflow: + +```bash +uv run python -m nexus_messaging.ondemandpattern.caller.app +``` + +Expected output: + +``` +started remote greeting workflow: UserId One +started remote greeting workflow: UserId Two +Supported languages for UserId One: [, ] +Supported languages for UserId Two: [, ] +UserId One changed language: ENGLISH -> ARABIC +UserId Two changed language: ENGLISH -> HINDI +Workflows approved +Workflow one result: مرحبا بالعالم +Workflow two result: नमस्ते दुनिया +``` diff --git a/nexus_messaging/ondemandpattern/__init__.py b/nexus_messaging/ondemandpattern/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nexus_messaging/ondemandpattern/caller/__init__.py b/nexus_messaging/ondemandpattern/caller/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nexus_messaging/ondemandpattern/caller/app.py b/nexus_messaging/ondemandpattern/caller/app.py new file mode 100644 index 00000000..a1837bab --- /dev/null +++ b/nexus_messaging/ondemandpattern/caller/app.py @@ -0,0 +1,41 @@ +import asyncio +import uuid +from typing import Optional + +from temporalio.client import Client +from temporalio.envconfig import ClientConfig +from temporalio.worker import Worker + +from nexus_messaging.ondemandpattern.caller.workflows import CallerRemoteWorkflow + +NAMESPACE = "nexus-messaging-caller-namespace" +TASK_QUEUE = "nexus-messaging-caller-remote-task-queue" + + +async def execute_caller_workflow(client: Optional[Client] = None) -> None: + if client is None: + config = ClientConfig.load_client_connect_config() + config.setdefault("target_host", "localhost:7233") + config.setdefault("namespace", NAMESPACE) + client = await Client.connect(**config) + + async with Worker( + client, + task_queue=TASK_QUEUE, + workflows=[CallerRemoteWorkflow], + ): + log = await client.execute_workflow( + CallerRemoteWorkflow.run, + id=f"nexus-messaging-remote-caller-{uuid.uuid4()}", + task_queue=TASK_QUEUE, + ) + for line in log: + print(line) + + +if __name__ == "__main__": + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(execute_caller_workflow()) + except KeyboardInterrupt: + loop.run_until_complete(loop.shutdown_asyncgens()) diff --git a/nexus_messaging/ondemandpattern/caller/workflows.py b/nexus_messaging/ondemandpattern/caller/workflows.py new file mode 100644 index 00000000..fafb9925 --- /dev/null +++ b/nexus_messaging/ondemandpattern/caller/workflows.py @@ -0,0 +1,149 @@ +""" +A caller workflow that creates and controls workflow instances through Nexus operations. +Unlike the entity (callerpattern), no workflow is pre-started; the caller creates them +on demand via the run_from_remote operation. +""" + +from temporalio import workflow + +with workflow.unsafe.imports_passed_through(): + from nexus_messaging.ondemandpattern.service import ( + ApproveInput, + GetLanguageInput, + GetLanguagesInput, + Language, + NexusRemoteGreetingService, + RunFromRemoteInput, + SetLanguageInput, + ) + +NEXUS_ENDPOINT = "nexus-messaging-nexus-endpoint" + +REMOTE_WORKFLOW_ONE = "UserId One" +REMOTE_WORKFLOW_TWO = "UserId Two" + + +@workflow.defn +class CallerRemoteWorkflow: + def __init__(self) -> None: + self.nexus_client = workflow.create_nexus_client( + service=NexusRemoteGreetingService, + endpoint=NEXUS_ENDPOINT, + ) + + @workflow.run + async def run(self) -> list[str]: + log: list[str] = [] + + # Each call is performed twice in this example. This assumes there are two + # users we want to process. The first calls start two workflows, one for each + # user. Subsequent calls perform different actions between the two users. + + # This is an async Nexus operation -- starts a workflow on the handler and + # returns a handle. Unlike the sync operations below, this does not block + # until the workflow completes. It is backed by workflow_run_operation on the + # handler side. + handle_one = await self.nexus_client.start_operation( + NexusRemoteGreetingService.run_from_remote, + RunFromRemoteInput(user_id=REMOTE_WORKFLOW_ONE), + ) + log.append(f"started remote greeting workflow: {REMOTE_WORKFLOW_ONE}") + workflow.logger.info("started remote greeting workflow %s", REMOTE_WORKFLOW_ONE) + + handle_two = await self.nexus_client.start_operation( + NexusRemoteGreetingService.run_from_remote, + RunFromRemoteInput(user_id=REMOTE_WORKFLOW_TWO), + ) + log.append(f"started remote greeting workflow: {REMOTE_WORKFLOW_TWO}") + workflow.logger.info("started remote greeting workflow %s", REMOTE_WORKFLOW_TWO) + + # Query the remote workflows for supported languages. + languages_output = await self.nexus_client.execute_operation( + NexusRemoteGreetingService.get_languages, + GetLanguagesInput(include_unsupported=False, user_id=REMOTE_WORKFLOW_ONE), + ) + log.append( + f"Supported languages for {REMOTE_WORKFLOW_ONE}: " + f"{languages_output.languages}" + ) + workflow.logger.info( + "supported languages are %s for workflow %s", + languages_output.languages, + REMOTE_WORKFLOW_ONE, + ) + + languages_output = await self.nexus_client.execute_operation( + NexusRemoteGreetingService.get_languages, + GetLanguagesInput(include_unsupported=False, user_id=REMOTE_WORKFLOW_TWO), + ) + log.append( + f"Supported languages for {REMOTE_WORKFLOW_TWO}: " + f"{languages_output.languages}" + ) + workflow.logger.info( + "supported languages are %s for workflow %s", + languages_output.languages, + REMOTE_WORKFLOW_TWO, + ) + + # Update the language on each remote workflow. + previous_language_one = await self.nexus_client.execute_operation( + NexusRemoteGreetingService.set_language, + SetLanguageInput(language=Language.ARABIC, user_id=REMOTE_WORKFLOW_ONE), + ) + + previous_language_two = await self.nexus_client.execute_operation( + NexusRemoteGreetingService.set_language, + SetLanguageInput(language=Language.HINDI, user_id=REMOTE_WORKFLOW_TWO), + ) + + # Confirm the changes by querying. + current_language = await self.nexus_client.execute_operation( + NexusRemoteGreetingService.get_language, + GetLanguageInput(user_id=REMOTE_WORKFLOW_ONE), + ) + log.append( + f"{REMOTE_WORKFLOW_ONE} changed language: " + f"{previous_language_one.name} -> {current_language.name}" + ) + workflow.logger.info( + "Language changed from %s to %s for workflow %s", + previous_language_one, + current_language, + REMOTE_WORKFLOW_ONE, + ) + + current_language = await self.nexus_client.execute_operation( + NexusRemoteGreetingService.get_language, + GetLanguageInput(user_id=REMOTE_WORKFLOW_TWO), + ) + log.append( + f"{REMOTE_WORKFLOW_TWO} changed language: " + f"{previous_language_two.name} -> {current_language.name}" + ) + workflow.logger.info( + "Language changed from %s to %s for workflow %s", + previous_language_two, + current_language, + REMOTE_WORKFLOW_TWO, + ) + + # Approve both workflows so they can complete. + await self.nexus_client.execute_operation( + NexusRemoteGreetingService.approve, + ApproveInput(name="remote-caller", user_id=REMOTE_WORKFLOW_ONE), + ) + await self.nexus_client.execute_operation( + NexusRemoteGreetingService.approve, + ApproveInput(name="remote-caller", user_id=REMOTE_WORKFLOW_TWO), + ) + log.append("Workflows approved") + + # Wait for the remote workflows to finish and return their results. + result = await handle_one + log.append(f"Workflow one result: {result}") + + result = await handle_two + log.append(f"Workflow two result: {result}") + + return log diff --git a/nexus_messaging/ondemandpattern/handler/__init__.py b/nexus_messaging/ondemandpattern/handler/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/nexus_messaging/ondemandpattern/handler/activities.py b/nexus_messaging/ondemandpattern/handler/activities.py new file mode 100644 index 00000000..ba028489 --- /dev/null +++ b/nexus_messaging/ondemandpattern/handler/activities.py @@ -0,0 +1,22 @@ +import asyncio +from typing import Optional + +from temporalio import activity + +from nexus_messaging.ondemandpattern.service import Language + + +@activity.defn +async def call_greeting_service(language: Language) -> Optional[str]: + """Simulates a call to a remote greeting service. Returns None if unsupported.""" + greetings = { + Language.ARABIC: "\u0645\u0631\u062d\u0628\u0627 \u0628\u0627\u0644\u0639\u0627\u0644\u0645", + Language.CHINESE: "\u4f60\u597d\uff0c\u4e16\u754c", + Language.ENGLISH: "Hello, world", + Language.FRENCH: "Bonjour, monde", + Language.HINDI: "\u0928\u092e\u0938\u094d\u0924\u0947 \u0926\u0941\u0928\u093f\u092f\u093e", + Language.PORTUGUESE: "Ol\u00e1 mundo", + Language.SPANISH: "Hola mundo", + } + await asyncio.sleep(0.2) + return greetings.get(language) diff --git a/nexus_messaging/ondemandpattern/handler/service_handler.py b/nexus_messaging/ondemandpattern/handler/service_handler.py new file mode 100644 index 00000000..2aeb2092 --- /dev/null +++ b/nexus_messaging/ondemandpattern/handler/service_handler.py @@ -0,0 +1,92 @@ +""" +Nexus operation handler for the on-demand pattern. Each operation receives the target +userId in its input, and run_from_remote starts a brand-new GreetingWorkflow. +""" + +from __future__ import annotations + +import nexusrpc +from temporalio import nexus +from temporalio.client import WorkflowHandle + +from nexus_messaging.ondemandpattern.handler.workflows import ( + ApproveInput as WorkflowApproveInput, + GetLanguagesInput as WorkflowGetLanguagesInput, + GreetingWorkflow, + SetLanguageInput as WorkflowSetLanguageInput, +) +from nexus_messaging.ondemandpattern.service import ( + ApproveInput, + ApproveOutput, + GetLanguageInput, + GetLanguagesInput, + GetLanguagesOutput, + Language, + NexusRemoteGreetingService, + RunFromRemoteInput, + SetLanguageInput, +) + +WORKFLOW_ID_PREFIX = "GreetingWorkflow_for_" + + +@nexusrpc.handler.service_handler(service=NexusRemoteGreetingService) +class NexusRemoteGreetingServiceHandler: + def _get_workflow_id(self, user_id: str) -> str: + return WORKFLOW_ID_PREFIX + user_id + + def _get_workflow_handle( + self, user_id: str + ) -> WorkflowHandle[GreetingWorkflow, str]: + return nexus.client().get_workflow_handle_for( + GreetingWorkflow.run, self._get_workflow_id(user_id) + ) + + # Starts a new GreetingWorkflow with the caller-specified user ID. + # This is an async Nexus operation backed by workflow_run_operation. + @nexus.workflow_run_operation + async def run_from_remote( + self, ctx: nexus.WorkflowRunOperationContext, input: RunFromRemoteInput + ) -> nexus.WorkflowHandle[str]: + return await ctx.start_workflow( + GreetingWorkflow.run, + id=self._get_workflow_id(input.user_id), + ) + + @nexusrpc.handler.sync_operation + async def get_languages( + self, ctx: nexusrpc.handler.StartOperationContext, input: GetLanguagesInput + ) -> GetLanguagesOutput: + return await self._get_workflow_handle(input.user_id).query( + GreetingWorkflow.get_languages, + WorkflowGetLanguagesInput(include_unsupported=input.include_unsupported), + ) + + @nexusrpc.handler.sync_operation + async def get_language( + self, ctx: nexusrpc.handler.StartOperationContext, input: GetLanguageInput + ) -> Language: + return await self._get_workflow_handle(input.user_id).query( + GreetingWorkflow.get_language, + ) + + # Routes to set_language_using_activity so that new languages not already in the + # greetings map can be fetched via an activity. + @nexusrpc.handler.sync_operation + async def set_language( + self, ctx: nexusrpc.handler.StartOperationContext, input: SetLanguageInput + ) -> Language: + return await self._get_workflow_handle(input.user_id).execute_update( + GreetingWorkflow.set_language_using_activity, + WorkflowSetLanguageInput(language=input.language), + ) + + @nexusrpc.handler.sync_operation + async def approve( + self, ctx: nexusrpc.handler.StartOperationContext, input: ApproveInput + ) -> ApproveOutput: + await self._get_workflow_handle(input.user_id).signal( + GreetingWorkflow.approve, + WorkflowApproveInput(name=input.name), + ) + return ApproveOutput() diff --git a/nexus_sync_operations/handler/worker.py b/nexus_messaging/ondemandpattern/handler/worker.py similarity index 58% rename from nexus_sync_operations/handler/worker.py rename to nexus_messaging/ondemandpattern/handler/worker.py index 97c8eb04..5eec9cfc 100644 --- a/nexus_sync_operations/handler/worker.py +++ b/nexus_messaging/ondemandpattern/handler/worker.py @@ -6,14 +6,16 @@ from temporalio.envconfig import ClientConfig from temporalio.worker import Worker -from message_passing.introduction.activities import call_greeting_service -from message_passing.introduction.workflows import GreetingWorkflow -from nexus_sync_operations.handler.service_handler import GreetingServiceHandler +from nexus_messaging.ondemandpattern.handler.activities import call_greeting_service +from nexus_messaging.ondemandpattern.handler.service_handler import ( + NexusRemoteGreetingServiceHandler, +) +from nexus_messaging.ondemandpattern.handler.workflows import GreetingWorkflow interrupt_event = asyncio.Event() -NAMESPACE = "nexus-sync-operations-handler-namespace" -TASK_QUEUE = "nexus-sync-operations-handler-task-queue" +NAMESPACE = "nexus-messaging-handler-namespace" +TASK_QUEUE = "nexus-messaging-handler-task-queue" async def main(client: Optional[Client] = None): @@ -25,20 +27,14 @@ async def main(client: Optional[Client] = None): config.setdefault("namespace", NAMESPACE) client = await Client.connect(**config) - # Create the nexus service handler instance, starting the long-running entity workflow that - # backs the Nexus service - greeting_service_handler = await GreetingServiceHandler.create( - "nexus-sync-operations-greeting-workflow", client, TASK_QUEUE - ) - async with Worker( client, task_queue=TASK_QUEUE, workflows=[GreetingWorkflow], activities=[call_greeting_service], - nexus_service_handlers=[greeting_service_handler], + nexus_service_handlers=[NexusRemoteGreetingServiceHandler()], ): - logging.info("Worker started, ctrl+c to exit") + logging.info("Handler worker started, ctrl+c to exit") await interrupt_event.wait() logging.info("Shutting down") diff --git a/nexus_messaging/ondemandpattern/handler/workflows.py b/nexus_messaging/ondemandpattern/handler/workflows.py new file mode 100644 index 00000000..ffaa6361 --- /dev/null +++ b/nexus_messaging/ondemandpattern/handler/workflows.py @@ -0,0 +1,102 @@ +""" +A long-running "entity" workflow that backs the NexusRemoteGreetingService Nexus +operations. The workflow exposes queries, an update, and a signal. These are private +implementation details of the Nexus service: the caller only interacts via Nexus +operations. + +Input types are defined locally (without workflow_id) because the handler strips the +workflow_id before dispatching to the workflow. +""" + +import asyncio +from dataclasses import dataclass +from datetime import timedelta + +from temporalio import workflow +from temporalio.exceptions import ApplicationError + +with workflow.unsafe.imports_passed_through(): + from nexus_messaging.ondemandpattern.handler.activities import call_greeting_service + from nexus_messaging.ondemandpattern.service import GetLanguagesOutput, Language + + +@dataclass +class GetLanguagesInput: + include_unsupported: bool + + +@dataclass +class SetLanguageInput: + language: Language + + +@dataclass +class ApproveInput: + name: str + + +@workflow.defn +class GreetingWorkflow: + def __init__(self) -> None: + self.approved_for_release = False + self.greetings: dict[Language, str] = { + Language.CHINESE: "\u4f60\u597d\uff0c\u4e16\u754c", + Language.ENGLISH: "Hello, world", + } + self.language = Language.ENGLISH + self.lock = asyncio.Lock() + + @workflow.run + async def run(self) -> str: + # Wait until approved and all in-flight update handlers have finished. + await workflow.wait_condition( + lambda: self.approved_for_release and workflow.all_handlers_finished() + ) + return self.greetings[self.language] + + @workflow.query + def get_languages(self, input: GetLanguagesInput) -> GetLanguagesOutput: + if input.include_unsupported: + languages = sorted(Language) + else: + languages = sorted(self.greetings) + return GetLanguagesOutput(languages=languages) + + @workflow.query + def get_language(self) -> Language: + return self.language + + @workflow.signal + def approve(self, input: ApproveInput) -> None: + workflow.logger.info("Approval signal received") + self.approved_for_release = True + + @workflow.update + def set_language(self, input: SetLanguageInput) -> Language: + workflow.logger.info("setLanguage update received") + previous_language, self.language = self.language, input.language + return previous_language + + @set_language.validator + def validate_set_language(self, input: SetLanguageInput) -> None: + if input.language not in self.greetings: + raise ValueError(f"{input.language.name} is not supported") + + # Changes the active language, calling an activity to fetch a greeting for new + # languages not already in the greetings map. + @workflow.update + async def set_language_using_activity(self, input: SetLanguageInput) -> Language: + if input.language not in self.greetings: + async with self.lock: + greeting = await workflow.execute_activity( + call_greeting_service, + input.language, + start_to_close_timeout=timedelta(seconds=10), + ) + if greeting is None: + raise ApplicationError( + f"Greeting service does not support {input.language.name}" + ) + self.greetings[input.language] = greeting + previous_language, self.language = self.language, input.language + return previous_language diff --git a/nexus_messaging/ondemandpattern/service.py b/nexus_messaging/ondemandpattern/service.py new file mode 100644 index 00000000..8f347d32 --- /dev/null +++ b/nexus_messaging/ondemandpattern/service.py @@ -0,0 +1,72 @@ +""" +Nexus service definition for the on-demand pattern. Every operation includes a userId +so the caller controls which workflow instance is targeted. This also exposes a +run_from_remote operation that starts a new GreetingWorkflow. +""" + +from dataclasses import dataclass +from enum import IntEnum + +import nexusrpc + + +class Language(IntEnum): + ARABIC = 1 + CHINESE = 2 + ENGLISH = 3 + FRENCH = 4 + HINDI = 5 + PORTUGUESE = 6 + SPANISH = 7 + + +@dataclass +class RunFromRemoteInput: + user_id: str + + +@dataclass +class GetLanguagesInput: + include_unsupported: bool + user_id: str + + +@dataclass +class GetLanguagesOutput: + languages: list[Language] + + +@dataclass +class GetLanguageInput: + user_id: str + + +@dataclass +class SetLanguageInput: + language: Language + user_id: str + + +@dataclass +class ApproveInput: + name: str + user_id: str + + +@dataclass +class ApproveOutput: + pass + + +@nexusrpc.service +class NexusRemoteGreetingService: + # Starts a new GreetingWorkflow with the given workflow ID (asynchronous). + run_from_remote: nexusrpc.Operation[RunFromRemoteInput, str] + # Returns the languages supported by the specified workflow. + get_languages: nexusrpc.Operation[GetLanguagesInput, GetLanguagesOutput] + # Returns the currently active language of the specified workflow. + get_language: nexusrpc.Operation[GetLanguageInput, Language] + # Changes the active language on the specified workflow, returning the previous one. + set_language: nexusrpc.Operation[SetLanguageInput, Language] + # Approves the specified workflow, allowing it to complete. + approve: nexusrpc.Operation[ApproveInput, ApproveOutput] diff --git a/nexus_sync_operations/README.md b/nexus_sync_operations/README.md deleted file mode 100644 index 10e266ec..00000000 --- a/nexus_sync_operations/README.md +++ /dev/null @@ -1,39 +0,0 @@ -This sample shows how to create a Nexus service that is backed by a long-running workflow and -exposes operations that execute updates and queries against that workflow. The long-running -workflow, and the updates/queries are private implementation detail of the nexus service: the caller -does not know how the operations are implemented. - -### Sample directory structure - -- [service.py](./service.py) - shared Nexus service definition -- [caller](./caller) - a caller workflow that executes Nexus operations, together with a worker and starter code -- [handler](./handler) - Nexus operation handlers, together with a workflow used by one of the Nexus operations, and a worker that polls for both workflow, activity, and Nexus tasks. - - -### Instructions - -Start a Temporal server. (See the main samples repo [README](../README.md)). - -Run the following to create the caller and handler namespaces, and the Nexus endpoint: - -``` -temporal operator namespace create --namespace nexus-sync-operations-handler-namespace -temporal operator namespace create --namespace nexus-sync-operations-caller-namespace - -temporal operator nexus endpoint create \ - --name nexus-sync-operations-nexus-endpoint \ - --target-namespace nexus-sync-operations-handler-namespace \ - --target-task-queue nexus-sync-operations-handler-task-queue \ - --description-file nexus_sync_operations/endpoint_description.md -``` - -In one terminal, run the Temporal worker in the handler namespace: -``` -uv run nexus_sync_operations/handler/worker.py -``` - -In another terminal, run the Temporal worker in the caller namespace and start the caller -workflow: -``` -uv run nexus_sync_operations/caller/app.py -``` diff --git a/nexus_sync_operations/caller/workflows.py b/nexus_sync_operations/caller/workflows.py deleted file mode 100644 index a358d764..00000000 --- a/nexus_sync_operations/caller/workflows.py +++ /dev/null @@ -1,46 +0,0 @@ -""" -This is a workflow that calls nexus operations. The caller does not have information about how these -operations are implemented by the nexus service. -""" - -from temporalio import workflow - -from message_passing.introduction import Language -from message_passing.introduction.workflows import GetLanguagesInput, SetLanguageInput - -with workflow.unsafe.imports_passed_through(): - from nexus_sync_operations.service import GreetingService - -NEXUS_ENDPOINT = "nexus-sync-operations-nexus-endpoint" - - -@workflow.defn -class CallerWorkflow: - @workflow.run - async def run(self) -> list[str]: - log = [] - nexus_client = workflow.create_nexus_client( - service=GreetingService, - endpoint=NEXUS_ENDPOINT, - ) - - # Get supported languages - supported_languages = await nexus_client.execute_operation( - GreetingService.get_languages, GetLanguagesInput(include_unsupported=False) - ) - log.append(f"supported languages: {supported_languages}") - - # Set language - previous_language = await nexus_client.execute_operation( - GreetingService.set_language, - SetLanguageInput(language=Language.ARABIC), - ) - assert ( - await nexus_client.execute_operation(GreetingService.get_language, None) - == Language.ARABIC - ) - log.append( - f"language changed: {previous_language.name} -> {Language.ARABIC.name}" - ) - - return log diff --git a/nexus_sync_operations/endpoint_description.md b/nexus_sync_operations/endpoint_description.md deleted file mode 100644 index a33b60cf..00000000 --- a/nexus_sync_operations/endpoint_description.md +++ /dev/null @@ -1,4 +0,0 @@ -## Service: [GreetingService](https://github.com/temporalio/samples-python/blob/main/nexus_sync_operations/service.py) -- operation: `get_languages` -- operation: `get_language` -- operation: `set_language` diff --git a/nexus_sync_operations/handler/service_handler.py b/nexus_sync_operations/handler/service_handler.py deleted file mode 100644 index 626948f0..00000000 --- a/nexus_sync_operations/handler/service_handler.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -This file demonstrates how to implement a Nexus service that is backed by a long-running workflow -and exposes operations that perform updates and queries against that workflow. -""" - -from __future__ import annotations - -import nexusrpc -from temporalio import nexus -from temporalio.client import Client, WorkflowHandle -from temporalio.common import WorkflowIDConflictPolicy - -from message_passing.introduction import Language -from message_passing.introduction.workflows import ( - GetLanguagesInput, - GreetingWorkflow, - SetLanguageInput, -) -from nexus_sync_operations.service import GreetingService - - -@nexusrpc.handler.service_handler(service=GreetingService) -class GreetingServiceHandler: - def __init__(self, workflow_id: str): - self.workflow_id = workflow_id - - @classmethod - async def create( - cls, workflow_id: str, client: Client, task_queue: str - ) -> GreetingServiceHandler: - # Start the long-running "entity" workflow, if it is not already running. - await client.start_workflow( - GreetingWorkflow.run, - id=workflow_id, - task_queue=task_queue, - id_conflict_policy=WorkflowIDConflictPolicy.USE_EXISTING, - ) - return cls(workflow_id) - - @property - def greeting_workflow_handle(self) -> WorkflowHandle[GreetingWorkflow, str]: - # In nexus operation handler code, nexus.client() is always available, returning a client - # connected to the handler namespace (it's the same client instance that your nexus worker - # is using to poll the server for nexus tasks). This client can be used to interact with the - # handler namespace, for example to send signals, queries, or updates. Remember however, - # that a sync_operation handler must return quickly (no more than a few seconds). To do - # long-running work in a nexus operation handler, use - # temporalio.nexus.workflow_run_operation (see the hello_nexus sample). - return nexus.client().get_workflow_handle_for( - GreetingWorkflow.run, self.workflow_id - ) - - # 👉 This is a handler for a nexus operation whose internal implementation involves executing a - # query against a long-running workflow that is private to the nexus service. - @nexusrpc.handler.sync_operation - async def get_languages( - self, ctx: nexusrpc.handler.StartOperationContext, input: GetLanguagesInput - ) -> list[Language]: - return await self.greeting_workflow_handle.query( - GreetingWorkflow.get_languages, input - ) - - # 👉 This is a handler for a nexus operation whose internal implementation involves executing a - # query against a long-running workflow that is private to the nexus service. - @nexusrpc.handler.sync_operation - async def get_language( - self, ctx: nexusrpc.handler.StartOperationContext, input: None - ) -> Language: - return await self.greeting_workflow_handle.query(GreetingWorkflow.get_language) - - # 👉 This is a handler for a nexus operation whose internal implementation involves executing an - # update against a long-running workflow that is private to the nexus service. Although updates - # can run for an arbitrarily long time, when exposing an update via a nexus sync operation the - # update should execute quickly (sync operations must complete in under 10s). - @nexusrpc.handler.sync_operation - async def set_language( - self, - ctx: nexusrpc.handler.StartOperationContext, - input: SetLanguageInput, - ) -> Language: - return await self.greeting_workflow_handle.execute_update( - GreetingWorkflow.set_language_using_activity, input - ) diff --git a/nexus_sync_operations/service.py b/nexus_sync_operations/service.py deleted file mode 100644 index 3436d5f3..00000000 --- a/nexus_sync_operations/service.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -This module defines a Nexus service that exposes three operations. - -It is used by the nexus service handler to validate that the operation handlers implement the -correct input and output types, and by the caller workflow to create a type-safe client. It does not -contain the implementation of the operations; see nexus_sync_operations.handler.service_handler for -that. -""" - -import nexusrpc - -from message_passing.introduction import Language -from message_passing.introduction.workflows import GetLanguagesInput, SetLanguageInput - - -@nexusrpc.service -class GreetingService: - get_languages: nexusrpc.Operation[GetLanguagesInput, list[Language]] - get_language: nexusrpc.Operation[None, Language] - set_language: nexusrpc.Operation[SetLanguageInput, Language] diff --git a/tests/nexus_messaging/callerpattern_test.py b/tests/nexus_messaging/callerpattern_test.py new file mode 100644 index 00000000..117f9370 --- /dev/null +++ b/tests/nexus_messaging/callerpattern_test.py @@ -0,0 +1,122 @@ +import asyncio +import uuid +from typing import Type + +import pytest +from temporalio import workflow +from temporalio.client import Client +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +import nexus_messaging.callerpattern.handler.worker +from nexus_messaging.callerpattern.caller.workflows import CallerWorkflow +from nexus_messaging.callerpattern.service import ( + GetLanguageInput, + GetLanguagesInput, + Language, + SetLanguageInput, +) +from tests.helpers.nexus import create_nexus_endpoint, delete_nexus_endpoint + +with workflow.unsafe.imports_passed_through(): + from nexus_messaging.callerpattern.service import NexusGreetingService + + +NEXUS_ENDPOINT = "nexus-messaging-nexus-endpoint" + + +@workflow.defn +class TestCallerWorkflow: + """Test workflow that calls Nexus operations and makes assertions.""" + + @workflow.run + async def run(self, user_id: str) -> None: + nexus_client = workflow.create_nexus_client( + service=NexusGreetingService, + endpoint=NEXUS_ENDPOINT, + ) + + supported_languages = await nexus_client.execute_operation( + NexusGreetingService.get_languages, + GetLanguagesInput(include_unsupported=False, user_id=user_id), + ) + assert supported_languages.languages == [Language.CHINESE, Language.ENGLISH] + + initial_language = await nexus_client.execute_operation( + NexusGreetingService.get_language, + GetLanguageInput(user_id=user_id), + ) + assert initial_language == Language.ENGLISH + + previous_language = await nexus_client.execute_operation( + NexusGreetingService.set_language, + SetLanguageInput(language=Language.CHINESE, user_id=user_id), + ) + assert previous_language == Language.ENGLISH + + current_language = await nexus_client.execute_operation( + NexusGreetingService.get_language, + GetLanguageInput(user_id=user_id), + ) + assert current_language == Language.CHINESE + + previous_language = await nexus_client.execute_operation( + NexusGreetingService.set_language, + SetLanguageInput(language=Language.ARABIC, user_id=user_id), + ) + assert previous_language == Language.CHINESE + + current_language = await nexus_client.execute_operation( + NexusGreetingService.get_language, + GetLanguageInput(user_id=user_id), + ) + assert current_language == Language.ARABIC + + +async def test_callerpattern(client: Client, env: WorkflowEnvironment): + if env.supports_time_skipping: + pytest.skip("Nexus tests don't work under the Java test server") + + await _run_caller_workflow(client, TestCallerWorkflow) + + +async def test_callerpattern_caller_workflow(client: Client, env: WorkflowEnvironment): + """Runs the CallerWorkflow from the sample to ensure it executes without errors.""" + if env.supports_time_skipping: + pytest.skip("Nexus tests don't work under the Java test server") + + await _run_caller_workflow(client, CallerWorkflow) + + +async def _run_caller_workflow(client: Client, wf: Type): + create_response = await create_nexus_endpoint( + name=NEXUS_ENDPOINT, + task_queue=nexus_messaging.callerpattern.handler.worker.TASK_QUEUE, + client=client, + ) + try: + handler_worker_task = asyncio.create_task( + nexus_messaging.callerpattern.handler.worker.main(client) + ) + try: + async with Worker( + client, + task_queue="test-caller-task-queue", + workflows=[wf], + ): + await client.execute_workflow( + wf.run, + arg="user-1", + id=str(uuid.uuid4()), + task_queue="test-caller-task-queue", + ) + finally: + nexus_messaging.callerpattern.handler.worker.interrupt_event.set() + await handler_worker_task + nexus_messaging.callerpattern.handler.worker.interrupt_event.clear() + finally: + await delete_nexus_endpoint( + id=create_response.endpoint.id, + version=create_response.endpoint.version, + client=client, + ) diff --git a/tests/nexus_messaging/ondemandpattern_test.py b/tests/nexus_messaging/ondemandpattern_test.py new file mode 100644 index 00000000..e43e3af3 --- /dev/null +++ b/tests/nexus_messaging/ondemandpattern_test.py @@ -0,0 +1,134 @@ +import asyncio +import uuid +from typing import Type + +import pytest +from temporalio import workflow +from temporalio.client import Client +from temporalio.testing import WorkflowEnvironment +from temporalio.worker import Worker + +import nexus_messaging.ondemandpattern.handler.worker +from nexus_messaging.ondemandpattern.caller.workflows import CallerRemoteWorkflow +from nexus_messaging.ondemandpattern.service import ( + ApproveInput, + GetLanguageInput, + GetLanguagesInput, + Language, + NexusRemoteGreetingService, + RunFromRemoteInput, + SetLanguageInput, +) +from tests.helpers.nexus import create_nexus_endpoint, delete_nexus_endpoint + +with workflow.unsafe.imports_passed_through(): + from nexus_messaging.ondemandpattern.service import NexusRemoteGreetingService + + +NEXUS_ENDPOINT = "nexus-messaging-nexus-endpoint" + + +@workflow.defn +class TestCallerRemoteWorkflow: + """Test workflow that creates remote workflows and makes assertions.""" + + @workflow.run + async def run(self) -> None: + nexus_client = workflow.create_nexus_client( + service=NexusRemoteGreetingService, + endpoint=NEXUS_ENDPOINT, + ) + + workflow_id = f"test-remote-{uuid.uuid4()}" + + # Start a remote workflow. + handle = await nexus_client.start_operation( + NexusRemoteGreetingService.run_from_remote, + RunFromRemoteInput(user_id=workflow_id), + ) + + # Query for supported languages. + languages_output = await nexus_client.execute_operation( + NexusRemoteGreetingService.get_languages, + GetLanguagesInput(include_unsupported=False, user_id=workflow_id), + ) + assert languages_output.languages == [Language.CHINESE, Language.ENGLISH] + + # Check initial language. + initial_language = await nexus_client.execute_operation( + NexusRemoteGreetingService.get_language, + GetLanguageInput(user_id=workflow_id), + ) + assert initial_language == Language.ENGLISH + + # Set language. + previous_language = await nexus_client.execute_operation( + NexusRemoteGreetingService.set_language, + SetLanguageInput(language=Language.ARABIC, user_id=workflow_id), + ) + assert previous_language == Language.ENGLISH + + current_language = await nexus_client.execute_operation( + NexusRemoteGreetingService.get_language, + GetLanguageInput(user_id=workflow_id), + ) + assert current_language == Language.ARABIC + + # Approve and wait for result. + await nexus_client.execute_operation( + NexusRemoteGreetingService.approve, + ApproveInput(name="test", user_id=workflow_id), + ) + + result = await handle + assert "\u0645\u0631\u062d\u0628\u0627" in result # Arabic greeting + + +async def test_ondemandpattern(client: Client, env: WorkflowEnvironment): + if env.supports_time_skipping: + pytest.skip("Nexus tests don't work under the Java test server") + + await _run_caller_workflow(client, TestCallerRemoteWorkflow) + + +async def test_ondemandpattern_caller_workflow( + client: Client, env: WorkflowEnvironment +): + """Runs the CallerRemoteWorkflow from the sample to ensure it executes without errors.""" + if env.supports_time_skipping: + pytest.skip("Nexus tests don't work under the Java test server") + + await _run_caller_workflow(client, CallerRemoteWorkflow) + + +async def _run_caller_workflow(client: Client, wf: Type): + create_response = await create_nexus_endpoint( + name=NEXUS_ENDPOINT, + task_queue=nexus_messaging.ondemandpattern.handler.worker.TASK_QUEUE, + client=client, + ) + try: + handler_worker_task = asyncio.create_task( + nexus_messaging.ondemandpattern.handler.worker.main(client) + ) + try: + async with Worker( + client, + task_queue="test-caller-remote-task-queue", + workflows=[wf], + ): + await client.execute_workflow( + wf.run, + id=str(uuid.uuid4()), + task_queue="test-caller-remote-task-queue", + ) + finally: + nexus_messaging.ondemandpattern.handler.worker.interrupt_event.set() + await handler_worker_task + nexus_messaging.ondemandpattern.handler.worker.interrupt_event.clear() + finally: + await delete_nexus_endpoint( + id=create_response.endpoint.id, + version=create_response.endpoint.version, + client=client, + ) diff --git a/tests/nexus_sync_operations/nexus_sync_operations_test.py b/tests/nexus_sync_operations/nexus_sync_operations_test.py index d74168cb..39914267 100644 --- a/tests/nexus_sync_operations/nexus_sync_operations_test.py +++ b/tests/nexus_sync_operations/nexus_sync_operations_test.py @@ -8,15 +8,15 @@ from temporalio.testing import WorkflowEnvironment from temporalio.worker import Worker -import nexus_sync_operations.handler.service_handler -import nexus_sync_operations.handler.worker +import nexus_sync_operations_DELETE_ME.handler.service_handler +import nexus_sync_operations_DELETE_ME.handler.worker from message_passing.introduction import Language from message_passing.introduction.workflows import GetLanguagesInput, SetLanguageInput -from nexus_sync_operations.caller.workflows import CallerWorkflow +from nexus_sync_operations_DELETE_ME.caller.workflows import CallerWorkflow from tests.helpers.nexus import create_nexus_endpoint, delete_nexus_endpoint with workflow.unsafe.imports_passed_through(): - from nexus_sync_operations.service import GreetingService + from nexus_sync_operations_DELETE_ME.service import GreetingService NEXUS_ENDPOINT = "nexus-sync-operations-nexus-endpoint" @@ -88,12 +88,12 @@ async def test_nexus_sync_operations_caller_workflow( async def _run_caller_workflow(client: Client, workflow: Type): create_response = await create_nexus_endpoint( name=NEXUS_ENDPOINT, - task_queue=nexus_sync_operations.handler.worker.TASK_QUEUE, + task_queue=nexus_sync_operations_DELETE_ME.handler.worker.TASK_QUEUE, client=client, ) try: handler_worker_task = asyncio.create_task( - nexus_sync_operations.handler.worker.main(client) + nexus_sync_operations_DELETE_ME.handler.worker.main(client) ) try: async with Worker( @@ -107,9 +107,9 @@ async def _run_caller_workflow(client: Client, workflow: Type): task_queue="test-caller-task-queue", ) finally: - nexus_sync_operations.handler.worker.interrupt_event.set() + nexus_sync_operations_DELETE_ME.handler.worker.interrupt_event.set() await handler_worker_task - nexus_sync_operations.handler.worker.interrupt_event.clear() + nexus_sync_operations_DELETE_ME.handler.worker.interrupt_event.clear() finally: await delete_nexus_endpoint( id=create_response.endpoint.id, From 8ed37215fa0d66a51e82cc15a800954d46c8b39c Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Mon, 13 Apr 2026 16:23:30 -0700 Subject: [PATCH 5/7] Fix to the tests --- tests/nexus_messaging/ondemandpattern_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/nexus_messaging/ondemandpattern_test.py b/tests/nexus_messaging/ondemandpattern_test.py index e43e3af3..843df3b4 100644 --- a/tests/nexus_messaging/ondemandpattern_test.py +++ b/tests/nexus_messaging/ondemandpattern_test.py @@ -39,7 +39,7 @@ async def run(self) -> None: endpoint=NEXUS_ENDPOINT, ) - workflow_id = f"test-remote-{uuid.uuid4()}" + workflow_id = f"test-remote-{workflow.uuid4()}" # Start a remote workflow. handle = await nexus_client.start_operation( From ca866f14f1b62881c7225e3e7787af98df8a1820 Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Tue, 14 Apr 2026 17:12:20 -0700 Subject: [PATCH 6/7] Updates from a code review --- nexus_messaging/callerpattern/README.md | 8 +- nexus_messaging/ondemandpattern/README.md | 8 +- .../handler/service_handler.py | 16 +-- .../ondemandpattern/handler/workflows.py | 31 ++--- tests/nexus_messaging/ondemandpattern_test.py | 1 - .../nexus_sync_operations_test.py | 118 ------------------ 6 files changed, 15 insertions(+), 167 deletions(-) delete mode 100644 tests/nexus_sync_operations/nexus_sync_operations_test.py diff --git a/nexus_messaging/callerpattern/README.md b/nexus_messaging/callerpattern/README.md index 085a4903..d4e73e04 100644 --- a/nexus_messaging/callerpattern/README.md +++ b/nexus_messaging/callerpattern/README.md @@ -1,4 +1,4 @@ -## Entity pattern +## Caller pattern The handler worker starts a `GreetingWorkflow` for a user ID. `NexusGreetingServiceHandler` holds that ID and routes every Nexus operation to it. @@ -15,12 +15,6 @@ The caller workflow: 3. Confirms the change via a second query (`get_language`) 4. Approves the workflow (`approve` -- backed by a `@workflow.signal`) -### Sample directory structure - -- [service.py](./service.py) - shared Nexus service definition -- [caller](./caller) - a caller workflow that executes Nexus operations, together with a starter -- [handler](./handler) - Nexus operation handlers, together with a workflow used by the Nexus operations, and a worker that polls for workflow, activity, and Nexus tasks - ### Running Start a Temporal server: diff --git a/nexus_messaging/ondemandpattern/README.md b/nexus_messaging/ondemandpattern/README.md index 260da9bd..53c3dae6 100644 --- a/nexus_messaging/ondemandpattern/README.md +++ b/nexus_messaging/ondemandpattern/README.md @@ -2,7 +2,7 @@ No workflow is pre-started. The caller creates and controls workflow instances through Nexus operations. `NexusRemoteGreetingService` adds a `run_from_remote` operation that starts a new -`GreetingWorkflow`, and every other operation includes a `workflow_id` so the handler knows which +`GreetingWorkflow`, and every other operation includes a `user_id` so the handler knows which instance to target. The caller workflow: @@ -13,12 +13,6 @@ The caller workflow: 5. Approves both workflows 6. Waits for each to complete and returns their results -### Sample directory structure - -- [service.py](./service.py) - shared Nexus service definition -- [caller](./caller) - a caller workflow that creates remote workflows and executes Nexus operations, together with a starter -- [handler](./handler) - Nexus operation handlers, together with a workflow started on demand by the Nexus operations, and a worker that polls for workflow, activity, and Nexus tasks - ### Running Start a Temporal server: diff --git a/nexus_messaging/ondemandpattern/handler/service_handler.py b/nexus_messaging/ondemandpattern/handler/service_handler.py index 2aeb2092..1351aae7 100644 --- a/nexus_messaging/ondemandpattern/handler/service_handler.py +++ b/nexus_messaging/ondemandpattern/handler/service_handler.py @@ -9,12 +9,7 @@ from temporalio import nexus from temporalio.client import WorkflowHandle -from nexus_messaging.ondemandpattern.handler.workflows import ( - ApproveInput as WorkflowApproveInput, - GetLanguagesInput as WorkflowGetLanguagesInput, - GreetingWorkflow, - SetLanguageInput as WorkflowSetLanguageInput, -) +from nexus_messaging.ondemandpattern.handler.workflows import GreetingWorkflow from nexus_messaging.ondemandpattern.service import ( ApproveInput, ApproveOutput, @@ -58,8 +53,7 @@ async def get_languages( self, ctx: nexusrpc.handler.StartOperationContext, input: GetLanguagesInput ) -> GetLanguagesOutput: return await self._get_workflow_handle(input.user_id).query( - GreetingWorkflow.get_languages, - WorkflowGetLanguagesInput(include_unsupported=input.include_unsupported), + GreetingWorkflow.get_languages, input ) @nexusrpc.handler.sync_operation @@ -77,8 +71,7 @@ async def set_language( self, ctx: nexusrpc.handler.StartOperationContext, input: SetLanguageInput ) -> Language: return await self._get_workflow_handle(input.user_id).execute_update( - GreetingWorkflow.set_language_using_activity, - WorkflowSetLanguageInput(language=input.language), + GreetingWorkflow.set_language_using_activity, input ) @nexusrpc.handler.sync_operation @@ -86,7 +79,6 @@ async def approve( self, ctx: nexusrpc.handler.StartOperationContext, input: ApproveInput ) -> ApproveOutput: await self._get_workflow_handle(input.user_id).signal( - GreetingWorkflow.approve, - WorkflowApproveInput(name=input.name), + GreetingWorkflow.approve, input ) return ApproveOutput() diff --git a/nexus_messaging/ondemandpattern/handler/workflows.py b/nexus_messaging/ondemandpattern/handler/workflows.py index ffaa6361..21227661 100644 --- a/nexus_messaging/ondemandpattern/handler/workflows.py +++ b/nexus_messaging/ondemandpattern/handler/workflows.py @@ -3,13 +3,9 @@ operations. The workflow exposes queries, an update, and a signal. These are private implementation details of the Nexus service: the caller only interacts via Nexus operations. - -Input types are defined locally (without workflow_id) because the handler strips the -workflow_id before dispatching to the workflow. """ import asyncio -from dataclasses import dataclass from datetime import timedelta from temporalio import workflow @@ -17,22 +13,13 @@ with workflow.unsafe.imports_passed_through(): from nexus_messaging.ondemandpattern.handler.activities import call_greeting_service - from nexus_messaging.ondemandpattern.service import GetLanguagesOutput, Language - - -@dataclass -class GetLanguagesInput: - include_unsupported: bool - - -@dataclass -class SetLanguageInput: - language: Language - - -@dataclass -class ApproveInput: - name: str + from nexus_messaging.ondemandpattern.service import ( + ApproveInput, + GetLanguagesInput, + GetLanguagesOutput, + Language, + SetLanguageInput, + ) @workflow.defn @@ -68,12 +55,12 @@ def get_language(self) -> Language: @workflow.signal def approve(self, input: ApproveInput) -> None: - workflow.logger.info("Approval signal received") + workflow.logger.info("Approval signal received for user %s", input.user_id) self.approved_for_release = True @workflow.update def set_language(self, input: SetLanguageInput) -> Language: - workflow.logger.info("setLanguage update received") + workflow.logger.info("setLanguage update received for user %s", input.user_id) previous_language, self.language = self.language, input.language return previous_language diff --git a/tests/nexus_messaging/ondemandpattern_test.py b/tests/nexus_messaging/ondemandpattern_test.py index 843df3b4..087285f1 100644 --- a/tests/nexus_messaging/ondemandpattern_test.py +++ b/tests/nexus_messaging/ondemandpattern_test.py @@ -15,7 +15,6 @@ GetLanguageInput, GetLanguagesInput, Language, - NexusRemoteGreetingService, RunFromRemoteInput, SetLanguageInput, ) diff --git a/tests/nexus_sync_operations/nexus_sync_operations_test.py b/tests/nexus_sync_operations/nexus_sync_operations_test.py deleted file mode 100644 index 39914267..00000000 --- a/tests/nexus_sync_operations/nexus_sync_operations_test.py +++ /dev/null @@ -1,118 +0,0 @@ -import asyncio -import uuid -from typing import Type - -import pytest -from temporalio import workflow -from temporalio.client import Client -from temporalio.testing import WorkflowEnvironment -from temporalio.worker import Worker - -import nexus_sync_operations_DELETE_ME.handler.service_handler -import nexus_sync_operations_DELETE_ME.handler.worker -from message_passing.introduction import Language -from message_passing.introduction.workflows import GetLanguagesInput, SetLanguageInput -from nexus_sync_operations_DELETE_ME.caller.workflows import CallerWorkflow -from tests.helpers.nexus import create_nexus_endpoint, delete_nexus_endpoint - -with workflow.unsafe.imports_passed_through(): - from nexus_sync_operations_DELETE_ME.service import GreetingService - - -NEXUS_ENDPOINT = "nexus-sync-operations-nexus-endpoint" - - -@workflow.defn -class TestCallerWorkflow: - """Test workflow that calls Nexus operations and makes assertions.""" - - @workflow.run - async def run(self) -> None: - nexus_client = workflow.create_nexus_client( - service=GreetingService, - endpoint=NEXUS_ENDPOINT, - ) - - supported_languages = await nexus_client.execute_operation( - GreetingService.get_languages, GetLanguagesInput(include_unsupported=False) - ) - assert supported_languages == [Language.CHINESE, Language.ENGLISH] - - initial_language = await nexus_client.execute_operation( - GreetingService.get_language, None - ) - assert initial_language == Language.ENGLISH - - previous_language = await nexus_client.execute_operation( - GreetingService.set_language, - SetLanguageInput(language=Language.CHINESE), - ) - assert previous_language == Language.ENGLISH - - current_language = await nexus_client.execute_operation( - GreetingService.get_language, None - ) - assert current_language == Language.CHINESE - - previous_language = await nexus_client.execute_operation( - GreetingService.set_language, - SetLanguageInput(language=Language.ARABIC), - ) - assert previous_language == Language.CHINESE - - current_language = await nexus_client.execute_operation( - GreetingService.get_language, None - ) - assert current_language == Language.ARABIC - - -async def test_nexus_sync_operations(client: Client, env: WorkflowEnvironment): - if env.supports_time_skipping: - pytest.skip("Nexus tests don't work under the Java test server") - - await _run_caller_workflow(client, TestCallerWorkflow) - - -async def test_nexus_sync_operations_caller_workflow( - client: Client, env: WorkflowEnvironment -): - """ - Runs the CallerWorkflow from the sample to ensure it executes without errors. - """ - if env.supports_time_skipping: - pytest.skip("Nexus tests don't work under the Java test server") - - await _run_caller_workflow(client, CallerWorkflow) - - -async def _run_caller_workflow(client: Client, workflow: Type): - create_response = await create_nexus_endpoint( - name=NEXUS_ENDPOINT, - task_queue=nexus_sync_operations_DELETE_ME.handler.worker.TASK_QUEUE, - client=client, - ) - try: - handler_worker_task = asyncio.create_task( - nexus_sync_operations_DELETE_ME.handler.worker.main(client) - ) - try: - async with Worker( - client, - task_queue="test-caller-task-queue", - workflows=[workflow], - ): - await client.execute_workflow( - workflow.run, - id=str(uuid.uuid4()), - task_queue="test-caller-task-queue", - ) - finally: - nexus_sync_operations_DELETE_ME.handler.worker.interrupt_event.set() - await handler_worker_task - nexus_sync_operations_DELETE_ME.handler.worker.interrupt_event.clear() - finally: - await delete_nexus_endpoint( - id=create_response.endpoint.id, - version=create_response.endpoint.version, - client=client, - ) From ea466a245fc75e42542125f78d49f1def580af0f Mon Sep 17 00:00:00 2001 From: Evan Reynolds Date: Thu, 16 Apr 2026 12:17:15 -0700 Subject: [PATCH 7/7] Bump pandas to 2.3.3 for Python 3.14 wheel support --- pyproject.toml | 2 +- uv.lock | 375 +++++++++++++++++++++++++------------------------ 2 files changed, 195 insertions(+), 182 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index caae123c..c111f898 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ pydantic-converter = ["pydantic>=2.10.6,<3"] sentry = ["sentry-sdk>=2.13.0"] trio-async = ["trio>=0.28.0,<0.29", "trio-asyncio>=0.15.0,<0.16"] cloud-export-to-parquet = [ - "pandas>=2.2.2,<3 ; python_version >= '3.10' and python_version < '4.0'", + "pandas>=2.3.3,<3 ; python_version >= '3.10' and python_version < '4.0'", "numpy>=1.26.0,<2 ; python_version >= '3.10' and python_version < '3.13'", "boto3>=1.34.89,<2", "pyarrow>=19.0.1", diff --git a/uv.lock b/uv.lock index dd39daa7..57e240c3 100644 --- a/uv.lock +++ b/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "aiohappyeyeballs" version = "2.6.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "aiohttp" version = "3.12.14" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, { name = "aiosignal" }, @@ -107,7 +107,7 @@ wheels = [ [[package]] name = "aiosignal" version = "1.4.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "frozenlist" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -120,7 +120,7 @@ wheels = [ [[package]] name = "annotated-types" version = "0.7.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, @@ -129,7 +129,7 @@ wheels = [ [[package]] name = "anyio" version = "4.9.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "idna" }, @@ -144,7 +144,7 @@ wheels = [ [[package]] name = "async-timeout" version = "4.0.3" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/87/d6/21b30a550dafea84b1b8eee21b5e23fa16d010ae006011221f33dcd8d7f8/async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f", size = 8345, upload-time = "2023-08-10T16:35:56.907Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a7/fa/e01228c2938de91d47b307831c62ab9e4001e747789d0b05baf779a6488c/async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028", size = 5721, upload-time = "2023-08-10T16:35:55.203Z" }, @@ -153,7 +153,7 @@ wheels = [ [[package]] name = "attrs" version = "25.3.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, @@ -162,7 +162,7 @@ wheels = [ [[package]] name = "boto3" version = "1.39.4" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, @@ -176,7 +176,7 @@ wheels = [ [[package]] name = "botocore" version = "1.39.4" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, @@ -190,7 +190,7 @@ wheels = [ [[package]] name = "certifi" version = "2025.7.9" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/de/8a/c729b6b60c66a38f590c4e774decc4b2ec7b0576be8f1aa984a53ffa812a/certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079", size = 160386, upload-time = "2025-07-09T02:13:58.874Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/66/f3/80a3f974c8b535d394ff960a11ac20368e06b736da395b551a49ce950cce/certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39", size = 159230, upload-time = "2025-07-09T02:13:57.007Z" }, @@ -199,7 +199,7 @@ wheels = [ [[package]] name = "cffi" version = "1.17.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser" }, ] @@ -256,7 +256,7 @@ wheels = [ [[package]] name = "charset-normalizer" version = "3.4.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367, upload-time = "2025-05-02T08:34:42.01Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/95/28/9901804da60055b406e1a1c5ba7aac1276fb77f1dde635aabfc7fd84b8ab/charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941", size = 201818, upload-time = "2025-05-02T08:31:46.725Z" }, @@ -317,7 +317,7 @@ wheels = [ [[package]] name = "click" version = "8.2.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] @@ -329,7 +329,7 @@ wheels = [ [[package]] name = "colorama" version = "0.4.6" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, @@ -338,7 +338,7 @@ wheels = [ [[package]] name = "cryptography" version = "38.0.4" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] @@ -361,7 +361,7 @@ wheels = [ [[package]] name = "dacite" version = "1.9.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/55/a0/7ca79796e799a3e782045d29bf052b5cde7439a2bbb17f15ff44f7aacc63/dacite-1.9.2.tar.gz", hash = "sha256:6ccc3b299727c7aa17582f0021f6ae14d5de47c7227932c47fec4cdfefd26f09", size = 22420, upload-time = "2025-02-05T09:27:29.757Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/94/35/386550fd60316d1e37eccdda609b074113298f23cef5bddb2049823fe666/dacite-1.9.2-py3-none-any.whl", hash = "sha256:053f7c3f5128ca2e9aceb66892b1a3c8936d02c686e707bee96e19deef4bc4a0", size = 16600, upload-time = "2025-02-05T09:27:24.345Z" }, @@ -370,7 +370,7 @@ wheels = [ [[package]] name = "dataclasses-json" version = "0.6.7" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "marshmallow" }, { name = "typing-inspect" }, @@ -383,7 +383,7 @@ wheels = [ [[package]] name = "distro" version = "1.9.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, @@ -392,7 +392,7 @@ wheels = [ [[package]] name = "exceptiongroup" version = "1.3.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -404,7 +404,7 @@ wheels = [ [[package]] name = "fastapi" version = "0.116.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "starlette" }, @@ -418,7 +418,7 @@ wheels = [ [[package]] name = "filelock" version = "3.18.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, @@ -427,7 +427,7 @@ wheels = [ [[package]] name = "frozenlist" version = "1.7.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/79/b1/b64018016eeb087db503b038296fd782586432b9c077fc5c7839e9cb6ef6/frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f", size = 45078, upload-time = "2025-06-09T23:02:35.538Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/af/36/0da0a49409f6b47cc2d060dc8c9040b897b5902a8a4e37d9bc1deb11f680/frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a", size = 81304, upload-time = "2025-06-09T22:59:46.226Z" }, @@ -521,7 +521,7 @@ wheels = [ [[package]] name = "fsspec" version = "2025.7.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8b/02/0835e6ab9cfc03916fe3f78c0956cfcdb6ff2669ffa6651065d5ebf7fc98/fsspec-2025.7.0.tar.gz", hash = "sha256:786120687ffa54b8283d942929540d8bc5ccfa820deb555a2b5d0ed2b737bf58", size = 304432, upload-time = "2025-07-15T16:05:21.19Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2f/e0/014d5d9d7a4564cf1c40b5039bc882db69fd881111e03ab3657ac0b218e2/fsspec-2025.7.0-py3-none-any.whl", hash = "sha256:8b012e39f63c7d5f10474de957f3ab793b47b45ae7d39f2fb735f8bbe25c0e21", size = 199597, upload-time = "2025-07-15T16:05:19.529Z" }, @@ -530,7 +530,7 @@ wheels = [ [[package]] name = "gevent" version = "25.9.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, @@ -582,7 +582,7 @@ wheels = [ [[package]] name = "googleapis-common-protos" version = "1.70.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] @@ -594,7 +594,7 @@ wheels = [ [[package]] name = "greenlet" version = "3.2.3" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752, upload-time = "2025-06-05T16:16:09.955Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/92/db/b4c12cff13ebac2786f4f217f06588bccd8b53d260453404ef22b121fc3a/greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be", size = 268977, upload-time = "2025-06-05T16:10:24.001Z" }, @@ -645,7 +645,7 @@ wheels = [ [[package]] name = "griffe" version = "1.7.3" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama" }, ] @@ -657,7 +657,7 @@ wheels = [ [[package]] name = "grpcio" version = "1.76.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] @@ -718,7 +718,7 @@ wheels = [ [[package]] name = "h11" version = "0.16.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, @@ -727,7 +727,7 @@ wheels = [ [[package]] name = "hf-xet" version = "1.1.5" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ed/d4/7685999e85945ed0d7f0762b686ae7015035390de1161dcea9d5276c134c/hf_xet-1.1.5.tar.gz", hash = "sha256:69ebbcfd9ec44fdc2af73441619eeb06b94ee34511bbcf57cd423820090f5694", size = 495969, upload-time = "2025-06-20T21:48:38.007Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/00/89/a1119eebe2836cb25758e7661d6410d3eae982e2b5e974bcc4d250be9012/hf_xet-1.1.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:f52c2fa3635b8c37c7764d8796dfa72706cc4eded19d638331161e82b0792e23", size = 2687929, upload-time = "2025-06-20T21:48:32.284Z" }, @@ -742,7 +742,7 @@ wheels = [ [[package]] name = "httpcore" version = "1.0.9" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, @@ -755,7 +755,7 @@ wheels = [ [[package]] name = "httptools" version = "0.6.4" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3b/6f/972f8eb0ea7d98a1c6be436e2142d51ad2a64ee18e02b0e7ff1f62171ab1/httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0", size = 198780, upload-time = "2024-10-16T19:44:06.882Z" }, @@ -791,7 +791,7 @@ wheels = [ [[package]] name = "httpx" version = "0.28.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "certifi" }, @@ -806,7 +806,7 @@ wheels = [ [[package]] name = "httpx-sse" version = "0.4.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, @@ -815,7 +815,7 @@ wheels = [ [[package]] name = "huggingface-hub" version = "0.34.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "fsspec" }, @@ -834,7 +834,7 @@ wheels = [ [[package]] name = "idna" version = "3.10" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, @@ -843,7 +843,7 @@ wheels = [ [[package]] name = "importlib-metadata" version = "8.7.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "zipp" }, ] @@ -855,7 +855,7 @@ wheels = [ [[package]] name = "iniconfig" version = "2.1.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, @@ -864,7 +864,7 @@ wheels = [ [[package]] name = "jinja2" version = "3.1.6" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] @@ -876,7 +876,7 @@ wheels = [ [[package]] name = "jiter" version = "0.10.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ee/9d/ae7ddb4b8ab3fb1b51faf4deb36cb48a4fbbd7cb36bad6a5fca4741306f7/jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500", size = 162759, upload-time = "2025-05-18T19:04:59.73Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/be/7e/4011b5c77bec97cb2b572f566220364e3e21b51c48c5bd9c4a9c26b41b67/jiter-0.10.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:cd2fb72b02478f06a900a5782de2ef47e0396b3e1f7d5aba30daeb1fce66f303", size = 317215, upload-time = "2025-05-18T19:03:04.303Z" }, @@ -948,7 +948,7 @@ wheels = [ [[package]] name = "jmespath" version = "1.0.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843, upload-time = "2022-06-17T18:00:12.224Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256, upload-time = "2022-06-17T18:00:10.251Z" }, @@ -957,7 +957,7 @@ wheels = [ [[package]] name = "jsonpatch" version = "1.33" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpointer" }, ] @@ -969,7 +969,7 @@ wheels = [ [[package]] name = "jsonpointer" version = "3.0.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114, upload-time = "2024-06-10T19:24:42.462Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, @@ -978,7 +978,7 @@ wheels = [ [[package]] name = "jsonschema" version = "4.24.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, @@ -993,7 +993,7 @@ wheels = [ [[package]] name = "jsonschema-specifications" version = "2025.4.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "referencing" }, ] @@ -1005,7 +1005,7 @@ wheels = [ [[package]] name = "langchain" version = "0.1.20" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "async-timeout", marker = "python_full_version < '3.11'" }, @@ -1029,7 +1029,7 @@ wheels = [ [[package]] name = "langchain-community" version = "0.0.38" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "dataclasses-json" }, @@ -1049,7 +1049,7 @@ wheels = [ [[package]] name = "langchain-core" version = "0.1.53" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, { name = "langsmith" }, @@ -1066,7 +1066,7 @@ wheels = [ [[package]] name = "langchain-openai" version = "0.0.6" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, { name = "numpy" }, @@ -1081,7 +1081,7 @@ wheels = [ [[package]] name = "langchain-text-splitters" version = "0.0.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "langchain-core" }, ] @@ -1093,7 +1093,7 @@ wheels = [ [[package]] name = "langsmith" version = "0.1.147" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "orjson", marker = "platform_python_implementation != 'PyPy'" }, @@ -1109,7 +1109,7 @@ wheels = [ [[package]] name = "litellm" version = "1.74.8" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "click" }, @@ -1131,7 +1131,7 @@ wheels = [ [[package]] name = "markdown-it-py" version = "3.0.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mdurl" }, ] @@ -1143,7 +1143,7 @@ wheels = [ [[package]] name = "markupsafe" version = "3.0.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357, upload-time = "2024-10-18T15:20:51.44Z" }, @@ -1201,7 +1201,7 @@ wheels = [ [[package]] name = "marshmallow" version = "3.26.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "packaging" }, ] @@ -1213,7 +1213,7 @@ wheels = [ [[package]] name = "mcp" version = "1.11.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "httpx" }, @@ -1235,7 +1235,7 @@ wheels = [ [[package]] name = "mdurl" version = "0.1.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, @@ -1244,7 +1244,7 @@ wheels = [ [[package]] name = "multidict" version = "6.6.3" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] @@ -1346,7 +1346,7 @@ wheels = [ [[package]] name = "mypy" version = "1.16.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "pathspec" }, @@ -1385,7 +1385,7 @@ wheels = [ [[package]] name = "mypy-extensions" version = "1.1.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, @@ -1394,7 +1394,7 @@ wheels = [ [[package]] name = "nexus-rpc" version = "1.3.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] @@ -1406,7 +1406,7 @@ wheels = [ [[package]] name = "nodeenv" version = "1.9.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, @@ -1415,7 +1415,7 @@ wheels = [ [[package]] name = "numpy" version = "1.26.4" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468, upload-time = "2024-02-05T23:48:01.194Z" }, @@ -1447,7 +1447,7 @@ wheels = [ [[package]] name = "openai" version = "1.108.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "distro" }, @@ -1466,7 +1466,7 @@ wheels = [ [[package]] name = "openai-agents" version = "0.3.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "griffe" }, { name = "mcp" }, @@ -1489,7 +1489,7 @@ litellm = [ [[package]] name = "opentelemetry-api" version = "1.35.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, @@ -1502,7 +1502,7 @@ wheels = [ [[package]] name = "opentelemetry-exporter-otlp-proto-common" version = "1.35.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-proto" }, ] @@ -1514,7 +1514,7 @@ wheels = [ [[package]] name = "opentelemetry-exporter-otlp-proto-grpc" version = "1.35.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "googleapis-common-protos" }, { name = "grpcio" }, @@ -1532,7 +1532,7 @@ wheels = [ [[package]] name = "opentelemetry-proto" version = "1.35.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "protobuf" }, ] @@ -1544,7 +1544,7 @@ wheels = [ [[package]] name = "opentelemetry-sdk" version = "1.35.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, @@ -1558,7 +1558,7 @@ wheels = [ [[package]] name = "opentelemetry-semantic-conventions" version = "0.56b0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, @@ -1571,7 +1571,7 @@ wheels = [ [[package]] name = "orjson" version = "3.11.4" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c6/fe/ed708782d6709cc60eb4c2d8a361a440661f74134675c72990f2c48c785f/orjson-3.11.4.tar.gz", hash = "sha256:39485f4ab4c9b30a3943cfe99e1a213c4776fb69e8abd68f66b83d5a0b0fdc6d", size = 5945188, upload-time = "2025-10-24T15:50:38.027Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e0/30/5aed63d5af1c8b02fbd2a8d83e2a6c8455e30504c50dbf08c8b51403d873/orjson-3.11.4-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e3aa2118a3ece0d25489cbe48498de8a5d580e42e8d9979f65bf47900a15aba1", size = 243870, upload-time = "2025-10-24T15:48:28.908Z" }, @@ -1652,7 +1652,7 @@ wheels = [ [[package]] name = "outcome" version = "1.3.0.post0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, ] @@ -1664,7 +1664,7 @@ wheels = [ [[package]] name = "packaging" version = "23.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/fb/2b/9b9c33ffed44ee921d0967086d653047286054117d584f1b1a7c22ceaf7b/packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", size = 146714, upload-time = "2023-10-01T13:50:05.279Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ec/1a/610693ac4ee14fcdf2d9bf3c493370e4f2ef7ae2e19217d7a237ff42367d/packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7", size = 53011, upload-time = "2023-10-01T13:50:03.745Z" }, @@ -1672,56 +1672,69 @@ wheels = [ [[package]] name = "pandas" -version = "2.3.1" -source = { registry = "https://pypi.org/simple/" } +version = "2.3.3" +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, { name = "python-dateutil" }, { name = "pytz" }, { name = "tzdata" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/6f/75aa71f8a14267117adeeed5d21b204770189c0a0025acbdc03c337b28fc/pandas-2.3.1.tar.gz", hash = "sha256:0a95b9ac964fe83ce317827f80304d37388ea77616b1425f0ae41c9d2d0d7bb2", size = 4487493, upload-time = "2025-07-07T19:20:04.079Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c4/ca/aa97b47287221fa37a49634532e520300088e290b20d690b21ce3e448143/pandas-2.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:22c2e866f7209ebc3a8f08d75766566aae02bcc91d196935a1d9e59c7b990ac9", size = 11542731, upload-time = "2025-07-07T19:18:12.619Z" }, - { url = "https://files.pythonhosted.org/packages/80/bf/7938dddc5f01e18e573dcfb0f1b8c9357d9b5fa6ffdee6e605b92efbdff2/pandas-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3583d348546201aff730c8c47e49bc159833f971c2899d6097bce68b9112a4f1", size = 10790031, upload-time = "2025-07-07T19:18:16.611Z" }, - { url = "https://files.pythonhosted.org/packages/ee/2f/9af748366763b2a494fed477f88051dbf06f56053d5c00eba652697e3f94/pandas-2.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f951fbb702dacd390561e0ea45cdd8ecfa7fb56935eb3dd78e306c19104b9b0", size = 11724083, upload-time = "2025-07-07T19:18:20.512Z" }, - { url = "https://files.pythonhosted.org/packages/2c/95/79ab37aa4c25d1e7df953dde407bb9c3e4ae47d154bc0dd1692f3a6dcf8c/pandas-2.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd05b72ec02ebfb993569b4931b2e16fbb4d6ad6ce80224a3ee838387d83a191", size = 12342360, upload-time = "2025-07-07T19:18:23.194Z" }, - { url = "https://files.pythonhosted.org/packages/75/a7/d65e5d8665c12c3c6ff5edd9709d5836ec9b6f80071b7f4a718c6106e86e/pandas-2.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1b916a627919a247d865aed068eb65eb91a344b13f5b57ab9f610b7716c92de1", size = 13202098, upload-time = "2025-07-07T19:18:25.558Z" }, - { url = "https://files.pythonhosted.org/packages/65/f3/4c1dbd754dbaa79dbf8b537800cb2fa1a6e534764fef50ab1f7533226c5c/pandas-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fe67dc676818c186d5a3d5425250e40f179c2a89145df477dd82945eaea89e97", size = 13837228, upload-time = "2025-07-07T19:18:28.344Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d6/d7f5777162aa9b48ec3910bca5a58c9b5927cfd9cfde3aa64322f5ba4b9f/pandas-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:2eb789ae0274672acbd3c575b0598d213345660120a257b47b5dafdc618aec83", size = 11336561, upload-time = "2025-07-07T19:18:31.211Z" }, - { url = "https://files.pythonhosted.org/packages/76/1c/ccf70029e927e473a4476c00e0d5b32e623bff27f0402d0a92b7fc29bb9f/pandas-2.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2b0540963d83431f5ce8870ea02a7430adca100cec8a050f0811f8e31035541b", size = 11566608, upload-time = "2025-07-07T19:18:33.86Z" }, - { url = "https://files.pythonhosted.org/packages/ec/d3/3c37cb724d76a841f14b8f5fe57e5e3645207cc67370e4f84717e8bb7657/pandas-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fe7317f578c6a153912bd2292f02e40c1d8f253e93c599e82620c7f69755c74f", size = 10823181, upload-time = "2025-07-07T19:18:36.151Z" }, - { url = "https://files.pythonhosted.org/packages/8a/4c/367c98854a1251940edf54a4df0826dcacfb987f9068abf3e3064081a382/pandas-2.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6723a27ad7b244c0c79d8e7007092d7c8f0f11305770e2f4cd778b3ad5f9f85", size = 11793570, upload-time = "2025-07-07T19:18:38.385Z" }, - { url = "https://files.pythonhosted.org/packages/07/5f/63760ff107bcf5146eee41b38b3985f9055e710a72fdd637b791dea3495c/pandas-2.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3462c3735fe19f2638f2c3a40bd94ec2dc5ba13abbb032dd2fa1f540a075509d", size = 12378887, upload-time = "2025-07-07T19:18:41.284Z" }, - { url = "https://files.pythonhosted.org/packages/15/53/f31a9b4dfe73fe4711c3a609bd8e60238022f48eacedc257cd13ae9327a7/pandas-2.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:98bcc8b5bf7afed22cc753a28bc4d9e26e078e777066bc53fac7904ddef9a678", size = 13230957, upload-time = "2025-07-07T19:18:44.187Z" }, - { url = "https://files.pythonhosted.org/packages/e0/94/6fce6bf85b5056d065e0a7933cba2616dcb48596f7ba3c6341ec4bcc529d/pandas-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d544806b485ddf29e52d75b1f559142514e60ef58a832f74fb38e48d757b299", size = 13883883, upload-time = "2025-07-07T19:18:46.498Z" }, - { url = "https://files.pythonhosted.org/packages/c8/7b/bdcb1ed8fccb63d04bdb7635161d0ec26596d92c9d7a6cce964e7876b6c1/pandas-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:b3cd4273d3cb3707b6fffd217204c52ed92859533e31dc03b7c5008aa933aaab", size = 11340212, upload-time = "2025-07-07T19:18:49.293Z" }, - { url = "https://files.pythonhosted.org/packages/46/de/b8445e0f5d217a99fe0eeb2f4988070908979bec3587c0633e5428ab596c/pandas-2.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:689968e841136f9e542020698ee1c4fbe9caa2ed2213ae2388dc7b81721510d3", size = 11588172, upload-time = "2025-07-07T19:18:52.054Z" }, - { url = "https://files.pythonhosted.org/packages/1e/e0/801cdb3564e65a5ac041ab99ea6f1d802a6c325bb6e58c79c06a3f1cd010/pandas-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:025e92411c16cbe5bb2a4abc99732a6b132f439b8aab23a59fa593eb00704232", size = 10717365, upload-time = "2025-07-07T19:18:54.785Z" }, - { url = "https://files.pythonhosted.org/packages/51/a5/c76a8311833c24ae61a376dbf360eb1b1c9247a5d9c1e8b356563b31b80c/pandas-2.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b7ff55f31c4fcb3e316e8f7fa194566b286d6ac430afec0d461163312c5841e", size = 11280411, upload-time = "2025-07-07T19:18:57.045Z" }, - { url = "https://files.pythonhosted.org/packages/da/01/e383018feba0a1ead6cf5fe8728e5d767fee02f06a3d800e82c489e5daaf/pandas-2.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7dcb79bf373a47d2a40cf7232928eb7540155abbc460925c2c96d2d30b006eb4", size = 11988013, upload-time = "2025-07-07T19:18:59.771Z" }, - { url = "https://files.pythonhosted.org/packages/5b/14/cec7760d7c9507f11c97d64f29022e12a6cc4fc03ac694535e89f88ad2ec/pandas-2.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56a342b231e8862c96bdb6ab97170e203ce511f4d0429589c8ede1ee8ece48b8", size = 12767210, upload-time = "2025-07-07T19:19:02.944Z" }, - { url = "https://files.pythonhosted.org/packages/50/b9/6e2d2c6728ed29fb3d4d4d302504fb66f1a543e37eb2e43f352a86365cdf/pandas-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ca7ed14832bce68baef331f4d7f294411bed8efd032f8109d690df45e00c4679", size = 13440571, upload-time = "2025-07-07T19:19:06.82Z" }, - { url = "https://files.pythonhosted.org/packages/80/a5/3a92893e7399a691bad7664d977cb5e7c81cf666c81f89ea76ba2bff483d/pandas-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ac942bfd0aca577bef61f2bc8da8147c4ef6879965ef883d8e8d5d2dc3e744b8", size = 10987601, upload-time = "2025-07-07T19:19:09.589Z" }, - { url = "https://files.pythonhosted.org/packages/32/ed/ff0a67a2c5505e1854e6715586ac6693dd860fbf52ef9f81edee200266e7/pandas-2.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9026bd4a80108fac2239294a15ef9003c4ee191a0f64b90f170b40cfb7cf2d22", size = 11531393, upload-time = "2025-07-07T19:19:12.245Z" }, - { url = "https://files.pythonhosted.org/packages/c7/db/d8f24a7cc9fb0972adab0cc80b6817e8bef888cfd0024eeb5a21c0bb5c4a/pandas-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6de8547d4fdb12421e2d047a2c446c623ff4c11f47fddb6b9169eb98ffba485a", size = 10668750, upload-time = "2025-07-07T19:19:14.612Z" }, - { url = "https://files.pythonhosted.org/packages/0f/b0/80f6ec783313f1e2356b28b4fd8d2148c378370045da918c73145e6aab50/pandas-2.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:782647ddc63c83133b2506912cc6b108140a38a37292102aaa19c81c83db2928", size = 11342004, upload-time = "2025-07-07T19:19:16.857Z" }, - { url = "https://files.pythonhosted.org/packages/e9/e2/20a317688435470872885e7fc8f95109ae9683dec7c50be29b56911515a5/pandas-2.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ba6aff74075311fc88504b1db890187a3cd0f887a5b10f5525f8e2ef55bfdb9", size = 12050869, upload-time = "2025-07-07T19:19:19.265Z" }, - { url = "https://files.pythonhosted.org/packages/55/79/20d746b0a96c67203a5bee5fb4e00ac49c3e8009a39e1f78de264ecc5729/pandas-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e5635178b387bd2ba4ac040f82bc2ef6e6b500483975c4ebacd34bec945fda12", size = 12750218, upload-time = "2025-07-07T19:19:21.547Z" }, - { url = "https://files.pythonhosted.org/packages/7c/0f/145c8b41e48dbf03dd18fdd7f24f8ba95b8254a97a3379048378f33e7838/pandas-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f3bf5ec947526106399a9e1d26d40ee2b259c66422efdf4de63c848492d91bb", size = 13416763, upload-time = "2025-07-07T19:19:23.939Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c0/54415af59db5cdd86a3d3bf79863e8cc3fa9ed265f0745254061ac09d5f2/pandas-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:1c78cf43c8fde236342a1cb2c34bcff89564a7bfed7e474ed2fffa6aed03a956", size = 10987482, upload-time = "2025-07-07T19:19:42.699Z" }, - { url = "https://files.pythonhosted.org/packages/48/64/2fd2e400073a1230e13b8cd604c9bc95d9e3b962e5d44088ead2e8f0cfec/pandas-2.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8dfc17328e8da77be3cf9f47509e5637ba8f137148ed0e9b5241e1baf526e20a", size = 12029159, upload-time = "2025-07-07T19:19:26.362Z" }, - { url = "https://files.pythonhosted.org/packages/d8/0a/d84fd79b0293b7ef88c760d7dca69828d867c89b6d9bc52d6a27e4d87316/pandas-2.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ec6c851509364c59a5344458ab935e6451b31b818be467eb24b0fe89bd05b6b9", size = 11393287, upload-time = "2025-07-07T19:19:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/50/ae/ff885d2b6e88f3c7520bb74ba319268b42f05d7e583b5dded9837da2723f/pandas-2.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:911580460fc4884d9b05254b38a6bfadddfcc6aaef856fb5859e7ca202e45275", size = 11309381, upload-time = "2025-07-07T19:19:31.436Z" }, - { url = "https://files.pythonhosted.org/packages/85/86/1fa345fc17caf5d7780d2699985c03dbe186c68fee00b526813939062bb0/pandas-2.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f4d6feeba91744872a600e6edbbd5b033005b431d5ae8379abee5bcfa479fab", size = 11883998, upload-time = "2025-07-07T19:19:34.267Z" }, - { url = "https://files.pythonhosted.org/packages/81/aa/e58541a49b5e6310d89474333e994ee57fea97c8aaa8fc7f00b873059bbf/pandas-2.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fe37e757f462d31a9cd7580236a82f353f5713a80e059a29753cf938c6775d96", size = 12704705, upload-time = "2025-07-07T19:19:36.856Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f9/07086f5b0f2a19872554abeea7658200824f5835c58a106fa8f2ae96a46c/pandas-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5db9637dbc24b631ff3707269ae4559bce4b7fd75c1c4d7e13f40edc42df4444", size = 13189044, upload-time = "2025-07-07T19:19:39.999Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/f7/f425a00df4fcc22b292c6895c6831c0c8ae1d9fac1e024d16f98a9ce8749/pandas-2.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:376c6446ae31770764215a6c937f72d917f214b43560603cd60da6408f183b6c", size = 11555763, upload-time = "2025-09-29T23:16:53.287Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/66d99628ff8ce7857aca52fed8f0066ce209f96be2fede6cef9f84e8d04f/pandas-2.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e19d192383eab2f4ceb30b412b22ea30690c9e618f78870357ae1d682912015a", size = 10801217, upload-time = "2025-09-29T23:17:04.522Z" }, + { url = "https://files.pythonhosted.org/packages/1d/03/3fc4a529a7710f890a239cc496fc6d50ad4a0995657dccc1d64695adb9f4/pandas-2.3.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf26f64126b6c7aec964f74266f435afef1c1b13da3b0636c7518a1fa3e2b1", size = 12148791, upload-time = "2025-09-29T23:17:18.444Z" }, + { url = "https://files.pythonhosted.org/packages/40/a8/4dac1f8f8235e5d25b9955d02ff6f29396191d4e665d71122c3722ca83c5/pandas-2.3.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd7478f1463441ae4ca7308a70e90b33470fa593429f9d4c578dd00d1fa78838", size = 12769373, upload-time = "2025-09-29T23:17:35.846Z" }, + { url = "https://files.pythonhosted.org/packages/df/91/82cc5169b6b25440a7fc0ef3a694582418d875c8e3ebf796a6d6470aa578/pandas-2.3.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4793891684806ae50d1288c9bae9330293ab4e083ccd1c5e383c34549c6e4250", size = 13200444, upload-time = "2025-09-29T23:17:49.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/ae/89b3283800ab58f7af2952704078555fa60c807fff764395bb57ea0b0dbd/pandas-2.3.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:28083c648d9a99a5dd035ec125d42439c6c1c525098c58af0fc38dd1a7a1b3d4", size = 13858459, upload-time = "2025-09-29T23:18:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/72/530900610650f54a35a19476eca5104f38555afccda1aa11a92ee14cb21d/pandas-2.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:503cf027cf9940d2ceaa1a93cfb5f8c8c7e6e90720a2850378f0b3f3b1e06826", size = 11346086, upload-time = "2025-09-29T23:18:18.505Z" }, + { url = "https://files.pythonhosted.org/packages/c1/fa/7ac648108144a095b4fb6aa3de1954689f7af60a14cf25583f4960ecb878/pandas-2.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602b8615ebcc4a0c1751e71840428ddebeb142ec02c786e8ad6b1ce3c8dec523", size = 11578790, upload-time = "2025-09-29T23:18:30.065Z" }, + { url = "https://files.pythonhosted.org/packages/9b/35/74442388c6cf008882d4d4bdfc4109be87e9b8b7ccd097ad1e7f006e2e95/pandas-2.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8fe25fc7b623b0ef6b5009149627e34d2a4657e880948ec3c840e9402e5c1b45", size = 10833831, upload-time = "2025-09-29T23:38:56.071Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e4/de154cbfeee13383ad58d23017da99390b91d73f8c11856f2095e813201b/pandas-2.3.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b468d3dad6ff947df92dcb32ede5b7bd41a9b3cceef0a30ed925f6d01fb8fa66", size = 12199267, upload-time = "2025-09-29T23:18:41.627Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c9/63f8d545568d9ab91476b1818b4741f521646cbdd151c6efebf40d6de6f7/pandas-2.3.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b98560e98cb334799c0b07ca7967ac361a47326e9b4e5a7dfb5ab2b1c9d35a1b", size = 12789281, upload-time = "2025-09-29T23:18:56.834Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/a5ac8c7a0e67fd1a6059e40aa08fa1c52cc00709077d2300e210c3ce0322/pandas-2.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37b5848ba49824e5c30bedb9c830ab9b7751fd049bc7914533e01c65f79791", size = 13240453, upload-time = "2025-09-29T23:19:09.247Z" }, + { url = "https://files.pythonhosted.org/packages/27/4d/5c23a5bc7bd209231618dd9e606ce076272c9bc4f12023a70e03a86b4067/pandas-2.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:db4301b2d1f926ae677a751eb2bd0e8c5f5319c9cb3f88b0becbbb0b07b34151", size = 13890361, upload-time = "2025-09-29T23:19:25.342Z" }, + { url = "https://files.pythonhosted.org/packages/8e/59/712db1d7040520de7a4965df15b774348980e6df45c129b8c64d0dbe74ef/pandas-2.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:f086f6fe114e19d92014a1966f43a3e62285109afe874f067f5abbdcbb10e59c", size = 11348702, upload-time = "2025-09-29T23:19:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fb/231d89e8637c808b997d172b18e9d4a4bc7bf31296196c260526055d1ea0/pandas-2.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d21f6d74eb1725c2efaa71a2bfc661a0689579b58e9c0ca58a739ff0b002b53", size = 11597846, upload-time = "2025-09-29T23:19:48.856Z" }, + { url = "https://files.pythonhosted.org/packages/5c/bd/bf8064d9cfa214294356c2d6702b716d3cf3bb24be59287a6a21e24cae6b/pandas-2.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3fd2f887589c7aa868e02632612ba39acb0b8948faf5cc58f0850e165bd46f35", size = 10729618, upload-time = "2025-09-29T23:39:08.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/56/cf2dbe1a3f5271370669475ead12ce77c61726ffd19a35546e31aa8edf4e/pandas-2.3.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecaf1e12bdc03c86ad4a7ea848d66c685cb6851d807a26aa245ca3d2017a1908", size = 11737212, upload-time = "2025-09-29T23:19:59.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89", size = 12362693, upload-time = "2025-09-29T23:20:14.098Z" }, + { url = "https://files.pythonhosted.org/packages/a6/de/8b1895b107277d52f2b42d3a6806e69cfef0d5cf1d0ba343470b9d8e0a04/pandas-2.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a68e15f780eddf2b07d242e17a04aa187a7ee12b40b930bfdd78070556550e98", size = 12771002, upload-time = "2025-09-29T23:20:26.76Z" }, + { url = "https://files.pythonhosted.org/packages/87/21/84072af3187a677c5893b170ba2c8fbe450a6ff911234916da889b698220/pandas-2.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:371a4ab48e950033bcf52b6527eccb564f52dc826c02afd9a1bc0ab731bba084", size = 13450971, upload-time = "2025-09-29T23:20:41.344Z" }, + { url = "https://files.pythonhosted.org/packages/86/41/585a168330ff063014880a80d744219dbf1dd7a1c706e75ab3425a987384/pandas-2.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:a16dcec078a01eeef8ee61bf64074b4e524a2a3f4b3be9326420cabe59c4778b", size = 10992722, upload-time = "2025-09-29T23:20:54.139Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4b/18b035ee18f97c1040d94debd8f2e737000ad70ccc8f5513f4eefad75f4b/pandas-2.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:56851a737e3470de7fa88e6131f41281ed440d29a9268dcbf0002da5ac366713", size = 11544671, upload-time = "2025-09-29T23:21:05.024Z" }, + { url = "https://files.pythonhosted.org/packages/31/94/72fac03573102779920099bcac1c3b05975c2cb5f01eac609faf34bed1ca/pandas-2.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdcd9d1167f4885211e401b3036c0c8d9e274eee67ea8d0758a256d60704cfe8", size = 10680807, upload-time = "2025-09-29T23:21:15.979Z" }, + { url = "https://files.pythonhosted.org/packages/16/87/9472cf4a487d848476865321de18cc8c920b8cab98453ab79dbbc98db63a/pandas-2.3.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e32e7cc9af0f1cc15548288a51a3b681cc2a219faa838e995f7dc53dbab1062d", size = 11709872, upload-time = "2025-09-29T23:21:27.165Z" }, + { url = "https://files.pythonhosted.org/packages/15/07/284f757f63f8a8d69ed4472bfd85122bd086e637bf4ed09de572d575a693/pandas-2.3.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318d77e0e42a628c04dc56bcef4b40de67918f7041c2b061af1da41dcff670ac", size = 12306371, upload-time = "2025-09-29T23:21:40.532Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/a3afc88fca4aa925804a27d2676d22dcd2031c2ebe08aabd0ae55b9ff282/pandas-2.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e0a175408804d566144e170d0476b15d78458795bb18f1304fb94160cabf40c", size = 12765333, upload-time = "2025-09-29T23:21:55.77Z" }, + { url = "https://files.pythonhosted.org/packages/8d/0f/b4d4ae743a83742f1153464cf1a8ecfafc3ac59722a0b5c8602310cb7158/pandas-2.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:93c2d9ab0fc11822b5eece72ec9587e172f63cff87c00b062f6e37448ced4493", size = 13418120, upload-time = "2025-09-29T23:22:10.109Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c7/e54682c96a895d0c808453269e0b5928a07a127a15704fedb643e9b0a4c8/pandas-2.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:f8bfc0e12dc78f777f323f55c58649591b2cd0c43534e8355c51d3fede5f4dee", size = 10993991, upload-time = "2025-09-29T23:25:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ca/3f8d4f49740799189e1395812f3bf23b5e8fc7c190827d55a610da72ce55/pandas-2.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:75ea25f9529fdec2d2e93a42c523962261e567d250b0013b16210e1d40d7c2e5", size = 12048227, upload-time = "2025-09-29T23:22:24.343Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5a/f43efec3e8c0cc92c4663ccad372dbdff72b60bdb56b2749f04aa1d07d7e/pandas-2.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74ecdf1d301e812db96a465a525952f4dde225fdb6d8e5a521d47e1f42041e21", size = 11411056, upload-time = "2025-09-29T23:22:37.762Z" }, + { url = "https://files.pythonhosted.org/packages/46/b1/85331edfc591208c9d1a63a06baa67b21d332e63b7a591a5ba42a10bb507/pandas-2.3.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6435cb949cb34ec11cc9860246ccb2fdc9ecd742c12d3304989017d53f039a78", size = 11645189, upload-time = "2025-09-29T23:22:51.688Z" }, + { url = "https://files.pythonhosted.org/packages/44/23/78d645adc35d94d1ac4f2a3c4112ab6f5b8999f4898b8cdf01252f8df4a9/pandas-2.3.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:900f47d8f20860de523a1ac881c4c36d65efcb2eb850e6948140fa781736e110", size = 12121912, upload-time = "2025-09-29T23:23:05.042Z" }, + { url = "https://files.pythonhosted.org/packages/53/da/d10013df5e6aaef6b425aa0c32e1fc1f3e431e4bcabd420517dceadce354/pandas-2.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a45c765238e2ed7d7c608fc5bc4a6f88b642f2f01e70c0c23d2224dd21829d86", size = 12712160, upload-time = "2025-09-29T23:23:28.57Z" }, + { url = "https://files.pythonhosted.org/packages/bd/17/e756653095a083d8a37cbd816cb87148debcfcd920129b25f99dd8d04271/pandas-2.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4fc4c21971a1a9f4bdb4c73978c7f7256caa3e62b323f70d6cb80db583350bc", size = 13199233, upload-time = "2025-09-29T23:24:24.876Z" }, + { url = "https://files.pythonhosted.org/packages/04/fd/74903979833db8390b73b3a8a7d30d146d710bd32703724dd9083950386f/pandas-2.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ee15f284898e7b246df8087fc82b87b01686f98ee67d85a17b7ab44143a3a9a0", size = 11540635, upload-time = "2025-09-29T23:25:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/21/00/266d6b357ad5e6d3ad55093a7e8efc7dd245f5a842b584db9f30b0f0a287/pandas-2.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1611aedd912e1ff81ff41c745822980c49ce4a7907537be8692c8dbc31924593", size = 10759079, upload-time = "2025-09-29T23:26:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/ca/05/d01ef80a7a3a12b2f8bbf16daba1e17c98a2f039cbc8e2f77a2c5a63d382/pandas-2.3.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d2cefc361461662ac48810cb14365a365ce864afe85ef1f447ff5a1e99ea81c", size = 11814049, upload-time = "2025-09-29T23:27:15.384Z" }, + { url = "https://files.pythonhosted.org/packages/15/b2/0e62f78c0c5ba7e3d2c5945a82456f4fac76c480940f805e0b97fcbc2f65/pandas-2.3.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ee67acbbf05014ea6c763beb097e03cd629961c8a632075eeb34247120abcb4b", size = 12332638, upload-time = "2025-09-29T23:27:51.625Z" }, + { url = "https://files.pythonhosted.org/packages/c5/33/dd70400631b62b9b29c3c93d2feee1d0964dc2bae2e5ad7a6c73a7f25325/pandas-2.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c46467899aaa4da076d5abc11084634e2d197e9460643dd455ac3db5856b24d6", size = 12886834, upload-time = "2025-09-29T23:28:21.289Z" }, + { url = "https://files.pythonhosted.org/packages/d3/18/b5d48f55821228d0d2692b34fd5034bb185e854bdb592e9c640f6290e012/pandas-2.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6253c72c6a1d990a410bc7de641d34053364ef8bcd3126f7e7450125887dffe3", size = 13409925, upload-time = "2025-09-29T23:28:58.261Z" }, + { url = "https://files.pythonhosted.org/packages/a6/3d/124ac75fcd0ecc09b8fdccb0246ef65e35b012030defb0e0eba2cbbbe948/pandas-2.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:1b07204a219b3b7350abaae088f451860223a52cfb8a6c53358e7948735158e5", size = 11109071, upload-time = "2025-09-29T23:32:27.484Z" }, + { url = "https://files.pythonhosted.org/packages/89/9c/0e21c895c38a157e0faa1fb64587a9226d6dd46452cac4532d80c3c4a244/pandas-2.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2462b1a365b6109d275250baaae7b760fd25c726aaca0054649286bcfbb3e8ec", size = 12048504, upload-time = "2025-09-29T23:29:31.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/b69a1c95df796858777b68fbe6a81d37443a33319761d7c652ce77797475/pandas-2.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0242fe9a49aa8b4d78a4fa03acb397a58833ef6199e9aa40a95f027bb3a1b6e7", size = 11410702, upload-time = "2025-09-29T23:29:54.591Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/702bde3ba0a94b8c73a0181e05144b10f13f29ebfc2150c3a79062a8195d/pandas-2.3.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a21d830e78df0a515db2b3d2f5570610f5e6bd2e27749770e8bb7b524b89b450", size = 11634535, upload-time = "2025-09-29T23:30:21.003Z" }, + { url = "https://files.pythonhosted.org/packages/a4/1e/1bac1a839d12e6a82ec6cb40cda2edde64a2013a66963293696bbf31fbbb/pandas-2.3.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e3ebdb170b5ef78f19bfb71b0dc5dc58775032361fa188e814959b74d726dd5", size = 12121582, upload-time = "2025-09-29T23:30:43.391Z" }, + { url = "https://files.pythonhosted.org/packages/44/91/483de934193e12a3b1d6ae7c8645d083ff88dec75f46e827562f1e4b4da6/pandas-2.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d051c0e065b94b7a3cea50eb1ec32e912cd96dba41647eb24104b6c6c14c5788", size = 12699963, upload-time = "2025-09-29T23:31:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/70/44/5191d2e4026f86a2a109053e194d3ba7a31a2d10a9c2348368c63ed4e85a/pandas-2.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3869faf4bd07b3b66a9f462417d0ca3a9df29a9f6abd5d0d0dbab15dac7abe87", size = 13202175, upload-time = "2025-09-29T23:31:59.173Z" }, ] [[package]] name = "pastel" version = "0.2.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/76/f1/4594f5e0fcddb6953e5b8fe00da8c317b8b41b547e2b3ae2da7512943c62/pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d", size = 7555, upload-time = "2020-09-16T19:21:12.43Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955, upload-time = "2020-09-16T19:21:11.409Z" }, @@ -1730,7 +1743,7 @@ wheels = [ [[package]] name = "pathspec" version = "0.12.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, @@ -1739,7 +1752,7 @@ wheels = [ [[package]] name = "pluggy" version = "1.6.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, @@ -1748,7 +1761,7 @@ wheels = [ [[package]] name = "poethepoet" version = "0.36.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pastel" }, { name = "pyyaml" }, @@ -1762,7 +1775,7 @@ wheels = [ [[package]] name = "propcache" version = "0.3.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a6/16/43264e4a779dd8588c21a70f0709665ee8f611211bdd2c87d952cfa7c776/propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168", size = 44139, upload-time = "2025-06-09T22:56:06.081Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/ab/14/510deed325e262afeb8b360043c5d7c960da7d3ecd6d6f9496c9c56dc7f4/propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770", size = 73178, upload-time = "2025-06-09T22:53:40.126Z" }, @@ -1851,7 +1864,7 @@ wheels = [ [[package]] name = "protobuf" version = "5.29.5" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/43/29/d09e70352e4e88c9c7a198d5645d7277811448d76c23b00345670f7c8a38/protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84", size = 425226, upload-time = "2025-05-28T23:51:59.82Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5f/11/6e40e9fc5bba02988a214c07cf324595789ca7820160bfd1f8be96e48539/protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079", size = 422963, upload-time = "2025-05-28T23:51:41.204Z" }, @@ -1865,7 +1878,7 @@ wheels = [ [[package]] name = "pyarrow" version = "22.0.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/30/53/04a7fdc63e6056116c9ddc8b43bc28c12cdd181b85cbeadb79278475f3ae/pyarrow-22.0.0.tar.gz", hash = "sha256:3d600dc583260d845c7d8a6db540339dd883081925da2bd1c5cb808f720b3cd9", size = 1151151, upload-time = "2025-10-24T12:30:00.762Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d9/9b/cb3f7e0a345353def531ca879053e9ef6b9f38ed91aebcf68b09ba54dec0/pyarrow-22.0.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:77718810bd3066158db1e95a63c160ad7ce08c6b0710bc656055033e39cdad88", size = 34223968, upload-time = "2025-10-24T10:03:31.21Z" }, @@ -1922,7 +1935,7 @@ wheels = [ [[package]] name = "pycparser" version = "2.22" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, @@ -1931,7 +1944,7 @@ wheels = [ [[package]] name = "pydantic" version = "2.12.5" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, { name = "pydantic-core" }, @@ -1946,7 +1959,7 @@ wheels = [ [[package]] name = "pydantic-core" version = "2.41.5" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] @@ -2064,7 +2077,7 @@ wheels = [ [[package]] name = "pydantic-settings" version = "2.10.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, @@ -2078,7 +2091,7 @@ wheels = [ [[package]] name = "pygments" version = "2.19.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, @@ -2087,7 +2100,7 @@ wheels = [ [[package]] name = "pyright" version = "1.1.403" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, @@ -2100,7 +2113,7 @@ wheels = [ [[package]] name = "pytest" version = "7.4.4" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, @@ -2117,7 +2130,7 @@ wheels = [ [[package]] name = "pytest-asyncio" version = "0.18.3" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, ] @@ -2130,7 +2143,7 @@ wheels = [ [[package]] name = "pytest-pretty" version = "1.3.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pytest" }, { name = "rich" }, @@ -2143,7 +2156,7 @@ wheels = [ [[package]] name = "python-dateutil" version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "six" }, ] @@ -2155,7 +2168,7 @@ wheels = [ [[package]] name = "python-dotenv" version = "1.1.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, @@ -2164,7 +2177,7 @@ wheels = [ [[package]] name = "python-multipart" version = "0.0.20" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, @@ -2173,7 +2186,7 @@ wheels = [ [[package]] name = "pytz" version = "2025.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, @@ -2182,7 +2195,7 @@ wheels = [ [[package]] name = "pywin32" version = "311" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } wheels = [ { url = "https://files.pythonhosted.org/packages/7b/40/44efbb0dfbd33aca6a6483191dae0716070ed99e2ecb0c53683f400a0b4f/pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3", size = 8760432, upload-time = "2025-07-14T20:13:05.9Z" }, { url = "https://files.pythonhosted.org/packages/5e/bf/360243b1e953bd254a82f12653974be395ba880e7ec23e3731d9f73921cc/pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b", size = 9590103, upload-time = "2025-07-14T20:13:07.698Z" }, @@ -2204,7 +2217,7 @@ wheels = [ [[package]] name = "pyyaml" version = "6.0.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199, upload-time = "2024-08-06T20:31:40.178Z" }, @@ -2248,7 +2261,7 @@ wheels = [ [[package]] name = "referencing" version = "0.36.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py" }, @@ -2262,7 +2275,7 @@ wheels = [ [[package]] name = "regex" version = "2024.11.6" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/8e/5f/bd69653fbfb76cf8604468d3b4ec4c403197144c7bfe0e6a5fc9e02a07cb/regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519", size = 399494, upload-time = "2024-11-06T20:12:31.635Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/95/3c/4651f6b130c6842a8f3df82461a8950f923925db8b6961063e82744bddcc/regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91", size = 482674, upload-time = "2024-11-06T20:08:57.575Z" }, @@ -2331,7 +2344,7 @@ wheels = [ [[package]] name = "requests" version = "2.32.4" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, @@ -2346,7 +2359,7 @@ wheels = [ [[package]] name = "requests-toolbelt" version = "1.0.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] @@ -2358,7 +2371,7 @@ wheels = [ [[package]] name = "rich" version = "14.0.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, @@ -2372,7 +2385,7 @@ wheels = [ [[package]] name = "rpds-py" version = "0.26.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a5/aa/4456d84bbb54adc6a916fb10c9b374f78ac840337644e4a5eda229c81275/rpds_py-0.26.0.tar.gz", hash = "sha256:20dae58a859b0906f0685642e591056f1e787f3a8b39c8e8749a45dc7d26bdb0", size = 27385, upload-time = "2025-07-01T15:57:13.958Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b9/31/1459645f036c3dfeacef89e8e5825e430c77dde8489f3b99eaafcd4a60f5/rpds_py-0.26.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:4c70c70f9169692b36307a95f3d8c0a9fcd79f7b4a383aad5eaa0e9718b79b37", size = 372466, upload-time = "2025-07-01T15:53:40.55Z" }, @@ -2498,7 +2511,7 @@ wheels = [ [[package]] name = "ruff" version = "0.5.7" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/bf/2b/69e5e412f9d390adbdbcbf4f64d6914fa61b44b08839a6584655014fc524/ruff-0.5.7.tar.gz", hash = "sha256:8dfc0a458797f5d9fb622dd0efc52d796f23f0a1493a9527f4e49a550ae9a7e5", size = 2449817, upload-time = "2024-08-08T15:43:07.467Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6b/eb/06e06aaf96af30a68e83b357b037008c54a2ddcbad4f989535007c700394/ruff-0.5.7-py3-none-linux_armv6l.whl", hash = "sha256:548992d342fc404ee2e15a242cdbea4f8e39a52f2e7752d0e4cbe88d2d2f416a", size = 9570571, upload-time = "2024-08-08T15:41:56.537Z" }, @@ -2523,7 +2536,7 @@ wheels = [ [[package]] name = "s3transfer" version = "0.13.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, ] @@ -2535,7 +2548,7 @@ wheels = [ [[package]] name = "sentry-sdk" version = "2.34.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "urllib3" }, @@ -2548,7 +2561,7 @@ wheels = [ [[package]] name = "setuptools" version = "80.9.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" }, @@ -2557,7 +2570,7 @@ wheels = [ [[package]] name = "six" version = "1.17.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, @@ -2566,7 +2579,7 @@ wheels = [ [[package]] name = "sniffio" version = "1.3.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, @@ -2575,7 +2588,7 @@ wheels = [ [[package]] name = "sortedcontainers" version = "2.4.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, @@ -2584,7 +2597,7 @@ wheels = [ [[package]] name = "sqlalchemy" version = "2.0.41" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, { name = "typing-extensions" }, @@ -2629,7 +2642,7 @@ wheels = [ [[package]] name = "sse-starlette" version = "2.4.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] @@ -2641,7 +2654,7 @@ wheels = [ [[package]] name = "starlette" version = "0.47.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, @@ -2654,7 +2667,7 @@ wheels = [ [[package]] name = "temporalio" version = "1.23.0" -source = { registry = "https://test.pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nexus-rpc" }, { name = "protobuf" }, @@ -2662,13 +2675,13 @@ dependencies = [ { name = "types-protobuf" }, { name = "typing-extensions" }, ] -sdist = { url = "https://test-files.pythonhosted.org/packages/67/48/ba7413e2fab8dcd277b9df00bafa572da24e9ca32de2f38d428dc3a2825c/temporalio-1.23.0.tar.gz", hash = "sha256:72750494b00eb73ded9db76195e3a9b53ff548780f73d878ec3f807ee3191410", size = 1933051, upload-time = "2026-02-18T17:40:03.902Z" } +sdist = { url = "https://files.pythonhosted.org/packages/67/48/ba7413e2fab8dcd277b9df00bafa572da24e9ca32de2f38d428dc3a2825c/temporalio-1.23.0.tar.gz", hash = "sha256:72750494b00eb73ded9db76195e3a9b53ff548780f73d878ec3f807ee3191410", size = 1933051, upload-time = "2026-02-18T17:48:22.353Z" } wheels = [ - { url = "https://test-files.pythonhosted.org/packages/6f/71/26c8f21dca9092201b3b9cb7aff42460b4864b5999aa4c6a4343ac66f1fd/temporalio-1.23.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6b69ac8d75f2d90e66f4edce4316f6a33badc4a30b22efc50e9eddaa9acdc216", size = 12311037, upload-time = "2026-02-18T17:39:27.941Z" }, - { url = "https://test-files.pythonhosted.org/packages/ec/47/43102816139f2d346680cb7cc1e53da5f6968355ac65b4d35d4edbfca896/temporalio-1.23.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:1bbbb2f9c3cdd09451565163f6d741e51f109694c49435d475fdfa42b597219d", size = 11821906, upload-time = "2026-02-18T17:39:35.343Z" }, - { url = "https://test-files.pythonhosted.org/packages/00/b0/899ff28464a0e17adf17476bdfac8faf4ea41870358ff2d14737e43f9e66/temporalio-1.23.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf6570e0ee696f99a38d855da4441a890c7187357c16505ed458ac9ef274ed70", size = 12063601, upload-time = "2026-02-18T17:39:43.299Z" }, - { url = "https://test-files.pythonhosted.org/packages/ed/17/b8c6d2ec3e113c6a788322513a5ff635bdd54b3791d092ed0e273467748a/temporalio-1.23.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b82d6cca54c9f376b50e941dd10d12f7fe5b692a314fb087be72cd2898646a79", size = 12394579, upload-time = "2026-02-18T17:39:52.935Z" }, - { url = "https://test-files.pythonhosted.org/packages/b4/b7/f9ef7fd5ee65aef7d59ab1e95cb1b45df2fe49c17e3aa4d650ae3322f015/temporalio-1.23.0-cp310-abi3-win_amd64.whl", hash = "sha256:43c3b99a46dd329761a256f3855710c4a5b322afc879785e468bdd0b94faace6", size = 12834494, upload-time = "2026-02-18T17:40:00.858Z" }, + { url = "https://files.pythonhosted.org/packages/6f/71/26c8f21dca9092201b3b9cb7aff42460b4864b5999aa4c6a4343ac66f1fd/temporalio-1.23.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6b69ac8d75f2d90e66f4edce4316f6a33badc4a30b22efc50e9eddaa9acdc216", size = 12311037, upload-time = "2026-02-18T17:47:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/ec/47/43102816139f2d346680cb7cc1e53da5f6968355ac65b4d35d4edbfca896/temporalio-1.23.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:1bbbb2f9c3cdd09451565163f6d741e51f109694c49435d475fdfa42b597219d", size = 11821906, upload-time = "2026-02-18T17:47:55.314Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/899ff28464a0e17adf17476bdfac8faf4ea41870358ff2d14737e43f9e66/temporalio-1.23.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf6570e0ee696f99a38d855da4441a890c7187357c16505ed458ac9ef274ed70", size = 12063601, upload-time = "2026-02-18T17:48:03.994Z" }, + { url = "https://files.pythonhosted.org/packages/ed/17/b8c6d2ec3e113c6a788322513a5ff635bdd54b3791d092ed0e273467748a/temporalio-1.23.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b82d6cca54c9f376b50e941dd10d12f7fe5b692a314fb087be72cd2898646a79", size = 12394579, upload-time = "2026-02-18T17:48:11.65Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b7/f9ef7fd5ee65aef7d59ab1e95cb1b45df2fe49c17e3aa4d650ae3322f015/temporalio-1.23.0-cp310-abi3-win_amd64.whl", hash = "sha256:43c3b99a46dd329761a256f3855710c4a5b322afc879785e468bdd0b94faace6", size = 12834494, upload-time = "2026-02-18T17:48:19.071Z" }, ] [package.optional-dependencies] @@ -2762,7 +2775,7 @@ bedrock = [{ name = "boto3", specifier = ">=1.34.92,<2" }] cloud-export-to-parquet = [ { name = "boto3", specifier = ">=1.34.89,<2" }, { name = "numpy", marker = "python_full_version >= '3.10' and python_full_version < '3.13'", specifier = ">=1.26.0,<2" }, - { name = "pandas", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = ">=2.2.2,<3" }, + { name = "pandas", marker = "python_full_version >= '3.10' and python_full_version < '4'", specifier = ">=2.3.3,<3" }, { name = "pyarrow", specifier = ">=19.0.1" }, ] dev = [ @@ -2815,7 +2828,7 @@ trio-async = [ [[package]] name = "tenacity" version = "8.5.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/a3/4d/6a19536c50b849338fcbe9290d562b52cbdcf30d8963d3588a68a4107df1/tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", size = 47309, upload-time = "2024-07-05T07:25:31.836Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165, upload-time = "2024-07-05T07:25:29.591Z" }, @@ -2824,7 +2837,7 @@ wheels = [ [[package]] name = "tiktoken" version = "0.12.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "regex" }, { name = "requests" }, @@ -2885,7 +2898,7 @@ wheels = [ [[package]] name = "tokenizers" version = "0.21.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, ] @@ -2910,7 +2923,7 @@ wheels = [ [[package]] name = "tomli" version = "2.2.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175, upload-time = "2024-11-27T22:38:36.873Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/43/ca/75707e6efa2b37c77dadb324ae7d9571cb424e61ea73fad7c56c2d14527f/tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249", size = 131077, upload-time = "2024-11-27T22:37:54.956Z" }, @@ -2949,7 +2962,7 @@ wheels = [ [[package]] name = "tqdm" version = "4.67.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] @@ -2961,7 +2974,7 @@ wheels = [ [[package]] name = "trio" version = "0.28.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "cffi", marker = "implementation_name != 'pypy' and os_name == 'nt'" }, @@ -2979,7 +2992,7 @@ wheels = [ [[package]] name = "trio-asyncio" version = "0.15.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "greenlet" }, @@ -2995,7 +3008,7 @@ wheels = [ [[package]] name = "types-protobuf" version = "6.30.2.20250703" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/dc/54/d63ce1eee8e93c4d710bbe2c663ec68e3672cf4f2fca26eecd20981c0c5d/types_protobuf-6.30.2.20250703.tar.gz", hash = "sha256:609a974754bbb71fa178fc641f51050395e8e1849f49d0420a6281ed8d1ddf46", size = 62300, upload-time = "2025-07-03T03:14:05.74Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7e/2b/5d0377c3d6e0f49d4847ad2c40629593fee4a5c9ec56eba26a15c708fbc0/types_protobuf-6.30.2.20250703-py3-none-any.whl", hash = "sha256:fa5aff9036e9ef432d703abbdd801b436a249b6802e4df5ef74513e272434e57", size = 76489, upload-time = "2025-07-03T03:14:04.453Z" }, @@ -3004,7 +3017,7 @@ wheels = [ [[package]] name = "types-pyyaml" version = "6.0.12.20250516" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/4e/22/59e2aeb48ceeee1f7cd4537db9568df80d62bdb44a7f9e743502ea8aab9c/types_pyyaml-6.0.12.20250516.tar.gz", hash = "sha256:9f21a70216fc0fa1b216a8176db5f9e0af6eb35d2f2932acb87689d03a5bf6ba", size = 17378, upload-time = "2025-05-16T03:08:04.897Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/99/5f/e0af6f7f6a260d9af67e1db4f54d732abad514252a7a378a6c4d17dd1036/types_pyyaml-6.0.12.20250516-py3-none-any.whl", hash = "sha256:8478208feaeb53a34cb5d970c56a7cd76b72659442e733e268a94dc72b2d0530", size = 20312, upload-time = "2025-05-16T03:08:04.019Z" }, @@ -3013,7 +3026,7 @@ wheels = [ [[package]] name = "types-requests" version = "2.32.4.20250611" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] @@ -3025,7 +3038,7 @@ wheels = [ [[package]] name = "typing-extensions" version = "4.14.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, @@ -3034,7 +3047,7 @@ wheels = [ [[package]] name = "typing-inspect" version = "0.9.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "mypy-extensions" }, { name = "typing-extensions" }, @@ -3047,7 +3060,7 @@ wheels = [ [[package]] name = "typing-inspection" version = "0.4.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] @@ -3059,7 +3072,7 @@ wheels = [ [[package]] name = "tzdata" version = "2025.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, @@ -3068,7 +3081,7 @@ wheels = [ [[package]] name = "urllib3" version = "2.5.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, @@ -3077,7 +3090,7 @@ wheels = [ [[package]] name = "uvicorn" version = "0.24.0.post1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, @@ -3102,7 +3115,7 @@ standard = [ [[package]] name = "uvloop" version = "0.21.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/3d/76/44a55515e8c9505aa1420aebacf4dd82552e5e15691654894e90d0bd051a/uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f", size = 1442019, upload-time = "2024-10-14T23:37:20.068Z" }, @@ -3134,7 +3147,7 @@ wheels = [ [[package]] name = "watchfiles" version = "1.1.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, ] @@ -3234,7 +3247,7 @@ wheels = [ [[package]] name = "websockets" version = "15.0.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" }, @@ -3293,7 +3306,7 @@ wheels = [ [[package]] name = "yarl" version = "1.20.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "multidict" }, @@ -3392,7 +3405,7 @@ wheels = [ [[package]] name = "zipp" version = "3.23.0" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, @@ -3401,7 +3414,7 @@ wheels = [ [[package]] name = "zope-event" version = "5.1" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "setuptools" }, ] @@ -3413,7 +3426,7 @@ wheels = [ [[package]] name = "zope-interface" version = "7.2" -source = { registry = "https://pypi.org/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "setuptools" }, ]