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.
Files changed (289) hide show
  1. planar/.__init__.py.un~ +0 -0
  2. planar/._version.py.un~ +0 -0
  3. planar/.app.py.un~ +0 -0
  4. planar/.cli.py.un~ +0 -0
  5. planar/.config.py.un~ +0 -0
  6. planar/.context.py.un~ +0 -0
  7. planar/.db.py.un~ +0 -0
  8. planar/.di.py.un~ +0 -0
  9. planar/.engine.py.un~ +0 -0
  10. planar/.files.py.un~ +0 -0
  11. planar/.log_context.py.un~ +0 -0
  12. planar/.log_metadata.py.un~ +0 -0
  13. planar/.logging.py.un~ +0 -0
  14. planar/.object_registry.py.un~ +0 -0
  15. planar/.otel.py.un~ +0 -0
  16. planar/.server.py.un~ +0 -0
  17. planar/.session.py.un~ +0 -0
  18. planar/.sqlalchemy.py.un~ +0 -0
  19. planar/.task_local.py.un~ +0 -0
  20. planar/.test_app.py.un~ +0 -0
  21. planar/.test_config.py.un~ +0 -0
  22. planar/.test_object_config.py.un~ +0 -0
  23. planar/.test_sqlalchemy.py.un~ +0 -0
  24. planar/.test_utils.py.un~ +0 -0
  25. planar/.util.py.un~ +0 -0
  26. planar/.utils.py.un~ +0 -0
  27. planar/__init__.py +26 -0
  28. planar/_version.py +1 -0
  29. planar/ai/.__init__.py.un~ +0 -0
  30. planar/ai/._models.py.un~ +0 -0
  31. planar/ai/.agent.py.un~ +0 -0
  32. planar/ai/.agent_utils.py.un~ +0 -0
  33. planar/ai/.events.py.un~ +0 -0
  34. planar/ai/.files.py.un~ +0 -0
  35. planar/ai/.models.py.un~ +0 -0
  36. planar/ai/.providers.py.un~ +0 -0
  37. planar/ai/.pydantic_ai.py.un~ +0 -0
  38. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  39. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  40. planar/ai/.step.py.un~ +0 -0
  41. planar/ai/.test_agent.py.un~ +0 -0
  42. planar/ai/.test_agent_serialization.py.un~ +0 -0
  43. planar/ai/.test_providers.py.un~ +0 -0
  44. planar/ai/.utils.py.un~ +0 -0
  45. planar/ai/__init__.py +15 -0
  46. planar/ai/agent.py +457 -0
  47. planar/ai/agent_utils.py +205 -0
  48. planar/ai/models.py +140 -0
  49. planar/ai/providers.py +1088 -0
  50. planar/ai/test_agent.py +1298 -0
  51. planar/ai/test_agent_serialization.py +229 -0
  52. planar/ai/test_providers.py +463 -0
  53. planar/ai/utils.py +102 -0
  54. planar/app.py +494 -0
  55. planar/cli.py +282 -0
  56. planar/config.py +544 -0
  57. planar/db/.db.py.un~ +0 -0
  58. planar/db/__init__.py +17 -0
  59. planar/db/alembic/env.py +136 -0
  60. planar/db/alembic/script.py.mako +28 -0
  61. planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
  62. planar/db/alembic.ini +128 -0
  63. planar/db/db.py +318 -0
  64. planar/files/.config.py.un~ +0 -0
  65. planar/files/.local.py.un~ +0 -0
  66. planar/files/.local_filesystem.py.un~ +0 -0
  67. planar/files/.model.py.un~ +0 -0
  68. planar/files/.models.py.un~ +0 -0
  69. planar/files/.s3.py.un~ +0 -0
  70. planar/files/.storage.py.un~ +0 -0
  71. planar/files/.test_files.py.un~ +0 -0
  72. planar/files/__init__.py +2 -0
  73. planar/files/models.py +162 -0
  74. planar/files/storage/.__init__.py.un~ +0 -0
  75. planar/files/storage/.base.py.un~ +0 -0
  76. planar/files/storage/.config.py.un~ +0 -0
  77. planar/files/storage/.context.py.un~ +0 -0
  78. planar/files/storage/.local_directory.py.un~ +0 -0
  79. planar/files/storage/.test_local_directory.py.un~ +0 -0
  80. planar/files/storage/.test_s3.py.un~ +0 -0
  81. planar/files/storage/base.py +61 -0
  82. planar/files/storage/config.py +44 -0
  83. planar/files/storage/context.py +15 -0
  84. planar/files/storage/local_directory.py +188 -0
  85. planar/files/storage/s3.py +220 -0
  86. planar/files/storage/test_local_directory.py +162 -0
  87. planar/files/storage/test_s3.py +299 -0
  88. planar/files/test_files.py +283 -0
  89. planar/human/.human.py.un~ +0 -0
  90. planar/human/.test_human.py.un~ +0 -0
  91. planar/human/__init__.py +2 -0
  92. planar/human/human.py +458 -0
  93. planar/human/models.py +80 -0
  94. planar/human/test_human.py +385 -0
  95. planar/logging/.__init__.py.un~ +0 -0
  96. planar/logging/.attributes.py.un~ +0 -0
  97. planar/logging/.formatter.py.un~ +0 -0
  98. planar/logging/.logger.py.un~ +0 -0
  99. planar/logging/.otel.py.un~ +0 -0
  100. planar/logging/.tracer.py.un~ +0 -0
  101. planar/logging/__init__.py +10 -0
  102. planar/logging/attributes.py +54 -0
  103. planar/logging/context.py +14 -0
  104. planar/logging/formatter.py +113 -0
  105. planar/logging/logger.py +114 -0
  106. planar/logging/otel.py +51 -0
  107. planar/modeling/.mixin.py.un~ +0 -0
  108. planar/modeling/.storage.py.un~ +0 -0
  109. planar/modeling/__init__.py +0 -0
  110. planar/modeling/field_helpers.py +59 -0
  111. planar/modeling/json_schema_generator.py +94 -0
  112. planar/modeling/mixins/__init__.py +10 -0
  113. planar/modeling/mixins/auditable.py +52 -0
  114. planar/modeling/mixins/test_auditable.py +97 -0
  115. planar/modeling/mixins/test_timestamp.py +134 -0
  116. planar/modeling/mixins/test_uuid_primary_key.py +52 -0
  117. planar/modeling/mixins/timestamp.py +53 -0
  118. planar/modeling/mixins/uuid_primary_key.py +19 -0
  119. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  120. planar/modeling/orm/__init__.py +18 -0
  121. planar/modeling/orm/planar_base_entity.py +29 -0
  122. planar/modeling/orm/query_filter_builder.py +122 -0
  123. planar/modeling/orm/reexports.py +15 -0
  124. planar/object_config/.object_config.py.un~ +0 -0
  125. planar/object_config/__init__.py +11 -0
  126. planar/object_config/models.py +114 -0
  127. planar/object_config/object_config.py +378 -0
  128. planar/object_registry.py +100 -0
  129. planar/registry_items.py +65 -0
  130. planar/routers/.__init__.py.un~ +0 -0
  131. planar/routers/.agents_router.py.un~ +0 -0
  132. planar/routers/.crud.py.un~ +0 -0
  133. planar/routers/.decision.py.un~ +0 -0
  134. planar/routers/.event.py.un~ +0 -0
  135. planar/routers/.file_attachment.py.un~ +0 -0
  136. planar/routers/.files.py.un~ +0 -0
  137. planar/routers/.files_router.py.un~ +0 -0
  138. planar/routers/.human.py.un~ +0 -0
  139. planar/routers/.info.py.un~ +0 -0
  140. planar/routers/.models.py.un~ +0 -0
  141. planar/routers/.object_config_router.py.un~ +0 -0
  142. planar/routers/.rule.py.un~ +0 -0
  143. planar/routers/.test_object_config_router.py.un~ +0 -0
  144. planar/routers/.test_workflow_router.py.un~ +0 -0
  145. planar/routers/.workflow.py.un~ +0 -0
  146. planar/routers/__init__.py +13 -0
  147. planar/routers/agents_router.py +197 -0
  148. planar/routers/entity_router.py +143 -0
  149. planar/routers/event.py +91 -0
  150. planar/routers/files.py +142 -0
  151. planar/routers/human.py +151 -0
  152. planar/routers/info.py +131 -0
  153. planar/routers/models.py +170 -0
  154. planar/routers/object_config_router.py +133 -0
  155. planar/routers/rule.py +108 -0
  156. planar/routers/test_agents_router.py +174 -0
  157. planar/routers/test_object_config_router.py +367 -0
  158. planar/routers/test_routes_security.py +169 -0
  159. planar/routers/test_rule_router.py +470 -0
  160. planar/routers/test_workflow_router.py +274 -0
  161. planar/routers/workflow.py +468 -0
  162. planar/rules/.decorator.py.un~ +0 -0
  163. planar/rules/.runner.py.un~ +0 -0
  164. planar/rules/.test_rules.py.un~ +0 -0
  165. planar/rules/__init__.py +23 -0
  166. planar/rules/decorator.py +184 -0
  167. planar/rules/models.py +355 -0
  168. planar/rules/rule_configuration.py +191 -0
  169. planar/rules/runner.py +64 -0
  170. planar/rules/test_rules.py +750 -0
  171. planar/scaffold_templates/app/__init__.py.j2 +0 -0
  172. planar/scaffold_templates/app/db/entities.py.j2 +11 -0
  173. planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
  174. planar/scaffold_templates/main.py.j2 +13 -0
  175. planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
  176. planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
  177. planar/scaffold_templates/pyproject.toml.j2 +10 -0
  178. planar/security/.jwt_middleware.py.un~ +0 -0
  179. planar/security/auth_context.py +148 -0
  180. planar/security/authorization.py +388 -0
  181. planar/security/default_policies.cedar +77 -0
  182. planar/security/jwt_middleware.py +116 -0
  183. planar/security/security_context.py +18 -0
  184. planar/security/tests/test_authorization_context.py +78 -0
  185. planar/security/tests/test_cedar_basics.py +41 -0
  186. planar/security/tests/test_cedar_policies.py +158 -0
  187. planar/security/tests/test_jwt_principal_context.py +179 -0
  188. planar/session.py +40 -0
  189. planar/sse/.constants.py.un~ +0 -0
  190. planar/sse/.example.html.un~ +0 -0
  191. planar/sse/.hub.py.un~ +0 -0
  192. planar/sse/.model.py.un~ +0 -0
  193. planar/sse/.proxy.py.un~ +0 -0
  194. planar/sse/constants.py +1 -0
  195. planar/sse/example.html +126 -0
  196. planar/sse/hub.py +216 -0
  197. planar/sse/model.py +8 -0
  198. planar/sse/proxy.py +257 -0
  199. planar/task_local.py +37 -0
  200. planar/test_app.py +51 -0
  201. planar/test_cli.py +372 -0
  202. planar/test_config.py +512 -0
  203. planar/test_object_config.py +527 -0
  204. planar/test_object_registry.py +14 -0
  205. planar/test_sqlalchemy.py +158 -0
  206. planar/test_utils.py +105 -0
  207. planar/testing/.client.py.un~ +0 -0
  208. planar/testing/.memory_storage.py.un~ +0 -0
  209. planar/testing/.planar_test_client.py.un~ +0 -0
  210. planar/testing/.predictable_tracer.py.un~ +0 -0
  211. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  212. planar/testing/.test_memory_storage.py.un~ +0 -0
  213. planar/testing/.workflow_observer.py.un~ +0 -0
  214. planar/testing/__init__.py +0 -0
  215. planar/testing/memory_storage.py +78 -0
  216. planar/testing/planar_test_client.py +54 -0
  217. planar/testing/synchronizable_tracer.py +153 -0
  218. planar/testing/test_memory_storage.py +143 -0
  219. planar/testing/workflow_observer.py +73 -0
  220. planar/utils.py +70 -0
  221. planar/workflows/.__init__.py.un~ +0 -0
  222. planar/workflows/.builtin_steps.py.un~ +0 -0
  223. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  224. planar/workflows/.context.py.un~ +0 -0
  225. planar/workflows/.contrib.py.un~ +0 -0
  226. planar/workflows/.decorators.py.un~ +0 -0
  227. planar/workflows/.durable_test.py.un~ +0 -0
  228. planar/workflows/.errors.py.un~ +0 -0
  229. planar/workflows/.events.py.un~ +0 -0
  230. planar/workflows/.exceptions.py.un~ +0 -0
  231. planar/workflows/.execution.py.un~ +0 -0
  232. planar/workflows/.human.py.un~ +0 -0
  233. planar/workflows/.lock.py.un~ +0 -0
  234. planar/workflows/.misc.py.un~ +0 -0
  235. planar/workflows/.model.py.un~ +0 -0
  236. planar/workflows/.models.py.un~ +0 -0
  237. planar/workflows/.notifications.py.un~ +0 -0
  238. planar/workflows/.orchestrator.py.un~ +0 -0
  239. planar/workflows/.runtime.py.un~ +0 -0
  240. planar/workflows/.serialization.py.un~ +0 -0
  241. planar/workflows/.step.py.un~ +0 -0
  242. planar/workflows/.step_core.py.un~ +0 -0
  243. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  244. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  245. planar/workflows/.test_concurrency.py.un~ +0 -0
  246. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  247. planar/workflows/.test_human.py.un~ +0 -0
  248. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  249. planar/workflows/.test_orchestrator.py.un~ +0 -0
  250. planar/workflows/.test_race_conditions.py.un~ +0 -0
  251. planar/workflows/.test_serialization.py.un~ +0 -0
  252. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  253. planar/workflows/.test_workflow.py.un~ +0 -0
  254. planar/workflows/.tracing.py.un~ +0 -0
  255. planar/workflows/.types.py.un~ +0 -0
  256. planar/workflows/.util.py.un~ +0 -0
  257. planar/workflows/.utils.py.un~ +0 -0
  258. planar/workflows/.workflow.py.un~ +0 -0
  259. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  260. planar/workflows/.wrappers.py.un~ +0 -0
  261. planar/workflows/__init__.py +42 -0
  262. planar/workflows/context.py +44 -0
  263. planar/workflows/contrib.py +190 -0
  264. planar/workflows/decorators.py +217 -0
  265. planar/workflows/events.py +185 -0
  266. planar/workflows/exceptions.py +34 -0
  267. planar/workflows/execution.py +198 -0
  268. planar/workflows/lock.py +229 -0
  269. planar/workflows/misc.py +5 -0
  270. planar/workflows/models.py +154 -0
  271. planar/workflows/notifications.py +96 -0
  272. planar/workflows/orchestrator.py +383 -0
  273. planar/workflows/query.py +256 -0
  274. planar/workflows/serialization.py +409 -0
  275. planar/workflows/step_core.py +373 -0
  276. planar/workflows/step_metadata.py +357 -0
  277. planar/workflows/step_testing_utils.py +86 -0
  278. planar/workflows/sub_workflow_runner.py +191 -0
  279. planar/workflows/test_concurrency_detection.py +120 -0
  280. planar/workflows/test_lock_timeout.py +140 -0
  281. planar/workflows/test_serialization.py +1195 -0
  282. planar/workflows/test_suspend_deserialization.py +231 -0
  283. planar/workflows/test_workflow.py +1967 -0
  284. planar/workflows/tracing.py +106 -0
  285. planar/workflows/wrappers.py +41 -0
  286. planar-0.5.0.dist-info/METADATA +285 -0
  287. planar-0.5.0.dist-info/RECORD +289 -0
  288. planar-0.5.0.dist-info/WHEEL +4 -0
  289. 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
+ ```