From 08f49031be41963453380ea8de3e2fdd271240e5 Mon Sep 17 00:00:00 2001 From: Matt Kaye Date: Thu, 27 Mar 2025 19:32:54 -0400 Subject: [PATCH] [Python] Feat: Replace `REST` Client (#1413) * fix: version * feat: first pass at new base rest client * fix: typing * fix: base client cleanup * feat: basic runs client * fix: finally!! * feat: helper functions for uuid and metadata conversion and api format fix * fix: patches * feat: run list apis * feat: add bulk replay and cancel * feat: replays and cancels * feat: result getter * feat: cron client * feat: scheduled workflows * feat: rate limit * refactor: don't export admin client anymore * feat: add a bunch more clients and remove the old `rest` thing * fix: scheduled workflow trigger time * fix: emptymodel default * refactor: stop passing pooled workflow run listener around everywhere * fix: more cleanup of context * refactor: remove unused stuff from runner * unwind: keep passing listeners around * fix: rm some unused stuff * fix: example * feat: metrics api * feat: a couple tests * feat: more default emptymodels * fix: tests * [Docs]: Misc. Python Migration Guide Issues, Rate limits, V1 new features (#1417) * fix: misc python migration guide * feat: rate limits docs * fix: lint * fix: lint * feat: skeleton * feat: add a bunch of docs * feat: bulk replay and cancel docs * fix: add task output to example * fix: otel docs + naming * fix: lint * fix: rm timeout * feat: initial python sdk guide * fix: raise on dupe on failure or on success * fix: dags docs * feat: 1.0.1 --- .github/workflows/sdk-python.yml | 2 +- frontend/docs/pages/home/_meta.json | 19 +- frontend/docs/pages/home/child-spawning.mdx | 2 +- frontend/docs/pages/home/concurrency-keys.mdx | 1 - .../pages/home/migration-guide-python.mdx | 7 +- frontend/docs/pages/home/opentelemetry.mdx | 23 +- frontend/docs/pages/home/rate-limits.mdx | 215 +++++- .../docs/pages/home/v1-bulk-operations.mdx | 89 +++ .../docs/pages/home/v1-complex-workflows.mdx | 76 ++ .../docs/pages/home/v1-durable-execution.mdx | 35 + .../pages/home/v1-new-features-overview.mdx | 80 +++ .../docs/pages/home/v1-sdk-improvements.mdx | 70 ++ frontend/docs/public/branching-dag.png | Bin 0 -> 138400 bytes sdks/python/conftest.py | 6 +- sdks/python/examples/api/api.py | 2 +- sdks/python/examples/api/async_api.py | 2 +- .../examples/bulk_fanout/bulk_trigger.py | 2 +- sdks/python/examples/bulk_fanout/worker.py | 2 +- .../python/examples/bulk_operations/cancel.py | 42 ++ .../python/examples/bulk_operations/replay.py | 41 ++ .../python/examples/cron/programatic-async.py | 4 +- sdks/python/examples/cron/programatic-sync.py | 4 +- sdks/python/examples/delayed/worker.py | 2 +- sdks/python/examples/fanout/trigger.py | 2 +- sdks/python/examples/fanout/worker.py | 14 +- sdks/python/examples/fanout_sync/worker.py | 2 +- .../examples/on_failure/test_on_failure.py | 4 +- .../opentelemetry_instrumentation/triggers.py | 8 +- sdks/python/examples/rate_limit/worker.py | 47 +- .../examples/scheduled/programatic-async.py | 6 +- .../examples/scheduled/programatic-sync.py | 4 +- sdks/python/hatchet_sdk/__init__.py | 5 + sdks/python/hatchet_sdk/client.py | 22 +- sdks/python/hatchet_sdk/clients/admin.py | 18 - .../rest/models/workflow_runs_metrics.py | 6 +- .../python/hatchet_sdk/clients/rest_client.py | 657 ------------------ .../hatchet_sdk/clients/v1/api_client.py | 81 +++ sdks/python/hatchet_sdk/context/context.py | 13 +- sdks/python/hatchet_sdk/features/cron.py | 244 +++---- sdks/python/hatchet_sdk/features/logs.py | 16 + sdks/python/hatchet_sdk/features/metrics.py | 75 ++ .../hatchet_sdk/features/rate_limits.py | 45 ++ sdks/python/hatchet_sdk/features/runs.py | 221 ++++++ sdks/python/hatchet_sdk/features/scheduled.py | 245 +++---- sdks/python/hatchet_sdk/features/workers.py | 41 ++ sdks/python/hatchet_sdk/features/workflows.py | 55 ++ sdks/python/hatchet_sdk/hatchet.py | 50 +- .../hatchet_sdk/runnables/standalone.py | 20 +- sdks/python/hatchet_sdk/runnables/types.py | 2 +- sdks/python/hatchet_sdk/runnables/workflow.py | 45 +- .../worker/action_listener_process.py | 6 +- .../worker/runner/run_loop_manager.py | 1 - .../hatchet_sdk/worker/runner/runner.py | 6 - sdks/python/openapi_patch.patch | 48 -- sdks/python/poetry.lock | 17 +- sdks/python/pyproject.toml | 3 +- sdks/python/tests/test_rest_api.py | 54 ++ .../src/v1/examples/rate_limit/run.ts | 17 + .../src/v1/examples/rate_limit/worker.ts | 14 + .../src/v1/examples/rate_limit/workflow.ts | 49 ++ 60 files changed, 1751 insertions(+), 1138 deletions(-) delete mode 100644 frontend/docs/pages/home/concurrency-keys.mdx create mode 100644 frontend/docs/pages/home/v1-bulk-operations.mdx create mode 100644 frontend/docs/pages/home/v1-complex-workflows.mdx create mode 100644 frontend/docs/pages/home/v1-durable-execution.mdx create mode 100644 frontend/docs/pages/home/v1-new-features-overview.mdx create mode 100644 frontend/docs/pages/home/v1-sdk-improvements.mdx create mode 100644 frontend/docs/public/branching-dag.png create mode 100644 sdks/python/examples/bulk_operations/cancel.py create mode 100644 sdks/python/examples/bulk_operations/replay.py delete mode 100644 sdks/python/hatchet_sdk/clients/rest_client.py create mode 100644 sdks/python/hatchet_sdk/clients/v1/api_client.py create mode 100644 sdks/python/hatchet_sdk/features/logs.py create mode 100644 sdks/python/hatchet_sdk/features/metrics.py create mode 100644 sdks/python/hatchet_sdk/features/rate_limits.py create mode 100644 sdks/python/hatchet_sdk/features/runs.py create mode 100644 sdks/python/hatchet_sdk/features/workers.py create mode 100644 sdks/python/hatchet_sdk/features/workflows.py create mode 100644 sdks/python/tests/test_rest_api.py create mode 100644 sdks/typescript/src/v1/examples/rate_limit/run.ts create mode 100644 sdks/typescript/src/v1/examples/rate_limit/worker.ts create mode 100644 sdks/typescript/src/v1/examples/rate_limit/workflow.ts diff --git a/.github/workflows/sdk-python.yml b/.github/workflows/sdk-python.yml index a3aecc492..69af81b06 100644 --- a/.github/workflows/sdk-python.yml +++ b/.github/workflows/sdk-python.yml @@ -134,7 +134,7 @@ jobs: run: | echo "Using HATCHET_CLIENT_NAMESPACE: $HATCHET_CLIENT_NAMESPACE" - poetry run pytest -s -vvv --maxfail=5 --timeout=180 --capture=no -n 5 + poetry run pytest -s -vvv --maxfail=5 --capture=no -n 5 - name: Upload engine logs if: always() diff --git a/frontend/docs/pages/home/_meta.json b/frontend/docs/pages/home/_meta.json index a1ea3b484..687575f88 100644 --- a/frontend/docs/pages/home/_meta.json +++ b/frontend/docs/pages/home/_meta.json @@ -94,8 +94,7 @@ "title": "Concurrency" }, "rate-limits": { - "title": "Rate Limits", - "display": "hidden" + "title": "Rate Limits" }, "--cancellation": { @@ -111,7 +110,21 @@ "title": "Bulk Cancellation", "display": "hidden" }, - "--v1": { + "--v1-new-features": { + "title": "V1 New Features", + "type": "separator" + }, + "v1-new-features-overview": "Overview", + "v1-complex-workflows": "Complex Workflows", + "v1-durable-execution": { + "title": "Durable Execution", + "display": "hidden" + }, + "v1-bulk-operations": "Bulk Cancel and Replay", + "v1-sdk-improvements": { + "title": "SDK Improvements" + }, + "--v1-migration-guides": { "title": "V1 Migration Guides", "type": "separator" }, diff --git a/frontend/docs/pages/home/child-spawning.mdx b/frontend/docs/pages/home/child-spawning.mdx index 971bd8e66..ad6309bb2 100644 --- a/frontend/docs/pages/home/child-spawning.mdx +++ b/frontend/docs/pages/home/child-spawning.mdx @@ -281,7 +281,7 @@ import asyncio async def run_child_workflows(n: int) -> list[dict[str, Any]]: return await child.aio_run_many([ - child.create_run_workflow_config( + child.create_bulk_run_item( options=TriggerWorkflowOptions( input=ChildInput(n=i), ) diff --git a/frontend/docs/pages/home/concurrency-keys.mdx b/frontend/docs/pages/home/concurrency-keys.mdx deleted file mode 100644 index 0a8d1453b..000000000 --- a/frontend/docs/pages/home/concurrency-keys.mdx +++ /dev/null @@ -1 +0,0 @@ -TODO V1 DOCS diff --git a/frontend/docs/pages/home/migration-guide-python.mdx b/frontend/docs/pages/home/migration-guide-python.mdx index 0282970e7..edc933fa9 100644 --- a/frontend/docs/pages/home/migration-guide-python.mdx +++ b/frontend/docs/pages/home/migration-guide-python.mdx @@ -25,7 +25,6 @@ The API has changed significantly in the V1 SDK. Even in this simple example, th 1. Tasks can now be declared with `hatchet.task`, meaning you no longer _have_ to create a workflow explicitly to define a task. This should feel similar to how e.g. Celery handles task definition. Note that we recommend declaring a workflow in many cases, but the simplest possible way to get set up is to use `hatchet.task`. 2. Tasks have a new signature. They now take two arguments: `input` and `context`. The `input` is either of type `input_validator` (a Pydantic model you provide to the workflow), or is an `EmptyModel`, which is a helper Pydantic model Hatchet provides and uses as a default. The `context` is once again the Hatchet `Context` object. 3. Workflows can now be registered on a worker by using the `workflows` keyword argument to the `worker` method, although the old `register_workflows` method is still available. -4. The Hatchet client's REST API wrappers have been significantly reworked. For instance, if you'd like to use the REST API to list out workflow runs, you can use `await hatchet.workfows.aio_list()` #### Pydantic @@ -60,13 +59,13 @@ Typing improvements: Naming changes: -1. We no longer have nested `aio` clients for async methods. Instead, async methods throughout the entire SDK are preferred by `aio_`, similar to [Langchain's use of the `a` prefix](https://python.langchain.com/docs/concepts/streaming/#stream-and-astream) to indicate async. For example, to run a workflow, you may now either use `workflow.run()` or `workflow.aio_run()`. -2. All functions on Hatchet clients are now _verbs_. For instance `hatchet.admin.workflow_run_get` is now `hatchet.admin.get_workflow_run`. +1. We no longer have nested `aio` clients for async methods. Instead, async methods throughout the entire SDK are prefixed by `aio_`, similar to [Langchain's use of the `a` prefix](https://python.langchain.com/docs/concepts/streaming/#stream-and-astream) to indicate async. For example, to run a workflow, you may now either use `workflow.run()` or `workflow.aio_run()`. +2. All functions on Hatchet clients are now _verbs_. For instance the way to list workflow runs is via `hatchet.workflows.list`. 3. `max_runs` on the worker has been renamed to `slots`. Removals: -1. `sync_to_async` has been removed. We recommend reading [our asyncio documentation](../sdks/python-sdk/asyncio.mdx) for our recommendations on handling blocking work in otherwise async tasks. +1. `sync_to_async` has been removed. We recommend reading [our asyncio documentation](./asyncio.mdx) for our recommendations on handling blocking work in otherwise async tasks. Other miscellaneous changes: diff --git a/frontend/docs/pages/home/opentelemetry.mdx b/frontend/docs/pages/home/opentelemetry.mdx index 6bdef5ada..e94ee49bd 100644 --- a/frontend/docs/pages/home/opentelemetry.mdx +++ b/frontend/docs/pages/home/opentelemetry.mdx @@ -33,14 +33,12 @@ You bring your own trace provider and plug it into the `HatchetInstrumentor`, ca ### Providing a `traceparent` -In some cases, you might also want to provide a `traceparent` so any spans created in Hatchet are children of a parent that was created elsewhere in your application. You can do that by providing a `traceparent` key in the `additional_metadata` field of the following methods: +In some cases, you might also want to provide a `traceparent` so any spans created in Hatchet are children of a parent that was created elsewhere in your application. You can do that by providing a `traceparent` key in the `additional_metadata` field of corresponding options field the following methods: -- `hatchet.event.push` -- `hatchet.event.bulk_push` -- `hatchet.admin.run_workflow` -- `hatchet.admin.run_workflows` -- `hatchet.admin.run_workflow_async` -- `hatchet.admin.run_workflows_async` +- `hatchet.event.push` via the `PushEventOptions` +- `hatchet.event.bulk_push` via the `BulkPushEventOptions` +- `Workflow.run` via the `TriggerWorkflowOptions` (and similarly for all other flavors of `run`, like `aio_run`, `run_nowait`, etc.) +- `Workflow.run_many` via the `TriggerWorkflowOptions` (and similarly for all other flavors of `run_many`, like `aio_run_many`, etc.) For example: @@ -48,11 +46,12 @@ For example: hatchet.event.push( "user:create", {'userId': '1234'}, - options={ - "additional_metadata": { - "traceparent":"00-f1aff5c5ea45185eff2a06fd5c0ed6c5-6f4116aff54d54d1-01" ## example traceparent - } -}) + options=PushEventOptions( + additional_metadata={ + "traceparent":"00-f1aff5c5ea45185eff2a06fd5c0ed6c5-6f4116aff54d54d1-01" ## example traceparent + } + ) +) ``` The `HatchetInstrumentor` also has some methods for generating traceparents that might be helpful: diff --git a/frontend/docs/pages/home/rate-limits.mdx b/frontend/docs/pages/home/rate-limits.mdx index 0a8d1453b..8457c49d1 100644 --- a/frontend/docs/pages/home/rate-limits.mdx +++ b/frontend/docs/pages/home/rate-limits.mdx @@ -1 +1,214 @@ -TODO V1 DOCS +import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; +import UniversalTabs from "../../components/UniversalTabs"; +import { GithubSnippet, getSnippets } from "@/components/code"; + +export const RateLimitPy = { + path: "examples/rate_limit/worker.py", +}; + +export const RateLimitTs = { + path: "src/v1/examples/rate_limit/workflow.ts", +}; + +export const getStaticProps = ({}) => getSnippets([RateLimitPy, RateLimitTs]); + +# Rate Limiting Step Runs in Hatchet + +Hatchet allows you to enforce rate limits on step runs in your workflows, enabling you to control the rate at which workflow runs consume resources, such as external API calls, database queries, or other services. By defining rate limits, you can prevent step runs from exceeding a certain number of requests per time window (e.g., per second, minute, or hour), ensuring efficient resource utilization and avoiding overloading external services. + +The state of active rate limits can be viewed in the dashboard in the `Rate Limit` resource tab. + +## Dynamic vs Static Rate Limits + +Hatchet offers two patterns for Rate Limiting step runs: + +1. [Dynamic Rate Limits](#dynamic-rate-limits): Allows for complex rate limiting scenarios, such as per-user limits, by using `input` or `additional_metadata` keys to upsert a limit at runtime. +2. [Static Rate Limits](#static-rate-limits): Allows for simple rate limiting for resources known prior to runtime (e.g., external APIs). + +## Dynamic Rate Limits + +Dynamic rate limits are ideal for complex scenarios where rate limits need to be partitioned by resources that are only known at runtime. + +This pattern is especially useful for: + +1. Rate limiting individual users or tenants +2. Implementing variable rate limits based on subscription tiers or user roles +3. Dynamically adjusting limits based on real-time system load or other factors + +### How It Works + +1. Define the dynamic rate limit key with a CEL (Common Expression Language) Expression on the key, referencing either `input` or `additional_metadata`. +2. Provide this key as part of the workflow trigger or event `input` or `additional_metadata` at runtime. +3. Hatchet will create or update the rate limit based on the provided key and enforce it for the step run. + + + Note: Dynamic keys are a shared resource, this means the same rendered cel on + multiple steps will be treated as one global rate limit. + + +### Declaring and Consuming Dynamic Rate Limits + + + +> Note: `dynamic_key` must be a CEL expression. `units` and `limits` can be either an integer or a CEL expression. + +First, let's create a workflow: + + + +Next, let's add a task that utilizes a dynamic rate limit: + + + + + +> Note: `dynamicKey` must be a CEL expression. `units` and `limit` can be either an integer or a CEL expression. + +First, let's create a workflow: + + + +Next, let's add a task that utilizes a dynamic rate limit: + + + + + +> Note: Go requires both a key and KeyExpr be set and the LimitValueExpr must be a CEL. + +```go +err = w.RegisterWorkflow( + &worker.WorkflowJob{ + Name: "rate-limit-workflow", + Description: "This illustrates dynamic rate limiting.", + On: worker.NoTrigger(), + Steps: []*worker.WorkflowStep{ + worker.Fn(StepOne).SetName("step-one").SetRateLimit( + worker.RateLimit{ + Key: "per-user-rate-limit", + Units: 1, + KeyExpr: "input.user_id", + LimitValueExpr: '"10"', + // import "github.com/hatchet-dev/hatchet/pkg/client/types" + Duration: types.Minute, + }, + ), + }, + }, +) +``` + + + + +## Static Rate Limits + +Static Rate Limits (formerly known as Global Rate Limits) are defined as part of your worker startup lifecycle prior to runtime. This model provides a single "source of truth" for pre-defined resources such as: + +1. External API resources that have a rate limit across all users or tenants +2. Database connection pools with a maximum number of concurrent connections +3. Shared computing resources with limited capacity + +### How It Works + +1. Declare static rate limits using the `put_rate_limit` method in the `Admin` client before starting your worker. +2. Specify the units of consumption for a specific rate limit key in each step definition using the `rate_limits` configuration. +3. Hatchet enforces the defined rate limits by tracking the number of units consumed by each step run across all workflow runs. + +If a step run exceeds the rate limit, Hatchet re-queues the step run until the rate limit is no longer exceeded. + +### Declaring Static Limits + +Define the static rate limits that can be consumed by any step run across all workflow runs using the `put_rate_limit` method in the `Admin` client within your code. + + + + +```python +RATE_LIMIT_KEY = "test-limit" + +hatchet.rate_limits.put(RATE_LIMIT_KEY, 10, RateLimitDuration.MINUTE) +``` + + + + +```typescript +await hatchet.ratelimits.upsert({ + key: "test-limit", + limit: 1, + duration: RateLimitDuration.MINUTE, +}); +``` + + + + +```go +err = c.Admin().PutRateLimit("example-limit", &types.RateLimitOpts{ + Max: 3, + Duration: "minute", +}) +``` + + + + +### Consuming Static Rate Limits + +With your rate limit key defined, specify the units of consumption for a specific key in each step definition by adding the `rate_limits` configuration to your step definition in your workflow. + + + + + + + + + +```typescript +const workflow: Workflow = { + // ... the rest of the workflow definition + steps: [ + { + name: "step1", + rate_limits: [{ key: "example-limit", units: 1 }], + run: async (ctx) => { + console.log( + "starting step1 with the following input", + ctx.workflowInput(), + ); + return { step1: "step1 results!" }; + }, + }, + ], +}; +``` + + + + +```go +err = w.RegisterWorkflow( + &worker.WorkflowJob{ + Name: "rate-limit-workflow", + Description: "This illustrates static rate limiting.", + On: worker.NoTrigger(), + Steps: []*worker.WorkflowStep{ + worker.Fn(StepOne).SetName("step-one").SetRateLimit( + worker.RateLimit{ + Units: 1, + Key: "example-limit", + }, + ), + }, + }, +) +``` + + + + +### Limiting Workflow Runs + +To rate limit an entire workflow run, it's recommended to specify the rate limit configuration on the entry step (i.e., the first step in the workflow). This will gate the execution of all downstream steps in the workflow. diff --git a/frontend/docs/pages/home/v1-bulk-operations.mdx b/frontend/docs/pages/home/v1-bulk-operations.mdx new file mode 100644 index 000000000..067c18eb0 --- /dev/null +++ b/frontend/docs/pages/home/v1-bulk-operations.mdx @@ -0,0 +1,89 @@ +import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; +import { GithubSnippet, getSnippets } from "@/components/code"; + +export const CancelPy = { + path: "examples/bulk_operations/cancel.py", +}; +export const ReplayPy = { + path: "examples/bulk_operations/replay.py", +}; + +export const getStaticProps = ({}) => getSnippets([CancelPy, ReplayPy]); + +## Bulk Cancellations and Replays + +V1 add the ability to cancel or replay workflow runs in bulk, which you can now do either in the Hatchet Dashboard or programmatically via the SDKs and the REST API. + +There are two ways of bulk cancelling or replaying workflows in both cases: + +1. You can provide a list of workflow run ids to cancel or replay, which will cancel or replay all of the workflows in the list. +2. You can provide a list of filters, similar to the list of filters on workflow runs in the Dashboard, and cancel or replay runs matching those filters. For instance, if you wanted to replay all failed runs of a `SimpleWorkflow` from the past fifteen minutes that had the `foo` field in `additional_metadata` set to `bar`, you could apply those filters and replay all of the matching runs. + +### Bulk Operations by Run Ids + +The first way to bulk cancel or replay runs is by providing a list of run ids. This is the most straightforward way to cancel or replay runs in bulk. + + +{/* TODO V1 DOCS - Add TS and Go */} + + + + In the Python SDK, the mechanics of bulk replaying and bulk cancelling + workflows are exactly the same. The only change would be replacing e.g. + `hatchet.runs.bulk_cancel` with `hatchet.runs.bulk_replay`. + + + First, we'll start by fetching a workflow via the REST API. + + + + Now that we have a workflow, we'll get runs for it, so that we can use them to bulk cancel by run id. + + + + And finally, we can cancel the runs in bulk. + + + + + Note that the Python SDK also exposes async versions of each of these methods: + + - `workflows.list` -> `await workflows.aio_list` + - `runs.list` -> `await runs.aio_list` + - `runs.bulk_cancel` -> `await runs.aio_bulk_cancel` + + + + {/* + TODO V1 DOCS + + + TODO V1 DOCS + */} + + +### Bulk Operations by Filters + +### Bulk Operations by Run Ids + +The second way to bulk cancel or replay runs is by providing a list of filters. This is the most powerful way to cancel or replay runs in bulk, as it allows you to cancel or replay all runs matching a set of arbitrary filters without needing to provide IDs for the runs in advance. + + +{/* TODO V1 DOCS - Add TS and Go */} + + + The example below provides some filters you might use to cancel or replay runs in bulk. Importantly, these filters are very similar to the filters you can use in the Hatchet Dashboard to filter which workflow runs are displaying. + + + + Running this request will cancel all workflow runs matching the filters provided. + + + {/* + TODO V1 DOCS + + + TODO V1 DOCS + */} + diff --git a/frontend/docs/pages/home/v1-complex-workflows.mdx b/frontend/docs/pages/home/v1-complex-workflows.mdx new file mode 100644 index 000000000..98242d8c5 --- /dev/null +++ b/frontend/docs/pages/home/v1-complex-workflows.mdx @@ -0,0 +1,76 @@ +import { GithubSnippet, getSnippets } from "@/components/code"; + +export const WaitsPy = { + path: "examples/waits/worker.py", +}; + +export const getStaticProps = ({}) => getSnippets([WaitsPy]); + +## Introduction + +Hatchet V1 introduces the ability to add conditions to tasks in your workflows that determine whether or not a task should be run, based on a number of conditions. There are three types of `Condition`s in Hatchet V1: + +1. Sleep conditions, which sleep for a specified duration before continuing +2. Event conditions, which wait for an event (and optionally a CEL expression evaluated on the payload of that event) before deciding how to continue +3. Parent conditions, which wait for a parent task to complete and then decide how to progress based on its output. + +These conditions can also be combined using an `Or` operator into groups of conditions where at least one must be satisfied in order for the group to evaluate to `True`. + +Conditions can be used at task _declaration_ time in three ways: + +1. They can be used in a `wait_for` fashion, where a task will wait for the conditions to evaluate to `True` before being run. +2. They can be used in a `skip_if` fashion, where a task will be skipped if the conditions evaluate to `True`. +3. They can be used in a `cancel_if` fashion, where a task will be cancelled if the conditions evaluate to `True`. + +## Use Cases + +There are a number of use cases that these features unlock. Some examples might be: + +1. A workflow that reads a feature flag, and then decides how to progress based on its value. In this case, you'd have two tasks that use parent conditions, where one task runs if the flag value is e.g. `True`, while the other runs if it's `False`. +2. Any type of human-in-the-loop workflow, where you want to wait for a human to e.g. approve something before continuing the run. + +## Example Workflow + +In this example, we're going to build the following workflow: + +![Branching DAG Workflow](/branching-dag.png) + +Note the branching logic (`left_branch` and `right_branch`), as well as the use of skips and waits. + +To get started, let's declare the workflow. + + + +Next, we'll start adding tasks to our workflow. First, we'll add a basic task that outputs a random number: + + + +Next, we'll add a task to the workflow that's a child of the first task, but it has a `wait_for` condition that sleeps for 10 seconds. + + + +This task will first wait for the parent task to complete, and then it'll sleep for 10 seconds before executing and returning another random number. + +Next, we'll add a task that will be skipped on an event: + + + +In this case, our task will wait for a 30 second sleep, and then it will be skipped if the `skip_on_event:skip` is fired. + +Next, let's add some branching logic. Here we'll add two more tasks, a left and right branch. + + + +These two tasks use the `ParentCondition` and `skip_if` together to check if the output of an upstream task was greater or less than `50`, respectively. Only one of the two tasks will run: whichever one's condition evaluates to `True`. + +Next, we'll add a task that waits for an event: + + + +And finally, we'll add the last task, which collects all of its parents and sums them up. + + + +Note that in this task, we rely on `ctx.was_skipped` to determine if a task was skipped. + +This workflow demonstrates the power of the new conditional logic in Hatchet V1. You can now create complex workflows that are much more dynamic than workflows in the previous version of Hatchet, and do all of it declaratively (rather than, for example, by dynamically spawning child workflows based on conditions in the parent). diff --git a/frontend/docs/pages/home/v1-durable-execution.mdx b/frontend/docs/pages/home/v1-durable-execution.mdx new file mode 100644 index 000000000..40e9c444f --- /dev/null +++ b/frontend/docs/pages/home/v1-durable-execution.mdx @@ -0,0 +1,35 @@ +import { GithubSnippet, getSnippets } from "@/components/code"; +import { Callout } from "nextra/components"; + +export const DurablePy = { + path: "examples/durable/worker.py", +}; + +export const getStaticProps = ({}) => getSnippets([DurablePy]); + +## Introduction + +Hatchet V1 supports a new set of durable execution features that build off of the [conditions listed above](#complex--conditional-workflow-logic). In the V1 SDKs, you can now call `context.wait_for` to durably wait for either an event or a sleep condition to complete before continuing your task. + + + If you register any durable tasks, the Hatchet SDK will run a second "durable" worker when starting your main worker for running the durable tasks. This durable worker will show up as a second worker in the Hatchet Dashboard. + +**If you register _any_ durable tasks on a workflow, _all_ of the tasks in that workflow will be run by the durable worker.** + + + +## Example Workflow + +Let's start by declaring a workflow that will run durably, on the "durable worker". + + + +Here, we've declared a Hatchet workflow just like any other. Now, we can add some tasks to it: + + + +We've added two tasks to our workflow. The first is a normal, "ephemeral" task, which does not leverage any of Hatchet's durable features. **Importantly, as mentioned above, this task will still be run by the durable worker.** + +Second, we've added a durable task, which we've created by using the `durable_task` method of the `Workflow`, as opposed to the `task` method. + +The durable task first waits for a sleep condition. Once the sleep has completed, it continues processing until it hits the second `wait_for`. At this point, it needs to wait for an event condition. Once it receives the event, the task prints `Event received` and completes. diff --git a/frontend/docs/pages/home/v1-new-features-overview.mdx b/frontend/docs/pages/home/v1-new-features-overview.mdx new file mode 100644 index 000000000..a4d0ff3a5 --- /dev/null +++ b/frontend/docs/pages/home/v1-new-features-overview.mdx @@ -0,0 +1,80 @@ +import { GithubSnippet, getSnippets } from "@/components/code"; +import { Callout } from "nextra/components"; + +export const WaitsTs = { + path: "src/v1/examples/simple/workflow.ts", +}; +export const WaitsPy = { + path: "examples/waits/worker.py", +}; + +export const getStaticProps = ({}) => getSnippets([WaitsPy, WaitsTs]); + +## V1 New Features + +For the past several months, we’ve been working on a complete rewrite of the Hatchet queue with a focus on performance and a set of feature requests which weren’t possible in the v0 architecture. + +Here, we'll go into a bit more depth on new features in V1. + +## New features + +At a high level, Hatchet v1 supports the following new features: + +- Complex/conditional workflow logic, like skipping or branching workflows +- Durable execution features: workflow signaling and durable sleep +- A documented, stable REST API for interacting with workflows +- Each SDK received a new `v1.0.x` release with the following improvements: + - Python received improved support for Pydantic validation, dynamic workflow composition, simpler workflow and task declaration, and significantly improved type support. + - Typescript received a new factory method for building workflows, and improved typing and validation support + - Go received improvements for registering workflows and defining tasks. +- Improved bulk cancellations and replays + +## Complex / Conditional Workflow Logic + +Hatchet V1 introduces the ability to add conditions to tasks in your workflows that determine whether or not a task should be run, based on a number of conditions. There are three types of `Condition`s in Hatchet V1: + +1. Sleep conditions, which sleep for a specified duration before continuing +2. Event conditions, which wait for an event (and optionally a CEL expression evaluated on the payload of that event) before deciding how to continue +3. Parent conditions, which wait for a parent task to complete and then decide how to progress based on its output. + +These conditions can also be combined using an `Or` operator into groups of conditions where at least one must be satisfied in order for the group to evaluate to `True`. + +Conditions can be used at task _declaration_ time in three ways: + +1. They can be used in a `wait_for` fashion, where a task will wait for the conditions to evaluate to `True` before being run. +2. They can be used in a `skip_if` fashion, where a task will be skipped if the conditions evaluate to `True`. +3. They can be used in a `cancel_if` fashion, where a task will be cancelled if the conditions evaluate to `True`. + +Check out [the conditional logic example](./v1-dags.mdx) for a detailed example of how to use conditional logic in your V1 workflows. + +## Durable Execution + +Hatchet V1 supports a new set of durable execution features that build off of the [conditions listed above](#complex--conditional-workflow-logic). In the V1 SDKs, you can now call `context.wait_for` to durably wait for either an event or a sleep condition to complete before continuing your task. + + + If you register any durable tasks, the Hatchet SDK will run a second "durable" worker when starting your main worker for running the durable tasks. This durable worker will show up as a second worker in the Hatchet Dashboard. + +**If you register _any_ durable tasks on a workflow, _all_ of the tasks in that workflow will be run by the durable worker.** + + + +Check out the [durable execution example](./v1-durable-execution.mdx) for a detailed example of how to use Hatchet's new durable execution features in your V1 workflows. + +## Bulk Cancellations and Replays + +Another new feature in V1 is the ability to cancel or replay workflow runs in bulk, which you can now do either in the Hatchet Dashboard or programmatically via the SDKs and the REST API. + +There are two ways of bulk cancelling or replaying workflows in both cases: + +1. You can provide a list of workflow run ids to cancel or replay, which will cancel or replay all of the workflows in the list. +2. You can provide a list of filters, similar to the list of filters on workflow runs in the Dashboard, and cancel or replay runs matching those filters. For instance, if you wanted to replay all failed runs of a `SimpleWorkflow` from the past fifteen minutes that had the `foo` field in `additional_metadata` set to `bar`, you could apply those filters and replay all of the matching runs. + +## SDK improvements + +We've made a number of significant improvements to our SDKs in the V1 release. You can read about the improvements to each SDK in their corresponding migration guides: + +- [Python SDK](./migration-guide-python.mdx) +- [TypeScript](./migration-guide-typescript.mdx) +- Go (coming soon) + +{/* TODO V1 DOCS - Go migration guide */} diff --git a/frontend/docs/pages/home/v1-sdk-improvements.mdx b/frontend/docs/pages/home/v1-sdk-improvements.mdx new file mode 100644 index 000000000..edac60057 --- /dev/null +++ b/frontend/docs/pages/home/v1-sdk-improvements.mdx @@ -0,0 +1,70 @@ +import { Callout, Card, Cards, Steps, Tabs } from "nextra/components"; +import UniversalTabs from "@/components/UniversalTabs"; + +## SDK Improvements in V1 + +The Hatchet SDKs have seen considerable improvements with the V1 release. + + + The examples in our documentation now use the V1 SDKs, so following individual + examples will help you get familiar with the new SDKs and understand how to + migrate from V0. + + + + + +### Highlights + +The Python SDK has a number of notable highlights to showcase for V1. Many of them have been highlighted elsewhere, such as [in the migration guide](./migration-guide-python.mdx), on the [Pydantic page](./pydantic.mdx), an in various examples. Here, we'll list out each of them, along with their motivations and benefits. + +First and foremost: Many of the changes in the V1 Python SDK are motivated by improved support for type checking and validation across large codebases and in production use-cases. With that in mind, the main highlights in the V1 Python SDK are: + +1. Workflows are now declared with `hatchet.workflow`, which returns a `Workflow` object, or `hatchet.task` (for simple cases) which returns a `Standalone` object. Workflows then have their corresponding tasks registered with `Workflow.task`. The `Workflow` object (and the `Standalone` object) can be reused easily across the codebase, and has wrapper methods like `run` and `schedule` that make it easy to run workflows. In these wrapper methods, inputs to the workflow are type checked, and you no longer need to specify the name of the workflow to run as a magic string. +2. Tasks have their inputs type checked, and inputs are now Pydantic models. The `input` field is either the model you provide to the workflow as the `input_validator`, or is an `EmptyModel`, which is a helper Pydantic model Hatchet provides and uses as a default. +3. In the new SDK, we define the `parents` of a task as a list of `Task` objects as opposed to as a list of strings. This also allows us to use `ctx.task_output(my_task)` to access the output of the `my_task` task in the a downstream task, while allowing that output to be type checked correctly. +4. In the new SDK, inputs are injected directly into the task as the first positional argument, so the signature of a task now will be `Callable[[YourWorkflowInputType, Context]]`. This replaces the old method of accessing workflow inputs via `context.workflow_input()`. + +#### Other Breaking Changes + +There have been a number of other breaking changes throughout the SDK in V1. + +Typing improvements: + +1. External-facing protobuf objects, such as `StickyStrategy` and `ConcurrencyLimitStrategy`, have been replaced by native Python enums to make working with them easier. +2. All external-facing types that are used for triggering workflows, scheduling workflows, etc. are now Pydantic objects, as opposed to being `TypedDict`s. +3. The return type of each `Task` is restricted to a `JSONSerializableMapping` or a Pydantic model, to better align with what the Hatchet Engine expects. +4. The `ClientConfig` now uses Pydantic Settings, and we've removed the static methods on the Client for `from_environment` and `from_config` in favor of passing configuration in correctly. See the [configuration example](./client.mdx) for more details. +5. The REST API wrappers, which previously were under `hatchet.rest`, have been completely overhauled. + +Naming changes: + +1. We no longer have nested `aio` clients for async methods. Instead, async methods throughout the entire SDK are prefixed by `aio_`, similar to [Langchain's use of the `a` prefix](https://python.langchain.com/docs/concepts/streaming/#stream-and-astream) to indicate async. For example, to run a workflow, you may now either use `workflow.run()` or `workflow.aio_run()`. +2. All functions on Hatchet clients are now _verbs_. For instance, if something was named `hatchet.nounVerb` before, it now will be something more like `hatchet.verb_noun`. For example, `hatchet.runs.get_result` gets the result of a workflow run. +3. `timeout`, the execution timeout of a task, has been renamed to `execution_timeout` for clarity. + +Removals: + +1. `sync_to_async` has been removed. We recommend reading [our asyncio documentation](./asyncio.mdx) for our recommendations on handling blocking work in otherwise async tasks. +2. The `AdminClient` has been removed, and refactored into individual clients. For example, if you absolutely need to create a workflow run manually without using `Workflow.run` or `Standalone.run`, you can use `hatchet.runs.create`. This replaces the old `hatchet.admin.run_workflow`. + +Other miscellaneous changes: + +1. As shown in the Pydantic example above, there is no longer a `spawn_workflow(s)` method on the `Context`. `run` is now the preferred method for spawning workflows, which will automatically propagate the parent's metadata to the child workflow. +2. All times and durations, such as `execution_timeout` and `schedule_timeout`, now allow `datetime.timedelta` objects instead of only allowing strings (e.g. `"10s"` can be `timedelta(seconds=10)`). + +#### Other New features + +There are a handful of other new features that will make interfacing with the SDK easier, which are listed below. + +1. Concurrency keys using the `input` to a workflow are now checked for validity at runtime. If the workflow's `input_validator` does not contain a field that's used in a key, Hatchet will reject the workflow when it's created. For example, if the key is `input.user_id`, the `input_validator` Pydantic model _must_ contain a `user_id` field. +2. There is now an `on_success_task` on the `Workflow` object, which works just like an on-failure task, but it runs after all upstream tasks in the workflow have _succeeded_. + + + +{/* TODO V1 Docs */} + + +{/* TODO V1 Docs */} + + diff --git a/frontend/docs/public/branching-dag.png b/frontend/docs/public/branching-dag.png new file mode 100644 index 0000000000000000000000000000000000000000..3f20ff0e8aaa209614b9a8e0dbdd6680e84894a6 GIT binary patch literal 138400 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μ0bfJbnu=Ojx0*yTRSxSv} zFT@4w!K`I}qJEk>O&PYn2*^Ii12%VhAgaB6_=42AhD%(YZw)r+uMRFJx4Y^rPsc0Fl*?;e$`^sj9jBVV8el4D7RKb-OT8}(N?q%FCWoy0Kiiw>f`)R<(5_eNOqyyUGNH!yb6#E$MrHiCrNUtipBC@t|s@C;!?)~ za>@A$30btsmXk%yUl`siR=68~elo#Rcx8S8tZy*JL znw_!yOOJ^aD$^i!H2;z+<4l8j!>4r5@okIZOepYlwiOklRVf z5u{~kk5CxP20Q=EXfL5ool5^@y{g=qb?~0npKPSt@9q!6h6OLv;&WCmjRAK z^5iL2q5>X6r>fgiHU2Qxb{wZ~%j$B#zY-Kad2E4y? z>+|nS?~E7Vc=uevNEd1toAiJ9FL}_ffTK{}@)R_2UNZj7Fu8<*BjllYtkTP!eOdsd7bm=xo_pil7jyXgnnQLtYD{V7RU(MA4qZC zB&_&p-88=edej=#{W{{_Ip=nOl_Y~^-G5K}*MkIe)PBpt2H*WKm%&-#-}(opL(hRK zunf4hKiHH&F0z+Tr8|=S{hy~EwMzg?TUrZA!YSkaLuL_v;B8AB#ER^rj&ozL)CY{6 zF)@kdLI=(+Ivg%Zk>{8~jXLebojErNGr0l&p z&hNVK`>3Pe=ljR|@$PY)bKlqXx?bb?nistu2k!6_t$a$!jw=ZFUx8AA%3id8qX-K! zUR_O|??iz!v>6pJ3O%nU2nJ>X2zsZA{ogJ0V^4gP)MdKG!*@)i59%D5Fs3ZG%{JdG z;{KWwltUn;_`gN~1f4Y)|1j@(=Z_Ubf%fMfem`CWR$A8?cM#NTI5YvG_EGjE5z7D* z0Fuag@=t14){5Ljf&zqpgAzI}e;`H|;cm7cz~dm}z5`X}rjwJ-D9Y~MD#r5YjU0G=NSafB4rN7lw4 ztf^ixy~KtbCnZPQq=r{Z&44-EiQj7s7yzRwxN*W|97P zCpeP|ip%mAGvgmNq8rPBz|7S>Y=~S_Lc!2hbSEFt)4{h}`JlH(K5eBT`n>)y7>=TO zv6%qh+5t3<4vBaFBP}q^(e`9{>^8LzyAJl#0e7Eibtm#b5V8a4gOfIxtJopFxKGnT@^oTpYodU7xV^D*UP%G zf2-Q$6G|+N+JjXWF~DvFGY=3MuRUlirHT30vdAsV%uXb6(Fg@|BA;Rvi8c)p;|zML z_{+#)4)e?_8eAC)E5yYc)k~ypRpz|rqT(_x1j0Z(QGSGo=I~~cDQazBG`!3MajiRP zE#$`(4Ar2F#Lm~O>47n1$rVTNLpYd=l0o_i6Zre@@IMkeNqT7czvqAF)%p_!fuF8- zq&sJ(UhcVP&zmmk+sK)qR-sCmp{ToHG!F4|#?GOEE#0~)@(QE+fe^odEmj3r*m-lN zzDi&V-}boM(-k5fwc~%lh7h4N!n4qN^r;6;*IQpR$|%PWF~LVbR??Na=a?Wuyv-mF z9L{qFmA|Ktq*9nv-C%pM_Jmy%9FM&JZ#e`15a$oWxtjxLc*Vopkzm08k7}eP3~Fn| zHTIk(i1tKC)GA>?>PWng{UEVkL}fw|8^L{D6QTtFffUcHH0g$?B4w#h;h~luyoy9j0&XCsezr0^^=e4^CG^(=t{<_Gw zD+#p{$n$wqL%X*k$d<$tIaw8Ypq5U?_U&E<>JXWQl$L}e*LSW-Vu1Oa0g{_WJ}@i_ z6T1L!Jh30J>nNM|pSS?BAGN)&h`X{Og8v|G8>Jrk1U$=^Wj}Xdra&Y(mp^K9hnR;C zm`G{OvwX8({{T{y@~09r2!DZ3j!CEbzTcn1p&(GyC{z^8L;MxMhx7m@dFezZ(Mx4L zC;HQ^YaF<~PeTeS)dYX;|LCkoZPmLVa>5V1>cY)6tEDWc9!NZxL+Aa_F zzoPRf5HDoss->YcWw;;ZMC9y%8DA#&5HRuRh@l#iNT39%{69|GZN2ZLSmNQy@a#;tj*S z{tRm53_*75KihvFB1(lT6R5Cz{(22WdI~(oilp!rFZ;>xY1YIlqBmTULQr2*t13Bs zI$TBI=eZ3Uvwtzw4GxyZ0CqMYA|CmDF@J(s;HA$QKW=-DQt>}ubs#+BIe5lPX&wFD zO9%&8{+;nJ&!n<&+r4qW!--&V|B>{JQuEOc{iCUds01SYNq4W>XS0_W#5#R+=uxZ~x6V6B<$vh6$U<1DrxYOKVqm4r_I^HA zbgX{_J~R|9fCI~|20Iz{9f_1B`~PS^hVdDt7Mq+MM-~zT#Wo%|7W_`?BC4+AkZe^j zE58!1{ZNz&y67D`C!6}W0-M>9bGFz`T6RgEz=G7mMX?K%JzpiZYdR(O#E z5CaELA*ilR4FC3$oMw1}e~5=NJWx8Hg2FUnDLAo@=mn02s0~wQ`DT}8fqTdRvbz~T z40PV@gi&{x`POZY&CE&H6eaABt#Be4_Cb{a2eyh3SGVp;5+{J1v3~(Coo8igBK8RY z33|NK*o7YFmG3g@tra9aRl{32Y{keMq{ufBszd+1z2C%ZO+>Bek2UCQ$>a796Gan& zq?42Jns-B`4e*zBq1sK~N3U-ZZ5F_A8#GdX<9MX;ugGX(8CjlwUT&EzOW&wP6LiM^kujP82SPkl z2jUR%GY~+HsQ~E39kaX>@4KV|!1BxJiN9zGfI#ElKmgS1L<&wJRkmPxt=GUY(0q6Q zoeo3x@r0yDN&U{mcYw^<13@I30S#I* z0T@+Dt0zPr2xMx#L2^%kNBGlyKix|L(Fl6#g$m!y>_T^m!d{Xz;FAC8KY$Of?r=`5 zVQ#&OEll5P2z4cM{~`nzcH{`uP`o0pp@4rW&Vi^jHYQ#LB)i9V7@*)o7>ivt$){Qq zm=xnrqKJ!#93v?105Jqn>?A0_M`L^Eqs+DyyZkjY4JC!C6Wve}@oHN40v+bOXMhtSH6!HGd^NcHd)_SkUV04_S4N3( zhB+}mGs%O<3zh%{as#dqc0&*4oP8dp%M*W>qg5Adc|eFw`**$`Pz>!%2^q3cdx9@} z_g9iQ!Nmq{w1K)mdG!eKT2YDJ;3;)%`W3{d2&#}H1!HADA{EO@cL2+M56O(*FheVX6y&l$bp*zRvdYLIlNsHyLbNP^fcvp~zc+bUr%^nnr9-@7ZBL znB5q_$+>YNJa8Ximk5XcWh8{OBT(8L9O0cLs%n2S;sEz>kce6rgX$s|-Txn!-CdqK$};l0A}}P)dp2>MC3uMQ@z1o7nf+brIm@@J&o2)AE1Xn8I&`?^a&l~Gu?IUKGn{9 zU-8RsYx(?er2IrgQoAk^9mwk~SZUs5)W5V+PBAON0Cje30JqmWmG)qwc zpqJWw^UDF1af}6WyS?p14UoR*%-~EJfZuFzADq+k#ea9RsN5NjaJ9U>UAyt!O@o?J z79Pa~vP5CDJ2iqX|7Y2On-tJYBK0?Ui0RN82w9;z<#sce4gyf7?bCyE3PkJy(8(dX zM($6j$uscN2}tA0DTB*3H1FM!(DmIUa5%qXMms|Fzh5~Cy>j_b2NHT0s05s{CXq!f zcs{64q>uCDguj&%heLr<)@;ssw8n<4u9pKa)qa!E9pr;Z_aGfz<3zB1o7}E*y#Z9u zAIQH9PH3V`l=#_TlSjZN|5Q58;d{!C?75l#!RDLo3S0N6X4ZVYYxaM|&wRdeQ9e2U z!Kj?!8553PA?UU1=|OTz3#C>?V*v}*pkmio^;E)#Bu3T`@t;ii#a;4IZ%Rl|ziKcu zzDrzOP}~zcKWQ?ZK9R05i_^1QRmE*08LO}(d)trwe8Z`V=oo5f8u6agfL)a@P)mU+ zLq=7{u2i$>VSzZqmu@OZ#TY6d97Mi9l8)RFi_|EfR_m_ZaUeeWFhS^z7%5c*2relD z-Vcom*acnwk2f-aH)^@7mqErMaRTtFnm75~-??VU4svsc`Q@E_-}|2vLqzNE5*e6( zm#!wx+m;^jjwbFv_> zPN)Sf{Mc^rh|##69V1K;aafk$xxtUhR4si|Xcy7mL0hmXlSYssstfEvxBYS$0Tm%n z34r9SfK5f8n3YszsvZ40$1V9FynqJC&`d2QyYNq*0z4t&C|zT^UtD{2zRJ&r7M;rnorJ&d1s3>9Wv!8juh{ZF!Q<&IY$3A)lEPH)JH4vtJ zFIaWwjCba)@ZvZy-*Q8B%tU!fsJuO@Ip>mY8sf7gf^6R3*DJgQc{c0gEcf1E{&gv= zCq9S7Ig;_vz+2{;Ycm~)V@?Nx$_Ef&K+p(@1tjDA!M=HNeND{7`F${^)uZP(mm?z_e>5frZWRRnED9I*tW_Qyj*0L>oK zbizdhslXnpo0jR-5VNq-d4P=XFQ;I4^d)?%jexB&_h&8P;)HN`;C)3J-|wY?f25`Y zalRp@hhTofx|NK8I7gN^AP9*YSZcMGSy-h%dm^O>SK&Bls+&8Xp5s+>Y}6_hw3!jf z3+gQ?Sa8}5RulRS61Njm&nG@-B{C@RxRfVMtgI8Gp#_^&S;93{;gGexgG&?IwcsGI zVAE1y&Eq^9F@iIUs<(%J4DG0 zDVR~U9(=N)T0kb5#LN0(60m(AAvDz@nUVt-xx1nOWi)7kp ztP=L!FI}eZlC;Eo15U0PbYkyZ3Sms!2!v8?g$=vQdkX)DW7F5pDnv>B<`uQw{;k0^ z^pd}vbckNqXBwE+92!6!yqlnj*o9Zn&Lrd6k9_lb5A+ZVCTMm80*`U5tIznbanL;c zU0N^lXE~72R7;=fU?N^?xFUcK9-iqph!2$`M-;teJ5qRL@~BHr1#+_Jr@%1?4jTNA z_@Ge+C(sVf5H!mD5Rw4ODu*yC=tk)O1->GxJes%4X)H&T+J$i_#dSO&_H8velIa=fPvO; zUSW8I^%$_WqDhH-%GSi>0N%PJIJ%XVf03w#0W>|l=9x3wt}kgSfsE(1lVZfe?St|q z0~|Bl+y!G=t~Y>VPLwIUB_@}W1o~1qgHExtk?uNk+HR7g63Nk4SEM*^t@?~1qAdW0 zQ%@L)A+gaNc`jhQd(bp4Z}^MddOc6ytkrJOffUF%HBaw+G)z!mi0Q4m(tZ~;LGsvm zB1}&;gzs=8w@j7A?#mOXA(i^bUJYrFVS?)SI8-Bcmn*RXt?E>3a&{L*I9lm|T7C2Z zNmYHU0GppFlO@}=c|G)$(S567ATXMEjX|u%UvvJ(bUAQOpDU-huc{(XVFDz?ayUio zW(^16zBk<*+3k~X2xNn8{~Db?2}}3KVDNsM((5;Ni7P7+t4=JHMG|W|ACxy~9=q6% z2F0CIJ%}uI)wvsj-w`&EBPD_n;vTD9L#$4!;(~28`K4E7b&%BHn`z z00AepH%UUf!<75a1GYsU5!(wde+;@W$NW0kZuUA$a*yk?DplN*o?SUiQi?Bp;K%Q* z%aIVz0?@V*FXEMUX(Uhz!Ie=PmvL{GlnWdNtRC`s;3j-b)vv*NkJg!*@7k#*2F2VM z(2y0US=@bciV)rSamN6oUiFqI(%)46h2LrU$j5Xdu3)tW$|S~yZWdQ=-M;Pjslt7- zCGbjrGXRWWeF4tpuo*=eg11n%t8o)YNzvaAyr~)tE|esw^P|}(EC^xjFXwXZk2dCb zj0W^hwrM(?e1P7AzCm?sX5fhP?4T~yJ%#fdn&fn;&%p6#boBMYPrU(>bKFMRX-Ghx zAUNQ@u5dM<+yCBxx7YfpTGQGDqc)Q9q3!Kw=C`*LW)8Yrat_>5R1_)Ff^w|s8wlT(v5b9}-tIq4i*B^JBdlih&e0Q(Gxt-ZrW;~UW!o>TLJ^@28 z+|50MfZ6W=2s5N)ckBYfzI39$rt+_EF|tcHa{u!p4QUnD46j}o{an*HKfiH7%mR&> zE;LYIiHeV>ekC0EDvet3PzxyquQW$UB!dOMzCN;pHgIpNR2Q4NenM<4wTSglWz_d6 z`NWGP(oN~NG%Vo>^hh8rF1eAIb3bMP(4prpU4aN1p0wUlh3UL*DMt8y(_qfJOIIgV z$TiG(nl{&G$mjB;W70WXqqUhV7u=rAR!i_YlkP%QB5i_9^`j|p7}Hx0+U-xS-2<~|U_r`5)E zd2)_3G@XC8r$TJ1_g%gmb+25aF%UH8Tc$EF?+K^^z5eRH*e?Co2nLblWp%r%X;uvf z-4|BPK(tew@TO67bSi65;w zMx*IK)Vz3jvTp$za;w-{P(cdie9ruw8kL~3_-F6>j9&Nqv`V-J=yr_7|jP02c3xF3hF&+8&y^A#BZms@VXOX9?;FC$u$cMJiuqF$zz0$>n>?N7IR>P z9FrB=Tili<-vggAPTf^9o8u`4b35OSn3HL6H&%hIVF+kbf9 zW8&#pZ~0W8B>PEgR`=m?{?VHjtIw$3c&9-4a{+6?2J2leXpY8(jhlSLW-Ck#-4v5$ z(=pz^c%FJb0ZFrRD5-<}=B;3y4}FBZ@_2Wdfd#Ix{rN$6;~Lohg>7T%Yw(91M`I`G z(wc-47QfCb&6Oc@%OByXeyS-d(>e?03p}fFZy%D%oo*G5Dl8O^Fl&id)%Fpq#)Eg8pgskt@qGImIL3n{;nPnZ-2gI6&>m_9V6daU`O;|CP5xsgFE&LwCP?$#DpkOL` z_9du$8_PhI8DNVtbyk~5h+3|!}j2xq_OEfm&fc|a}B{xQ~>7yL)4_Hd9pPaB}`c~PF?oZfe;i81)Si0 zJc3Xmglx8rBGzF=>%?{-EvIQjvW^hK!;Vl}F*n12@Y;TsRu%2Qz&{WuV{fNg)x7K3l?(j zZ~3?lcj28?faS}=#Zb4aY_3i>+>_U<=X#_y>((IY7r~=`>2cN4tUeWE)vtZQ%dh#a zj?a6u-M%{fKGxwVnNjO(m+PzI#9k5V`jmy zTYemBjV^Ie-IcDdIZW5c99gi49KlH-YX@H!H`0G`YfZr2bDhr6YpY!q=r;XUTsr{?gOO~8H@oUxsnQTKdKlaM|fr>;PvP}O$<>XDwDoq`#iphcP9p{^8O z!+ZfDNY3o%l!R?R04&S=55LFSv%s268>c|&ukNce2OGq{{ixsIVBBEMdrez@0&@QW^d%2F4qU8Cq<)zGL%E@a)vIsxK6QNb9Dz@zf^fCOSi1%XOqY!Ecv_E*|1=6DDFJ$rmLIc`Q7RV%=py5Y?Jm)ZH0%4)CgjLk?ufM?$`1 z2|%*&yb;7hM9|j{ykRu+&7*-|K)arAm+d|v&Z}R^d&gy9_QS99 zo2zrS?A!lDMMqm4&mS(3d92g_-7I(+>&xO@b-MhQ=ZgXxn=-FSouP|YSH7nBx)0() zKlIjM{Svl*M9NooUV&2r-)A9d+iggwN>foxEZDy&)G5&?8t}JD*VuJ z-w@8Ly+|Ktp|bW-MzXUYlby#w!IUYE4VNn8HxeR#2DlIY8Td{A;XHn?%O2B~_zSc5 zbT%#8v%QaOPjezma}X8B^T1>9{Ef9Qzbcs|*3(*qrp3A%Yn5dl zFNcbr>vtN6tFg9m$e@$4>B}>lzmlefWBoj_EH0?wd1c^yRA*W^j^Yb{ zSV*Mxo2m88a>Rk#fbxH~%A>@daN-`4U$LI*Pw?NRxH9&zCI`SWVsMJ2hSrSLIYp|b zBHp<;$7;0gfzH;ZW$|O%N}QPCDpq`PrfV`VMSS7M+(U=>0o5-oLt9056C3`%k|hIQ z75efcXmNcFHS4yeTsjB!*ZO5XTqDoChbcZkHBcsUeAVZg8F|K#YzG+e)65qb5nCTyucRhZi%lvWG zM&MbK3|2{7aOdpRAGJSolpDxf@{NI7$6uM8np-KZJn=X;S4+mL=%|TX509RtrF+q< z!t5gJfunj0ZC!pM3HbD)(ZZF1c6^uqW5M{fZs6&yyvuhmX&KtUy3|nVEd|VNYJcPQ zJdXcH{M1x^m_8~X9UCC!0IVw^L=&fq zCe~3Oi-GS{xhXRZ>n)TQsUOQ1X8khme*BGw$EG9sk7TBAW#NYp4yRL9cFw%5w6+A7 ztW;Xdh}tdhb%^H-iEwg=(h4>1NEglMOMx}=PYX(tJWgszI(eW6!k9k_i8+MKRcOS zOjT3I_vhZNglT8iiRXK@B4W7hco0(naT%U7iZmFCfulSbfP3#v>0bWu_qEJ9$OFAWD$ zL|*L!knn!e3^*W8Kui=rz`$nHcT6!m2%aPCEP8l<{>;h`r{SXww|FVP;q?}h=JK;~ zM#>GmX1|=l#b+Vx{|HPzTdm?b?zxnC^ompaBr92dbVyW1o{OUOrEK!%pr2}w!7aJ! zmuz3gN7I1m!rGn|?|*81c$gGZI5TupfQNaew@~c3>o?avy8a{aTLr!0;gZ($1Hw_A zu7$V{LWLvj`aCJihf*rG%FHDzoEBrDH~hTnHbj*<`;yFAe-1DK*?&{0-A-n!1^@+> z_qlgP&DR;|8XIkgQW4MdV@mbhLoFU!?L%x^y_@{ehM)P1eR=d8Mqh_cF8P2_qLTP^ z{u+_2t?aphSx?D+52KU)tCg#i;J*PWOM%I($(iFck{;G0;rtkdVAru%{+FsbnUbpWMbP0lcb{c z?v^rFaB=x?GsT~!*edgd``8I`|FVJUtYC-Zn+>Z}De+g1^at>JZ0GU&^W)qX9bIEY zhUh9+ad?~KUD>|QD#z;cyypY;eet+v?*qjd3L+1FK4%l-q3i6G(el)m`O;X_Ah7{% z{xMt2w)IRAUj7zrIG{UooXz?BykTd0gtg3hM1U^TSJGQcuucyI#y!%nHV{`o9+&~o)ly1MsZE-ra4(O)9(USB}(`Zg{v)ZcY* zDc8L#elEnNQtXa0i{WJTzWN&Wp;?LHf_An;G-6Y&t@i{hFU-DN>L1{xGIL7$$wcA+ zOun5BOE!X>u0hC2PW|03!F`I__}s0T38M1%RmIc@zV&`SpdpXcq)jSPu;V*S3%wI& z=NxOO%oO?;pV+meY$^LMTMSOi^RKyeS2!sy)=OkIF?beZ617%G0!l3Wq=LTUyHSC+<4Bs+2CvIF%mOuyFKF@Y|ZaIPW9pD zS&5QdHpVO9B3jpuW5X}1Hk@$Xi~9J+UR95VOA(7f<`VoKi&d5F1$;UL;B|9R)q}Bo zlobq%o*TA$ftDzyGPCY0<%?aLlj+hO`IInMBI8^20IL!+k$bP*wuV#WYVJLqJNJP8 zdC=Z$_fsYQ7;GY^C5udWV?ElWKBP!4`qr=X4X$7fCnK$gH-@ZjtmGLZd9@B#ZjZ{> z-;JP^0R-k?1cHu+g6)*+D-OnB;?-$0=HsvoQkcD;hnNB5?v-G|qqb z1smNyeaWq_iXXc8@hy%HQ9==h)17*Qf(pJaIc*98lj|*>(k0&|qf?xZQyGKY(<3T- z=T`bMx{tmf1nKbgAUEt8F+f>RO@B+`;YyuHkjVWlk<;m`!mUP-Ba~W#I{GDmI?g}e zkMYZujrYZrE|+mMy31`0zw{W2wyd06-(-Byrmy{* zAY|QN^{l}_o!Ym~PIKkS-t^B^3*$LfJ-3+B2PjnR@j$cXC*W!GKgenIOvf9qP25y; zb#(nWPfJ10?qR2y@8+bLd;bC7(M#$>p5u9M75wYIR^0YpdKqjD)U@RGSE1qmz+k|e zYb~?o9R15#E;h%HFos-Sd7QkgQ9k86TziDAqhLDI*3;Z|b`zg@ukSifCa8KI5~PaD zUS6)-pAYWcsRVP^D@*jgX!4zUDc&|}rL%H8#nsl+drVFZD!UJn8ebS zn3hZ$&vZwujAn03cW-1&>#~-$UIV*f$92CQ?iaa})B4Jxva;I%>(BpAw0}aq_-?q! zJD0qB8(>4(UgOUVJJ-^!3X=@5g@wwAY${X_Ohem6Ftja_n_4{xY(>VR2uxfezLZ}4aR+Ecsb5HwD7abcXMD?FWC6|b)bnMLs)yLzpY^(d%46^|2v+6f^YXN z1v0u;zdSr^)nmv6hMcK1moY!mz9*aEPDU%A9qpN~>Fc}whGhY!Y z993oDn+H^6HNl@56rsQ+w|yf{zjW!nlvsjOaK_+1fkKz7myRzMuUhE`lNEW*XzA0~ za~EH`+Dn#yzI;q~SbWx!EzaV?FPno0_%1~ZnNPRW z{q);YCqk`y&9frFl{YrS)3%;c6rZ>h9HwJzLRI+LyubU^Cj%}n?vO5Z)4$lJb{2F} zjngo)T>Q-4F}r$8-6o|a5KQDBt^b;zCnId6Rm$hiC()q%xUJ#N=G)DJ^TmDz-$RYA zLYScJHr-G@09H>MxYLn30WPTDIjvO1n8^3z5a9e%H6S!%g!E6SDjNX9v9#p4R{Qov zr%Y?~^hGe={Oam__GWvIQPVrnmCGTsP5Cmm{Do9z72{2|RP0-=4W;K@ zbKheWJ(l!qYHJS#FE%m!*l1*wJXBB1WILtNX@K2ONucxE?3-k!u^$#^)MuJ;O!=91 zQAZ{!V6$+4evxh8AvTW>Y4dN%*b~;qHZ5?DKQK6>W0Lqf{+eJBn*(Al)9GYBAqU1m z(vQ;2bHNzT9CF3tr{@t1ysqR=a1Cdt?W=<~Xw3LJif-$*kmzVBWjIwlznJHvZfTeC z+<02bfDUlc@yF>GVZd1p^)xy3x%v`9k#LX)q#zsrq#&7|RC|DHygsZ9V*s+U*$mV1 z7fZliUGBZkG_=-W>)Rw*YWQlP)T=^QU^2MS0DD@B+N@%d70kb5>gY2S}yipeLa^jv?h5lrbK|JzsTHHbOwD^WcrAP_9Ly#S7}=0 zr&zMqdd)xNnSOr8ccL6O&FCLEw1`f&*6=7S8d`XX({A~ieJFo>^~NuM_x_R4=;nd* z1=s?qn22=p{NAuj)&YB8@D?!W-_^D41rzy>wl>E1O&hbIuThz_ABzX+*yQoc|7sR3uix&37*a$=IwqNko9h8h>;IUh+DzPkC_YAQ}lV7x;HmL(a z9rgzlW~>@98#BSbnn;85gd5H=@UPD`)YK@t-Cf&clq^2yO22wcTEKFkRLnOJKhCEW zG0_5p-Xyj~Muoopg>3*iq_}zXA4y>LTFKY%P~6<;s^Gmkx3@u9Z8$n%>(bTjPl}ek zM>R@;F?pH?4o)Fddu;y1S#fTFCJJ-cg|gjlKH)Ta8+wf_pKYmn)4!`?g}qJ1S2S*z zN8i({WJ&Oakag7vt1Wx8*y`x;NWz-q+Ao)?s&S$Bm@^^q>Oj=aCRx<^s55s@jMvhf zX_e-vq?KI%>e8iu!9tydBL?x}*r6o#lQcMwwd2hM{mW62!#p|seS*uq<{d1cC|ir426c{MaD;T2g)QMkLFX^dO9v}$n@<;c ztmyrdD!<5u$&BJ^oWcPUuA}_^iP>>J`BSz4AS}q4z9E)Y*LyD z*)KVr2Dh5w-G^pKFT625mFax3X!&HAo}}G$BIC^lB-G~%PT{C<9w9}9gt#eBz+rf| z(wyGJoSl#8Y{Tkh$}d+b(eY3jfcq9ouj)%F>UtZAI^hoFJFl;$NU%$6r~UluVCKxM zE5MUc)BP+rXQEXN%B@e88=4nAxZ6?zF5Bahv%mD#hK+^INg4{N=}IoWYLFBtpNQbs zQ@v0_VOB@!xh=>}&Sj3BiEjfD2d`ztsx-5Od7nZ^W@&V)rFCAHT^e^TBPljNhWywh}~f z*B$rcd#_*1v?Ap@k@_53FjTs}8F z*TRUmTw9YFWTCloSHTz$hT!}8$2+UZ}KzNpyhp97c1rsXP@`SxLxsXPW9;u&UK8zd=4GV09>-|6c1M&x%}PfMq{eS6g9 zFF%OS61ZS*qGAul+FRV;fBsM(c~7D}PFn`AZB;Q6?vw33EpJIvtQeTd-;mgUncwZk zz-o?(_E)UK)o**f4-d|jz7S!>CyP|^dCW<%TUxmQ^1pYLi~BWapmY~`BmJi`Es<7;g(Nl z;n&4vi*`wyG}epin^m~b9;zOmw-1^>X!SVs=1eVKm*r8*?T2#yXXRE9XSxCkm)WH5 z8Vwb6i$gD}<$N>CIZKm)Z?y;()l-Ii!DyCp<)|JjPJ&bXK0x( zr=?-0e#dpquC12BLM5!rhZ$`<-cZ{2n4XGfINS^I6nB$h%+zJiri-9fu!pwd`|4AW zK)zcXXpP`}R4CwBagr-=f^G-PCLMml_&q;x{<>Du1lcdd_D+)>r8G7bX(B&XFK6le zOKc#kvUHA1Yh3xER$4vRi;*WXv);2}HH?)YtheHF=eW%pE;x9i_T18-{nzz;>3gN$ z18g7vx;i_{vZ=)Zw+XMi^KH5dxZhq~5iv?Pd~kkml3CMfV`OUA|I9kPIso8Pl@%*< z*%3I_j3y)dNoi8oGU@u>MTz*HapvhiJkfknv z-d1&}J&Cm7--Dks0jV-iVi8HlL-y7-{U737Cbeqf^21G#liDYq@B#iBNMwUG8rLNS z9Q)YV%M%rGGic+TP*kCkr`N*s1i!Oa*Y8nH3omtrLYWXUTQBRDUjrc0F;_jJIR5T9 z*$=2Ue%`ZVW7SCgI`!SUmN!OZkMn!o+1`Li19@5oQhToyW53;+G?RzVwmgoUDgk|m zxuBQ2Fu7Tv;G*4kx!fgs1Fng@2a%0IEGWYHg@Jndg~ri3Qsj2@jugqWTN4^F(psZM zzm9&f_-r(C73_KO8}Hn|;O#OivD0k(wCA6SoA-%pXmC_Ce`A(%8Y0(*OFkcgcn5QA z<}+m9NDi{q1+JV3zb6;4-=t7b z9*=&0(dp$ZUX-of-fsA-S$EiPXozWWu~PnnjHL5;YKGXabzA$z7+;O5g*u1I!Evgp ztT)R+>A{X2_(NMi$7Uz$fGhO>u;O~!QYP+DKijRlt@8v;N;S?E3m2FNs^uk zQ{blqUE$@YVeYSYycHxi*{E+)`pxw;)hyp;lu58QV;ocNPt*OU#ywYe)+= zCbg2=@+J@3ceDVJ;! z%19g`Cbob9dWj^BYII+{I?|kQ_mHflfBuW_cMs1Cu0tpv3QVw_cgWgGuzys@r#%kpL9|2D;L3e^s5)J?;w@5PfFxd3Z*x>A*~pfG8e-D8tby-jB@8fy;HO5g!Y#0+a{O z+}_55ndxAF1h{JxJBNweHLd_Wvz?;$(H;8fsOAt@C=xBvSeCXLDCj-$ ztM}BU)0O=)i9B8e?VM!tZZE3>S-zXQ`Q_+6Xg#1J+!&^&UH4`VZUwkH3R^@fmj!ma zPr!wZ55UVCEn8ASxfA>V=(!OLAJ0FQ-Xr46vnkwa2XF2fYEr>G$>c4F)2I2Fl92jA zvr9Y{v4Zt2pVmIckK|RUBwfGEKE6!B>GC2!v=D|K$d??1 zYsa*zxwPWwD9xN+o3N9R?guS4LZW?jk^RgjAq|@`PPqZ=Lz1lT`^E;=XZeNTjK2|7 z2$+=6K{WCfO$mB4z%3$KD1S@1^cI*)+?U`80Xgg)-LL1P3QuB=vuCs05f=&-dJnw| z$cqCT)&&oALJNPB(w3CMO%Wok(~wh7RRD;niy z-{(91dO3arl4UR$GA|}rE6#Jq?C^9vS$=O?x3o|797yl@nO8&!zV)s$YgoV9bSeNJ z>!ooq&TxKHg$jDy?@E;&e+hh+_#IHmx=J>4E#?|1uLEB)Ajwo=WIhdM4fnjqjDmt! zwGr|T+U)_Sm=CJfZ!qKIX}wRhRO0CG0vb~A%7L1q%*$inHt;LA6czleb&9c#pmJkq zyJ5;J2zV}YyH?L=R;@?y9A1e|`q!FU%} z?le-;&KAe|MZ47Mp6Ivg&Sest@?jUl)tP}!(5c~j`YPC3t0Fo#S9{IkQ=8jUe~dUU zNUfiQ3udi`^!m<3bt+EkH3lT)Uij$HpSx;JKjKhyO}X0_loUy_@mMjY!?of+WI=g@ zOx!E(-`ok>GUPdM3>H@F=UAGlU)YxCX;TpTboTRt23xARDKL;GiY9|rqC|a6vt&GZ z+ORK@Z}|wwiDRXkdaJzoQyig=!WAms|6XCVMJ>nMObw@pWjcyydLO8CS6FITYYFh! z+R`T!KbU+vIPn}v`Z+HYHs);-tmu3UB3Fq6OQh1LVAo0zwAJoZI8`2aBf_Ad+kMdT za6vlXj7|(q290Q<#C-)``^DL;j9`QOV!aY@6-<4|2~=Vn>i9XHkSGRrOAEtRwTu;o zSEZhlk-hZk_7fer_n7)ie)cWN5hd@9Kaym&?{8>l5x4vp*P7D)R`QfX#cRdX00=dr z8=+9oPOA&Tr)Xu$HzEPLscfc-qVNFIaOK41`kZ^NrPHG;?=f%XE!&TQ`d)s=mU!La z)ff?x+XCXOp`W&egmoZ>_9rQgtlH zscIMn+^};^CQemIam!U?BE=(!;{I`+qe7N1JRcL%9p~IL=5E^{`d{er4VmT-T6z!qt0juq+L1)mW z8$*0O-B;x6Iou-6%X93MJQt7cY_#)<@wJy8pdn#VU2XZL6jWOGLcv^={23BFPTU8y zC{5VZt^zGDa8`i>Tr)4y@55xPmOw8#2;0nS^@X}ftDMZZ5NF8ypmWVtv(m_P6pp9pWPa!Pzo!7HO|J-1j~P(4gT@ zld}m;m&c!+PdB_$bH4RBvVvZqcnYuO?y?z==w$h2%apHD7@ERrC4#K2rAKr$MEUZ@ zvQ3m&6hjLrL{IPisFktdba50?7B>y<&&F>o`GTy^KOkVgT51yTxh+4$o zt}RUKE$~$-JV!NBwSpIW21ZrT*lMRGn%YOl`lT6X2FFLtR?26a#}XP>7Ih5^kv^cy zpy;pBFeUXcktG~GB+GA9ul_DfE55Q59!bbl0qQXC8@q(zzJ`BnT+k{!QnWwQVlp#^ zxzoby)q~93=YSvN&I^c12)WD0C0dsVg4=}RnEi#1%4KQ9|2$yMkinlnt3xtZHz868 z_jhgq9vDsG@Axx#aS!@1r3s^eaeKeOc|&qbctJt`aO_VQQ5YBrsj$UuNf?t49NaWk zy%}vWWR!Oz^SX5{3)E-fcv`7Nvx%YL@Ry0kODW_B%ts51dsTTVvJ6S*gi*+?Q)n{i z0QM|gB-$rwDT6G(Ua4V!=4-JO?&_Py~IX)t3lz2Y7B(rdL%a($9r2&ykdZ$Z_0RiPb3ZLeq& z$~RTpPBkSytf_D{eV9CYsonsS*wSt2tg@NdS(zF4Ci|J^vpU0~>UR`$AOAzO`)Dfg zIH)RdS<*zcS>48ag#<6G$5&-FkvM|nk^WFBJo69oQ+V*6Z~TaC1^q3Kiqxrx_upSp zgsZ$cia{(lX(sxVLIiKU_Oy<5kLbk26;Nb$ny#C%*CN11D6{rVbFb1vXN~*=<3nGS ztB2aw-Pi#*)&y0UKQnAQU_-e8@J|F@;$On`VX$_iJQb#QYel||#3?o%mfS4RQnt`0 zeshgmE2uC=D^9CXKC=^)sGkI#LkziHZkY}ntB$<#q8S=rw^ZFS3t_=?lCe9Gh`qzflo zkLOwFWpvtO{WNEbSQgtjmd75Uj*&BjR3TWl2&_%0%^=dM` zss5>;AripYXrZKuKp|Z!(BP#Y@_64b3hhEG8?EU?y+Fy=kFHN&_oEh$mpAXK>X8sGVSG~_6U^`H`(wZr-$t@3y*^t2?2v|+thE&URypJ z#t*L7@mOvcvwXhI%4RuYJO6d*=89*NqhHcI;i13_QlHRRh ztQ6`VXhfJ@5&G}9K$#nH2y>(_h+Rbbrrr%LLl?U94_uD1hoQy zSD;odp<+x-1g@~bOCg<_V!1Yt3+*l7zbMh5mlit@V(%}FZ)MW;Mh`V)kbBS#-c4^nh`^c+Wc zp{zg|A*ugrdDa2s4lB>yG8esLYYy2Ev=LBt3}yHZqS2q|6O>kij!q+1=Qo6Rz+o0y zn^NRBy3EXx1Ln!pH$HaQu~PBE)uh6&%Z~(MB3%GlI^e6K1yRpq^%F3b>QTJ~g=uh! zU@ZI+xf5Z7t$_zGirbwlRpUh9ixQc3mExMar|4E1s28;R5(fta{udwloeB&2Iec<9 ztQCR(a(rQfUjh$0No98oWFq6t3=&B)hj_LIB$Q^PhkIzdgWP&Ka$&h5?FCw+&@>l5 zAOvsjCD{c0LKp^%!js(smrtskh8SrNnwyHk{9x6pio=P`;Y~5v#io}52vOAYZ89t$ z2tuUR0u7BHzukt1sz=a$l-76>EcE)u>C!u#2z*c?ZA(T(si}$WavxG6@|veLF;hw49UrooU@* zt)fevA>>wVs>_-3b{F!>A$9ZnGj!3U=H-DI4`~cl@5u%ex z{mya33b{^qbh}i%cNc@l>(fKG0l9i6*At&3iX+bGCYR-&YIwc~T8?tfBh#eV5}7^r zF#^SWJb>W{+l2S3$u$oZ_0=Ku|DazhL2;JvtFMm25Eaqbtw}yH^}mC|1&8L=d-vBN zgC0?!mUn#h@bIf`mupu@-rdXCoXvn^q5n&{jVW_fh+4kEuw7t9cw~iY&0|@eu&;$P zVP2AlnkQ^$-y}Ud_7qc9BnQj%cSxqIXbX{pgO`OqPj)AK`fxJ>kqy`Ydb!izlzL#% z-~j>2Go|}X?s+sK95?*UDgutP)PZIrZfATNtgi{1)1`dFXl#7QhS}x7xB#rbT2F=z zga^h1N3}jQCfo-HdJNpet3PVtCTae(gM+CK@Bg0a(27P)%Cdon`Ir%|ra+_Iq?+m9 zv-;rZeImuu_)1 z?>P}ZKw$Jc6cpjBRV)`(IG-->oE$vqYb7q4pdX>r1kEeXPX*W}G;($KTtV<{0Z2nx zJTfATxd?OL+eNX_cO&f=5G)+u9i%NdF%ftsWYeOZM6(4A=1IoMuD`<6Z6Y?8qTq;Z zO6GsrWe2)$ef z2u$=GS@e=wGK*bcZ9d_$4uSjVOSkBGMmBHR>OS)nxfEw;8xjH|r3HvmDg1@RU>!(( z-cO%gWP0=x*Ehi;85Gh|2eNK-cMqB$+gq?{bCb`vztqJMQ3;UZyXu*Cnn+*@4$0aw zZ5EpSPNlyf88^qAdsz05BNHMAVJzsSvfxSb0T+4ZFAJ=>?lLm4jeYkJS&%Oer7L0*h{;m?V4+BkF%_ zvDyng^?>7hM>goUj6W`|v^P)X0pnXT=ZBjJV3V(C+AhZl6HlP+3mfl7Z6b$$* z6@zkn+99hI;mL9rR_N5S)xV~E3>XNLZRn+|S6|JPa1G#$C&J+o4YKwNA(75-Jv<#vNGNaNjc4B0uByM$qs_3RMgRn0Zk$#s^+#2mwFHWKepaGp6d7iACD-btdLD) zA5lnVD0^?m%-%%yo)Ou5lg&Z)9$DFY&$74d&F^^*<@No2-nZMi^+(5fKCkPs*LA;2 zdb>Aar;3&UR605(H{-`Q`T&h8IsutF(oUNJ6^M`BoYQ_JLUsvZrEB>c(*XHn87~KG zGG+?LM((gUQyw$36RiLAKbD3CR9)-FKRi968`vU%1zzs}R80c%a=F`6B|ccf4b;d6 zHlsEZ!aB7a`_NOuK=}QC@n}RDfkXahFGgW#4R}TF@qrZE_p<(Sk1@nm^HuR`(@dmB z*X}~KPu=E?5d7c$4WjBc6n}kevs-d7lMg#S!2}O$W+jZs|M;hZ_dKZJ#;D`o;Hq`h z>kzP-@X^5#K>&m^;F_P&)VMp8N8tGM3&($WFneUbN?xParJ&tvvJ67;cNs`wXv*?V z*@08$pX&=n4Ooo=05H=3e*uhuOOVB%NKY1wz#Lcm7x%u&1Z!{OTfy_e6wX>@#u~nu ziBdr>>akdY!PV0IU+|g;*~Y+mS=&587)yc>oJW_lndNWY6-@;18Mo*gldFO*!Jc`R zhHw}<_YIonx>Wv6uN)@#96--$<^KV|NojyzW8un-S}V*Sgqf*(9($;jwgX^NgCCH} z3YiiP<`Nk5uz{jM-iZq^8FKSy07FITLo~(@OgaZ`OFl!_1;pKhgu49w6x)yfvJeGO zz$`2dt47jYN4LOFp6S~s&9tTvGre6bMeRgCYLqir1B9!#x z3VtyACYi$R-=R>41R+t{57(c|Qj9rG5>CKQM*R=1X#!eZtJ*qh>GaOg#;_n4IL1dj zpano+C5+0#GGbWfjD_K}O({@eNsl?6FuTRolzEB(8?_Qi~*BpU?#QjWiF`_8sQn-l{5;;OW2#qrcEMDIb8s z58?*X$Zfq7;Na(A@d>sX!jcVMQ~jwMJd6K9EPkLCx1hZQ;C{8gkT-%c(w*1w6O-at z=}W{_7KPjH777Cy_FKE-j4{uzajX^LN>FJ^If2F=JMS27V@&8W4dI^BqRzO`-C#ih zxO$Qv9AXML=`w(!mJav)tzi7x=z-9EHGMS@Of62K4t^|4cGXdNQO`+>~M#%3xvJZx^1xC$1CBGMPBCvgB^Pa_; zuy}gfelxy};(Tz0g?z0Z>t@o+J@qKVe<}te_5Y1NfvX@Ky~Ytc_^o3_VTPX%(RmrS zbV2?Wj)W$W_pDA_*)`LtWUXACH11^)Y%~9Z+|z(;lB-*Hx!C2vvSVyDvYG*$_#yzR z=!a_B*^LlDQ*B+j<5oo^gRPCO;g;hO?S_UCn5Ucs;`K7SqTA>hrYUep0ML?hEvor_ z-kX#R14wFS7p)fhZbJt^X3;$kIOEpN`GJG-JhRcpFnjzzW(kA=>(A#VZrfs4a01b?3;3pY0=`#4RX)kGPVjTA!-alTO59r6xuFBHL0aH}2s zFb6im8X-zn*J04&A#0Q0c599)pR@~p@$cEYUykwGqPlvvxxP7SGq3;#Bk=zR+QZA{ zwbf!sdQW4LGK_NLumTBHLYp4*Siov`07ZH05eH4E6t1_U9*h9<09|%Jb8gzw%21pp z^6#5BCo-WL;NCb76jF@&!2u;DunrW4GQ_=arqzGq?=2lnDjwDmySc&Gkt*`s>8~g4 zGD=|ej|S*u3qvl~owy>b!<(Zy-6HJ%MoyQ*LwSeZHxmnc4`9XWP)6Gj#;Fm$=FElA%6t%~W(K zrAgCQ_|;PY%}TSOsIZ8f>fv`qtw*wrJ-_+Z>2AcE1vNdnMiAHVZ$Sw#J^s7|!u~cI zoJ9pR3Oi2RYyjCcGIz>dvJ_xa{S|0yn!Nubd513uAONoQXbsUs7WtOJqT{{izaD5L zIlfc>w?E|cxvg9xQBfiEJS*P(3cGMvKk|QooB&sdvqUH?q52_i^DDrNs3)>+8gh#< z>iegV{aZv5AUzW#t1Fis*|9U?&F>uID0grghiT>iMX2-O0*+dv0ma{8=5<(_p@$jDC9aM}0s>S17I6mw@uuA7Jy7J*HOJnhaQbgu08X0JFlY(94kS zKd-sfU8vwPQG{sV1*qoPYJC)Jg2^=oUJ&nwK_TY-&kNh2TiN!IK~0~#pNH4m=V_#1 zdY(P!bTj9A3B6M`IRlmr-&8RoG|1}6@CboeD&Pw|30ysZzNWtnP=zsTw7ZcGX$Fr2ZDrFcRPB*vk4UA%U+0yL6;n6ieEd|sJhdG$pV2Aq3fnXJw0f)U97@gn;-%nFu z0$zoL5d*y=Op<`{FLmi?Lo||e23KVi%+D?x%#a z-c?%x+`%;;o>YiA!fnqKm-<(Uou$Un->Qi`aEh2#!AP`uv&JfNQmys4;x#}bz|H{h zFPjQxcfloq1!P-9k4q6TLXp{gBdQPK6%wN|?-SffVJVXQZjBCsFtf2@7hw|&mv0fc zm7RWUCe7+@f1erubOhIO64`CKY*4#io<1G%0yh`>FvGG$0LoT=w@t;yQAzBtCH2A- zc9U0%Oc?}vKVvTg<5**kBV?gpeqaKY`40dPsxLs7s)ew*&uyD|-R5a0o$k}zn><_` zNc%q!XI9@X!h;A|moGy(gU-8TkZBS~zS-B2Z_lWH0z!ixvjvVSJe0S&+=Vw_+EAJG zd7@nI3|1~Tbx{V{rGrL^IYjSm5NV|%Ftgj-NzAbzEVJ<*PBao@g-TE68#O` zZNCs;b$GPt?&iE3$q=IQl}Qu}l=0i5K=|)>wY2B`m@J1e4~r|zrZ8Qtddlvz2lGQ8 zOQs3$bhL?9W4DbmH1x7~>7}^GroJqf0gidnr3pMD^AvrS06>{ZX4WdU&7451SM01{!lU$}stv?cNWmHr zFf|C=y84v?AGIs$MV?Cm#B|uj(UER*x~Sx}|LAPnYgDf%er%!heh+>UJI$Smco#n~ z$k)B)7H=fN;CERZ!LUwGLj^;V$l0Gzj6IKHsIHb7l>O6H07^KoP00tCQt&CJI^IT} zsxU*XvEABKt)Zd{5*O^8G*5@rchEs^-8{urR*A>5yJ#HIa=vK1#7RU0px%%f~MHk^MNf36Num3MiL-C~ z4{((4pH0y$BSb92_(^q(>VkDlZHi_NHiH+T zO6FSbT|{}<7c)Kx1^vq#BT}8XPyQi=#fa! zJx-0RO2l!cHvtA25k#RKg}8#@8>Awr&K4tqG>d9qtBl&$v8%c^RK0fjU2wK{Fsv)( z0a5moR!kgpgBs91cpWkAHzp!8zeCDQn)jCIn-7NNT_&eH7Y;DV>cH3m4CmR1F6naE z|27L`&gl0$dEz2uN~)d1-@woXK11PI<;|&Y%ZY#pfB())RHwUEtS13L)k%%ZaimU?+TFBqYU^V>10tZJHR5Jj_wT>Yd?&JiI z*}49u?+3sxnhf68#-m{wVoh4UqzV=bz47KKPREvK@f@w>4)2~2}&q0%aV3k)%p<&8p$$l1)E+el>ve8Kq>aQP3Y2)YBH zkc$4W^DNDyG*FKiAG9VL0F`lRUclbL+s4sBJw1#k5i%b%Y3R!#nm<6PkXQ9mrO;(u zK8XPi;`IS2PC4b$idnKp|KtI$ocBz<4Dyzw9RMXXIWZmI!C!%K@CxAI;h5Y_`vLao zYV1`DCNY}4F7uDj@;e;>b2*aS+iMQrIyfJgVqdZWOfny3eayHulfd$TYVTQM+1E5t zeAYa43oO`4qQ*ub9C;v^_u*DYfz>6VbbA-`n^_+IcsPPagbJ7^WX^JnLIx?{oQ@7j zR8-)`2)PNUmyg6%eiMV^08|sk;-A-@J~`gqxTs1uy{#bvqy?^Pnkj?Ksh0c!gQwWm zk3;rh5zsLp4Kq{hcf+wj{r~J~x9B@?k=5Mk_Vtz=F%GOCs0WP;>xG8aK4{bGC1Pbd zJ%jZVV1!+iNZI@iDRt2!!U0+AP#?mguwKCEb5s;l!(hV=8#Vw2M-_UU0D8r1_e%y*~={BREJQsI?MKHioc9XW-a2-Z1OeB_vCG(SUUg)`(E6U^bW@6(-+>!!sh%>3E zr$!}c@)!aFp>p2u0i@AQ!fS@`mOI5Wl`6`OB+&nc$-Ev=XOE(riuk-{Xa6>vW}!w2 zdh$}53($=D!&rhl%@FhM3e(6+$u zA9%1d%4dF5JX4=Vr|^DxXo>kJ_q{zRg*-7_x_A}%S^d`L=>$i~a=*;dq;4Ru^?OwBz8@a)bZYXDv+@F?i? zy?AmDPP;$FL+DiwvBV7j>?U-~6f6`?XHp}2Fgk<{>`Y!1h~8vp@{9h__4J9d8ldkc z_S`2;D{nay9M?M}sJZqTlku<4AHFr7)Q8rj1pr5nlB!Nde`FAwX+^t!E_aJb4v5ZY z3JjsIf!FhC;&Wa=9Gh?)TmoHwV-rAgRKlC$DU6B0Pyz?KKC<59?}U@s>Z1TqA5K^E z0!iJo>235NG$`Dla>-ev;6{b%3~tgcWQBEMr^t1Fp5AEzRkM!@8Yo>5~gR z%_KWgMl8S%073RzU>t<8)0%?LB;4SycEG`-0wsgMuD!(ZYyh*UdJHC(%D{sDUm)=k zgfQA&f>X%a_vK$+CQd;*@|5vZLDll)S@AVq0x!N7 z7(h`YP1@hg^405#D)R{#V!49in4HH2GQ;;>));sTU7zMK6HbC$4s@bFf13Nq7q4X6 z>D3rUDb{2bR4-B4Ol(eS?y{lkqbFu?#aU!BR`7AR^*YSg7_Amf0YF5cfY)tTGOeY9 z%zYFrU;n4b?z~F~gC9qM*>6y5#H>*~P=Lx)OE!jOcZ>fA3ojDe5KVteHJ-RTF6Iq6 zk>&Vgb%^xnpII8<)T+i2dLKFzolVY%)Ve9>cP23#s~O>FC-iVRfLZcR3jh@PlqAb! zA{m$Y6qqA@gHo|KCUu!NX=o|b3*K3qyvA!-9w|)RKYUduo$+B*O%At>SCI(w7vLJA za!kr@yBJU*_=ec|rb&J>v-m4Q`D&02%*6iR13Du(c&E#HGuPK-m6U%W6j# zD3)wboMz z@#;`hiAkDX&+&MT0StqnuOeW~7~X(;p|B}D`6R}H;cE;lW1q05^Y4IOWXl#v6^SQc zpl51q$J)%MuVd;$>SX;4JRtnm;joJCjazt6Myy^A;-gB_Us~+RAOO@S`#Vq(@Zr>i z<|@F2Hk{}uH2z1yZ;?Et`5E~-Q3vw|(~oBZ$Y|)s5e3#hxF}qx-V<=)V~PNd+S1CV z(|!|vLQ7spH<AJ^bAG|8f5q2?>;FM zztm!I@WC-wOGrvVbpaVMF?bx#Cou(7SU;r5#=XJe)InKsta%%a8Cee*)rCxx+?l#G zp3tC{6lgZ^{r){cV=@YvB>VNUtDdAJ0lAp0ZIHy`{eKt3+lU1RP8i7$a+@N(WC*5q z)72Au^a0^^4Ois%w4xe>zY*UO?}mhbV%)2M3p9MJI(L11Xaw2!wwr)?y8#_)8g|-_ zh0z&>_9$w4oC4VpE1WI?aIt=-_XgYA`mTw!)*Pjsf(zZwXa;vA4Oq==L2=$J4hzRD zr>;KuApHVyFQ;4tCAdRVkX{2Ki?>&#Yo^MUh1(M^oyw0$Bb78A!R=JJj=WwwzqH;x zQBt}(we%wR8RYx}gX5ncaWBqaQ8*^a?Z`^Rv!tw#h52rvWO-w6@%=<`MowT; zl}co%yliaq$%*~aDVL;^?X`%Q5=7D4d$+ji-E_Nhw~lfYXt-EF&Um8sr9!T}hxtsk zK$cW8<@Q`uegk{yLf<3(;S4z^?U%as&JJrq-#Wj1`H}&kvG@0?I~gt=Z>)C5etKcC z#;B#Ju~fA+T$F!C6j@amL5_!#RbtRL@SEc2>WXH$va96!NWMfM!DxmkPM6%kx6n>z zLNRK|D~aNAdl80owiYt$19jnp`rHaFrPqzmRIZx^ z&wl?*W>w`BAYe0pztWeSweV94BN(uqz4B!$3T8+J-ry>$rEkVzTS?q*{rAzQr6Tj> zWnD?JR*#7rhJJYYMqK-%$kd(L4QMR^$7!sp)&Q4QV=gr~r+{ zEEjeXLjsmLRrd9qTD83h<_bw>!_}4Q`P)9_xhZSGKQQeOCu`q3>fq)6k8pWNQnsIZhRXs5N2$=X5=kGalfEY#7t>v><%~i@kVV#L%Lm#zs z3KE4ee%|WfWwPTJG$JlY*OM<`W-6Ih|E2eXdyzh1#=2LX#dQkJ6>|h4TT^Al#s!l_ z{rMVc<(W2uj|ks$ldhYpw8bJLvl*RaSIDQvGN<+NI3xp7n@_(zyN?|AmfF7w=UF1y zelM2KKYJ+z94h6IAr_r3k{tXABQKVf!*X6eP6wl?!}bb8e^;$;cT&3Zo8NE(BCcZH zs{@;?rrZSfPyN?Cmm#_GIjaz4;6Tu8mp=nprP?DZ595KSWK5Qk_Ak9XZ^DS zJg32_$-Na^*l_AbZZwrg-2~ag>69zfKsVBp*XI zV(Dd?+ZNAiA$qtmMu8hsjbdqO$?`kAPc7D7O z|6w&8#vlKCg;l4dzBjh8kmNpaUk3CK{@jN{D!I6a3Di>W*zE3Zhj7pY5kSq9H{RTm`-dug@FUK`=A{P@$D@K*e{0ELsns0}_19JT^nk3`N#?@>3J=#8+Ui{1T zno_L=s%SIZ7ixY{jEPwjEp06_0k3C<4}R**We5hY8|iJ6gL)l*xBf=c$Iz#hihH6p z>RFg6mm^C_6cei_F5~SpP|{Opj=z$$u`gp)^+^Z-V_oJx?CvVHxaP}Rn#I=b3r$pH zIFGYh>^9Fi6W8O~WbXw3QHVM0brX`SDQ8GvE$lMTQdrq`Q3%$sf^%YuXWJ0#jAIb* zwI$Fjuz%yU<09~p5|LR;alS|O#Z))aJV?_UjKxzSbJTk{;JaXL8%V&Kp4k((x|cwS z^~7SJvS=X@$0^d{saDh<|5h)SCpwlq2w-tIxI1qH82bw`UR;BTnIY_2{nO(KUXvUD zG72b@ULp<%s#*U&jeQ8RKH#|*34(_>7809qx!{`FT;TH6jD^G3DHqTwautT!sAiL*EkY`R17q#gpc^ovzYX9z4RC zQ7qK#xeDu~5t1Od-{-q&BuTF{xxGJLVm<#p_he8!CS!v0%g1wuDO)?omaK4NzQ{zy zCJ>3W{4mRs-z2t$cWguzF}rc4mjS~SQDrI;?u3&{Py&0I5Ltvbc)M2nr5LWZ<*%Mp ziTH{<%ZnSA`0w98Z{a3}N99CC9aR<3D{w$wwxe`)6$)pEFHzR&ox1sMo?w*SMr=#N@MnS54vhpxpEhJD|LKF z4@LQw{L315YfY)8&N3t(1+D8JL0y;f#; z(gea)k%;HHA;55 z3LCC60(I3Kogu+(^x$um`q$IUm)2XBKWcUY(_-+BJfjGLNZ*QNU9pG9{mxR>4ds_l z>XxpteT34+q_cq3}9^i(EVT5-y)9PHQ{>DVm()MJmykOcsu$i|XF%h#LY6CBK!pz#;_%z-k3shv~HbSnH9n;<8Z2xoIGUpjX`bLUvE$xim`n78yl z)f7(^IzLLCZXl@*fdl2J%+gJQ}e=xFvQIH$jGB zKeg@9KX@eCl%l~xw_j5582YE-RFgLr_2vgiM({ZF5vg9jK@N?-&=|mP-eKuU$oPOU z9rkFgZQPM*U-a!@i>-uZirJu7<~T_zTRS+v z0W#`h`C^F`i5!Xm|XlKZd zY6Tx*bcNe|(d@x|qx^3)Lee)MW6s`QhAA>yBu$qZpTc)0P?khxWk1R>Z!XVSt9dD> z6{UTC6l0l-*P)zRMc#X6_EYlV%lLubp;9kA(Y0oZ;I~qsFy;riS(YCx1bRIygzyz} zm6P;)W3o+-)<-sm%K-=@4InTu+=g^i*B)-TeH)u|)a+sAG?u+0#>6oYk{bMS_;p{G zs=L~D>p|JpxE%L#*wor=+5JEgR%MnPjlRV5=;|YT@o0lAZ$LbrbTaRcDDCIiZmA5H ztFP}2xh|dbPzFRxFHi;Mh=dCg1hM9g4F(Z6(ly{u=I(mzX|lLc0Q}2 z*^ZrOx~~G`nFMm|eBmj(jakc)eU`+zlX}dkcL<2oKZON3+fCEa{6EY4+KUtG}-IUf+L+HaKq)Ej^N} zkZn@&V)BpO*0e;PqKlLb3FMvGWa;3Z+nEu?WwjA-tP~wRORL$}P>Bc=ztT0$_Fzw}Z-BzZ(a?ctqIikz@Xc#5Qq#{GR?{#?7A*^b>AhmB z+BlHP0IPNw)4r%Kix*RX;dXb2e1S9m$|)kr~&-?>@E}2x~xB2;!P9KH6b@T9!xz@87vn>gPP71 zurubWjjBhTsFUQ=@cQcA`BRc3NEqRUk*2FCAbO*a6Tij*3e8l1oGv2=qAL5{;`xbO zQnL%oswlUs3&i!oy27~Sw9`y+qLm^V%4NYvWu|#gJ&f2Y2|WO&EZJ$w{qQ_xWofF3 zjk06|T(TF7)`e5`X5mv%Dsu8#@Er3K{j!bzfC9C;%)EHqFYL?>DZqVM)pZJ{;zZnR zACZW;9h-9UE1Do{GRd?_QFQEPV+ubQmJC}3dpKl)WZtN?xKJ&&8WhjEVsfUv_h_UI z%B_n`d`{fWqK|z+&lr5bQx(^O8>0ndOgQwmjE5U@ePIRW`-Q58&7YG?tFQccW0uhU ze++$Q_K2p}ie|I%G-z2x{tvl%9WmOGzERothz3(XJcg=?M?d8~EydMdZowNLc~fZl zB?PbE1Ts$wX4Un;eF%kRNyk1v5CZy4hB0nJV;xYP71>$I;6Q^zs5+Mw4O}k z*#7EE_p_oXiyo8b()tzmv8h{UD05A_Y$50Xk42Y7;$oA6!R`Dgk;U+jA#0uKuA2W> z5G3*oQh<@<<)=8{;tWaT&Y};21jCf4Z{8u5$O?~`0mD?xb;ZAcI|9g&UWNz|^Vu&1aa66I+NF-)-0c2-28Ht=&8V4rl<3G= zwyPM!>M(Dg3*6B$WUG!dU7ukeWHRYNPEKxk8E2smr8?&h;n`ezPHtc{STAe-H>&$Wx}0VD4?kcKHqI>;s{u)H3u@p|5U>6!E0cCr0Ui#RPIuQWT4=5 z4)~?sFD(a$v^IfQr)^$6F$@MDm(dE~RL^D|{xH^UCTx&xii}t6VVGbwl1*-NISJA~ zA8OOg0S|pP=6J^AQ9$QO_pDN-;fmULBsW%{9@W+L(g8=WBTRsPp*@0j0Gr#X{)H3| zN+>E>x9E=rNpWVIPJ~>II#D{YC5M8s%0w@Nbb}-^r^aE5mv~2r+%-E7C7~sqRs~lFL$&#fm7&#vs!;HDrdB$S#BES`NQqH zj#n-%^9vo1rVEa~!w}qT3no7$`*tn^54n$5Wp#1cr&!L==wHjmUwbFN%Z@43cz~Eu zDigj`Kqe833k?bXlPHq;xPy%6ZB(FjnlHA@kXqf~K!!&W@y7)p53W5pC+*Y(ML5zz^?TGh;&=~O>1hX}e@n%B!5 zt*vczyPOdT5zpgx&*IoOxQ7oCKSwaw#Voy+yt4O4b9QQhR#`ULi-)8(}udXZ0myjM}v-Tj5t+U$|?PI`wIEhI4w37K}Qfyn5ods8l=P+@S4IU}E&FTl+UY zYX4&Yd1%(!qGr}2ge5>KmL=(ARfxUviGJN{N5A=wtU5Ix9+}#BhDv%QEJRcsnL#&f z%P^wJ@|_%%&BfXeIQktfiw2)Ad@ba*CRrCv{gc>}Gv5nr?7b%knc$h78D$9Pj=jC+ z;+1-P?Ej)KW_39AaouU#NA;tX>ws?#`%l;QrYT1uD(@Zkmq)vmV@7Iilo-rdwW+!% zON|I06P{*{dY?HR*9|s^=d66wsIm2$EOiquDN=o|>kg)|kZwwNe`~=@S%3jc-rf;J*;WSI%XHxmZyo&z#A}AZ~ zPSsK+fNH_+*>sSJwZo%7aX6EG%!269uRWdv=96sgo7sPgI36h!WERgm3H^Ha^3i?) zH&7mI6iyL!>uKRWm5eX@RX`IU$04BD82oCH6SqJy#JDcy>B`4?aHaDBomu?%AxJx; zLxZWZ2)KmZzeS&JwjhXvU2-STVdjA6kgzOmaZy(R(nwNb&4<}kW;_;dK3$orw~y)M zJe)){$RERM+Rp1(sl;|#UaGq$diHVCpt`nH)SjZ-coy=~<-|3Y#`nB)v-)r|>*Z_C zw*HAw-yh`N>*J}cGpa0M`n{)dm4$5|cj`h*bq7qK~ z^>RTHEAHxJmnFnbXDNNhgD)#(j(~$9`MDs4mQEoQlw<1*F-+ue9GKkIpEtv1#gM9fK}@wm&x0g{!-#P@cLi?PCDE_2Ucy-*{Ey!7%My+5q=U)-{D zzQgI7HIg?*2j2kAEeP|swx-HL&&y%RzC=+l8#4_+wYM zNi+=`J?eY6eyTg`J?_!C*IL37TxpAA@a5$n$G(E3hRr{alwfEagV6~DH7H9HKgaLt zOJw&kF$f*COQg~r{Ky$Yn#;(6;O%kr3r%_(iY*_o^FFlHF(O1SQ;sAg6R|p7rT%m^ zc;cLf_9a;N^s?HJk0p{uraN_}-d}ogr{OwP^AU;Pc>gN0;X-g1tCZ@u)R~&&K~A73 zf$HD?I|Hr{s2t4*gxa#Alq5NolaA!e${l}bWVpX~_GqM@P^1VV>1qRDDA z-xtA8xU22H$xvw6wy!u{XMC0NI~t`EVkO{~W|!Z4_nEB;RKH~>wk(6mGb@F%Yq?u0 zfd8q!)*-v?{)qK#__*n0DLacz8&Qs+C<~yK*T>r?>$@=`72$?mqr{~)=V+7rKY6NC zh@L1FnwtUb;d=RET}$=rpS5gBijXijH=EW{$0h#GXvV@9;Q|s|V|&cZdh6n6TNUbY z>cfjtt5@fF#zw5)XuN>xPW%xmmw0#eSMu&tsSYgp@=cex6O+|HY&b7P{qahz*TN1C zr*qKuKc4R4j4Ti5Fw1}c;96a1O}i9RdyU}~y6|y(8gYFxt&m`a6p+nc1IhCv()1=D zR3o9UR`p2R<483hDx+vWksdmvSNv3GcUmUuiNV~m(;77?Y{=yxt;r_RtTH(^=de5C zx;)=$z{bSn%MiWvRiSTU=wQd6sdWf%3qEgIxZ=#i*lYq-&0SRWCa?6qS1~mi3nX($ z@T;LBE8H0$tZe(Fv|VkpBkJ=Zz=TjhwV<=MhS2z~I>!446QhZ0JC?Xka+hGQrj=RY zyX|Jf?X{bVIA^;i*BG+WQOhG{PTe$OOUIkfyS z(ENZS=KIO=uirYQ;+VR-3Z#QVF|97`39QVK#xkDdq%`lV#M1qO&3{r4WHc+S9vqce zyB;IDDBTY^TlKn@Ey;Xr!%!j=_gR7?+rgyadXx{(=3o_B`*##lvebz?P)ef=iz*ZZ zYR3*k*%9RCyHw)ANkTGm2JG?-^4Vnh${c-sARS;(Oy_Cc$sUbP`P|SfO38MiM&!3l z<)@x%4X$1)6=H`p&(WU)4L+;6>DDQ`bqSEQ6)wrzjMoEtjqGV3#x(uGud$j&`VRYx znF?J`rI$gmWnA=_;F#82YpgiKUPdmw_rW00q%jR2~&%0lteMVHNMPpLE|D8ABZF8$ZY0-

Z-zncEo)wz@8 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%0DS)cCbJmSS-=ClaXP;@0RH~o)^>NX5MvgH zx~xjEItNLQT_0o(-vUzb=P5AJdoWB3I}K)1vDMTUiqP6qz?+K^w}oN20>A-5+hsXEWuq>SIz)%kIiRut# zD#yb49}r)L7254GkGrIIK*p(tM$?6DF9k+Xs|PeER06Bnn# zWWI~D3twsBz}vDm9cixQZnxdjZRl>@%A=BeJ7n&|rkHf2c#Bb#)TM%(N2>4Ug)R_T-tFxZwC&n;cqnEg#JO*=#4Du+IvMA>Vum zv*IN4G}<>wy5Sp$#B-_jG1*qH?3>J@TEMDXlGC0x)zzlG1Unz-FOvl<{VD>imYds) zKW7XtF8!0Vy7Q~JI6kn7ZRAzVU&sV5!e6H)j2(~g?gPsXzC zv;{dH`TvyarfcQ`D?7I^5AcAni*+Ys&{Dw#gZ2+|?3RDXR>#M67Btx~kaoSGKWcB( zFih9X(Ao zI1!M_AC3*~91k0CDv0aN$P2%i{oV4T42$8e4@Jn$+4lcU zz|hu7Cc4TMZMB!y6u37@-N40%y(c|VZj2<8o6siSzfNWLoPsA_(3Irrm+g9HWHz3^ zNjIV?K5pl3)DTiqj6eV@yJD1%!Fy{RWco zzC*aqs(XMucgrNWv!HEYz&Sga46aMykV_+;u|76wg=~c6)o%J&p7U`Zf*-^<+wNN0hU5+oJSYRmb9@68RCb z);+EK>i134@M2G7lN5WJjd6S=s)hSllZnh2dnnkOwj7Y?UBmvoDm8X_$G$eBVB68w zwz}-5rLjynfgXE&J8HM9@rhDNt~R}rEXPn~8V}+ycqmlq@bY1zCRt+#u(JTLLDRFYZHmN zaELm%$1!;;xj3fM`{l8_j)uPWw74S!mI=H}YqqG~DI2;lE$beyepxlVaCYAxlN08ZG#9Ne|ovSkb zVzE3xTilIK!8r2_`!>kXHP_KFy81-9_!K)59DyQPP}MDRKV^L?Eqw-du@bnnjD+qB zZg`A24iUy#$0RD7x{zYOtw`tQ{=;OHwURr=Z`>N@k|BZ}#F3 zMLx8_2h7B9>j%NmCpHKOtvJ8M7cP*zxg9dzg@(2O=s(F5E$SPHcQ>wWkOi{?QJp8Z z&iQ<+e`axKDnT6e{+=&mpIKcwnBPN$-wi4ZQvYBXpZ%7q*Nz;-h`wOetWu_(Kumqp zHdhBK2*_H=wH)upkf3z+Kbv+(p#dxKcmH(zzr2SiO5rIGHHbU!?M6`kX0t$=09LTj z*TcK%HnKtajvhc%_QN($)+S*yYh!IM*!Pg>S3VQpG%(pP3;(;p&o7VKa None: def worker() -> Generator[subprocess.Popen[bytes], None, None]: hatchet = Hatchet() - api = TenantApi(hatchet.rest.api_client) + api = TenantApi() + api.api_client.configuration = Configuration( + host=hatchet.config.server_url, access_token=hatchet.config.token + ) try: asyncio.run( diff --git a/sdks/python/examples/api/api.py b/sdks/python/examples/api/api.py index 530232efa..fc106cf45 100644 --- a/sdks/python/examples/api/api.py +++ b/sdks/python/examples/api/api.py @@ -4,7 +4,7 @@ hatchet = Hatchet(debug=True) def main() -> None: - workflow_list = hatchet.rest.workflow_list() + workflow_list = hatchet.workflows.list() rows = workflow_list.rows or [] for workflow in rows: diff --git a/sdks/python/examples/api/async_api.py b/sdks/python/examples/api/async_api.py index b4c706e45..c11d51fb6 100644 --- a/sdks/python/examples/api/async_api.py +++ b/sdks/python/examples/api/async_api.py @@ -6,7 +6,7 @@ hatchet = Hatchet(debug=True) async def main() -> None: - workflow_list = await hatchet.rest.aio_list_workflows() + workflow_list = await hatchet.workflows.aio_list() rows = workflow_list.rows or [] for workflow in rows: diff --git a/sdks/python/examples/bulk_fanout/bulk_trigger.py b/sdks/python/examples/bulk_fanout/bulk_trigger.py index 1c7c6ae37..d43800852 100644 --- a/sdks/python/examples/bulk_fanout/bulk_trigger.py +++ b/sdks/python/examples/bulk_fanout/bulk_trigger.py @@ -10,7 +10,7 @@ hatchet = Hatchet() async def main() -> None: results = bulk_parent_wf.run_many( workflows=[ - bulk_parent_wf.create_run_workflow_config( + bulk_parent_wf.create_bulk_run_item( input=ParentInput(n=i), options=TriggerWorkflowOptions( additional_metadata={ diff --git a/sdks/python/examples/bulk_fanout/worker.py b/sdks/python/examples/bulk_fanout/worker.py index 594c46cd9..276c22709 100644 --- a/sdks/python/examples/bulk_fanout/worker.py +++ b/sdks/python/examples/bulk_fanout/worker.py @@ -26,7 +26,7 @@ bulk_child_wf = hatchet.workflow(name="BulkFanoutChild", input_validator=ChildIn async def spawn(input: ParentInput, ctx: Context) -> dict[str, list[dict[str, Any]]]: # 👀 Create each workflow run to spawn child_workflow_runs = [ - bulk_child_wf.create_run_workflow_config( + bulk_child_wf.create_bulk_run_item( input=ChildInput(a=str(i)), key=f"child{i}", options=TriggerWorkflowOptions(additional_metadata={"hello": "earth"}), diff --git a/sdks/python/examples/bulk_operations/cancel.py b/sdks/python/examples/bulk_operations/cancel.py new file mode 100644 index 000000000..5ca30b5c3 --- /dev/null +++ b/sdks/python/examples/bulk_operations/cancel.py @@ -0,0 +1,42 @@ +# ❓ Setup + +from datetime import datetime, timedelta + +from hatchet_sdk import BulkCancelReplayOpts, Hatchet, RunFilter, V1TaskStatus + +hatchet = Hatchet() + +workflows = hatchet.workflows.list() + +assert workflows.rows + +workflow = workflows.rows[0] + +# !! + +# ❓ List runs +workflow_runs = hatchet.runs.list(workflow_ids=[workflow.metadata.id]) +# !! + +# ❓ Cancel by run ids +workflow_run_ids = [workflow_run.metadata.id for workflow_run in workflow_runs.rows] + +bulk_cancel_by_ids = BulkCancelReplayOpts(ids=workflow_run_ids) + +hatchet.runs.bulk_cancel(bulk_cancel_by_ids) +# !! + +# ❓ Cancel by filters + +bulk_cancel_by_filters = BulkCancelReplayOpts( + filters=RunFilter( + since=datetime.today() - timedelta(days=1), + until=datetime.now(), + statuses=[V1TaskStatus.RUNNING], + workflow_ids=[workflow.metadata.id], + additional_metadata={"key": "value"}, + ) +) + +hatchet.runs.bulk_cancel(bulk_cancel_by_filters) +# !! diff --git a/sdks/python/examples/bulk_operations/replay.py b/sdks/python/examples/bulk_operations/replay.py new file mode 100644 index 000000000..07d622115 --- /dev/null +++ b/sdks/python/examples/bulk_operations/replay.py @@ -0,0 +1,41 @@ +# ❓ Setup + +from datetime import datetime, timedelta + +from hatchet_sdk import BulkCancelReplayOpts, Hatchet, RunFilter, V1TaskStatus + +hatchet = Hatchet() + +workflows = hatchet.workflows.list() + +assert workflows.rows + +workflow = workflows.rows[0] + +# !! + +# ❓ List runs +workflow_runs = hatchet.runs.list(workflow_ids=[workflow.metadata.id]) +# !! + +# ❓ Replay by run ids +workflow_run_ids = [workflow_run.metadata.id for workflow_run in workflow_runs.rows] + +bulk_replay_by_ids = BulkCancelReplayOpts(ids=workflow_run_ids) + +hatchet.runs.bulk_replay(bulk_replay_by_ids) +# !! + +# ❓ Replay by filters +bulk_replay_by_filters = BulkCancelReplayOpts( + filters=RunFilter( + since=datetime.today() - timedelta(days=1), + until=datetime.now(), + statuses=[V1TaskStatus.RUNNING], + workflow_ids=[workflow.metadata.id], + additional_metadata={"key": "value"}, + ) +) + +hatchet.runs.bulk_replay(bulk_replay_by_filters) +# !! diff --git a/sdks/python/examples/cron/programatic-async.py b/sdks/python/examples/cron/programatic-async.py index 3b4da3613..78acda4c8 100644 --- a/sdks/python/examples/cron/programatic-async.py +++ b/sdks/python/examples/cron/programatic-async.py @@ -32,9 +32,9 @@ async def create_cron() -> None: # !! # ❓ Get - cron_trigger = await hatchet.cron.aio_get(cron_trigger=cron_trigger.metadata.id) + cron_trigger = await hatchet.cron.aio_get(cron_id=cron_trigger.metadata.id) # !! # ❓ Delete - await hatchet.cron.aio_delete(cron_trigger=cron_trigger.metadata.id) + await hatchet.cron.aio_delete(cron_id=cron_trigger.metadata.id) # !! diff --git a/sdks/python/examples/cron/programatic-sync.py b/sdks/python/examples/cron/programatic-sync.py index 90e6bce4e..6b7f91438 100644 --- a/sdks/python/examples/cron/programatic-sync.py +++ b/sdks/python/examples/cron/programatic-sync.py @@ -32,9 +32,9 @@ cron_triggers = hatchet.cron.list() # !! # ❓ Get -cron_trigger = hatchet.cron.get(cron_trigger=cron_trigger.metadata.id) +cron_trigger = hatchet.cron.get(cron_id=cron_trigger.metadata.id) # !! # ❓ Delete -hatchet.cron.delete(cron_trigger=cron_trigger.metadata.id) +hatchet.cron.delete(cron_id=cron_trigger.metadata.id) # !! diff --git a/sdks/python/examples/delayed/worker.py b/sdks/python/examples/delayed/worker.py index e291d271e..09b197e75 100644 --- a/sdks/python/examples/delayed/worker.py +++ b/sdks/python/examples/delayed/worker.py @@ -27,7 +27,7 @@ def schedule(input: PrinterInput, ctx: Context) -> None: future_time = now + timedelta(seconds=15) print(f"scheduling for \t {future_time.strftime('%H:%M:%S')}") - print_printer_wf.schedule([future_time], input=input) + print_printer_wf.schedule(future_time, input=input) @print_schedule_wf.task() diff --git a/sdks/python/examples/fanout/trigger.py b/sdks/python/examples/fanout/trigger.py index 066cbf7f4..1fcf763b1 100644 --- a/sdks/python/examples/fanout/trigger.py +++ b/sdks/python/examples/fanout/trigger.py @@ -8,7 +8,7 @@ hatchet = Hatchet() async def main() -> None: - parent_wf.run( + await parent_wf.aio_run( ParentInput(n=2), options=TriggerWorkflowOptions(additional_metadata={"hello": "moon"}), ) diff --git a/sdks/python/examples/fanout/worker.py b/sdks/python/examples/fanout/worker.py index e4052268e..809045e94 100644 --- a/sdks/python/examples/fanout/worker.py +++ b/sdks/python/examples/fanout/worker.py @@ -29,7 +29,7 @@ async def spawn(input: ParentInput, ctx: Context) -> dict[str, Any]: result = await child_wf.aio_run_many( [ - child_wf.create_run_workflow_config( + child_wf.create_bulk_run_item( input=ChildInput(a=str(i)), options=TriggerWorkflowOptions( additional_metadata={"hello": "earth"}, key=f"child{i}" @@ -52,17 +52,21 @@ async def spawn(input: ParentInput, ctx: Context) -> dict[str, Any]: @child_wf.task() def process(input: ChildInput, ctx: Context) -> dict[str, str]: print(f"child process {input.a}") - return {"status": "success " + input.a} + return {"status": input.a} -@child_wf.task() +@child_wf.task(parents=[process]) def process2(input: ChildInput, ctx: Context) -> dict[str, str]: - print("child process2") - return {"status2": "success"} + process_output = ctx.task_output(process) + a = process_output["status"] + + return {"status2": a + "2"} # ‼️ +child_wf.create_bulk_run_item() + def main() -> None: worker = hatchet.worker("fanout-worker", slots=40, workflows=[parent_wf, child_wf]) diff --git a/sdks/python/examples/fanout_sync/worker.py b/sdks/python/examples/fanout_sync/worker.py index 7e7e392bb..095403eae 100644 --- a/sdks/python/examples/fanout_sync/worker.py +++ b/sdks/python/examples/fanout_sync/worker.py @@ -28,7 +28,7 @@ def spawn(input: ParentInput, ctx: Context) -> dict[str, list[dict[str, Any]]]: results = sync_fanout_child.run_many( [ - sync_fanout_child.create_run_workflow_config( + sync_fanout_child.create_bulk_run_item( input=ChildInput(a=str(i)), key=f"child{i}", options=TriggerWorkflowOptions(additional_metadata={"hello": "earth"}), diff --git a/sdks/python/examples/on_failure/test_on_failure.py b/sdks/python/examples/on_failure/test_on_failure.py index 83565f379..410960b9c 100644 --- a/sdks/python/examples/on_failure/test_on_failure.py +++ b/sdks/python/examples/on_failure/test_on_failure.py @@ -20,9 +20,7 @@ async def test_run_timeout(aiohatchet: Hatchet, worker: Worker) -> None: await asyncio.sleep(5) # Wait for the on_failure job to finish - details = await aiohatchet.rest.workflow_runs_api.v1_workflow_run_get( - run.workflow_run_id - ) + details = await aiohatchet.runs.aio_get(run.workflow_run_id) assert len(details.tasks) == 2 assert sum(t.status == V1TaskStatus.COMPLETED for t in details.tasks) == 1 diff --git a/sdks/python/examples/opentelemetry_instrumentation/triggers.py b/sdks/python/examples/opentelemetry_instrumentation/triggers.py index 4a07dd5ee..c6871670e 100644 --- a/sdks/python/examples/opentelemetry_instrumentation/triggers.py +++ b/sdks/python/examples/opentelemetry_instrumentation/triggers.py @@ -103,12 +103,12 @@ def run_workflows() -> None: with tracer.start_as_current_span("run_workflows"): otel_workflow.run_many( [ - otel_workflow.create_run_workflow_config( + otel_workflow.create_bulk_run_item( options=TriggerWorkflowOptions( additional_metadata=create_additional_metadata() ) ), - otel_workflow.create_run_workflow_config( + otel_workflow.create_bulk_run_item( options=TriggerWorkflowOptions( additional_metadata=create_additional_metadata() ) @@ -122,12 +122,12 @@ async def async_run_workflows() -> None: with tracer.start_as_current_span("async_run_workflows"): await otel_workflow.aio_run_many( [ - otel_workflow.create_run_workflow_config( + otel_workflow.create_bulk_run_item( options=TriggerWorkflowOptions( additional_metadata=create_additional_metadata() ) ), - otel_workflow.create_run_workflow_config( + otel_workflow.create_bulk_run_item( options=TriggerWorkflowOptions( additional_metadata=create_additional_metadata() ) diff --git a/sdks/python/examples/rate_limit/worker.py b/sdks/python/examples/rate_limit/worker.py index ee2b8421e..1a9bada49 100644 --- a/sdks/python/examples/rate_limit/worker.py +++ b/sdks/python/examples/rate_limit/worker.py @@ -1,19 +1,56 @@ -from hatchet_sdk import Context, EmptyModel, Hatchet +from pydantic import BaseModel + +from hatchet_sdk import Context, Hatchet from hatchet_sdk.rate_limit import RateLimit, RateLimitDuration hatchet = Hatchet(debug=True) -rate_limit_workflow = hatchet.workflow(name="RateLimitWorkflow") + +# ❓ Workflow +class RateLimitInput(BaseModel): + user_id: str + + +rate_limit_workflow = hatchet.workflow( + name="RateLimitWorkflow", input_validator=RateLimitInput +) + +# !! + + +# ❓ Static RATE_LIMIT_KEY = "test-limit" @rate_limit_workflow.task(rate_limits=[RateLimit(static_key=RATE_LIMIT_KEY, units=1)]) -def step1(input: EmptyModel, ctx: Context) -> None: - print("executed step1") +def step_1(input: RateLimitInput, ctx: Context) -> None: + print("executed step_1") + + +# !! + +# ❓ Dynamic + + +@rate_limit_workflow.task( + rate_limits=[ + RateLimit( + dynamic_key="input.user_id", + units=1, + limit=10, + duration=RateLimitDuration.MINUTE, + ) + ] +) +def step_2(input: RateLimitInput, ctx: Context) -> None: + print("executed step_2") + + +# !! def main() -> None: - hatchet.admin.put_rate_limit(RATE_LIMIT_KEY, 2, RateLimitDuration.SECOND) + hatchet.rate_limits.put(RATE_LIMIT_KEY, 2, RateLimitDuration.SECOND) worker = hatchet.worker( "rate-limit-worker", slots=10, workflows=[rate_limit_workflow] diff --git a/sdks/python/examples/scheduled/programatic-async.py b/sdks/python/examples/scheduled/programatic-async.py index 2d25b9ebf..f3642c7da 100644 --- a/sdks/python/examples/scheduled/programatic-async.py +++ b/sdks/python/examples/scheduled/programatic-async.py @@ -22,7 +22,7 @@ async def create_scheduled() -> None: # !! # ❓ Delete - await hatchet.scheduled.aio_delete(scheduled=scheduled_run.metadata.id) + await hatchet.scheduled.aio_delete(scheduled_id=scheduled_run.metadata.id) # !! # ❓ List @@ -30,5 +30,7 @@ async def create_scheduled() -> None: # !! # ❓ Get - scheduled_run = await hatchet.scheduled.aio_get(scheduled=scheduled_run.metadata.id) + scheduled_run = await hatchet.scheduled.aio_get( + scheduled_id=scheduled_run.metadata.id + ) # !! diff --git a/sdks/python/examples/scheduled/programatic-sync.py b/sdks/python/examples/scheduled/programatic-sync.py index 313becd4b..7b87d1986 100644 --- a/sdks/python/examples/scheduled/programatic-sync.py +++ b/sdks/python/examples/scheduled/programatic-sync.py @@ -20,7 +20,7 @@ id = scheduled_run.metadata.id # the id of the scheduled run trigger # !! # ❓ Delete -hatchet.scheduled.delete(scheduled=scheduled_run.metadata.id) +hatchet.scheduled.delete(scheduled_id=scheduled_run.metadata.id) # !! # ❓ List @@ -28,5 +28,5 @@ scheduled_runs = hatchet.scheduled.list() # !! # ❓ Get -scheduled_run = hatchet.scheduled.get(scheduled=scheduled_run.metadata.id) +scheduled_run = hatchet.scheduled.get(scheduled_id=scheduled_run.metadata.id) # !! diff --git a/sdks/python/hatchet_sdk/__init__.py b/sdks/python/hatchet_sdk/__init__.py index c6e4d2b6f..abf233445 100644 --- a/sdks/python/hatchet_sdk/__init__.py +++ b/sdks/python/hatchet_sdk/__init__.py @@ -98,6 +98,7 @@ from hatchet_sdk.clients.rest.models.user_tenant_memberships_list import ( UserTenantMembershipsList, ) from hatchet_sdk.clients.rest.models.user_tenant_public import UserTenantPublic +from hatchet_sdk.clients.rest.models.v1_task_status import V1TaskStatus from hatchet_sdk.clients.rest.models.worker_list import WorkerList from hatchet_sdk.clients.rest.models.workflow import Workflow from hatchet_sdk.clients.rest.models.workflow_deployment_config import ( @@ -135,6 +136,7 @@ from hatchet_sdk.contracts.workflows_pb2 import ( RateLimitDuration, WorkerLabelComparator, ) +from hatchet_sdk.features.runs import BulkCancelReplayOpts, RunFilter from hatchet_sdk.hatchet import Hatchet from hatchet_sdk.runnables.task import Task from hatchet_sdk.runnables.types import ( @@ -264,4 +266,7 @@ __all__ = [ "DurableContext", "RegisterDurableEventRequest", "TaskDefaults", + "BulkCancelReplayOpts", + "RunFilter", + "V1TaskStatus", ] diff --git a/sdks/python/hatchet_sdk/client.py b/sdks/python/hatchet_sdk/client.py index f87df0a8d..ca1f29845 100644 --- a/sdks/python/hatchet_sdk/client.py +++ b/sdks/python/hatchet_sdk/client.py @@ -5,11 +5,18 @@ import grpc from hatchet_sdk.clients.admin import AdminClient from hatchet_sdk.clients.dispatcher.dispatcher import DispatcherClient from hatchet_sdk.clients.events import EventClient, new_event -from hatchet_sdk.clients.rest_client import RestApi from hatchet_sdk.clients.run_event_listener import RunEventListenerClient from hatchet_sdk.clients.workflow_listener import PooledWorkflowRunListener from hatchet_sdk.config import ClientConfig from hatchet_sdk.connection import new_conn +from hatchet_sdk.features.cron import CronClient +from hatchet_sdk.features.logs import LogsClient +from hatchet_sdk.features.metrics import MetricsClient +from hatchet_sdk.features.rate_limits import RateLimitsClient +from hatchet_sdk.features.runs import RunsClient +from hatchet_sdk.features.scheduled import ScheduledClient +from hatchet_sdk.features.workers import WorkersClient +from hatchet_sdk.features.workflows import WorkflowsClient class Client: @@ -20,7 +27,6 @@ class Client: admin_client: AdminClient | None = None, dispatcher_client: DispatcherClient | None = None, workflow_listener: PooledWorkflowRunListener | None | None = None, - rest_client: RestApi | None = None, debug: bool = False, ): try: @@ -35,10 +41,16 @@ class Client: self.admin = admin_client or AdminClient(config) self.dispatcher = dispatcher_client or DispatcherClient(config) self.event = event_client or new_event(conn, config) - self.rest = rest_client or RestApi( - config.server_url, config.token, config.tenant_id - ) self.listener = RunEventListenerClient(config) self.workflow_listener = workflow_listener self.logInterceptor = config.logger self.debug = debug + + self.cron = CronClient(self.config) + self.logs = LogsClient(self.config) + self.metrics = MetricsClient(self.config) + self.rate_limits = RateLimitsClient(self.config) + self.runs = RunsClient(self.config) + self.scheduled = ScheduledClient(self.config) + self.workers = WorkersClient(self.config) + self.workflows = WorkflowsClient(self.config) diff --git a/sdks/python/hatchet_sdk/clients/admin.py b/sdks/python/hatchet_sdk/clients/admin.py index 4fc891270..9bd0d6a8a 100644 --- a/sdks/python/hatchet_sdk/clients/admin.py +++ b/sdks/python/hatchet_sdk/clients/admin.py @@ -163,12 +163,6 @@ class AdminClient: workflow: workflow_protos.CreateWorkflowVersionRequest, overrides: workflow_protos.CreateWorkflowVersionRequest | None = None, ) -> workflow_protos.CreateWorkflowVersionResponse: - ## IMPORTANT: The `pooled_workflow_listener` must be created 1) lazily, and not at `init` time, and 2) on the - ## main thread. If 1) is not followed, you'll get an error about something being attached to the wrong event - ## loop. If 2) is not followed, you'll get an error about the event loop not being set up. - if not self.pooled_workflow_listener: - self.pooled_workflow_listener = PooledWorkflowRunListener(self.config) - return await asyncio.to_thread(self.put_workflow, name, workflow, overrides) @tenacity_retry @@ -178,12 +172,6 @@ class AdminClient: limit: int, duration: RateLimitDuration = RateLimitDuration.SECOND, ) -> None: - ## IMPORTANT: The `pooled_workflow_listener` must be created 1) lazily, and not at `init` time, and 2) on the - ## main thread. If 1) is not followed, you'll get an error about something being attached to the wrong event - ## loop. If 2) is not followed, you'll get an error about the event loop not being set up. - if not self.pooled_workflow_listener: - self.pooled_workflow_listener = PooledWorkflowRunListener(self.config) - return await asyncio.to_thread(self.put_rate_limit, key, limit, duration) @tenacity_retry @@ -194,12 +182,6 @@ class AdminClient: input: JSONSerializableMapping = {}, options: ScheduleTriggerWorkflowOptions = ScheduleTriggerWorkflowOptions(), ) -> v0_workflow_protos.WorkflowVersion: - ## IMPORTANT: The `pooled_workflow_listener` must be created 1) lazily, and not at `init` time, and 2) on the - ## main thread. If 1) is not followed, you'll get an error about something being attached to the wrong event - ## loop. If 2) is not followed, you'll get an error about the event loop not being set up. - if not self.pooled_workflow_listener: - self.pooled_workflow_listener = PooledWorkflowRunListener(self.config) - return await asyncio.to_thread( self.schedule_workflow, name, schedules, input, options ) diff --git a/sdks/python/hatchet_sdk/clients/rest/models/workflow_runs_metrics.py b/sdks/python/hatchet_sdk/clients/rest/models/workflow_runs_metrics.py index 71b6351b4..5f70c4414 100644 --- a/sdks/python/hatchet_sdk/clients/rest/models/workflow_runs_metrics.py +++ b/sdks/python/hatchet_sdk/clients/rest/models/workflow_runs_metrics.py @@ -22,13 +22,17 @@ from typing import Any, ClassVar, Dict, List, Optional, Set from pydantic import BaseModel, ConfigDict from typing_extensions import Self +from hatchet_sdk.clients.rest.models.workflow_runs_metrics_counts import ( + WorkflowRunsMetricsCounts, +) + class WorkflowRunsMetrics(BaseModel): """ WorkflowRunsMetrics """ # noqa: E501 - counts: Optional[Dict[str, Any]] = None + counts: Optional[WorkflowRunsMetricsCounts] = None __properties: ClassVar[List[str]] = ["counts"] model_config = ConfigDict( diff --git a/sdks/python/hatchet_sdk/clients/rest_client.py b/sdks/python/hatchet_sdk/clients/rest_client.py deleted file mode 100644 index 5dc97f692..000000000 --- a/sdks/python/hatchet_sdk/clients/rest_client.py +++ /dev/null @@ -1,657 +0,0 @@ -import asyncio -import atexit -import datetime -import threading -from typing import Coroutine, TypeVar - -from pydantic import StrictInt - -from hatchet_sdk.clients.rest.api.event_api import EventApi -from hatchet_sdk.clients.rest.api.log_api import LogApi -from hatchet_sdk.clients.rest.api.step_run_api import StepRunApi -from hatchet_sdk.clients.rest.api.worker_api import WorkerApi -from hatchet_sdk.clients.rest.api.workflow_api import WorkflowApi -from hatchet_sdk.clients.rest.api.workflow_run_api import WorkflowRunApi -from hatchet_sdk.clients.rest.api.workflow_runs_api import WorkflowRunsApi -from hatchet_sdk.clients.rest.api_client import ApiClient -from hatchet_sdk.clients.rest.configuration import Configuration -from hatchet_sdk.clients.rest.models.create_cron_workflow_trigger_request import ( - CreateCronWorkflowTriggerRequest, -) -from hatchet_sdk.clients.rest.models.cron_workflows import CronWorkflows -from hatchet_sdk.clients.rest.models.cron_workflows_list import CronWorkflowsList -from hatchet_sdk.clients.rest.models.cron_workflows_order_by_field import ( - CronWorkflowsOrderByField, -) -from hatchet_sdk.clients.rest.models.event_list import EventList -from hatchet_sdk.clients.rest.models.event_order_by_direction import ( - EventOrderByDirection, -) -from hatchet_sdk.clients.rest.models.event_order_by_field import EventOrderByField -from hatchet_sdk.clients.rest.models.event_update_cancel200_response import ( - EventUpdateCancel200Response, -) -from hatchet_sdk.clients.rest.models.log_line_level import LogLineLevel -from hatchet_sdk.clients.rest.models.log_line_list import LogLineList -from hatchet_sdk.clients.rest.models.log_line_order_by_direction import ( - LogLineOrderByDirection, -) -from hatchet_sdk.clients.rest.models.log_line_order_by_field import LogLineOrderByField -from hatchet_sdk.clients.rest.models.replay_event_request import ReplayEventRequest -from hatchet_sdk.clients.rest.models.replay_workflow_runs_request import ( - ReplayWorkflowRunsRequest, -) -from hatchet_sdk.clients.rest.models.replay_workflow_runs_response import ( - ReplayWorkflowRunsResponse, -) -from hatchet_sdk.clients.rest.models.schedule_workflow_run_request import ( - ScheduleWorkflowRunRequest, -) -from hatchet_sdk.clients.rest.models.scheduled_workflows import ScheduledWorkflows -from hatchet_sdk.clients.rest.models.scheduled_workflows_list import ( - ScheduledWorkflowsList, -) -from hatchet_sdk.clients.rest.models.scheduled_workflows_order_by_field import ( - ScheduledWorkflowsOrderByField, -) -from hatchet_sdk.clients.rest.models.trigger_workflow_run_request import ( - TriggerWorkflowRunRequest, -) -from hatchet_sdk.clients.rest.models.workflow import Workflow -from hatchet_sdk.clients.rest.models.workflow_kind import WorkflowKind -from hatchet_sdk.clients.rest.models.workflow_list import WorkflowList -from hatchet_sdk.clients.rest.models.workflow_run import WorkflowRun -from hatchet_sdk.clients.rest.models.workflow_run_list import WorkflowRunList -from hatchet_sdk.clients.rest.models.workflow_run_order_by_direction import ( - WorkflowRunOrderByDirection, -) -from hatchet_sdk.clients.rest.models.workflow_run_order_by_field import ( - WorkflowRunOrderByField, -) -from hatchet_sdk.clients.rest.models.workflow_run_status import WorkflowRunStatus -from hatchet_sdk.clients.rest.models.workflow_runs_cancel_request import ( - WorkflowRunsCancelRequest, -) -from hatchet_sdk.clients.rest.models.workflow_version import WorkflowVersion -from hatchet_sdk.utils.typing import JSONSerializableMapping - -## Type variables to use with coroutines. -## See https://stackoverflow.com/questions/73240620/the-right-way-to-type-hint-a-coroutine-function -## Return type -R = TypeVar("R") - -## Yield type -Y = TypeVar("Y") - -## Send type -S = TypeVar("S") - - -class RestApi: - def __init__(self, host: str, api_key: str, tenant_id: str): - self.tenant_id = tenant_id - - self.config = Configuration( - host=host, - access_token=api_key, - ) - - self._loop = asyncio.new_event_loop() - self._thread = threading.Thread(target=self._run_event_loop, daemon=True) - self._thread.start() - - # Register the cleanup method to be called on exit - atexit.register(self._cleanup) - - ## IMPORTANT: These clients need to be instantiated lazily because they rely on - ## an event loop to be running, which may not be the case when the `Hatchet` client is instantiated. - self._api_client: ApiClient | None = None - self._workflow_api: WorkflowApi | None = None - self._workflow_run_api: WorkflowRunApi | None = None - self._workflow_runs_api: WorkflowRunsApi | None = None - self._step_run_api: StepRunApi | None = None - self._event_api: EventApi | None = None - self._log_api: LogApi | None = None - self._worker_api: WorkerApi | None = None - - @property - def api_client(self) -> ApiClient: - ## IMPORTANT: This client needs to be instantiated lazily because it relies on an event - ## loop to be running, which may not be the case when the `Hatchet` client is instantiated. - if self._api_client is None: - self._api_client = ApiClient(configuration=self.config) - return self._api_client - - @property - def workflow_api(self) -> WorkflowApi: - ## IMPORTANT: This client needs to be instantiated lazily because it relies on an event - ## loop to be running, which may not be the case when the `Hatchet` client is instantiated. - if self._workflow_api is None: - self._workflow_api = WorkflowApi(self.api_client) - return self._workflow_api - - @property - def workflow_run_api(self) -> WorkflowRunApi: - ## IMPORTANT: This client needs to be instantiated lazily because it relies on an event - ## loop to be running, which may not be the case when the `Hatchet` client is instantiated. - if self._workflow_run_api is None: - self._workflow_run_api = WorkflowRunApi(self.api_client) - return self._workflow_run_api - - @property - def workflow_runs_api(self) -> WorkflowRunsApi: - ## IMPORTANT: This client needs to be instantiated lazily because it relies on an event - ## loop to be running, which may not be the case when the `Hatchet` client is instantiated. - if self._workflow_runs_api is None: - self._workflow_runs_api = WorkflowRunsApi(self.api_client) - return self._workflow_runs_api - - @property - def worker_api(self) -> WorkerApi: - ## IMPORTANT: This client needs to be instantiated lazily because it relies on an event - ## loop to be running, which may not be the case when the `Hatchet` client is instantiated. - if self._worker_api is None: - self._worker_api = WorkerApi(self.api_client) - - return self._worker_api - - @property - def step_run_api(self) -> StepRunApi: - ## IMPORTANT: This client needs to be instantiated lazily because it relies on an event - ## loop to be running, which may not be the case when the `Hatchet` client is instantiated. - if self._step_run_api is None: - self._step_run_api = StepRunApi(self.api_client) - return self._step_run_api - - @property - def event_api(self) -> EventApi: - ## IMPORTANT: This client needs to be instantiated lazily because it relies on an event - ## loop to be running, which may not be the case when the `Hatchet` client is instantiated. - if self._event_api is None: - self._event_api = EventApi(self.api_client) - return self._event_api - - @property - def log_api(self) -> LogApi: - ## IMPORTANT: This client needs to be instantiated lazily because it relies on an event - ## loop to be running, which may not be the case when the `Hatchet` client is instantiated. - if self._log_api is None: - self._log_api = LogApi(self.api_client) - return self._log_api - - async def close(self) -> None: - # Ensure the aiohttp client session is closed - await self.api_client.close() # type: ignore[no-untyped-call] - - async def aio_list_workflows(self) -> WorkflowList: - return await self.workflow_api.workflow_list( - tenant=self.tenant_id, - ) - - async def aio_get_workflow(self, workflow_id: str) -> Workflow: - return await self.workflow_api.workflow_get( - workflow=workflow_id, - ) - - async def aio_get_workflow_version( - self, workflow_id: str, version: str | None = None - ) -> WorkflowVersion: - return await self.workflow_api.workflow_version_get( - workflow=workflow_id, - version=version, - ) - - async def aio_list_workflow_runs( - self, - workflow_id: str | None = None, - offset: int | None = None, - limit: int | None = None, - event_id: str | None = None, - parent_workflow_run_id: str | None = None, - parent_step_run_id: str | None = None, - statuses: list[WorkflowRunStatus] | None = None, - kinds: list[WorkflowKind] | None = None, - additional_metadata: list[str] | None = None, - order_by_field: WorkflowRunOrderByField | None = None, - order_by_direction: WorkflowRunOrderByDirection | None = None, - ) -> WorkflowRunList: - return await self.workflow_api.workflow_run_list( - tenant=self.tenant_id, - offset=offset, - limit=limit, - workflow_id=workflow_id, - event_id=event_id, - parent_workflow_run_id=parent_workflow_run_id, - parent_step_run_id=parent_step_run_id, - statuses=statuses, - kinds=kinds, - additional_metadata=additional_metadata, - order_by_field=order_by_field, - order_by_direction=order_by_direction, - ) - - async def aio_get_workflow_run(self, workflow_run_id: str) -> WorkflowRun: - return await self.workflow_api.workflow_run_get( - tenant=self.tenant_id, - workflow_run=workflow_run_id, - ) - - async def aio_replay_workflow_run( - self, workflow_run_ids: list[str] - ) -> ReplayWorkflowRunsResponse: - return await self.workflow_run_api.workflow_run_update_replay( - tenant=self.tenant_id, - replay_workflow_runs_request=ReplayWorkflowRunsRequest( - workflowRunIds=workflow_run_ids, - ), - ) - - async def aio_cancel_workflow_run( - self, workflow_run_id: str - ) -> EventUpdateCancel200Response: - return await self.workflow_run_api.workflow_run_cancel( - tenant=self.tenant_id, - workflow_runs_cancel_request=WorkflowRunsCancelRequest( - workflowRunIds=[workflow_run_id], - ), - ) - - async def aio_bulk_cancel_workflow_runs( - self, workflow_run_ids: list[str] - ) -> EventUpdateCancel200Response: - return await self.workflow_run_api.workflow_run_cancel( - tenant=self.tenant_id, - workflow_runs_cancel_request=WorkflowRunsCancelRequest( - workflowRunIds=workflow_run_ids, - ), - ) - - async def aio_create_workflow_run( - self, - workflow_id: str, - input: JSONSerializableMapping, - version: str | None = None, - additional_metadata: JSONSerializableMapping = {}, - ) -> WorkflowRun: - return await self.workflow_run_api.workflow_run_create( - workflow=workflow_id, - version=version, - trigger_workflow_run_request=TriggerWorkflowRunRequest( - input=dict(input), - additionalMetadata=dict(additional_metadata), - ), - ) - - async def aio_create_cron( - self, - workflow_name: str, - cron_name: str, - expression: str, - input: JSONSerializableMapping, - additional_metadata: JSONSerializableMapping, - ) -> CronWorkflows: - return await self.workflow_run_api.cron_workflow_trigger_create( - tenant=self.tenant_id, - workflow=workflow_name, - create_cron_workflow_trigger_request=CreateCronWorkflowTriggerRequest( - cronName=cron_name, - cronExpression=expression, - input=dict(input), - additionalMetadata=dict(additional_metadata), - ), - ) - - async def aio_delete_cron(self, cron_trigger_id: str) -> None: - await self.workflow_api.workflow_cron_delete( - tenant=self.tenant_id, - cron_workflow=cron_trigger_id, - ) - - async def aio_list_crons( - self, - offset: StrictInt | None = None, - limit: StrictInt | None = None, - workflow_id: str | None = None, - additional_metadata: list[str] | None = None, - order_by_field: CronWorkflowsOrderByField | None = None, - order_by_direction: WorkflowRunOrderByDirection | None = None, - ) -> CronWorkflowsList: - return await self.workflow_api.cron_workflow_list( - tenant=self.tenant_id, - offset=offset, - limit=limit, - workflow_id=workflow_id, - additional_metadata=additional_metadata, - order_by_field=order_by_field, - order_by_direction=order_by_direction, - ) - - async def aio_get_cron(self, cron_trigger_id: str) -> CronWorkflows: - return await self.workflow_api.workflow_cron_get( - tenant=self.tenant_id, - cron_workflow=cron_trigger_id, - ) - - async def aio_create_schedule( - self, - name: str, - trigger_at: datetime.datetime, - input: JSONSerializableMapping, - additional_metadata: JSONSerializableMapping, - ) -> ScheduledWorkflows: - return await self.workflow_run_api.scheduled_workflow_run_create( - tenant=self.tenant_id, - workflow=name, - schedule_workflow_run_request=ScheduleWorkflowRunRequest( - triggerAt=trigger_at, - input=dict(input), - additionalMetadata=dict(additional_metadata), - ), - ) - - async def aio_delete_schedule(self, scheduled_trigger_id: str) -> None: - await self.workflow_api.workflow_scheduled_delete( - tenant=self.tenant_id, - scheduled_workflow_run=scheduled_trigger_id, - ) - - async def aio_list_schedule( - self, - offset: StrictInt | None = None, - limit: StrictInt | None = None, - workflow_id: str | None = None, - additional_metadata: list[str] | None = None, - parent_workflow_run_id: str | None = None, - parent_step_run_id: str | None = None, - order_by_field: ScheduledWorkflowsOrderByField | None = None, - order_by_direction: WorkflowRunOrderByDirection | None = None, - ) -> ScheduledWorkflowsList: - return await self.workflow_api.workflow_scheduled_list( - tenant=self.tenant_id, - offset=offset, - limit=limit, - workflow_id=workflow_id, - parent_workflow_run_id=parent_workflow_run_id, - parent_step_run_id=parent_step_run_id, - additional_metadata=additional_metadata, - order_by_field=order_by_field, - order_by_direction=order_by_direction, - ) - - async def aio_get_schedule(self, scheduled_trigger_id: str) -> ScheduledWorkflows: - return await self.workflow_api.workflow_scheduled_get( - tenant=self.tenant_id, - scheduled_workflow_run=scheduled_trigger_id, - ) - - async def aio_list_logs( - self, - step_run_id: str, - offset: int | None = None, - limit: int | None = None, - levels: list[LogLineLevel] | None = None, - search: str | None = None, - order_by_field: LogLineOrderByField | None = None, - order_by_direction: LogLineOrderByDirection | None = None, - ) -> LogLineList: - return await self.log_api.log_line_list( - step_run=step_run_id, - offset=offset, - limit=limit, - levels=levels, - search=search, - order_by_field=order_by_field, - order_by_direction=order_by_direction, - ) - - async def aio_list_events( - self, - offset: int | None = None, - limit: int | None = None, - keys: list[str] | None = None, - workflows: list[str] | None = None, - statuses: list[WorkflowRunStatus] | None = None, - search: str | None = None, - order_by_field: EventOrderByField | None = None, - order_by_direction: EventOrderByDirection | None = None, - additional_metadata: list[str] | None = None, - ) -> EventList: - return await self.event_api.event_list( - tenant=self.tenant_id, - offset=offset, - limit=limit, - keys=keys, - workflows=workflows, - statuses=statuses, - search=search, - order_by_field=order_by_field, - order_by_direction=order_by_direction, - additional_metadata=additional_metadata, - ) - - async def aio_replay_events(self, event_ids: list[str] | EventList) -> EventList: - if isinstance(event_ids, EventList): - rows = event_ids.rows or [] - event_ids = [r.metadata.id for r in rows] - - return await self.event_api.event_update_replay( - tenant=self.tenant_id, - replay_event_request=ReplayEventRequest(eventIds=event_ids), - ) - - def _cleanup(self) -> None: - """ - Stop the running thread and clean up the event loop. - """ - self._run_coroutine(self.close()) - self._loop.call_soon_threadsafe(self._loop.stop) - self._thread.join() - - def _run_event_loop(self) -> None: - """ - Run the asyncio event loop in a separate thread. - """ - asyncio.set_event_loop(self._loop) - self._loop.run_forever() - - def _run_coroutine(self, coro: Coroutine[Y, S, R]) -> R: - """ - Execute a coroutine in the event loop and return the result. - """ - future = asyncio.run_coroutine_threadsafe(coro, self._loop) - return future.result() - - def workflow_list(self) -> WorkflowList: - return self._run_coroutine(self.aio_list_workflows()) - - def workflow_get(self, workflow_id: str) -> Workflow: - return self._run_coroutine(self.aio_get_workflow(workflow_id)) - - def workflow_version_get( - self, workflow_id: str, version: str | None = None - ) -> WorkflowVersion: - return self._run_coroutine(self.aio_get_workflow_version(workflow_id, version)) - - def workflow_run_list( - self, - workflow_id: str | None = None, - offset: int | None = None, - limit: int | None = None, - event_id: str | None = None, - parent_workflow_run_id: str | None = None, - parent_step_run_id: str | None = None, - statuses: list[WorkflowRunStatus] | None = None, - kinds: list[WorkflowKind] | None = None, - additional_metadata: list[str] | None = None, - order_by_field: WorkflowRunOrderByField | None = None, - order_by_direction: WorkflowRunOrderByDirection | None = None, - ) -> WorkflowRunList: - return self._run_coroutine( - self.aio_list_workflow_runs( - workflow_id=workflow_id, - offset=offset, - limit=limit, - event_id=event_id, - parent_workflow_run_id=parent_workflow_run_id, - parent_step_run_id=parent_step_run_id, - statuses=statuses, - kinds=kinds, - additional_metadata=additional_metadata, - order_by_field=order_by_field, - order_by_direction=order_by_direction, - ) - ) - - def workflow_run_get(self, workflow_run_id: str) -> WorkflowRun: - return self._run_coroutine(self.aio_get_workflow_run(workflow_run_id)) - - def workflow_run_cancel(self, workflow_run_id: str) -> EventUpdateCancel200Response: - return self._run_coroutine(self.aio_cancel_workflow_run(workflow_run_id)) - - def workflow_run_bulk_cancel( - self, workflow_run_ids: list[str] - ) -> EventUpdateCancel200Response: - return self._run_coroutine(self.aio_bulk_cancel_workflow_runs(workflow_run_ids)) - - def workflow_run_create( - self, - workflow_id: str, - input: JSONSerializableMapping, - version: str | None = None, - additional_metadata: JSONSerializableMapping = {}, - ) -> WorkflowRun: - return self._run_coroutine( - self.aio_create_workflow_run( - workflow_id, input, version, additional_metadata - ) - ) - - def cron_create( - self, - workflow_name: str, - cron_name: str, - expression: str, - input: JSONSerializableMapping, - additional_metadata: JSONSerializableMapping, - ) -> CronWorkflows: - return self._run_coroutine( - self.aio_create_cron( - workflow_name, cron_name, expression, input, additional_metadata - ) - ) - - def cron_delete(self, cron_trigger_id: str) -> None: - self._run_coroutine(self.aio_delete_cron(cron_trigger_id)) - - def cron_list( - self, - offset: int | None = None, - limit: int | None = None, - workflow_id: str | None = None, - additional_metadata: list[str] | None = None, - order_by_field: CronWorkflowsOrderByField | None = None, - order_by_direction: WorkflowRunOrderByDirection | None = None, - ) -> CronWorkflowsList: - return self._run_coroutine( - self.aio_list_crons( - offset, - limit, - workflow_id, - additional_metadata, - order_by_field, - order_by_direction, - ) - ) - - def cron_get(self, cron_trigger_id: str) -> CronWorkflows: - return self._run_coroutine(self.aio_get_cron(cron_trigger_id)) - - def schedule_create( - self, - workflow_name: str, - trigger_at: datetime.datetime, - input: JSONSerializableMapping, - additional_metadata: JSONSerializableMapping, - ) -> ScheduledWorkflows: - return self._run_coroutine( - self.aio_create_schedule( - workflow_name, trigger_at, input, additional_metadata - ) - ) - - def schedule_delete(self, scheduled_trigger_id: str) -> None: - self._run_coroutine(self.aio_delete_schedule(scheduled_trigger_id)) - - def schedule_list( - self, - offset: int | None = None, - limit: int | None = None, - workflow_id: str | None = None, - additional_metadata: list[str] | None = None, - order_by_field: CronWorkflowsOrderByField | None = None, - order_by_direction: WorkflowRunOrderByDirection | None = None, - ) -> ScheduledWorkflowsList: - return self._run_coroutine( - self.aio_list_schedule( - offset, - limit, - workflow_id, - additional_metadata, - order_by_field, - order_by_direction, - ) - ) - - def schedule_get(self, scheduled_trigger_id: str) -> ScheduledWorkflows: - return self._run_coroutine(self.aio_get_schedule(scheduled_trigger_id)) - - def list_logs( - self, - step_run_id: str, - offset: int | None = None, - limit: int | None = None, - levels: list[LogLineLevel] | None = None, - search: str | None = None, - order_by_field: LogLineOrderByField | None = None, - order_by_direction: LogLineOrderByDirection | None = None, - ) -> LogLineList: - return self._run_coroutine( - self.aio_list_logs( - step_run_id=step_run_id, - offset=offset, - limit=limit, - levels=levels, - search=search, - order_by_field=order_by_field, - order_by_direction=order_by_direction, - ) - ) - - def events_list( - self, - offset: int | None = None, - limit: int | None = None, - keys: list[str] | None = None, - workflows: list[str] | None = None, - statuses: list[WorkflowRunStatus] | None = None, - search: str | None = None, - order_by_field: EventOrderByField | None = None, - order_by_direction: EventOrderByDirection | None = None, - additional_metadata: list[str] | None = None, - ) -> EventList: - return self._run_coroutine( - self.aio_list_events( - offset=offset, - limit=limit, - keys=keys, - workflows=workflows, - statuses=statuses, - search=search, - order_by_field=order_by_field, - order_by_direction=order_by_direction, - additional_metadata=additional_metadata, - ) - ) - - def events_replay(self, event_ids: list[str] | EventList) -> EventList: - return self._run_coroutine(self.aio_replay_events(event_ids)) diff --git a/sdks/python/hatchet_sdk/clients/v1/api_client.py b/sdks/python/hatchet_sdk/clients/v1/api_client.py new file mode 100644 index 000000000..4487b6311 --- /dev/null +++ b/sdks/python/hatchet_sdk/clients/v1/api_client.py @@ -0,0 +1,81 @@ +import asyncio +from concurrent.futures import ThreadPoolExecutor +from typing import AsyncContextManager, Callable, Coroutine, ParamSpec, TypeVar + +from hatchet_sdk.clients.rest.api_client import ApiClient +from hatchet_sdk.clients.rest.configuration import Configuration +from hatchet_sdk.config import ClientConfig +from hatchet_sdk.utils.typing import JSONSerializableMapping + +## Type variables to use with coroutines. +## See https://stackoverflow.com/questions/73240620/the-right-way-to-type-hint-a-coroutine-function +## Return type +R = TypeVar("R") + +## Yield type +Y = TypeVar("Y") + +## Send type +S = TypeVar("S") + +P = ParamSpec("P") + + +def maybe_additional_metadata_to_kv( + additional_metadata: dict[str, str] | JSONSerializableMapping | None +) -> list[str] | None: + if not additional_metadata: + return None + + return [f"{k}:{v}" for k, v in additional_metadata.items()] + + +class BaseRestClient: + def __init__(self, config: ClientConfig) -> None: + self.tenant_id = config.tenant_id + + self.client_config = config + self.api_config = Configuration( + host=config.server_url, + access_token=config.token, + ) + + self.api_config.datetime_format = "%Y-%m-%dT%H:%M:%S.%fZ" + + def client(self) -> AsyncContextManager[ApiClient]: + return ApiClient(self.api_config) + + def _run_async_function_do_not_use_directly( + self, + async_func: Callable[P, Coroutine[Y, S, R]], + *args: P.args, + **kwargs: P.kwargs, + ) -> R: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(async_func(*args, **kwargs)) + finally: + loop.close() + + def _run_async_from_sync( + self, + async_func: Callable[P, Coroutine[Y, S, R]], + *args: P.args, + **kwargs: P.kwargs, + ) -> R: + try: + loop = asyncio.get_event_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + return loop.run_until_complete(async_func(*args, **kwargs)) + else: + with ThreadPoolExecutor() as executor: + future = executor.submit( + lambda: self._run_async_function_do_not_use_directly( + async_func, *args, **kwargs + ) + ) + return future.result() diff --git a/sdks/python/hatchet_sdk/context/context.py b/sdks/python/hatchet_sdk/context/context.py index 09057bb44..f9c936aaa 100644 --- a/sdks/python/hatchet_sdk/context/context.py +++ b/sdks/python/hatchet_sdk/context/context.py @@ -17,9 +17,6 @@ from hatchet_sdk.clients.durable_event_listener import ( RegisterDurableEventRequest, ) from hatchet_sdk.clients.events import EventClient -from hatchet_sdk.clients.rest_client import RestApi -from hatchet_sdk.clients.run_event_listener import RunEventListenerClient -from hatchet_sdk.clients.workflow_listener import PooledWorkflowRunListener from hatchet_sdk.context.worker_context import WorkerContext from hatchet_sdk.logger import logger from hatchet_sdk.utils.timedelta_to_expression import Duration, timedelta_to_expr @@ -53,12 +50,8 @@ class Context: dispatcher_client: DispatcherClient, admin_client: AdminClient, event_client: EventClient, - rest_client: RestApi, - workflow_listener: PooledWorkflowRunListener | None, durable_event_listener: DurableEventListener | None, - workflow_run_event_listener: RunEventListenerClient, worker: WorkerContext, - namespace: str = "", validator_registry: dict[str, WorkflowValidator] = {}, ): self.worker = worker @@ -68,16 +61,12 @@ class Context: self.action = action - self.step_run_id: str = action.step_run_id + self.step_run_id = action.step_run_id self.exit_flag = False self.dispatcher_client = dispatcher_client self.admin_client = admin_client self.event_client = event_client - self.rest_client = rest_client - self.workflow_listener = workflow_listener self.durable_event_listener = durable_event_listener - self.workflow_run_event_listener = workflow_run_event_listener - self.namespace = namespace # FIXME: this limits the number of concurrent log requests to 1, which means we can do about # 100 log lines per second but this depends on network. diff --git a/sdks/python/hatchet_sdk/features/cron.py b/sdks/python/hatchet_sdk/features/cron.py index dee27379b..8183f4595 100644 --- a/sdks/python/hatchet_sdk/features/cron.py +++ b/sdks/python/hatchet_sdk/features/cron.py @@ -1,8 +1,11 @@ -from typing import List, Union - from pydantic import BaseModel, Field, field_validator -from hatchet_sdk.client import Client +from hatchet_sdk.clients.rest.api.workflow_api import WorkflowApi +from hatchet_sdk.clients.rest.api.workflow_run_api import WorkflowRunApi +from hatchet_sdk.clients.rest.api_client import ApiClient +from hatchet_sdk.clients.rest.models.create_cron_workflow_trigger_request import ( + CreateCronWorkflowTriggerRequest, +) from hatchet_sdk.clients.rest.models.cron_workflows import CronWorkflows from hatchet_sdk.clients.rest.models.cron_workflows_list import CronWorkflowsList from hatchet_sdk.clients.rest.models.cron_workflows_order_by_field import ( @@ -11,6 +14,10 @@ from hatchet_sdk.clients.rest.models.cron_workflows_order_by_field import ( from hatchet_sdk.clients.rest.models.workflow_run_order_by_direction import ( WorkflowRunOrderByDirection, ) +from hatchet_sdk.clients.v1.api_client import ( + BaseRestClient, + maybe_additional_metadata_to_kv, +) from hatchet_sdk.utils.typing import JSONSerializableMapping @@ -62,119 +69,12 @@ class CreateCronTriggerConfig(BaseModel): return v -class CronClient: - """ - Client for managing workflow cron triggers synchronously. +class CronClient(BaseRestClient): + def _wra(self, client: ApiClient) -> WorkflowRunApi: + return WorkflowRunApi(client) - Attributes: - _client (Client): The underlying client used to interact with the REST API. - aio (CronClientAsync): Asynchronous counterpart of CronClient. - """ - - _client: Client - - def __init__(self, _client: Client): - """ - Initializes the CronClient with a given Client instance. - - Args: - _client (Client): The client instance to be used for REST interactions. - """ - self._client = _client - - def create( - self, - workflow_name: str, - cron_name: str, - expression: str, - input: JSONSerializableMapping, - additional_metadata: JSONSerializableMapping, - ) -> CronWorkflows: - """ - Creates a new workflow cron trigger. - - Args: - workflow_name (str): The name of the workflow to trigger. - cron_name (str): The name of the cron trigger. - expression (str): The cron expression defining the schedule. - input (dict): The input data for the cron workflow. - additional_metadata (dict[str, str]): Additional metadata associated with the cron trigger (e.g. {"key1": "value1", "key2": "value2"}). - - Returns: - CronWorkflows: The created cron workflow instance. - """ - validated_input = CreateCronTriggerConfig( - expression=expression, input=input, additional_metadata=additional_metadata - ) - - return self._client.rest.cron_create( - workflow_name, - cron_name, - validated_input.expression, - validated_input.input, - validated_input.additional_metadata, - ) - - def delete(self, cron_trigger: Union[str, CronWorkflows]) -> None: - """ - Deletes a workflow cron trigger. - - Args: - cron_trigger (Union[str, CronWorkflows]): The cron trigger ID or CronWorkflows instance to delete. - """ - self._client.rest.cron_delete( - cron_trigger.metadata.id - if isinstance(cron_trigger, CronWorkflows) - else cron_trigger - ) - - def list( - self, - offset: int | None = None, - limit: int | None = None, - workflow_id: str | None = None, - additional_metadata: list[str] | None = None, - order_by_field: CronWorkflowsOrderByField | None = None, - order_by_direction: WorkflowRunOrderByDirection | None = None, - ) -> CronWorkflowsList: - """ - Retrieves a list of all workflow cron triggers matching the criteria. - - Args: - offset (int | None): The offset to start the list from. - limit (int | None): The maximum number of items to return. - workflow_id (str | None): The ID of the workflow to filter by. - additional_metadata (list[str] | None): Filter by additional metadata keys (e.g. ["key1:value1", "key2:value2"]). - order_by_field (CronWorkflowsOrderByField | None): The field to order the list by. - order_by_direction (WorkflowRunOrderByDirection | None): The direction to order the list by. - - Returns: - CronWorkflowsList: A list of cron workflows. - """ - return self._client.rest.cron_list( - offset=offset, - limit=limit, - workflow_id=workflow_id, - additional_metadata=additional_metadata, - order_by_field=order_by_field, - order_by_direction=order_by_direction, - ) - - def get(self, cron_trigger: Union[str, CronWorkflows]) -> CronWorkflows: - """ - Retrieves a specific workflow cron trigger by ID. - - Args: - cron_trigger (Union[str, CronWorkflows]): The cron trigger ID or CronWorkflows instance to retrieve. - - Returns: - CronWorkflows: The requested cron workflow instance. - """ - return self._client.rest.cron_get( - cron_trigger.metadata.id - if isinstance(cron_trigger, CronWorkflows) - else cron_trigger - ) + def _wa(self, client: ApiClient) -> WorkflowApi: + return WorkflowApi(client) async def aio_create( self, @@ -201,33 +101,56 @@ class CronClient: expression=expression, input=input, additional_metadata=additional_metadata ) - return await self._client.rest.aio_create_cron( - workflow_name=workflow_name, - cron_name=cron_name, - expression=validated_input.expression, - input=validated_input.input, - additional_metadata=validated_input.additional_metadata, + async with self.client() as client: + return await self._wra(client).cron_workflow_trigger_create( + tenant=self.client_config.tenant_id, + workflow=workflow_name, + create_cron_workflow_trigger_request=CreateCronWorkflowTriggerRequest( + cronName=cron_name, + cronExpression=validated_input.expression, + input=dict(validated_input.input), + additionalMetadata=dict(validated_input.additional_metadata), + ), + ) + + def create( + self, + workflow_name: str, + cron_name: str, + expression: str, + input: JSONSerializableMapping, + additional_metadata: JSONSerializableMapping, + ) -> CronWorkflows: + return self._run_async_from_sync( + self.aio_create, + workflow_name, + cron_name, + expression, + input, + additional_metadata, ) - async def aio_delete(self, cron_trigger: Union[str, CronWorkflows]) -> None: + async def aio_delete(self, cron_id: str) -> None: """ Asynchronously deletes a workflow cron trigger. Args: - cron_trigger (Union[str, CronWorkflows]): The cron trigger ID or CronWorkflows instance to delete. + cron_id (str): The cron trigger ID or CronWorkflows instance to delete. """ - await self._client.rest.aio_delete_cron( - cron_trigger.metadata.id - if isinstance(cron_trigger, CronWorkflows) - else cron_trigger - ) + async with self.client() as client: + await self._wa(client).workflow_cron_delete( + tenant=self.client_config.tenant_id, cron_workflow=str(cron_id) + ) + + def delete(self, cron_id: str) -> None: + return self._run_async_from_sync(self.aio_delete, cron_id) async def aio_list( self, offset: int | None = None, limit: int | None = None, workflow_id: str | None = None, - additional_metadata: List[str] | None = None, + additional_metadata: JSONSerializableMapping | None = None, order_by_field: CronWorkflowsOrderByField | None = None, order_by_direction: WorkflowRunOrderByDirection | None = None, ) -> CronWorkflowsList: @@ -245,7 +168,44 @@ class CronClient: Returns: CronWorkflowsList: A list of cron workflows. """ - return await self._client.rest.aio_list_crons( + async with self.client() as client: + return await self._wa(client).cron_workflow_list( + tenant=self.client_config.tenant_id, + offset=offset, + limit=limit, + workflow_id=workflow_id, + additional_metadata=maybe_additional_metadata_to_kv( + additional_metadata + ), + order_by_field=order_by_field, + order_by_direction=order_by_direction, + ) + + def list( + self, + offset: int | None = None, + limit: int | None = None, + workflow_id: str | None = None, + additional_metadata: JSONSerializableMapping | None = None, + order_by_field: CronWorkflowsOrderByField | None = None, + order_by_direction: WorkflowRunOrderByDirection | None = None, + ) -> CronWorkflowsList: + """ + Synchronously retrieves a list of all workflow cron triggers matching the criteria. + + Args: + offset (int | None): The offset to start the list from. + limit (int | None): The maximum number of items to return. + workflow_id (str | None): The ID of the workflow to filter by. + additional_metadata (list[str] | None): Filter by additional metadata keys (e.g. ["key1:value1", "key2:value2"]). + order_by_field (CronWorkflowsOrderByField | None): The field to order the list by. + order_by_direction (WorkflowRunOrderByDirection | None): The direction to order the list by. + + Returns: + CronWorkflowsList: A list of cron workflows. + """ + return self._run_async_from_sync( + self.aio_list, offset=offset, limit=limit, workflow_id=workflow_id, @@ -254,19 +214,29 @@ class CronClient: order_by_direction=order_by_direction, ) - async def aio_get(self, cron_trigger: Union[str, CronWorkflows]) -> CronWorkflows: + async def aio_get(self, cron_id: str) -> CronWorkflows: """ Asynchronously retrieves a specific workflow cron trigger by ID. Args: - cron_trigger (Union[str, CronWorkflows]): The cron trigger ID or CronWorkflows instance to retrieve. + cron_id (str): The cron trigger ID or CronWorkflows instance to retrieve. Returns: CronWorkflows: The requested cron workflow instance. """ + async with self.client() as client: + return await self._wa(client).workflow_cron_get( + tenant=self.client_config.tenant_id, cron_workflow=str(cron_id) + ) - return await self._client.rest.aio_get_cron( - cron_trigger.metadata.id - if isinstance(cron_trigger, CronWorkflows) - else cron_trigger - ) + def get(self, cron_id: str) -> CronWorkflows: + """ + Synchronously retrieves a specific workflow cron trigger by ID. + + Args: + cron_id (str): The cron trigger ID or CronWorkflows instance to retrieve. + + Returns: + CronWorkflows: The requested cron workflow instance. + """ + return self._run_async_from_sync(self.aio_get, cron_id) diff --git a/sdks/python/hatchet_sdk/features/logs.py b/sdks/python/hatchet_sdk/features/logs.py new file mode 100644 index 000000000..873458058 --- /dev/null +++ b/sdks/python/hatchet_sdk/features/logs.py @@ -0,0 +1,16 @@ +from hatchet_sdk.clients.rest.api.log_api import LogApi +from hatchet_sdk.clients.rest.api_client import ApiClient +from hatchet_sdk.clients.rest.models.v1_log_line_list import V1LogLineList +from hatchet_sdk.clients.v1.api_client import BaseRestClient + + +class LogsClient(BaseRestClient): + def _la(self, client: ApiClient) -> LogApi: + return LogApi(client) + + async def aio_list(self, task_run_id: str) -> V1LogLineList: + async with self.client() as client: + return await self._la(client).v1_log_line_list(task=task_run_id) + + def list(self, task_run_id: str) -> V1LogLineList: + return self._run_async_from_sync(self.aio_list, task_run_id) diff --git a/sdks/python/hatchet_sdk/features/metrics.py b/sdks/python/hatchet_sdk/features/metrics.py new file mode 100644 index 000000000..d6a03ab3f --- /dev/null +++ b/sdks/python/hatchet_sdk/features/metrics.py @@ -0,0 +1,75 @@ +from hatchet_sdk.clients.rest.api.tenant_api import TenantApi +from hatchet_sdk.clients.rest.api.workflow_api import WorkflowApi +from hatchet_sdk.clients.rest.api_client import ApiClient +from hatchet_sdk.clients.rest.models.tenant_queue_metrics import TenantQueueMetrics +from hatchet_sdk.clients.rest.models.tenant_step_run_queue_metrics import ( + TenantStepRunQueueMetrics, +) +from hatchet_sdk.clients.rest.models.workflow_metrics import WorkflowMetrics +from hatchet_sdk.clients.rest.models.workflow_run_status import WorkflowRunStatus +from hatchet_sdk.clients.v1.api_client import ( + BaseRestClient, + maybe_additional_metadata_to_kv, +) +from hatchet_sdk.utils.typing import JSONSerializableMapping + + +class MetricsClient(BaseRestClient): + def _wa(self, client: ApiClient) -> WorkflowApi: + return WorkflowApi(client) + + def _ta(self, client: ApiClient) -> TenantApi: + return TenantApi(client) + + async def aio_get_workflow_metrics( + self, + workflow_id: str, + status: WorkflowRunStatus | None = None, + group_key: str | None = None, + ) -> WorkflowMetrics: + async with self.client() as client: + return await self._wa(client).workflow_get_metrics( + workflow=workflow_id, status=status, group_key=group_key + ) + + def get_workflow_metrics( + self, + workflow_id: str, + status: WorkflowRunStatus | None = None, + group_key: str | None = None, + ) -> WorkflowMetrics: + return self._run_async_from_sync( + self.aio_get_workflow_metrics, workflow_id, status, group_key + ) + + async def aio_get_queue_metrics( + self, + workflow_ids: list[str] | None = None, + additional_metadata: JSONSerializableMapping | None = None, + ) -> TenantQueueMetrics: + async with self.client() as client: + return await self._wa(client).tenant_get_queue_metrics( + tenant=self.client_config.tenant_id, + workflows=workflow_ids, + additional_metadata=maybe_additional_metadata_to_kv( + additional_metadata + ), + ) + + def get_queue_metrics( + self, + workflow_ids: list[str] | None = None, + additional_metadata: JSONSerializableMapping | None = None, + ) -> TenantQueueMetrics: + return self._run_async_from_sync( + self.aio_get_queue_metrics, workflow_ids, additional_metadata + ) + + async def aio_get_task_metrics(self) -> TenantStepRunQueueMetrics: + async with self.client() as client: + return await self._ta(client).tenant_get_step_run_queue_metrics( + tenant=self.client_config.tenant_id + ) + + def get_task_metrics(self) -> TenantStepRunQueueMetrics: + return self._run_async_from_sync(self.aio_get_task_metrics) diff --git a/sdks/python/hatchet_sdk/features/rate_limits.py b/sdks/python/hatchet_sdk/features/rate_limits.py new file mode 100644 index 000000000..992873566 --- /dev/null +++ b/sdks/python/hatchet_sdk/features/rate_limits.py @@ -0,0 +1,45 @@ +import asyncio + +from hatchet_sdk.clients.rest.tenacity_utils import tenacity_retry +from hatchet_sdk.clients.v1.api_client import BaseRestClient +from hatchet_sdk.connection import new_conn +from hatchet_sdk.contracts import workflows_pb2 as v0_workflow_protos +from hatchet_sdk.contracts.v1 import workflows_pb2 as workflow_protos +from hatchet_sdk.contracts.workflows_pb2_grpc import WorkflowServiceStub +from hatchet_sdk.metadata import get_metadata +from hatchet_sdk.rate_limit import RateLimitDuration +from hatchet_sdk.utils.proto_enums import convert_python_enum_to_proto + + +class RateLimitsClient(BaseRestClient): + @tenacity_retry + def put( + self, + key: str, + limit: int, + duration: RateLimitDuration = RateLimitDuration.SECOND, + ) -> None: + duration_proto = convert_python_enum_to_proto( + duration, workflow_protos.RateLimitDuration + ) + + conn = new_conn(self.client_config, False) + client = WorkflowServiceStub(conn) # type: ignore[no-untyped-call] + + client.PutRateLimit( + v0_workflow_protos.PutRateLimitRequest( + key=key, + limit=limit, + duration=duration_proto, # type: ignore[arg-type] + ), + metadata=get_metadata(self.client_config.token), + ) + + @tenacity_retry + async def aio_put( + self, + key: str, + limit: int, + duration: RateLimitDuration = RateLimitDuration.SECOND, + ) -> None: + await asyncio.to_thread(self.put, key, limit, duration) diff --git a/sdks/python/hatchet_sdk/features/runs.py b/sdks/python/hatchet_sdk/features/runs.py new file mode 100644 index 000000000..d316f8884 --- /dev/null +++ b/sdks/python/hatchet_sdk/features/runs.py @@ -0,0 +1,221 @@ +from datetime import datetime, timedelta +from typing import Literal, overload + +from pydantic import BaseModel, model_validator + +from hatchet_sdk.clients.rest.api.task_api import TaskApi +from hatchet_sdk.clients.rest.api.workflow_runs_api import WorkflowRunsApi +from hatchet_sdk.clients.rest.api_client import ApiClient +from hatchet_sdk.clients.rest.models.v1_cancel_task_request import V1CancelTaskRequest +from hatchet_sdk.clients.rest.models.v1_replay_task_request import V1ReplayTaskRequest +from hatchet_sdk.clients.rest.models.v1_task_filter import V1TaskFilter +from hatchet_sdk.clients.rest.models.v1_task_status import V1TaskStatus +from hatchet_sdk.clients.rest.models.v1_task_summary_list import V1TaskSummaryList +from hatchet_sdk.clients.rest.models.v1_trigger_workflow_run_request import ( + V1TriggerWorkflowRunRequest, +) +from hatchet_sdk.clients.rest.models.v1_workflow_run_details import V1WorkflowRunDetails +from hatchet_sdk.clients.v1.api_client import ( + BaseRestClient, + maybe_additional_metadata_to_kv, +) +from hatchet_sdk.utils.typing import JSONSerializableMapping + + +class RunFilter(BaseModel): + since: datetime + until: datetime | None = None + statuses: list[V1TaskStatus] | None = None + workflow_ids: list[str] | None = None + additional_metadata: dict[str, str] | None = None + + +class BulkCancelReplayOpts(BaseModel): + ids: list[str] | None = None + filters: RunFilter | None = None + + @model_validator(mode="after") + def validate_model(self) -> "BulkCancelReplayOpts": + if not self.ids and not self.filters: + raise ValueError("ids or filters must be set") + + if self.ids and self.filters: + raise ValueError("ids and filters cannot both be set") + + return self + + @property + def v1_task_filter(self) -> V1TaskFilter | None: + if not self.filters: + return None + + return V1TaskFilter( + since=self.filters.since, + until=self.filters.until, + statuses=self.filters.statuses, + workflowIds=self.filters.workflow_ids, + additionalMetadata=maybe_additional_metadata_to_kv( + self.filters.additional_metadata + ), + ) + + @overload + def to_request(self, request_type: Literal["replay"]) -> V1ReplayTaskRequest: ... + + @overload + def to_request(self, request_type: Literal["cancel"]) -> V1CancelTaskRequest: ... + + def to_request( + self, request_type: Literal["replay", "cancel"] + ) -> V1ReplayTaskRequest | V1CancelTaskRequest: + if request_type == "replay": + return V1ReplayTaskRequest( + externalIds=self.ids, + filter=self.v1_task_filter, + ) + + if request_type == "cancel": + return V1CancelTaskRequest( + externalIds=self.ids, + filter=self.v1_task_filter, + ) + + +class RunsClient(BaseRestClient): + def _wra(self, client: ApiClient) -> WorkflowRunsApi: + return WorkflowRunsApi(client) + + def _ta(self, client: ApiClient) -> TaskApi: + return TaskApi(client) + + async def aio_get(self, workflow_run_id: str) -> V1WorkflowRunDetails: + async with self.client() as client: + return await self._wra(client).v1_workflow_run_get(str(workflow_run_id)) + + def get(self, workflow_run_id: str) -> V1WorkflowRunDetails: + return self._run_async_from_sync(self.aio_get, workflow_run_id) + + async def aio_list( + self, + since: datetime = datetime.now() - timedelta(hours=1), + only_tasks: bool = False, + offset: int | None = None, + limit: int | None = None, + statuses: list[V1TaskStatus] | None = None, + until: datetime | None = None, + additional_metadata: dict[str, str] | None = None, + workflow_ids: list[str] | None = None, + worker_id: str | None = None, + parent_task_external_id: str | None = None, + ) -> V1TaskSummaryList: + async with self.client() as client: + return await self._wra(client).v1_workflow_run_list( + tenant=self.client_config.tenant_id, + since=since, + only_tasks=only_tasks, + offset=offset, + limit=limit, + statuses=statuses, + until=until, + additional_metadata=maybe_additional_metadata_to_kv( + additional_metadata + ), + workflow_ids=workflow_ids, + worker_id=worker_id, + parent_task_external_id=parent_task_external_id, + ) + + def list( + self, + since: datetime = datetime.now() - timedelta(hours=1), + only_tasks: bool = False, + offset: int | None = None, + limit: int | None = None, + statuses: list[V1TaskStatus] | None = None, + until: datetime | None = None, + additional_metadata: dict[str, str] | None = None, + workflow_ids: list[str] | None = None, + worker_id: str | None = None, + parent_task_external_id: str | None = None, + ) -> V1TaskSummaryList: + return self._run_async_from_sync( + self.aio_list, + since=since, + only_tasks=only_tasks, + offset=offset, + limit=limit, + statuses=statuses, + until=until, + additional_metadata=additional_metadata, + workflow_ids=workflow_ids, + worker_id=worker_id, + parent_task_external_id=parent_task_external_id, + ) + + async def aio_create( + self, + workflow_name: str, + input: JSONSerializableMapping, + additional_metadata: JSONSerializableMapping = {}, + ) -> V1WorkflowRunDetails: + async with self.client() as client: + return await self._wra(client).v1_workflow_run_create( + tenant=self.client_config.tenant_id, + v1_trigger_workflow_run_request=V1TriggerWorkflowRunRequest( + workflowName=workflow_name, + input=dict(input), + additionalMetadata=dict(additional_metadata), + ), + ) + + def create( + self, + workflow_name: str, + input: JSONSerializableMapping, + additional_metadata: JSONSerializableMapping = {}, + ) -> V1WorkflowRunDetails: + return self._run_async_from_sync( + self.aio_create, workflow_name, input, additional_metadata + ) + + async def aio_replay(self, run_id: str) -> None: + await self.aio_bulk_replay(opts=BulkCancelReplayOpts(ids=[run_id])) + + def replay(self, run_id: str) -> None: + return self._run_async_from_sync(self.aio_replay, run_id) + + async def aio_bulk_replay(self, opts: BulkCancelReplayOpts) -> None: + async with self.client() as client: + await self._ta(client).v1_task_replay( + tenant=self.client_config.tenant_id, + v1_replay_task_request=opts.to_request("replay"), + ) + + def bulk_replay(self, opts: BulkCancelReplayOpts) -> None: + return self._run_async_from_sync(self.aio_bulk_replay, opts) + + async def aio_cancel(self, run_id: str) -> None: + await self.aio_bulk_cancel(opts=BulkCancelReplayOpts(ids=[run_id])) + + def cancel(self, run_id: str) -> None: + return self._run_async_from_sync(self.aio_cancel, run_id) + + async def aio_bulk_cancel(self, opts: BulkCancelReplayOpts) -> None: + async with self.client() as client: + await self._ta(client).v1_task_cancel( + tenant=self.client_config.tenant_id, + v1_cancel_task_request=opts.to_request("cancel"), + ) + + def bulk_cancel(self, opts: BulkCancelReplayOpts) -> None: + return self._run_async_from_sync(self.aio_bulk_cancel, opts) + + async def aio_get_result(self, run_id: str) -> JSONSerializableMapping: + details = await self.aio_get(run_id) + + return details.run.output + + def get_result(self, run_id: str) -> JSONSerializableMapping: + details = self.get(run_id) + + return details.run.output diff --git a/sdks/python/hatchet_sdk/features/scheduled.py b/sdks/python/hatchet_sdk/features/scheduled.py index d8025d24e..c51ca04b7 100644 --- a/sdks/python/hatchet_sdk/features/scheduled.py +++ b/sdks/python/hatchet_sdk/features/scheduled.py @@ -1,12 +1,13 @@ import datetime -from typing import List, Optional, Union +from typing import Optional -from pydantic import BaseModel, Field - -from hatchet_sdk.client import Client -from hatchet_sdk.clients.rest.models.cron_workflows_order_by_field import ( - CronWorkflowsOrderByField, +from hatchet_sdk.clients.rest.api.workflow_api import WorkflowApi +from hatchet_sdk.clients.rest.api.workflow_run_api import WorkflowRunApi +from hatchet_sdk.clients.rest.api_client import ApiClient +from hatchet_sdk.clients.rest.models.schedule_workflow_run_request import ( + ScheduleWorkflowRunRequest, ) +from hatchet_sdk.clients.rest.models.scheduled_run_status import ScheduledRunStatus from hatchet_sdk.clients.rest.models.scheduled_workflows import ScheduledWorkflows from hatchet_sdk.clients.rest.models.scheduled_workflows_list import ( ScheduledWorkflowsList, @@ -17,43 +18,49 @@ from hatchet_sdk.clients.rest.models.scheduled_workflows_order_by_field import ( from hatchet_sdk.clients.rest.models.workflow_run_order_by_direction import ( WorkflowRunOrderByDirection, ) +from hatchet_sdk.clients.v1.api_client import ( + BaseRestClient, + maybe_additional_metadata_to_kv, +) from hatchet_sdk.utils.typing import JSONSerializableMapping -class CreateScheduledTriggerConfig(BaseModel): - """ - Schema for creating a scheduled workflow run. +class ScheduledClient(BaseRestClient): + def _wra(self, client: ApiClient) -> WorkflowRunApi: + return WorkflowRunApi(client) - Attributes: - input (JSONSerializableMapping): The input data for the scheduled workflow. - additional_metadata (JSONSerializableMapping): Additional metadata associated with the future run (e.g. ["key1:value1", "key2:value2"]). - trigger_at (Optional[datetime.datetime]): The datetime when the run should be triggered. - """ + def _wa(self, client: ApiClient) -> WorkflowApi: + return WorkflowApi(client) - input: JSONSerializableMapping = Field(default_factory=dict) - additional_metadata: JSONSerializableMapping = Field(default_factory=dict) - trigger_at: datetime.datetime - - -class ScheduledClient: - """ - Client for managing scheduled workflows synchronously. - - Attributes: - _client (Client): The underlying client used to interact with the REST API. - aio (ScheduledClientAsync): Asynchronous counterpart of ScheduledClient. - """ - - _client: Client - - def __init__(self, _client: Client) -> None: + async def aio_create( + self, + workflow_name: str, + trigger_at: datetime.datetime, + input: JSONSerializableMapping, + additional_metadata: JSONSerializableMapping, + ) -> ScheduledWorkflows: """ - Initializes the ScheduledClient with a given Client instance. + Creates a new scheduled workflow run asynchronously. Args: - _client (Client): The client instance to be used for REST interactions. + workflow_name (str): The name of the scheduled workflow. + trigger_at (datetime.datetime): The datetime when the run should be triggered. + input (JSONSerializableMapping): The input data for the scheduled workflow. + additional_metadata (JSONSerializableMapping): Additional metadata associated with the future run. + + Returns: + ScheduledWorkflows: The created scheduled workflow instance. """ - self._client = _client + async with self.client() as client: + return await self._wra(client).scheduled_workflow_run_create( + tenant=self.client_config.tenant_id, + workflow=workflow_name, + schedule_workflow_run_request=ScheduleWorkflowRunRequest( + triggerAt=trigger_at, + input=dict(input), + additionalMetadata=dict(additional_metadata), + ), + ) def create( self, @@ -75,37 +82,39 @@ class ScheduledClient: ScheduledWorkflows: The created scheduled workflow instance. """ - validated_input = CreateScheduledTriggerConfig( - trigger_at=trigger_at, input=input, additional_metadata=additional_metadata - ) - - return self._client.rest.schedule_create( + return self._run_async_from_sync( + self.aio_create, workflow_name, - validated_input.trigger_at, - validated_input.input, - validated_input.additional_metadata, + trigger_at, + input, + additional_metadata, ) - def delete(self, scheduled: Union[str, ScheduledWorkflows]) -> None: + async def aio_delete(self, scheduled_id: str) -> None: """ Deletes a scheduled workflow run. Args: - scheduled (Union[str, ScheduledWorkflows]): The scheduled workflow trigger ID or ScheduledWorkflows instance to delete. + scheduled_id (str): The scheduled workflow trigger ID to delete. """ - self._client.rest.schedule_delete( - scheduled.metadata.id - if isinstance(scheduled, ScheduledWorkflows) - else scheduled - ) + async with self.client() as client: + await self._wa(client).workflow_scheduled_delete( + tenant=self.client_config.tenant_id, + scheduled_workflow_run=scheduled_id, + ) - def list( + def delete(self, scheduled_id: str) -> None: + self._run_async_from_sync(self.aio_delete, scheduled_id) + + async def aio_list( self, offset: int | None = None, limit: int | None = None, workflow_id: str | None = None, - additional_metadata: Optional[List[str]] = None, - order_by_field: Optional[CronWorkflowsOrderByField] = None, + parent_workflow_run_id: str | None = None, + statuses: list[ScheduledRunStatus] | None = None, + additional_metadata: Optional[JSONSerializableMapping] = None, + order_by_field: Optional[ScheduledWorkflowsOrderByField] = None, order_by_direction: Optional[WorkflowRunOrderByDirection] = None, ) -> ScheduledWorkflowsList: """ @@ -115,120 +124,94 @@ class ScheduledClient: offset (int | None): The starting point for the list. limit (int | None): The maximum number of items to return. workflow_id (str | None): Filter by specific workflow ID. - additional_metadata (Optional[List[str]]): Filter by additional metadata keys (e.g. ["key1:value1", "key2:value2"]). - order_by_field (Optional[CronWorkflowsOrderByField]): Field to order the results by. + parent_workflow_run_id (str | None): Filter by parent workflow run ID. + statuses (list[ScheduledRunStatus] | None): Filter by status. + additional_metadata (Optional[List[dict[str, str]]]): Filter by additional metadata. + order_by_field (Optional[ScheduledWorkflowsOrderByField]): Field to order the results by. order_by_direction (Optional[WorkflowRunOrderByDirection]): Direction to order the results. Returns: List[ScheduledWorkflows]: A list of scheduled workflows matching the criteria. """ - return self._client.rest.schedule_list( - offset=offset, - limit=limit, - workflow_id=workflow_id, - additional_metadata=additional_metadata, - order_by_field=order_by_field, - order_by_direction=order_by_direction, - ) + async with self.client() as client: + return await self._wa(client).workflow_scheduled_list( + tenant=self.client_config.tenant_id, + offset=offset, + limit=limit, + order_by_field=order_by_field, + order_by_direction=order_by_direction, + workflow_id=workflow_id, + additional_metadata=maybe_additional_metadata_to_kv( + additional_metadata + ), + parent_workflow_run_id=parent_workflow_run_id, + statuses=statuses, + ) - def get(self, scheduled: Union[str, ScheduledWorkflows]) -> ScheduledWorkflows: - """ - Retrieves a specific scheduled workflow by scheduled run trigger ID. - - Args: - scheduled (Union[str, ScheduledWorkflows]): The scheduled workflow trigger ID or ScheduledWorkflows instance to retrieve. - - Returns: - ScheduledWorkflows: The requested scheduled workflow instance. - """ - return self._client.rest.schedule_get( - scheduled.metadata.id - if isinstance(scheduled, ScheduledWorkflows) - else scheduled - ) - - async def aio_create( - self, - workflow_name: str, - trigger_at: datetime.datetime, - input: JSONSerializableMapping, - additional_metadata: JSONSerializableMapping, - ) -> ScheduledWorkflows: - """ - Creates a new scheduled workflow run asynchronously. - - Args: - workflow_name (str): The name of the scheduled workflow. - trigger_at (datetime.datetime): The datetime when the run should be triggered. - input (JSONSerializableMapping): The input data for the scheduled workflow. - additional_metadata (JSONSerializableMapping): Additional metadata associated with the future run. - - Returns: - ScheduledWorkflows: The created scheduled workflow instance. - """ - return await self._client.rest.aio_create_schedule( - workflow_name, trigger_at, input, additional_metadata - ) - - async def aio_delete(self, scheduled: Union[str, ScheduledWorkflows]) -> None: - """ - Deletes a scheduled workflow asynchronously. - - Args: - scheduled (Union[str, ScheduledWorkflows]): The scheduled workflow trigger ID or ScheduledWorkflows instance to delete. - """ - await self._client.rest.aio_delete_schedule( - scheduled.metadata.id - if isinstance(scheduled, ScheduledWorkflows) - else scheduled - ) - - async def aio_list( + def list( self, offset: int | None = None, limit: int | None = None, workflow_id: str | None = None, - additional_metadata: Optional[List[str]] = None, + parent_workflow_run_id: str | None = None, + statuses: list[ScheduledRunStatus] | None = None, + additional_metadata: Optional[JSONSerializableMapping] = None, order_by_field: Optional[ScheduledWorkflowsOrderByField] = None, order_by_direction: Optional[WorkflowRunOrderByDirection] = None, ) -> ScheduledWorkflowsList: """ - Retrieves a list of scheduled workflows based on provided filters asynchronously. + Retrieves a list of scheduled workflows based on provided filters. Args: offset (int | None): The starting point for the list. limit (int | None): The maximum number of items to return. workflow_id (str | None): Filter by specific workflow ID. - additional_metadata (Optional[List[str]]): Filter by additional metadata keys (e.g. ["key1:value1", "key2:value2"]). - order_by_field (Optional[CronWorkflowsOrderByField]): Field to order the results by. + parent_workflow_run_id (str | None): Filter by parent workflow run ID. + statuses (list[ScheduledRunStatus] | None): Filter by status. + additional_metadata (Optional[List[dict[str, str]]]): Filter by additional metadata. + order_by_field (Optional[ScheduledWorkflowsOrderByField]): Field to order the results by. order_by_direction (Optional[WorkflowRunOrderByDirection]): Direction to order the results. Returns: - ScheduledWorkflowsList: A list of scheduled workflows matching the criteria. + List[ScheduledWorkflows]: A list of scheduled workflows matching the criteria. """ - return await self._client.rest.aio_list_schedule( + return self._run_async_from_sync( + self.aio_list, offset=offset, limit=limit, workflow_id=workflow_id, additional_metadata=additional_metadata, order_by_field=order_by_field, order_by_direction=order_by_direction, + parent_workflow_run_id=parent_workflow_run_id, + statuses=statuses, ) - async def aio_get( - self, scheduled: Union[str, ScheduledWorkflows] - ) -> ScheduledWorkflows: + async def aio_get(self, scheduled_id: str) -> ScheduledWorkflows: """ - Retrieves a specific scheduled workflow by scheduled run trigger ID asynchronously. + Retrieves a specific scheduled workflow by scheduled run trigger ID. Args: - scheduled (Union[str, ScheduledWorkflows]): The scheduled workflow trigger ID or ScheduledWorkflows instance to retrieve. + scheduled (str): The scheduled workflow trigger ID to retrieve. Returns: ScheduledWorkflows: The requested scheduled workflow instance. """ - return await self._client.rest.aio_get_schedule( - scheduled.metadata.id - if isinstance(scheduled, ScheduledWorkflows) - else scheduled - ) + + async with self.client() as client: + return await self._wa(client).workflow_scheduled_get( + tenant=self.client_config.tenant_id, + scheduled_workflow_run=scheduled_id, + ) + + def get(self, scheduled_id: str) -> ScheduledWorkflows: + """ + Retrieves a specific scheduled workflow by scheduled run trigger ID. + + Args: + scheduled (str): The scheduled workflow trigger ID to retrieve. + + Returns: + ScheduledWorkflows: The requested scheduled workflow instance. + """ + return self._run_async_from_sync(self.aio_get, scheduled_id) diff --git a/sdks/python/hatchet_sdk/features/workers.py b/sdks/python/hatchet_sdk/features/workers.py new file mode 100644 index 000000000..4738272be --- /dev/null +++ b/sdks/python/hatchet_sdk/features/workers.py @@ -0,0 +1,41 @@ +from hatchet_sdk.clients.rest.api.worker_api import WorkerApi +from hatchet_sdk.clients.rest.api_client import ApiClient +from hatchet_sdk.clients.rest.models.update_worker_request import UpdateWorkerRequest +from hatchet_sdk.clients.rest.models.worker import Worker +from hatchet_sdk.clients.rest.models.worker_list import WorkerList +from hatchet_sdk.clients.v1.api_client import BaseRestClient + + +class WorkersClient(BaseRestClient): + def _wa(self, client: ApiClient) -> WorkerApi: + return WorkerApi(client) + + async def aio_get(self, worker_id: str) -> Worker: + async with self.client() as client: + return await self._wa(client).worker_get(worker_id) + + def get(self, worker_id: str) -> Worker: + return self._run_async_from_sync(self.aio_get, worker_id) + + async def aio_list( + self, + ) -> WorkerList: + async with self.client() as client: + return await self._wa(client).worker_list( + tenant=self.client_config.tenant_id, + ) + + def list( + self, + ) -> WorkerList: + return self._run_async_from_sync(self.aio_list) + + async def aio_update(self, worker_id: str, opts: UpdateWorkerRequest) -> Worker: + async with self.client() as client: + return await self._wa(client).worker_update( + worker=worker_id, + update_worker_request=opts, + ) + + def update(self, worker_id: str, opts: UpdateWorkerRequest) -> Worker: + return self._run_async_from_sync(self.aio_update, worker_id, opts) diff --git a/sdks/python/hatchet_sdk/features/workflows.py b/sdks/python/hatchet_sdk/features/workflows.py new file mode 100644 index 000000000..87cb87696 --- /dev/null +++ b/sdks/python/hatchet_sdk/features/workflows.py @@ -0,0 +1,55 @@ +from hatchet_sdk.clients.rest.api.workflow_api import WorkflowApi +from hatchet_sdk.clients.rest.api.workflow_run_api import WorkflowRunApi +from hatchet_sdk.clients.rest.api_client import ApiClient +from hatchet_sdk.clients.rest.models.workflow import Workflow +from hatchet_sdk.clients.rest.models.workflow_list import WorkflowList +from hatchet_sdk.clients.rest.models.workflow_version import WorkflowVersion +from hatchet_sdk.clients.v1.api_client import BaseRestClient + + +class WorkflowsClient(BaseRestClient): + def _wra(self, client: ApiClient) -> WorkflowRunApi: + return WorkflowRunApi(client) + + def _wa(self, client: ApiClient) -> WorkflowApi: + return WorkflowApi(client) + + async def aio_get(self, workflow_id: str) -> Workflow: + async with self.client() as client: + return await self._wa(client).workflow_get(workflow_id) + + def get(self, workflow_id: str) -> Workflow: + return self._run_async_from_sync(self.aio_get, workflow_id) + + async def aio_list( + self, + workflow_name: str | None = None, + limit: int | None = None, + offset: int | None = None, + ) -> WorkflowList: + async with self.client() as client: + return await self._wa(client).workflow_list( + tenant=self.client_config.tenant_id, + limit=limit, + offset=offset, + name=workflow_name, + ) + + def list( + self, + workflow_name: str | None = None, + limit: int | None = None, + offset: int | None = None, + ) -> WorkflowList: + return self._run_async_from_sync(self.aio_list, workflow_name, limit, offset) + + async def aio_get_version( + self, workflow_id: str, version: str | None = None + ) -> WorkflowVersion: + async with self.client() as client: + return await self._wa(client).workflow_version_get(workflow_id, version) + + def get_version( + self, workflow_id: str, version: str | None = None + ) -> WorkflowVersion: + return self._run_async_from_sync(self.aio_get_version, workflow_id, version) diff --git a/sdks/python/hatchet_sdk/hatchet.py b/sdks/python/hatchet_sdk/hatchet.py index 55e7c0dcd..ffff37113 100644 --- a/sdks/python/hatchet_sdk/hatchet.py +++ b/sdks/python/hatchet_sdk/hatchet.py @@ -4,14 +4,18 @@ from typing import Any, Callable, Type, cast, overload from hatchet_sdk import Context, DurableContext from hatchet_sdk.client import Client -from hatchet_sdk.clients.admin import AdminClient from hatchet_sdk.clients.dispatcher.dispatcher import DispatcherClient from hatchet_sdk.clients.events import EventClient -from hatchet_sdk.clients.rest_client import RestApi from hatchet_sdk.clients.run_event_listener import RunEventListenerClient from hatchet_sdk.config import ClientConfig from hatchet_sdk.features.cron import CronClient +from hatchet_sdk.features.logs import LogsClient +from hatchet_sdk.features.metrics import MetricsClient +from hatchet_sdk.features.rate_limits import RateLimitsClient +from hatchet_sdk.features.runs import RunsClient from hatchet_sdk.features.scheduled import ScheduledClient +from hatchet_sdk.features.workers import WorkersClient +from hatchet_sdk.features.workflows import WorkflowsClient from hatchet_sdk.labels import DesiredWorkerLabel from hatchet_sdk.logger import logger from hatchet_sdk.rate_limit import RateLimit @@ -48,10 +52,6 @@ class Hatchet: rest (RestApi): Interface for REST API operations. """ - _client: Client - cron: CronClient - scheduled: ScheduledClient - def __init__( self, debug: bool = False, @@ -75,12 +75,38 @@ class Hatchet: logger.setLevel(logging.DEBUG) self._client = client if client else Client(config=config, debug=debug) - self.cron = CronClient(self._client) - self.scheduled = ScheduledClient(self._client) @property - def admin(self) -> AdminClient: - return self._client.admin + def cron(self) -> CronClient: + return self._client.cron + + @property + def logs(self) -> LogsClient: + return self._client.logs + + @property + def metrics(self) -> MetricsClient: + return self._client.metrics + + @property + def rate_limits(self) -> RateLimitsClient: + return self._client.rate_limits + + @property + def runs(self) -> RunsClient: + return self._client.runs + + @property + def scheduled(self) -> ScheduledClient: + return self._client.scheduled + + @property + def workers(self) -> WorkersClient: + return self._client.workers + + @property + def workflows(self) -> WorkflowsClient: + return self._client.workflows @property def dispatcher(self) -> DispatcherClient: @@ -90,10 +116,6 @@ class Hatchet: def event(self) -> EventClient: return self._client.event - @property - def rest(self) -> RestApi: - return self._client.rest - @property def listener(self) -> RunEventListenerClient: return self._client.listener diff --git a/sdks/python/hatchet_sdk/runnables/standalone.py b/sdks/python/hatchet_sdk/runnables/standalone.py index 8d6bd6bc9..a03c545c5 100644 --- a/sdks/python/hatchet_sdk/runnables/standalone.py +++ b/sdks/python/hatchet_sdk/runnables/standalone.py @@ -2,8 +2,6 @@ import asyncio from datetime import datetime from typing import Any, Generic, cast, get_type_hints -from google.protobuf import timestamp_pb2 - from hatchet_sdk.clients.admin import ( ScheduleTriggerWorkflowOptions, TriggerWorkflowOptions, @@ -12,7 +10,7 @@ from hatchet_sdk.clients.admin import ( from hatchet_sdk.clients.rest.models.cron_workflows import CronWorkflows from hatchet_sdk.contracts.workflows_pb2 import WorkflowVersion from hatchet_sdk.runnables.task import Task -from hatchet_sdk.runnables.types import R, TWorkflowInput +from hatchet_sdk.runnables.types import EmptyModel, R, TWorkflowInput from hatchet_sdk.runnables.workflow import BaseWorkflow, Workflow from hatchet_sdk.utils.aio_utils import get_active_event_loop from hatchet_sdk.utils.typing import JSONSerializableMapping, is_basemodel_subclass @@ -81,14 +79,14 @@ class Standalone(BaseWorkflow[TWorkflowInput], Generic[TWorkflowInput, R]): def run( self, - input: TWorkflowInput | None = None, + input: TWorkflowInput = cast(TWorkflowInput, EmptyModel()), options: TriggerWorkflowOptions = TriggerWorkflowOptions(), ) -> R: return self._extract_result(self._workflow.run(input, options)) async def aio_run( self, - input: TWorkflowInput | None = None, + input: TWorkflowInput = cast(TWorkflowInput, EmptyModel()), options: TriggerWorkflowOptions = TriggerWorkflowOptions(), ) -> R: result = await self._workflow.aio_run(input, options) @@ -96,7 +94,7 @@ class Standalone(BaseWorkflow[TWorkflowInput], Generic[TWorkflowInput, R]): def run_no_wait( self, - input: TWorkflowInput | None = None, + input: TWorkflowInput = cast(TWorkflowInput, EmptyModel()), options: TriggerWorkflowOptions = TriggerWorkflowOptions(), ) -> TaskRunRef[TWorkflowInput, R]: ref = self._workflow.run_no_wait(input, options) @@ -105,7 +103,7 @@ class Standalone(BaseWorkflow[TWorkflowInput], Generic[TWorkflowInput, R]): async def aio_run_no_wait( self, - input: TWorkflowInput | None = None, + input: TWorkflowInput = cast(TWorkflowInput, EmptyModel()), options: TriggerWorkflowOptions = TriggerWorkflowOptions(), ) -> TaskRunRef[TWorkflowInput, R]: ref = await self._workflow.aio_run_no_wait(input, options) @@ -140,24 +138,24 @@ class Standalone(BaseWorkflow[TWorkflowInput], Generic[TWorkflowInput, R]): def schedule( self, - schedules: list[datetime], + run_at: datetime, input: TWorkflowInput | None = None, options: ScheduleTriggerWorkflowOptions = ScheduleTriggerWorkflowOptions(), ) -> WorkflowVersion: return self._workflow.schedule( - schedules=schedules, + run_at=run_at, input=input, options=options, ) async def aio_schedule( self, - schedules: list[datetime | timestamp_pb2.Timestamp], + run_at: datetime, input: TWorkflowInput, options: ScheduleTriggerWorkflowOptions = ScheduleTriggerWorkflowOptions(), ) -> WorkflowVersion: return await self._workflow.aio_schedule( - schedules=schedules, + run_at=run_at, input=input, options=options, ) diff --git a/sdks/python/hatchet_sdk/runnables/types.py b/sdks/python/hatchet_sdk/runnables/types.py index 20490920e..33fb284e7 100644 --- a/sdks/python/hatchet_sdk/runnables/types.py +++ b/sdks/python/hatchet_sdk/runnables/types.py @@ -21,7 +21,7 @@ DEFAULT_PRIORITY = 1 class EmptyModel(BaseModel): - model_config = ConfigDict(extra="allow") + model_config = ConfigDict(extra="allow", frozen=True) class StickyStrategy(str, Enum): diff --git a/sdks/python/hatchet_sdk/runnables/workflow.py b/sdks/python/hatchet_sdk/runnables/workflow.py index e0eeebf75..caeac84fe 100644 --- a/sdks/python/hatchet_sdk/runnables/workflow.py +++ b/sdks/python/hatchet_sdk/runnables/workflow.py @@ -29,6 +29,7 @@ from hatchet_sdk.runnables.types import ( DEFAULT_EXECUTION_TIMEOUT, DEFAULT_SCHEDULE_TIMEOUT, ConcurrencyExpression, + EmptyModel, R, StepType, TWorkflowInput, @@ -271,7 +272,7 @@ class BaseWorkflow(Generic[TWorkflowInput]): def is_durable(self) -> bool: return any(task.is_durable for task in self.tasks) - def create_run_workflow_config( + def create_bulk_run_item( self, input: TWorkflowInput | None = None, key: str | None = None, @@ -293,10 +294,10 @@ class Workflow(BaseWorkflow[TWorkflowInput]): def run_no_wait( self, - input: TWorkflowInput | None = None, + input: TWorkflowInput = cast(TWorkflowInput, EmptyModel()), options: TriggerWorkflowOptions = TriggerWorkflowOptions(), ) -> WorkflowRunRef: - return self.client.admin.run_workflow( + return self.client._client.admin.run_workflow( workflow_name=self.config.name, input=input.model_dump() if input else {}, options=options, @@ -304,10 +305,10 @@ class Workflow(BaseWorkflow[TWorkflowInput]): def run( self, - input: TWorkflowInput | None = None, + input: TWorkflowInput = cast(TWorkflowInput, EmptyModel()), options: TriggerWorkflowOptions = TriggerWorkflowOptions(), ) -> dict[str, Any]: - ref = self.client.admin.run_workflow( + ref = self.client._client.admin.run_workflow( workflow_name=self.config.name, input=input.model_dump() if input else {}, options=options, @@ -317,10 +318,10 @@ class Workflow(BaseWorkflow[TWorkflowInput]): async def aio_run_no_wait( self, - input: TWorkflowInput | None = None, + input: TWorkflowInput = cast(TWorkflowInput, EmptyModel()), options: TriggerWorkflowOptions = TriggerWorkflowOptions(), ) -> WorkflowRunRef: - return await self.client.admin.aio_run_workflow( + return await self.client._client.admin.aio_run_workflow( workflow_name=self.config.name, input=input.model_dump() if input else {}, options=options, @@ -328,10 +329,10 @@ class Workflow(BaseWorkflow[TWorkflowInput]): async def aio_run( self, - input: TWorkflowInput | None = None, + input: TWorkflowInput = cast(TWorkflowInput, EmptyModel()), options: TriggerWorkflowOptions = TriggerWorkflowOptions(), ) -> dict[str, Any]: - ref = await self.client.admin.aio_run_workflow( + ref = await self.client._client.admin.aio_run_workflow( workflow_name=self.config.name, input=input.model_dump() if input else {}, options=options, @@ -343,7 +344,7 @@ class Workflow(BaseWorkflow[TWorkflowInput]): self, workflows: list[WorkflowRunTriggerConfig], ) -> list[dict[str, Any]]: - refs = self.client.admin.run_workflows( + refs = self.client._client.admin.run_workflows( workflows=workflows, ) @@ -353,7 +354,7 @@ class Workflow(BaseWorkflow[TWorkflowInput]): self, workflows: list[WorkflowRunTriggerConfig], ) -> list[dict[str, Any]]: - refs = await self.client.admin.aio_run_workflows( + refs = await self.client._client.admin.aio_run_workflows( workflows=workflows, ) @@ -363,7 +364,7 @@ class Workflow(BaseWorkflow[TWorkflowInput]): self, workflows: list[WorkflowRunTriggerConfig], ) -> list[WorkflowRunRef]: - return self.client.admin.run_workflows( + return self.client._client.admin.run_workflows( workflows=workflows, ) @@ -371,32 +372,32 @@ class Workflow(BaseWorkflow[TWorkflowInput]): self, workflows: list[WorkflowRunTriggerConfig], ) -> list[WorkflowRunRef]: - return await self.client.admin.aio_run_workflows( + return await self.client._client.admin.aio_run_workflows( workflows=workflows, ) def schedule( self, - schedules: list[datetime], + run_at: datetime, input: TWorkflowInput | None = None, options: ScheduleTriggerWorkflowOptions = ScheduleTriggerWorkflowOptions(), ) -> WorkflowVersion: - return self.client.admin.schedule_workflow( + return self.client._client.admin.schedule_workflow( name=self.config.name, - schedules=cast(list[datetime | timestamp_pb2.Timestamp], schedules), + schedules=cast(list[datetime | timestamp_pb2.Timestamp], [run_at]), input=input.model_dump() if input else {}, options=options, ) async def aio_schedule( self, - schedules: list[datetime | timestamp_pb2.Timestamp], + run_at: datetime, input: TWorkflowInput, options: ScheduleTriggerWorkflowOptions = ScheduleTriggerWorkflowOptions(), ) -> WorkflowVersion: - return await self.client.admin.aio_schedule_workflow( + return await self.client._client.admin.aio_schedule_workflow( name=self.config.name, - schedules=schedules, + schedules=cast(list[datetime | timestamp_pb2.Timestamp], [run_at]), input=input.model_dump(), options=options, ) @@ -661,6 +662,9 @@ class Workflow(BaseWorkflow[TWorkflowInput]): concurrency=concurrency, ) + if self._on_failure_task: + raise ValueError("Only one on-failure task is allowed") + self._on_failure_task = task return task @@ -722,6 +726,9 @@ class Workflow(BaseWorkflow[TWorkflowInput]): parents=[], ) + if self._on_failure_task: + raise ValueError("Only one on-failure task is allowed") + self._on_success_task = task return task diff --git a/sdks/python/hatchet_sdk/worker/action_listener_process.py b/sdks/python/hatchet_sdk/worker/action_listener_process.py index c8f706665..875351ae7 100644 --- a/sdks/python/hatchet_sdk/worker/action_listener_process.py +++ b/sdks/python/hatchet_sdk/worker/action_listener_process.py @@ -98,9 +98,9 @@ class WorkerActionListenerProcess: if self.listener is None: raise ValueError("listener not started") - await self.client.rest.worker_api.worker_update( - worker=self.listener.worker_id, - update_worker_request=UpdateWorkerRequest(isPaused=True), + await self.client.workers.aio_update( + worker_id=self.listener.worker_id, + opts=UpdateWorkerRequest(isPaused=True), ) async def start(self, retry_attempt: int = 0) -> None: diff --git a/sdks/python/hatchet_sdk/worker/runner/run_loop_manager.py b/sdks/python/hatchet_sdk/worker/runner/run_loop_manager.py index adf9cf770..f2d3ce300 100644 --- a/sdks/python/hatchet_sdk/worker/runner/run_loop_manager.py +++ b/sdks/python/hatchet_sdk/worker/runner/run_loop_manager.py @@ -83,7 +83,6 @@ class WorkerActionRunLoopManager: async def _start_action_loop(self) -> None: self.runner = Runner( - self.name, self.event_queue, self.config, self.slots, diff --git a/sdks/python/hatchet_sdk/worker/runner/runner.py b/sdks/python/hatchet_sdk/worker/runner/runner.py index 7d17ed845..33539f62c 100644 --- a/sdks/python/hatchet_sdk/worker/runner/runner.py +++ b/sdks/python/hatchet_sdk/worker/runner/runner.py @@ -55,7 +55,6 @@ class WorkerStatus(Enum): class Runner: def __init__( self, - name: str, event_queue: "Queue[ActionEvent]", config: ClientConfig, slots: int | None = None, @@ -67,7 +66,6 @@ class Runner: # We store the config so we can dynamically create clients for the dispatcher client. self.config = config self.client = Client(config) - self.name = self.client.config.namespace + name self.slots = slots self.tasks: dict[str, asyncio.Task[Any]] = {} # Store run ids and futures self.contexts: dict[str, Context] = {} # Store run ids and contexts @@ -286,12 +284,8 @@ class Runner: self.dispatcher_client, self.admin_client, self.client.event, - self.client.rest, - self.client.workflow_listener, self.durable_event_listener, - self.workflow_run_event_listener, self.worker_context, - self.client.config.namespace, validator_registry=self.validator_registry, ) diff --git a/sdks/python/openapi_patch.patch b/sdks/python/openapi_patch.patch index 3eb91ed71..df5fdd443 100644 --- a/sdks/python/openapi_patch.patch +++ b/sdks/python/openapi_patch.patch @@ -1,51 +1,3 @@ -diff --git a/hatchet_sdk/clients/rest/api/workflow_api.py b/hatchet_sdk/clients/rest/api/workflow_api.py -index 5716532..c32ba44 100644 ---- a/hatchet_sdk/clients/rest/api/workflow_api.py -+++ b/hatchet_sdk/clients/rest/api/workflow_api.py -@@ -2185,9 +2185,7 @@ class WorkflowApi: - _query_params.append( - ( - "createdAfter", -- created_after.strftime( -- self.api_client.configuration.datetime_format -- ), -+ created_after.isoformat(), - ) - ) - else: -@@ -2198,9 +2196,7 @@ class WorkflowApi: - _query_params.append( - ( - "createdBefore", -- created_before.strftime( -- self.api_client.configuration.datetime_format -- ), -+ created_before.isoformat(), - ) - ) - else: -@@ -2789,9 +2785,7 @@ class WorkflowApi: - _query_params.append( - ( - "createdAfter", -- created_after.strftime( -- self.api_client.configuration.datetime_format -- ), -+ created_after.isoformat(), - ) - ) - else: -@@ -2802,9 +2796,7 @@ class WorkflowApi: - _query_params.append( - ( - "createdBefore", -- created_before.strftime( -- self.api_client.configuration.datetime_format -- ), -+ created_before.isoformat(), - ) - ) - else: diff --git a/hatchet_sdk/clients/rest/models/workflow_runs_metrics.py b/hatchet_sdk/clients/rest/models/workflow_runs_metrics.py index 71b6351..5f70c44 100644 --- a/hatchet_sdk/clients/rest/models/workflow_runs_metrics.py diff --git a/sdks/python/poetry.lock b/sdks/python/poetry.lock index c127fa6f3..5cd611c8e 100644 --- a/sdks/python/poetry.lock +++ b/sdks/python/poetry.lock @@ -1649,21 +1649,6 @@ tomli = {version = ">=2.0.1", markers = "python_version < \"3.11\""} [package.extras] testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "pytest-mock (>=3.14)"] -[[package]] -name = "pytest-timeout" -version = "2.3.1" -description = "pytest plugin to abort hanging tests" -optional = false -python-versions = ">=3.7" -groups = ["test"] -files = [ - {file = "pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9"}, - {file = "pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e"}, -] - -[package.dependencies] -pytest = ">=7.0.0" - [[package]] name = "pytest-xdist" version = "3.6.1" @@ -2228,4 +2213,4 @@ otel = ["opentelemetry-api", "opentelemetry-distro", "opentelemetry-exporter-otl [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "65992bc11019834de43601c8270498043b52fac647191525b320a9f336733151" +content-hash = "279893210c7efc1de29f7e54efa01b630ef4f28e7c6ad4bbac4aea4967fc6044" diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml index 64a601746..14eeb98f4 100644 --- a/sdks/python/pyproject.toml +++ b/sdks/python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "hatchet-sdk" -version = "1.0.0" +version = "1.0.1" description = "" authors = ["Alexander Belanger "] readme = "README.md" @@ -53,7 +53,6 @@ ruff = "^0.9.7" types-requests = "^2.32.0.20241016" [tool.poetry.group.test.dependencies] -pytest-timeout = "^2.3.1" pytest-env = "^1.1.5" [tool.poetry.extras] diff --git a/sdks/python/tests/test_rest_api.py b/sdks/python/tests/test_rest_api.py new file mode 100644 index 000000000..e55d9c1e2 --- /dev/null +++ b/sdks/python/tests/test_rest_api.py @@ -0,0 +1,54 @@ +import asyncio + +import pytest + +from examples.dag.worker import dag_workflow +from hatchet_sdk import Hatchet + + +@pytest.mark.asyncio(loop_scope="function") +async def test_list_runs(hatchet: Hatchet) -> None: + dag_result = await dag_workflow.aio_run() + + runs = await hatchet.runs.aio_list( + limit=10_000, + only_tasks=True, + ) + + for v in dag_result.values(): + assert v in [r.output for r in runs.rows] + + +@pytest.mark.asyncio(loop_scope="function") +async def test_get_run(hatchet: Hatchet) -> None: + dag_ref = await dag_workflow.aio_run_no_wait() + + await asyncio.sleep(3) + + run = await hatchet.runs.aio_get(dag_ref.workflow_run_id) + + assert dag_workflow.config.name in run.run.display_name + assert run.run.status.value == "COMPLETED" + assert len(run.shape) == 4 + assert {t.name for t in dag_workflow.tasks} == {t.task_name for t in run.shape} + + +@pytest.mark.asyncio(loop_scope="function") +async def test_list_workflows(hatchet: Hatchet) -> None: + workflows = await hatchet.workflows.aio_list( + workflow_name=dag_workflow.config.name, limit=1, offset=0 + ) + + assert workflows.rows + assert len(workflows.rows) == 1 + + workflow = workflows.rows[0] + + """Using endswith because of namespacing in CI""" + assert workflow.name.endswith(dag_workflow.config.name) + + fetched_workflow = await hatchet.workflows.aio_get(workflow.metadata.id) + + """Using endswith because of namespacing in CI""" + assert fetched_workflow.name.endswith(dag_workflow.config.name) + assert fetched_workflow.metadata.id == workflow.metadata.id diff --git a/sdks/typescript/src/v1/examples/rate_limit/run.ts b/sdks/typescript/src/v1/examples/rate_limit/run.ts new file mode 100644 index 000000000..64ffdd2cc --- /dev/null +++ b/sdks/typescript/src/v1/examples/rate_limit/run.ts @@ -0,0 +1,17 @@ +/* eslint-disable no-console */ +import { rateLimitWorkflow } from './workflow'; + +async function main() { + try { + const res = await rateLimitWorkflow.run({ userId: 'abc' }); + console.log(res); + } catch (e) { + console.log('error', e); + } +} + +if (require.main === module) { + main() + .catch(console.error) + .finally(() => process.exit(0)); +} diff --git a/sdks/typescript/src/v1/examples/rate_limit/worker.ts b/sdks/typescript/src/v1/examples/rate_limit/worker.ts new file mode 100644 index 000000000..881525350 --- /dev/null +++ b/sdks/typescript/src/v1/examples/rate_limit/worker.ts @@ -0,0 +1,14 @@ +import { hatchet } from '../hatchet-client'; +import { rateLimitWorkflow } from './workflow'; + +async function main() { + const worker = await hatchet.worker('rate-limit-worker', { + workflows: [rateLimitWorkflow], + }); + + await worker.start(); +} + +if (require.main === module) { + main(); +} diff --git a/sdks/typescript/src/v1/examples/rate_limit/workflow.ts b/sdks/typescript/src/v1/examples/rate_limit/workflow.ts new file mode 100644 index 000000000..10953fe9b --- /dev/null +++ b/sdks/typescript/src/v1/examples/rate_limit/workflow.ts @@ -0,0 +1,49 @@ +import { RateLimitDuration } from '@hatchet/protoc/v1/workflows'; +import { hatchet } from '../hatchet-client'; + +// ❓ Workflow +type RateLimitInput = { + userId: string; +}; + +export const rateLimitWorkflow = hatchet.workflow({ + name: 'RateLimitWorkflow', +}); + +// !! + +// ❓ Static +const RATE_LIMIT_KEY = 'test-limit'; + +const task1 = rateLimitWorkflow.task({ + name: 'task1', + fn: (input) => { + console.log('executed task1'); + }, + rateLimits: [ + { + staticKey: RATE_LIMIT_KEY, + units: 1, + }, + ], +}); + +// !! + +// ❓ Dynamic +const task2 = rateLimitWorkflow.task({ + name: 'task2', + fn: (input) => { + console.log('executed task2 for user: ', input.userId); + }, + rateLimits: [ + { + dynamicKey: 'input.userId', + units: 1, + limit: 10, + duration: RateLimitDuration.MINUTE, + }, + ], +}); + +// !!