planar 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- planar/.__init__.py.un~ +0 -0
- planar/._version.py.un~ +0 -0
- planar/.app.py.un~ +0 -0
- planar/.cli.py.un~ +0 -0
- planar/.config.py.un~ +0 -0
- planar/.context.py.un~ +0 -0
- planar/.db.py.un~ +0 -0
- planar/.di.py.un~ +0 -0
- planar/.engine.py.un~ +0 -0
- planar/.files.py.un~ +0 -0
- planar/.log_context.py.un~ +0 -0
- planar/.log_metadata.py.un~ +0 -0
- planar/.logging.py.un~ +0 -0
- planar/.object_registry.py.un~ +0 -0
- planar/.otel.py.un~ +0 -0
- planar/.server.py.un~ +0 -0
- planar/.session.py.un~ +0 -0
- planar/.sqlalchemy.py.un~ +0 -0
- planar/.task_local.py.un~ +0 -0
- planar/.test_app.py.un~ +0 -0
- planar/.test_config.py.un~ +0 -0
- planar/.test_object_config.py.un~ +0 -0
- planar/.test_sqlalchemy.py.un~ +0 -0
- planar/.test_utils.py.un~ +0 -0
- planar/.util.py.un~ +0 -0
- planar/.utils.py.un~ +0 -0
- planar/__init__.py +26 -0
- planar/_version.py +1 -0
- planar/ai/.__init__.py.un~ +0 -0
- planar/ai/._models.py.un~ +0 -0
- planar/ai/.agent.py.un~ +0 -0
- planar/ai/.agent_utils.py.un~ +0 -0
- planar/ai/.events.py.un~ +0 -0
- planar/ai/.files.py.un~ +0 -0
- planar/ai/.models.py.un~ +0 -0
- planar/ai/.providers.py.un~ +0 -0
- planar/ai/.pydantic_ai.py.un~ +0 -0
- planar/ai/.pydantic_ai_agent.py.un~ +0 -0
- planar/ai/.pydantic_ai_provider.py.un~ +0 -0
- planar/ai/.step.py.un~ +0 -0
- planar/ai/.test_agent.py.un~ +0 -0
- planar/ai/.test_agent_serialization.py.un~ +0 -0
- planar/ai/.test_providers.py.un~ +0 -0
- planar/ai/.utils.py.un~ +0 -0
- planar/ai/__init__.py +15 -0
- planar/ai/agent.py +457 -0
- planar/ai/agent_utils.py +205 -0
- planar/ai/models.py +140 -0
- planar/ai/providers.py +1088 -0
- planar/ai/test_agent.py +1298 -0
- planar/ai/test_agent_serialization.py +229 -0
- planar/ai/test_providers.py +463 -0
- planar/ai/utils.py +102 -0
- planar/app.py +494 -0
- planar/cli.py +282 -0
- planar/config.py +544 -0
- planar/db/.db.py.un~ +0 -0
- planar/db/__init__.py +17 -0
- planar/db/alembic/env.py +136 -0
- planar/db/alembic/script.py.mako +28 -0
- planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
- planar/db/alembic.ini +128 -0
- planar/db/db.py +318 -0
- planar/files/.config.py.un~ +0 -0
- planar/files/.local.py.un~ +0 -0
- planar/files/.local_filesystem.py.un~ +0 -0
- planar/files/.model.py.un~ +0 -0
- planar/files/.models.py.un~ +0 -0
- planar/files/.s3.py.un~ +0 -0
- planar/files/.storage.py.un~ +0 -0
- planar/files/.test_files.py.un~ +0 -0
- planar/files/__init__.py +2 -0
- planar/files/models.py +162 -0
- planar/files/storage/.__init__.py.un~ +0 -0
- planar/files/storage/.base.py.un~ +0 -0
- planar/files/storage/.config.py.un~ +0 -0
- planar/files/storage/.context.py.un~ +0 -0
- planar/files/storage/.local_directory.py.un~ +0 -0
- planar/files/storage/.test_local_directory.py.un~ +0 -0
- planar/files/storage/.test_s3.py.un~ +0 -0
- planar/files/storage/base.py +61 -0
- planar/files/storage/config.py +44 -0
- planar/files/storage/context.py +15 -0
- planar/files/storage/local_directory.py +188 -0
- planar/files/storage/s3.py +220 -0
- planar/files/storage/test_local_directory.py +162 -0
- planar/files/storage/test_s3.py +299 -0
- planar/files/test_files.py +283 -0
- planar/human/.human.py.un~ +0 -0
- planar/human/.test_human.py.un~ +0 -0
- planar/human/__init__.py +2 -0
- planar/human/human.py +458 -0
- planar/human/models.py +80 -0
- planar/human/test_human.py +385 -0
- planar/logging/.__init__.py.un~ +0 -0
- planar/logging/.attributes.py.un~ +0 -0
- planar/logging/.formatter.py.un~ +0 -0
- planar/logging/.logger.py.un~ +0 -0
- planar/logging/.otel.py.un~ +0 -0
- planar/logging/.tracer.py.un~ +0 -0
- planar/logging/__init__.py +10 -0
- planar/logging/attributes.py +54 -0
- planar/logging/context.py +14 -0
- planar/logging/formatter.py +113 -0
- planar/logging/logger.py +114 -0
- planar/logging/otel.py +51 -0
- planar/modeling/.mixin.py.un~ +0 -0
- planar/modeling/.storage.py.un~ +0 -0
- planar/modeling/__init__.py +0 -0
- planar/modeling/field_helpers.py +59 -0
- planar/modeling/json_schema_generator.py +94 -0
- planar/modeling/mixins/__init__.py +10 -0
- planar/modeling/mixins/auditable.py +52 -0
- planar/modeling/mixins/test_auditable.py +97 -0
- planar/modeling/mixins/test_timestamp.py +134 -0
- planar/modeling/mixins/test_uuid_primary_key.py +52 -0
- planar/modeling/mixins/timestamp.py +53 -0
- planar/modeling/mixins/uuid_primary_key.py +19 -0
- planar/modeling/orm/.planar_base_model.py.un~ +0 -0
- planar/modeling/orm/__init__.py +18 -0
- planar/modeling/orm/planar_base_entity.py +29 -0
- planar/modeling/orm/query_filter_builder.py +122 -0
- planar/modeling/orm/reexports.py +15 -0
- planar/object_config/.object_config.py.un~ +0 -0
- planar/object_config/__init__.py +11 -0
- planar/object_config/models.py +114 -0
- planar/object_config/object_config.py +378 -0
- planar/object_registry.py +100 -0
- planar/registry_items.py +65 -0
- planar/routers/.__init__.py.un~ +0 -0
- planar/routers/.agents_router.py.un~ +0 -0
- planar/routers/.crud.py.un~ +0 -0
- planar/routers/.decision.py.un~ +0 -0
- planar/routers/.event.py.un~ +0 -0
- planar/routers/.file_attachment.py.un~ +0 -0
- planar/routers/.files.py.un~ +0 -0
- planar/routers/.files_router.py.un~ +0 -0
- planar/routers/.human.py.un~ +0 -0
- planar/routers/.info.py.un~ +0 -0
- planar/routers/.models.py.un~ +0 -0
- planar/routers/.object_config_router.py.un~ +0 -0
- planar/routers/.rule.py.un~ +0 -0
- planar/routers/.test_object_config_router.py.un~ +0 -0
- planar/routers/.test_workflow_router.py.un~ +0 -0
- planar/routers/.workflow.py.un~ +0 -0
- planar/routers/__init__.py +13 -0
- planar/routers/agents_router.py +197 -0
- planar/routers/entity_router.py +143 -0
- planar/routers/event.py +91 -0
- planar/routers/files.py +142 -0
- planar/routers/human.py +151 -0
- planar/routers/info.py +131 -0
- planar/routers/models.py +170 -0
- planar/routers/object_config_router.py +133 -0
- planar/routers/rule.py +108 -0
- planar/routers/test_agents_router.py +174 -0
- planar/routers/test_object_config_router.py +367 -0
- planar/routers/test_routes_security.py +169 -0
- planar/routers/test_rule_router.py +470 -0
- planar/routers/test_workflow_router.py +274 -0
- planar/routers/workflow.py +468 -0
- planar/rules/.decorator.py.un~ +0 -0
- planar/rules/.runner.py.un~ +0 -0
- planar/rules/.test_rules.py.un~ +0 -0
- planar/rules/__init__.py +23 -0
- planar/rules/decorator.py +184 -0
- planar/rules/models.py +355 -0
- planar/rules/rule_configuration.py +191 -0
- planar/rules/runner.py +64 -0
- planar/rules/test_rules.py +750 -0
- planar/scaffold_templates/app/__init__.py.j2 +0 -0
- planar/scaffold_templates/app/db/entities.py.j2 +11 -0
- planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
- planar/scaffold_templates/main.py.j2 +13 -0
- planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
- planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
- planar/scaffold_templates/pyproject.toml.j2 +10 -0
- planar/security/.jwt_middleware.py.un~ +0 -0
- planar/security/auth_context.py +148 -0
- planar/security/authorization.py +388 -0
- planar/security/default_policies.cedar +77 -0
- planar/security/jwt_middleware.py +116 -0
- planar/security/security_context.py +18 -0
- planar/security/tests/test_authorization_context.py +78 -0
- planar/security/tests/test_cedar_basics.py +41 -0
- planar/security/tests/test_cedar_policies.py +158 -0
- planar/security/tests/test_jwt_principal_context.py +179 -0
- planar/session.py +40 -0
- planar/sse/.constants.py.un~ +0 -0
- planar/sse/.example.html.un~ +0 -0
- planar/sse/.hub.py.un~ +0 -0
- planar/sse/.model.py.un~ +0 -0
- planar/sse/.proxy.py.un~ +0 -0
- planar/sse/constants.py +1 -0
- planar/sse/example.html +126 -0
- planar/sse/hub.py +216 -0
- planar/sse/model.py +8 -0
- planar/sse/proxy.py +257 -0
- planar/task_local.py +37 -0
- planar/test_app.py +51 -0
- planar/test_cli.py +372 -0
- planar/test_config.py +512 -0
- planar/test_object_config.py +527 -0
- planar/test_object_registry.py +14 -0
- planar/test_sqlalchemy.py +158 -0
- planar/test_utils.py +105 -0
- planar/testing/.client.py.un~ +0 -0
- planar/testing/.memory_storage.py.un~ +0 -0
- planar/testing/.planar_test_client.py.un~ +0 -0
- planar/testing/.predictable_tracer.py.un~ +0 -0
- planar/testing/.synchronizable_tracer.py.un~ +0 -0
- planar/testing/.test_memory_storage.py.un~ +0 -0
- planar/testing/.workflow_observer.py.un~ +0 -0
- planar/testing/__init__.py +0 -0
- planar/testing/memory_storage.py +78 -0
- planar/testing/planar_test_client.py +54 -0
- planar/testing/synchronizable_tracer.py +153 -0
- planar/testing/test_memory_storage.py +143 -0
- planar/testing/workflow_observer.py +73 -0
- planar/utils.py +70 -0
- planar/workflows/.__init__.py.un~ +0 -0
- planar/workflows/.builtin_steps.py.un~ +0 -0
- planar/workflows/.concurrency_tracing.py.un~ +0 -0
- planar/workflows/.context.py.un~ +0 -0
- planar/workflows/.contrib.py.un~ +0 -0
- planar/workflows/.decorators.py.un~ +0 -0
- planar/workflows/.durable_test.py.un~ +0 -0
- planar/workflows/.errors.py.un~ +0 -0
- planar/workflows/.events.py.un~ +0 -0
- planar/workflows/.exceptions.py.un~ +0 -0
- planar/workflows/.execution.py.un~ +0 -0
- planar/workflows/.human.py.un~ +0 -0
- planar/workflows/.lock.py.un~ +0 -0
- planar/workflows/.misc.py.un~ +0 -0
- planar/workflows/.model.py.un~ +0 -0
- planar/workflows/.models.py.un~ +0 -0
- planar/workflows/.notifications.py.un~ +0 -0
- planar/workflows/.orchestrator.py.un~ +0 -0
- planar/workflows/.runtime.py.un~ +0 -0
- planar/workflows/.serialization.py.un~ +0 -0
- planar/workflows/.step.py.un~ +0 -0
- planar/workflows/.step_core.py.un~ +0 -0
- planar/workflows/.sub_workflow_runner.py.un~ +0 -0
- planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
- planar/workflows/.test_concurrency.py.un~ +0 -0
- planar/workflows/.test_concurrency_detection.py.un~ +0 -0
- planar/workflows/.test_human.py.un~ +0 -0
- planar/workflows/.test_lock_timeout.py.un~ +0 -0
- planar/workflows/.test_orchestrator.py.un~ +0 -0
- planar/workflows/.test_race_conditions.py.un~ +0 -0
- planar/workflows/.test_serialization.py.un~ +0 -0
- planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
- planar/workflows/.test_workflow.py.un~ +0 -0
- planar/workflows/.tracing.py.un~ +0 -0
- planar/workflows/.types.py.un~ +0 -0
- planar/workflows/.util.py.un~ +0 -0
- planar/workflows/.utils.py.un~ +0 -0
- planar/workflows/.workflow.py.un~ +0 -0
- planar/workflows/.workflow_wrapper.py.un~ +0 -0
- planar/workflows/.wrappers.py.un~ +0 -0
- planar/workflows/__init__.py +42 -0
- planar/workflows/context.py +44 -0
- planar/workflows/contrib.py +190 -0
- planar/workflows/decorators.py +217 -0
- planar/workflows/events.py +185 -0
- planar/workflows/exceptions.py +34 -0
- planar/workflows/execution.py +198 -0
- planar/workflows/lock.py +229 -0
- planar/workflows/misc.py +5 -0
- planar/workflows/models.py +154 -0
- planar/workflows/notifications.py +96 -0
- planar/workflows/orchestrator.py +383 -0
- planar/workflows/query.py +256 -0
- planar/workflows/serialization.py +409 -0
- planar/workflows/step_core.py +373 -0
- planar/workflows/step_metadata.py +357 -0
- planar/workflows/step_testing_utils.py +86 -0
- planar/workflows/sub_workflow_runner.py +191 -0
- planar/workflows/test_concurrency_detection.py +120 -0
- planar/workflows/test_lock_timeout.py +140 -0
- planar/workflows/test_serialization.py +1195 -0
- planar/workflows/test_suspend_deserialization.py +231 -0
- planar/workflows/test_workflow.py +1967 -0
- planar/workflows/tracing.py +106 -0
- planar/workflows/wrappers.py +41 -0
- planar-0.5.0.dist-info/METADATA +285 -0
- planar-0.5.0.dist-info/RECORD +289 -0
- planar-0.5.0.dist-info/WHEEL +4 -0
- planar-0.5.0.dist-info/entry_points.txt +3 -0
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
@@ -0,0 +1,42 @@
|
|
1
|
+
from .decorators import (
|
2
|
+
WorkflowWrapper,
|
3
|
+
as_step,
|
4
|
+
step,
|
5
|
+
workflow,
|
6
|
+
)
|
7
|
+
from .execution import (
|
8
|
+
execute,
|
9
|
+
)
|
10
|
+
from .models import (
|
11
|
+
LockedResource,
|
12
|
+
StepStatus,
|
13
|
+
Workflow,
|
14
|
+
WorkflowStep,
|
15
|
+
)
|
16
|
+
from .notifications import (
|
17
|
+
Notification,
|
18
|
+
WorkflowNotification,
|
19
|
+
WorkflowNotificationCallback,
|
20
|
+
workflow_notification_context,
|
21
|
+
)
|
22
|
+
from .orchestrator import WorkflowOrchestrator, orchestrator_context
|
23
|
+
from .step_core import suspend
|
24
|
+
|
25
|
+
__all__ = [
|
26
|
+
"WorkflowWrapper",
|
27
|
+
"workflow",
|
28
|
+
"step",
|
29
|
+
"as_step",
|
30
|
+
"suspend",
|
31
|
+
"execute",
|
32
|
+
"orchestrator_context",
|
33
|
+
"workflow_notification_context",
|
34
|
+
"Workflow",
|
35
|
+
"WorkflowStep",
|
36
|
+
"LockedResource",
|
37
|
+
"StepStatus",
|
38
|
+
"WorkflowOrchestrator",
|
39
|
+
"WorkflowNotificationCallback",
|
40
|
+
"WorkflowNotification",
|
41
|
+
"Notification",
|
42
|
+
]
|
@@ -0,0 +1,44 @@
|
|
1
|
+
from dataclasses import dataclass, field
|
2
|
+
from uuid import UUID
|
3
|
+
|
4
|
+
from planar.task_local import TaskLocal
|
5
|
+
from planar.workflows.models import Workflow, WorkflowStep
|
6
|
+
|
7
|
+
|
8
|
+
@dataclass(kw_only=True)
|
9
|
+
class ExecutionContext:
|
10
|
+
workflow: Workflow
|
11
|
+
# This might seem redundant, but it is actually necessary to prevent
|
12
|
+
# implicit DB calls when accessing ctx.workflow.id which causes greenlet
|
13
|
+
# I/O errors with async SQLAlchemy. This can happen for example when a
|
14
|
+
# rollback is issued, which causes SQLAlchemy to expire the objects managed
|
15
|
+
# by the session.
|
16
|
+
workflow_id: UUID
|
17
|
+
current_step_id: int = 0
|
18
|
+
step_stack: list[WorkflowStep] = field(default_factory=list)
|
19
|
+
# The start_workflow helper (decorators.py) has
|
20
|
+
# the same parameters as the original function,
|
21
|
+
# so we can't pass this as an argument. Instead
|
22
|
+
# we use a context variable to signal that the
|
23
|
+
# started workflow should set this one as the
|
24
|
+
# parent.
|
25
|
+
bind_parent_workflow: bool = False
|
26
|
+
|
27
|
+
|
28
|
+
data: TaskLocal[ExecutionContext] = TaskLocal()
|
29
|
+
|
30
|
+
|
31
|
+
def in_context() -> bool:
|
32
|
+
return data.is_set()
|
33
|
+
|
34
|
+
|
35
|
+
def get_context() -> ExecutionContext:
|
36
|
+
return data.get()
|
37
|
+
|
38
|
+
|
39
|
+
def set_context(ctx: ExecutionContext):
|
40
|
+
return data.set(ctx)
|
41
|
+
|
42
|
+
|
43
|
+
def delete_context():
|
44
|
+
return data.clear()
|
@@ -0,0 +1,190 @@
|
|
1
|
+
from datetime import datetime, timedelta
|
2
|
+
from functools import wraps
|
3
|
+
from typing import Any, Callable, Coroutine, Dict
|
4
|
+
|
5
|
+
from planar.logging import get_logger
|
6
|
+
from planar.session import get_session
|
7
|
+
from planar.utils import P, T, U, utc_now
|
8
|
+
from planar.workflows import step
|
9
|
+
from planar.workflows.context import get_context
|
10
|
+
from planar.workflows.events import check_event_exists, get_latest_event
|
11
|
+
from planar.workflows.step_core import Suspend, suspend_workflow
|
12
|
+
from planar.workflows.tracing import trace
|
13
|
+
|
14
|
+
logger = get_logger(__name__)
|
15
|
+
|
16
|
+
|
17
|
+
@step()
|
18
|
+
async def get_deadline(max_wait_time: float) -> datetime:
|
19
|
+
return utc_now() + timedelta(seconds=max_wait_time)
|
20
|
+
|
21
|
+
|
22
|
+
@step(display_name="Wait for event")
|
23
|
+
async def wait_for_event(
|
24
|
+
event_key: str,
|
25
|
+
max_wait_time: float = -1,
|
26
|
+
) -> Dict[str, Any]:
|
27
|
+
"""
|
28
|
+
Creates a durable step that waits for a specific event to be emitted.
|
29
|
+
|
30
|
+
Args:
|
31
|
+
event_key: The event identifier to wait for
|
32
|
+
|
33
|
+
Returns:
|
34
|
+
The event payload as a dictionary
|
35
|
+
"""
|
36
|
+
logger.debug("waiting for event", event_key=event_key, max_wait_time=max_wait_time)
|
37
|
+
await trace("enter", event_key=event_key)
|
38
|
+
|
39
|
+
# Get workflow context
|
40
|
+
ctx = get_context()
|
41
|
+
workflow_id = ctx.workflow.id
|
42
|
+
|
43
|
+
deadline = None
|
44
|
+
if max_wait_time >= 0:
|
45
|
+
deadline = await get_deadline(max_wait_time)
|
46
|
+
logger.debug(
|
47
|
+
"calculated deadline for event", event_key=event_key, deadline=deadline
|
48
|
+
)
|
49
|
+
await trace(
|
50
|
+
"deadline",
|
51
|
+
event_key=event_key,
|
52
|
+
max_wait_time=max_wait_time,
|
53
|
+
deadline=deadline,
|
54
|
+
)
|
55
|
+
|
56
|
+
async def transaction():
|
57
|
+
# Check if the event already exists
|
58
|
+
event_exists = await check_event_exists(event_key, workflow_id=workflow_id)
|
59
|
+
logger.debug(
|
60
|
+
"event exists check for workflow",
|
61
|
+
event_key=event_key,
|
62
|
+
exists=event_exists,
|
63
|
+
)
|
64
|
+
await trace("check-event-exists", event_key=event_key, exists=event_exists)
|
65
|
+
|
66
|
+
if event_exists:
|
67
|
+
# Event exists, get the event data and continue execution immediately
|
68
|
+
event = await get_latest_event(event_key, workflow_id=workflow_id)
|
69
|
+
logger.info(
|
70
|
+
"event already exists, proceeding with payload",
|
71
|
+
event_key=event_key,
|
72
|
+
payload=event.payload if event else None,
|
73
|
+
)
|
74
|
+
await trace("existing-event", event_key=event_key)
|
75
|
+
return event.payload if event and event.payload else {}
|
76
|
+
|
77
|
+
# If deadline has passed, raise an exception
|
78
|
+
now = utc_now()
|
79
|
+
if deadline is not None and now > deadline:
|
80
|
+
logger.warning(
|
81
|
+
"timeout waiting for event",
|
82
|
+
event_key=event_key,
|
83
|
+
deadline=deadline,
|
84
|
+
current_time=now,
|
85
|
+
)
|
86
|
+
await trace("deadline-timeout", event_key=event_key)
|
87
|
+
raise ValueError(f"Timeout waiting for event ${event_key}")
|
88
|
+
|
89
|
+
logger.info(
|
90
|
+
"event not found, suspending workflow",
|
91
|
+
event_key=event_key,
|
92
|
+
)
|
93
|
+
return suspend_workflow(
|
94
|
+
interval=timedelta(seconds=max_wait_time) if max_wait_time > 0 else None,
|
95
|
+
event_key=event_key,
|
96
|
+
)
|
97
|
+
|
98
|
+
session = get_session()
|
99
|
+
result = await session.run_transaction(transaction)
|
100
|
+
if isinstance(result, Suspend):
|
101
|
+
# Suspend until event is emitted
|
102
|
+
logger.debug(
|
103
|
+
"workflow suspended, waiting for event",
|
104
|
+
event_key=event_key,
|
105
|
+
)
|
106
|
+
await trace("suspend", event_key=event_key)
|
107
|
+
await (
|
108
|
+
result
|
109
|
+
) # This will re-raise the Suspend object's exception or re-enter the generator
|
110
|
+
assert False, "Suspend should never return normally" # Should not be reached
|
111
|
+
logger.info(
|
112
|
+
"event received or processed for workflow",
|
113
|
+
event_key=event_key,
|
114
|
+
result=result,
|
115
|
+
)
|
116
|
+
return result
|
117
|
+
|
118
|
+
|
119
|
+
def wait(
|
120
|
+
poll_interval: float = 60.0,
|
121
|
+
max_wait_time: float = 3600.0,
|
122
|
+
):
|
123
|
+
"""
|
124
|
+
Creates a durable step that repeatedly checks a condition until it returns True.
|
125
|
+
|
126
|
+
This decorator wraps a function that returns a boolean. The function will be
|
127
|
+
called repeatedly until it returns True or until max_wait_time is reached.
|
128
|
+
|
129
|
+
Args:
|
130
|
+
poll_interval: How often to check the condition
|
131
|
+
max_wait_time: Maximum time to wait before failing
|
132
|
+
|
133
|
+
Returns:
|
134
|
+
A decorator that converts a boolean-returning function into a step
|
135
|
+
that waits for the condition to be true
|
136
|
+
"""
|
137
|
+
|
138
|
+
def decorator(
|
139
|
+
func: Callable[P, Coroutine[T, U, bool]],
|
140
|
+
) -> Callable[P, Coroutine[T, U, None]]:
|
141
|
+
@step()
|
142
|
+
@wraps(func)
|
143
|
+
async def wait_step(*args: P.args, **kwargs: P.kwargs) -> None:
|
144
|
+
logger.debug(
|
145
|
+
"wait step called",
|
146
|
+
func_name=func.__name__,
|
147
|
+
poll_interval=poll_interval,
|
148
|
+
max_wait_time=max_wait_time,
|
149
|
+
)
|
150
|
+
# Set up deadline for timeout
|
151
|
+
deadline = None
|
152
|
+
if max_wait_time >= 0:
|
153
|
+
deadline = await get_deadline(max_wait_time)
|
154
|
+
logger.debug(
|
155
|
+
"calculated deadline for wait step",
|
156
|
+
func_name=func.__name__,
|
157
|
+
deadline=deadline,
|
158
|
+
)
|
159
|
+
|
160
|
+
# Check the condition
|
161
|
+
result = await func(*args, **kwargs)
|
162
|
+
logger.debug(
|
163
|
+
"condition check returned", func_name=func.__name__, result=result
|
164
|
+
)
|
165
|
+
|
166
|
+
# If condition is met, return immediately
|
167
|
+
if result:
|
168
|
+
logger.info("condition met, proceeding", func_name=func.__name__)
|
169
|
+
return
|
170
|
+
|
171
|
+
# If deadline has passed, raise an exception
|
172
|
+
if deadline is not None and utc_now() > deadline:
|
173
|
+
logger.warning(
|
174
|
+
"timeout waiting for condition to be met",
|
175
|
+
func_name=func.__name__,
|
176
|
+
deadline=deadline,
|
177
|
+
)
|
178
|
+
raise ValueError("Timeout waiting for condition to be met")
|
179
|
+
|
180
|
+
# Otherwise, suspend the workflow to retry later
|
181
|
+
logger.info(
|
182
|
+
"condition not met, suspending",
|
183
|
+
func_name=func.__name__,
|
184
|
+
poll_interval_seconds=poll_interval,
|
185
|
+
)
|
186
|
+
await suspend_workflow(interval=timedelta(seconds=poll_interval))
|
187
|
+
|
188
|
+
return wait_step
|
189
|
+
|
190
|
+
return decorator
|
@@ -0,0 +1,217 @@
|
|
1
|
+
import inspect
|
2
|
+
import weakref
|
3
|
+
from datetime import timedelta
|
4
|
+
from functools import wraps
|
5
|
+
from typing import Callable, Coroutine, Type, cast
|
6
|
+
from uuid import UUID
|
7
|
+
from weakref import WeakKeyDictionary
|
8
|
+
|
9
|
+
import planar.workflows.notifications as notifications
|
10
|
+
from planar.logging import get_logger
|
11
|
+
from planar.session import get_session
|
12
|
+
from planar.utils import P, R, T, U
|
13
|
+
from planar.workflows.context import (
|
14
|
+
get_context,
|
15
|
+
in_context,
|
16
|
+
)
|
17
|
+
from planar.workflows.execution import register_workflow_function
|
18
|
+
from planar.workflows.models import StepType, Workflow, WorkflowStatus
|
19
|
+
from planar.workflows.orchestrator import WorkflowOrchestrator
|
20
|
+
from planar.workflows.serialization import serialize_args
|
21
|
+
from planar.workflows.step_core import _step, suspend_workflow
|
22
|
+
from planar.workflows.wrappers import StepWrapper, WorkflowWrapper
|
23
|
+
|
24
|
+
logger = get_logger(__name__)
|
25
|
+
|
26
|
+
|
27
|
+
def step(
|
28
|
+
*,
|
29
|
+
max_retries: int = 0,
|
30
|
+
step_type: StepType = StepType.COMPUTE,
|
31
|
+
return_type: Type | None = None,
|
32
|
+
display_name: str | None = None,
|
33
|
+
):
|
34
|
+
"""
|
35
|
+
Decorator to define a step in a workflow.
|
36
|
+
|
37
|
+
This decorator is used to define a step function.
|
38
|
+
It will register the function with the workflow engine and allow it to be executed.
|
39
|
+
|
40
|
+
"""
|
41
|
+
step_decorator = _step(max_retries=max_retries, return_type=return_type)
|
42
|
+
|
43
|
+
def decorator(
|
44
|
+
func: Callable[P, Coroutine[T, U, R]],
|
45
|
+
step_type: StepType = step_type,
|
46
|
+
display_name: str | None = display_name,
|
47
|
+
) -> StepWrapper[P, T, U, R]:
|
48
|
+
wrapper = step_decorator(func, step_type=step_type, display_name=display_name)
|
49
|
+
|
50
|
+
@workflow(name=func.__name__ + ".auto_workflow")
|
51
|
+
async def auto_workflow(*args: P.args, **kwargs: P.kwargs) -> R:
|
52
|
+
"""
|
53
|
+
This is a special workflow that is used to run a step in a separate asyncio task
|
54
|
+
"""
|
55
|
+
result = await wrapper(*args, **kwargs)
|
56
|
+
return result
|
57
|
+
|
58
|
+
@wraps(func)
|
59
|
+
def run_step(*args: P.args, **kwargs: P.kwargs) -> Coroutine[T, U, R]:
|
60
|
+
"""
|
61
|
+
If not in workflow context, then simply call the function directly.
|
62
|
+
|
63
|
+
This allows users to use their workflow code within and outside of
|
64
|
+
workflows.
|
65
|
+
"""
|
66
|
+
if not in_context():
|
67
|
+
return func(*args, **kwargs)
|
68
|
+
return wrapper(*args, **kwargs)
|
69
|
+
|
70
|
+
step_wrapper = StepWrapper(
|
71
|
+
original_fn=func,
|
72
|
+
wrapper=wrapper,
|
73
|
+
wrapped_fn=run_step,
|
74
|
+
auto_workflow=auto_workflow,
|
75
|
+
)
|
76
|
+
return step_wrapper
|
77
|
+
|
78
|
+
return decorator
|
79
|
+
|
80
|
+
|
81
|
+
def workflow(*, name: str | None = None):
|
82
|
+
"""
|
83
|
+
Decorator to define a workflow.
|
84
|
+
|
85
|
+
This decorator is used to define a workflow function.
|
86
|
+
It will register the function with the workflow engine and allow it to be executed.
|
87
|
+
|
88
|
+
"""
|
89
|
+
|
90
|
+
def decorator(func: Callable[P, Coroutine[T, U, R]]) -> WorkflowWrapper[P, T, U, R]:
|
91
|
+
if not inspect.iscoroutinefunction(func):
|
92
|
+
raise TypeError("Workflow functions must be coroutines")
|
93
|
+
|
94
|
+
function_name = name or func.__name__
|
95
|
+
|
96
|
+
@wraps(func)
|
97
|
+
async def start_workflow(*args: P.args, **kwargs: P.kwargs) -> Workflow:
|
98
|
+
session = get_session()
|
99
|
+
serialized_args, serialized_kwargs = serialize_args(func, args, kwargs)
|
100
|
+
workflow = Workflow(
|
101
|
+
function_name=function_name,
|
102
|
+
args=serialized_args,
|
103
|
+
kwargs=serialized_kwargs,
|
104
|
+
)
|
105
|
+
|
106
|
+
if in_context():
|
107
|
+
ctx = get_context()
|
108
|
+
if ctx.bind_parent_workflow:
|
109
|
+
logger.debug(
|
110
|
+
"binding parent workflow", parent_workflow_id=ctx.workflow_id
|
111
|
+
)
|
112
|
+
workflow.parent_id = ctx.workflow.id
|
113
|
+
|
114
|
+
session.add(workflow)
|
115
|
+
await session.commit()
|
116
|
+
notifications.workflow_started(workflow)
|
117
|
+
if WorkflowOrchestrator.is_set():
|
118
|
+
orchestrator = WorkflowOrchestrator.get()
|
119
|
+
orchestrator.poll_soon()
|
120
|
+
return workflow
|
121
|
+
|
122
|
+
@_step()
|
123
|
+
async def start_workflow_step(*args: P.args, **kwargs: P.kwargs) -> UUID:
|
124
|
+
workflow = await start_workflow(*args, **kwargs)
|
125
|
+
return workflow.id
|
126
|
+
|
127
|
+
async def wait_for_completion(workflow_id: UUID):
|
128
|
+
orchestrator = WorkflowOrchestrator.get()
|
129
|
+
return cast(R, await orchestrator.wait_for_completion(workflow_id))
|
130
|
+
|
131
|
+
async def run_workflow_in_new_context(*args: P.args, **kwargs: P.kwargs) -> R:
|
132
|
+
async with WorkflowOrchestrator.ensure_started():
|
133
|
+
# Running outside of a workflow execution context.
|
134
|
+
# Start workflow normally and wait for completion.
|
135
|
+
workflow = await start_workflow(*args, **kwargs)
|
136
|
+
workflow_id = workflow.id
|
137
|
+
return await wait_for_completion(workflow_id)
|
138
|
+
|
139
|
+
async def run_child_workflow(*args: P.args, **kwargs: P.kwargs) -> R:
|
140
|
+
ctx = get_context()
|
141
|
+
# Since the parent will wait, we have to
|
142
|
+
# create the association when creating
|
143
|
+
# the child
|
144
|
+
ctx.bind_parent_workflow = True
|
145
|
+
workflow_id = await start_workflow_step(*args, **kwargs)
|
146
|
+
# Considering the current workflow will
|
147
|
+
# suspend (this lose the context) it is
|
148
|
+
# not necessary to reset the flag here.
|
149
|
+
# Leaving it only for documenting intent
|
150
|
+
# that this is a temporary setting.
|
151
|
+
ctx.bind_parent_workflow = False
|
152
|
+
session = get_session()
|
153
|
+
async with session.begin_read():
|
154
|
+
workflow = await session.get(Workflow, workflow_id)
|
155
|
+
assert workflow
|
156
|
+
|
157
|
+
if workflow.status == WorkflowStatus.PENDING:
|
158
|
+
# Suspend for 0 seconds. Since the poll query only selects
|
159
|
+
# workflows that have no children, suspending for 0 seconds
|
160
|
+
# mean this workflow will wakeup as soon as all children finish
|
161
|
+
await suspend_workflow(interval=timedelta(seconds=0))
|
162
|
+
|
163
|
+
return await wait_for_completion(workflow_id)
|
164
|
+
|
165
|
+
@wraps(func)
|
166
|
+
def run_workflow(*args: P.args, **kwargs: P.kwargs) -> Coroutine[T, U, R]:
|
167
|
+
if not in_context():
|
168
|
+
return run_workflow_in_new_context(*args, **kwargs)
|
169
|
+
return run_child_workflow(*args, **kwargs)
|
170
|
+
|
171
|
+
register_workflow_function(function_name, func)
|
172
|
+
|
173
|
+
wf_wrapper = WorkflowWrapper(
|
174
|
+
function_name=function_name,
|
175
|
+
original_fn=func,
|
176
|
+
start=start_workflow,
|
177
|
+
start_step=start_workflow_step,
|
178
|
+
wait_for_completion=wait_for_completion,
|
179
|
+
wrapped_fn=run_workflow,
|
180
|
+
)
|
181
|
+
|
182
|
+
return wf_wrapper
|
183
|
+
|
184
|
+
return decorator
|
185
|
+
|
186
|
+
|
187
|
+
__AS_STEP_CACHE = WeakKeyDictionary()
|
188
|
+
|
189
|
+
|
190
|
+
def __is_workflow_step(callable: Callable[P, Coroutine[T, U, R]]) -> bool:
|
191
|
+
return isinstance(callable, StepWrapper)
|
192
|
+
|
193
|
+
|
194
|
+
def as_step(
|
195
|
+
func: Callable[P, Coroutine[T, U, R]],
|
196
|
+
step_type: StepType,
|
197
|
+
display_name: str | None = None,
|
198
|
+
return_type: Type[R] | None = None,
|
199
|
+
) -> Callable[P, Coroutine[T, U, R]]:
|
200
|
+
"""
|
201
|
+
This utility fn is for treating async functions as steps without modifying
|
202
|
+
the original function.
|
203
|
+
|
204
|
+
Only use this where it doesn't make sense to use the @step decorator (such as third
|
205
|
+
party functions)
|
206
|
+
"""
|
207
|
+
if __is_workflow_step(func):
|
208
|
+
return func
|
209
|
+
# cache the result to avoid reapplying the step decorator for this callable in the future
|
210
|
+
weak_step_callable = __AS_STEP_CACHE.get(func, None)
|
211
|
+
step_callable = weak_step_callable() if weak_step_callable is not None else None
|
212
|
+
if step_callable is None:
|
213
|
+
step_callable = step(return_type=return_type)(
|
214
|
+
func, step_type=step_type, display_name=display_name
|
215
|
+
)
|
216
|
+
__AS_STEP_CACHE[func] = weakref.ref(step_callable)
|
217
|
+
return step_callable
|