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
@@ -0,0 +1,106 @@
|
|
1
|
+
import asyncio
|
2
|
+
import inspect
|
3
|
+
import os
|
4
|
+
from abc import ABC, abstractmethod
|
5
|
+
from contextlib import asynccontextmanager
|
6
|
+
from contextvars import ContextVar
|
7
|
+
from datetime import datetime
|
8
|
+
from typing import TypeAlias
|
9
|
+
from uuid import UUID
|
10
|
+
|
11
|
+
from planar.logging import get_logger
|
12
|
+
|
13
|
+
TraceArg: TypeAlias = UUID | datetime | str | int | float | bool | None
|
14
|
+
|
15
|
+
|
16
|
+
class Tracer(ABC):
|
17
|
+
@abstractmethod
|
18
|
+
async def trace(
|
19
|
+
self,
|
20
|
+
module_name: str,
|
21
|
+
function_name: str,
|
22
|
+
message: str,
|
23
|
+
task_name: str,
|
24
|
+
pid: int,
|
25
|
+
kwargs: dict[str, TraceArg],
|
26
|
+
) -> None: ...
|
27
|
+
|
28
|
+
@classmethod
|
29
|
+
def format(
|
30
|
+
cls,
|
31
|
+
module_name: str,
|
32
|
+
function_name: str,
|
33
|
+
task_name: str,
|
34
|
+
pid: int,
|
35
|
+
message: str,
|
36
|
+
kwargs: dict[str, TraceArg],
|
37
|
+
) -> str:
|
38
|
+
return " - ".join(
|
39
|
+
[
|
40
|
+
f"{module_name}.{function_name}",
|
41
|
+
message,
|
42
|
+
" ".join(f"{k}={v}" for k, v in kwargs.items()),
|
43
|
+
f"{task_name} PID={pid}",
|
44
|
+
]
|
45
|
+
)
|
46
|
+
|
47
|
+
|
48
|
+
class LoggingTracer(Tracer):
|
49
|
+
async def trace(
|
50
|
+
self,
|
51
|
+
module_name: str,
|
52
|
+
function_name: str,
|
53
|
+
message: str,
|
54
|
+
task_name: str,
|
55
|
+
pid: int,
|
56
|
+
kwargs: dict[str, TraceArg],
|
57
|
+
):
|
58
|
+
get_logger(module_name).debug(
|
59
|
+
self.format(module_name, function_name, task_name, pid, message, kwargs)
|
60
|
+
)
|
61
|
+
|
62
|
+
|
63
|
+
def __get_trace_caller():
|
64
|
+
frame = inspect.currentframe()
|
65
|
+
try:
|
66
|
+
assert frame
|
67
|
+
parent_frame = frame.f_back
|
68
|
+
assert parent_frame
|
69
|
+
assert parent_frame.f_code.co_name == "trace"
|
70
|
+
# Get the frame of the caller (2 level ups from current frame)
|
71
|
+
caller_frame = parent_frame.f_back
|
72
|
+
assert caller_frame
|
73
|
+
# Get the function name from the caller's frame
|
74
|
+
module_name = caller_frame.f_globals["__name__"]
|
75
|
+
return str(module_name), caller_frame.f_code.co_qualname
|
76
|
+
finally:
|
77
|
+
# Always delete the frame reference to prevent reference cycles
|
78
|
+
del frame
|
79
|
+
|
80
|
+
|
81
|
+
__PID = os.getpid()
|
82
|
+
|
83
|
+
|
84
|
+
async def trace(message: str, **kwargs: TraceArg) -> None:
|
85
|
+
tracer = tracer_var.get(None)
|
86
|
+
if not tracer:
|
87
|
+
return
|
88
|
+
# Get useful information about the caller and forward it to the tracer
|
89
|
+
module_name, func_name = __get_trace_caller()
|
90
|
+
current_task = asyncio.current_task()
|
91
|
+
assert current_task
|
92
|
+
await tracer.trace(
|
93
|
+
module_name, func_name, message, current_task.get_name(), __PID, kwargs
|
94
|
+
)
|
95
|
+
|
96
|
+
|
97
|
+
tracer_var: ContextVar[Tracer] = ContextVar("tracer")
|
98
|
+
|
99
|
+
|
100
|
+
@asynccontextmanager
|
101
|
+
async def tracer_context(tracer: Tracer):
|
102
|
+
token = tracer_var.set(tracer)
|
103
|
+
try:
|
104
|
+
yield
|
105
|
+
finally:
|
106
|
+
tracer_var.reset(token)
|
@@ -0,0 +1,41 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
from typing import Callable, Coroutine, Generic
|
3
|
+
from uuid import UUID
|
4
|
+
|
5
|
+
from planar.utils import P, R, T, U
|
6
|
+
from planar.workflows.models import Workflow
|
7
|
+
|
8
|
+
|
9
|
+
@dataclass(kw_only=True)
|
10
|
+
class Wrapper(Generic[P, T, U, R]):
|
11
|
+
original_fn: Callable[P, Coroutine[T, U, R]]
|
12
|
+
wrapped_fn: Callable[P, Coroutine[T, U, R]]
|
13
|
+
__doc__: str | None
|
14
|
+
|
15
|
+
def __post_init__(self):
|
16
|
+
self.__doc__ = self.original_fn.__doc__
|
17
|
+
|
18
|
+
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Coroutine[T, U, R]:
|
19
|
+
return self.wrapped_fn(*args, **kwargs)
|
20
|
+
|
21
|
+
@property
|
22
|
+
def name(self):
|
23
|
+
return self.wrapped_fn.__name__
|
24
|
+
|
25
|
+
@property
|
26
|
+
def __name__(self):
|
27
|
+
return self.original_fn.__name__
|
28
|
+
|
29
|
+
|
30
|
+
@dataclass(kw_only=True)
|
31
|
+
class WorkflowWrapper(Wrapper[P, T, U, R]):
|
32
|
+
function_name: str
|
33
|
+
start: Callable[P, Coroutine[T, U, Workflow]]
|
34
|
+
start_step: Callable[P, Coroutine[T, U, UUID]]
|
35
|
+
wait_for_completion: Callable[[UUID], Coroutine[T, U, R]]
|
36
|
+
|
37
|
+
|
38
|
+
@dataclass(kw_only=True)
|
39
|
+
class StepWrapper(Wrapper[P, T, U, R]):
|
40
|
+
wrapper: Callable[P, Coroutine[T, U, R]]
|
41
|
+
auto_workflow: WorkflowWrapper[P, T, U, R]
|
@@ -0,0 +1,285 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: planar
|
3
|
+
Version: 0.5.0
|
4
|
+
Summary: Add your description here
|
5
|
+
License-Expression: LicenseRef-Proprietary
|
6
|
+
Requires-Python: >=3.12
|
7
|
+
Requires-Dist: aiofiles>=24.1.0
|
8
|
+
Requires-Dist: aiosqlite>=0.21.0
|
9
|
+
Requires-Dist: alembic>=1.14.1
|
10
|
+
Requires-Dist: anthropic>=0.49.0
|
11
|
+
Requires-Dist: asyncpg
|
12
|
+
Requires-Dist: boto3
|
13
|
+
Requires-Dist: cedarpy>=4.1.0
|
14
|
+
Requires-Dist: fastapi[standard]>=0.115.7
|
15
|
+
Requires-Dist: inflection>=0.5.1
|
16
|
+
Requires-Dist: openai>=1.75
|
17
|
+
Requires-Dist: pydantic-ai-slim>=0.4.2
|
18
|
+
Requires-Dist: pygments>=2.19.1
|
19
|
+
Requires-Dist: pyjwt[crypto]
|
20
|
+
Requires-Dist: python-multipart>=0.0.20
|
21
|
+
Requires-Dist: sqlalchemy[asyncio]>=2.0.37
|
22
|
+
Requires-Dist: sqlmodel>=0.0.22
|
23
|
+
Requires-Dist: typer>=0.15.2
|
24
|
+
Requires-Dist: typing-extensions>=4.12.2
|
25
|
+
Requires-Dist: zen-engine>=0.40.0
|
26
|
+
Provides-Extra: otel
|
27
|
+
Requires-Dist: opentelemetry-api>=1.34.1; extra == 'otel'
|
28
|
+
Requires-Dist: opentelemetry-exporter-otlp>=1.34.1; extra == 'otel'
|
29
|
+
Requires-Dist: opentelemetry-instrumentation-logging>=0.55b1; extra == 'otel'
|
30
|
+
Requires-Dist: opentelemetry-sdk>=1.34.1; extra == 'otel'
|
31
|
+
Description-Content-Type: text/markdown
|
32
|
+
|
33
|
+
# Planar
|
34
|
+
|
35
|
+
Planar is a Python framework built on FastAPI and SQLModel that lets you build web services with advanced workflow capabilities. Its core features
|
36
|
+
include:
|
37
|
+
1. Automatic CRUD API generation for your entities
|
38
|
+
2. A workflow orchestration system for building complex, resumable business processes
|
39
|
+
3. File attachment handling with flexible storage options
|
40
|
+
4. Database integration with migration support via Alembic
|
41
|
+
The framework is designed for building services that need both standard REST API endpoints and more complex stateful workflows. The examples show it
|
42
|
+
being used for services that manage entities with status transitions and attached files, like invoice processing systems.
|
43
|
+
|
44
|
+
## Workflow System
|
45
|
+
The workflow system in Planar is a sophisticated orchestration framework that enables defining, executing, and managing long-running workflows with
|
46
|
+
persistence. Here's what it does:
|
47
|
+
1. Core Concept: Implements a durable workflow system that can survive process restarts by storing workflow state in a database. It allows workflows to
|
48
|
+
be suspended and resumed.
|
49
|
+
2. Key Features:
|
50
|
+
- Persistent Steps: Each step in a workflow is tracked in the database
|
51
|
+
- Automatic Retries: Failed steps can be retried automatically
|
52
|
+
- Suspendable Workflows: Workflows can be suspended and resumed later
|
53
|
+
- Concurrency Control: Uses a locking mechanism to prevent multiple executions
|
54
|
+
- Recovery: Can recover from crashes by detecting stalled workflows
|
55
|
+
3. Main Components:
|
56
|
+
- @workflow decorator: Marks a function as a workflow with persistence
|
57
|
+
- @step decorator: Wraps function calls inside a workflow to make them resumable
|
58
|
+
- Suspend class: Allows pausing workflow execution
|
59
|
+
- workflow_orchestrator: Background task that finds and resumes suspended workflows
|
60
|
+
4. REST API Integration:
|
61
|
+
- Automatically creates API endpoints for starting workflows
|
62
|
+
- Provides status endpoints to check workflow progress
|
63
|
+
This is essentially a state machine for managing long-running business processes that need to be resilient to failures and can span multiple
|
64
|
+
requests/processes.
|
65
|
+
|
66
|
+
### Coroutines and the suspension mechanism
|
67
|
+
Coroutines are the heart of Planar's workflow system. Here's how they work:
|
68
|
+
|
69
|
+
#### Coroutine Usage
|
70
|
+
|
71
|
+
Planar builds on Python's async/await system but adds durability. When you create a workflow:
|
72
|
+
|
73
|
+
@workflow
|
74
|
+
async def process_order(order_id: str):
|
75
|
+
# workflow steps
|
76
|
+
|
77
|
+
The system:
|
78
|
+
|
79
|
+
1. Enforces that all workflows and steps must be coroutines (async def)
|
80
|
+
2. Accesses the underlying generator of the coroutine via coro.__await__()
|
81
|
+
3. Manually drives this generator by calling next(gen) and gen.send(result)
|
82
|
+
4. Intercepts any values yielded from the coroutine to implement suspension
|
83
|
+
|
84
|
+
The execute() function (lines 278-335) is the core that drives coroutine execution. It:
|
85
|
+
- Takes control of the coroutine's generator
|
86
|
+
- Processes each yielded value
|
87
|
+
- Handles regular awaits vs. suspensions differently
|
88
|
+
- Persists workflow state at suspension points
|
89
|
+
|
90
|
+
Suspend Mechanism
|
91
|
+
|
92
|
+
The Suspend class (lines 55-72) enables pausing workflows:
|
93
|
+
|
94
|
+
class Suspend:
|
95
|
+
def __init__(self, *, wakeup_at=None, interval=None):
|
96
|
+
# Set when to wake up
|
97
|
+
|
98
|
+
def __await__(self):
|
99
|
+
result = yield self
|
100
|
+
return result
|
101
|
+
|
102
|
+
When you call:
|
103
|
+
await suspend(interval=timedelta(minutes=5))
|
104
|
+
|
105
|
+
What happens:
|
106
|
+
1. The suspend() function uses the @step() decorator to mark it as resumable
|
107
|
+
2. Inside it creates and awaits a Suspend object
|
108
|
+
3. The __await__ method yields self (the Suspend instance) to the executor
|
109
|
+
4. The execute() function detects this is a Suspend object (lines 303-307)
|
110
|
+
5. It sets the workflow status to SUSPENDED and persists the wake-up time
|
111
|
+
6. Later, the orchestrator finds workflows ready to resume based on wakeup_at
|
112
|
+
7. When resumed, execution continues right after the suspension point
|
113
|
+
|
114
|
+
YieldWrapper
|
115
|
+
|
116
|
+
The YieldWrapper class (lines 48-53) is crucial for handling regular async operations:
|
117
|
+
|
118
|
+
class YieldWrapper:
|
119
|
+
def __init__(self, value):
|
120
|
+
self.value = value
|
121
|
+
def __await__(self):
|
122
|
+
return (yield self.value)
|
123
|
+
|
124
|
+
For non-Suspend yields (regular awaits), the system:
|
125
|
+
1. Wraps the yielded value in YieldWrapper
|
126
|
+
2. Awaits it to get the result from asyncio
|
127
|
+
3. Sends the result back to the workflow's generator
|
128
|
+
|
129
|
+
This allows you to use normal async functions inside workflows:
|
130
|
+
|
131
|
+
@workflow
|
132
|
+
async def my_workflow():
|
133
|
+
# This works because YieldWrapper passes through regular awaits
|
134
|
+
data = await fetch_data_from_api()
|
135
|
+
# This suspends the workflow
|
136
|
+
await suspend(interval=timedelta(hours=1))
|
137
|
+
# When resumed days later, continues here
|
138
|
+
return process_result(data)
|
139
|
+
|
140
|
+
The magic is that the workflow appears to be a normal async function, but the state is persisted across suspensions, allowing workflows to survive
|
141
|
+
process restarts or even server reboots.
|
142
|
+
|
143
|
+
|
144
|
+
## Getting started
|
145
|
+
|
146
|
+
Install dependencies: `uv sync --extra otel`
|
147
|
+
|
148
|
+
## Using the Planar CLI
|
149
|
+
|
150
|
+
Planar includes a command-line interface (CLI) for running applications with environment-specific configurations:
|
151
|
+
|
152
|
+
### Creating a New Project
|
153
|
+
|
154
|
+
```bash
|
155
|
+
planar scaffold [OPTIONS]
|
156
|
+
```
|
157
|
+
|
158
|
+
Options:
|
159
|
+
- `--name TEXT`: Name of the new project (will prompt if not provided)
|
160
|
+
- `--directory PATH`: Target directory for the project (default: current directory)
|
161
|
+
|
162
|
+
The scaffold command creates a new Planar project with:
|
163
|
+
- Basic project structure with `app/` directory
|
164
|
+
- Example invoice processing workflow with AI agent integration
|
165
|
+
- Database entity definitions
|
166
|
+
- Development and production configuration files
|
167
|
+
- Ready-to-use pyproject.toml with Planar dependency
|
168
|
+
|
169
|
+
### Running in Development Mode
|
170
|
+
|
171
|
+
```bash
|
172
|
+
planar dev [PATH] [OPTIONS]
|
173
|
+
```
|
174
|
+
|
175
|
+
Arguments:
|
176
|
+
- `[PATH]`: Optional path to the Python file containing the Planar app instance. Defaults to searching for `app.py` or `main.py` in the current directory.
|
177
|
+
|
178
|
+
Options:
|
179
|
+
- `--port INTEGER`: Port to run on (default: 8000)
|
180
|
+
- `--host TEXT`: Host to run on (default: 127.0.0.1)
|
181
|
+
- `--config PATH`: Path to config file. If set, overrides default config file lookup.
|
182
|
+
- `--app TEXT`: Name of the PlanarApp instance variable within the file (default: 'app').
|
183
|
+
|
184
|
+
Development mode enables:
|
185
|
+
- Hot reloading on code changes
|
186
|
+
- Defaults to development-friendly CORS settings (allows localhost origins)
|
187
|
+
- Sets `PLANAR_ENV=dev`
|
188
|
+
|
189
|
+
### Running in Production Mode
|
190
|
+
|
191
|
+
```bash
|
192
|
+
planar prod [PATH] [OPTIONS]
|
193
|
+
```
|
194
|
+
|
195
|
+
Arguments & Options:
|
196
|
+
- Same as `dev` mode, but with production defaults.
|
197
|
+
- Default host is 0.0.0.0 (accessible externally)
|
198
|
+
- Defaults to stricter CORS settings for production use
|
199
|
+
- Hot reloading disabled for better performance
|
200
|
+
- Sets `PLANAR_ENV=prod`
|
201
|
+
|
202
|
+
### Configuration Loading Logic
|
203
|
+
|
204
|
+
When the Planar application starts (typically via the `planar dev` or `planar prod` CLI commands), it determines the configuration settings using the following process:
|
205
|
+
|
206
|
+
1. **Determine Base Configuration:** Based on the environment (`dev` or `prod`, controlled by `PLANAR_ENV` or the CLI command used), Planar establishes a set of built-in default settings (e.g., default database path, CORS settings, debug flags).
|
207
|
+
2. **Configuration Override File:** Planar searches for a single YAML configuration file to use for overriding the defaults, checking in this specific order:
|
208
|
+
* **a. Explicit Path:** Checks if the `--config PATH` option was used or if the `PLANAR_CONFIG` environment variable is set. If either is present and points to an existing file, that file is selected as the config override file.
|
209
|
+
* **b. Environment-Specific File:** If no explicit path was provided, it looks for `planar.{env}.yaml` (e.g., `planar.dev.yaml` for the `dev` environment) in both the app directory and the current directory. If found, this file is selected.
|
210
|
+
* **c. Generic File:** If neither an explicit path nor an environment-specific file was found, it looks for `planar.yaml` in the current directory. If found, this file is selected.
|
211
|
+
|
212
|
+
**Important Note:** This configuration loading logic is bypassed entirely if you initialize the `PlanarApp` instance in your Python code by directly passing a `PlanarConfig` object to its `config` parameter.
|
213
|
+
|
214
|
+
Example override file (`planar.dev.yaml` or `planar.yaml`):
|
215
|
+
This file only needs to contain the settings you wish to override from the defaults.
|
216
|
+
|
217
|
+
```yaml
|
218
|
+
# Example: Only override AI provider keys and SQLAlchemy debug setting
|
219
|
+
|
220
|
+
# Settings not specified here (like db_connections, app config, cors)
|
221
|
+
# will retain their default values for the 'dev' environment after merging.
|
222
|
+
|
223
|
+
ai_providers:
|
224
|
+
openai:
|
225
|
+
api_key: ${OPENAI_API_KEY} # Read API key from environment variable
|
226
|
+
|
227
|
+
# Optional: Override a specific nested value
|
228
|
+
# storage:
|
229
|
+
# directory: .custom_dev_files
|
230
|
+
|
231
|
+
|
232
|
+
# Optional: setup logging config
|
233
|
+
logging:
|
234
|
+
planar:
|
235
|
+
level: INFO # enable INFO level logging for all modules in the "planar" package.
|
236
|
+
planar.workflows:
|
237
|
+
level: DEBUG # enable DEBUG level logging for all modules in the "planar.workflows" package.
|
238
|
+
```
|
239
|
+
|
240
|
+
## To run the examples
|
241
|
+
|
242
|
+
- `uv run planar dev examples/expense_approval_workflow/main.py`
|
243
|
+
- `uv run planar dev examples/event_based_workflow/main.py`
|
244
|
+
- `uv run planar dev examples/simple_service/main.py`
|
245
|
+
|
246
|
+
The API docs can then be accessed on http://127.0.0.1:8000/docs
|
247
|
+
|
248
|
+
## Testing
|
249
|
+
|
250
|
+
We use pytest for testing Planar:
|
251
|
+
|
252
|
+
- To run the tests: `uv run pytest`
|
253
|
+
- By default, tests only run on SQLite using temporary databases
|
254
|
+
- In CI/CD we also test PostgreSQL
|
255
|
+
|
256
|
+
### Testing with PostgreSQL locally
|
257
|
+
|
258
|
+
To test with PostgreSQL locally, you'll need a PostgreSQL container running:
|
259
|
+
|
260
|
+
```bash
|
261
|
+
docker run --restart=always --name planar-postgres -e POSTGRES_PASSWORD=123 -p 127.0.0.1:5432:5432 -d docker.io/library/postgres
|
262
|
+
```
|
263
|
+
|
264
|
+
Ensure the container name is `planar-postgres`.
|
265
|
+
|
266
|
+
To run tests with PostgreSQL:
|
267
|
+
|
268
|
+
```bash
|
269
|
+
PLANAR_TEST_POSTGRESQL=1 PLANAR_TEST_POSTGRESQL_CONTAINER=planar-postgres uv run pytest -s
|
270
|
+
```
|
271
|
+
|
272
|
+
To disable SQLite testing:
|
273
|
+
|
274
|
+
```bash
|
275
|
+
PLANAR_TEST_SQLITE=0 uv run pytest
|
276
|
+
```
|
277
|
+
|
278
|
+
## Pre-commit hooks
|
279
|
+
|
280
|
+
We use [pre-commit](https://pre-commit.com/) to manage pre-commit hooks. To install the pre-commit hooks, run the following command:
|
281
|
+
|
282
|
+
```bash
|
283
|
+
uv tool install pre-commit
|
284
|
+
uv tool run pre-commit install
|
285
|
+
```
|