planar 0.10.0__py3-none-any.whl → 0.12.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 (73) hide show
  1. planar/app.py +26 -6
  2. planar/cli.py +26 -0
  3. planar/data/__init__.py +1 -0
  4. planar/data/config.py +12 -1
  5. planar/data/connection.py +89 -4
  6. planar/data/dataset.py +13 -7
  7. planar/data/utils.py +145 -25
  8. planar/db/alembic/env.py +68 -57
  9. planar/db/alembic.ini +1 -1
  10. planar/files/storage/config.py +7 -1
  11. planar/routers/dataset_router.py +5 -1
  12. planar/routers/info.py +79 -36
  13. planar/scaffold_templates/pyproject.toml.j2 +1 -1
  14. planar/testing/fixtures.py +7 -4
  15. planar/testing/planar_test_client.py +8 -0
  16. planar/version.py +27 -0
  17. planar-0.12.0.dist-info/METADATA +202 -0
  18. {planar-0.10.0.dist-info → planar-0.12.0.dist-info}/RECORD +20 -71
  19. planar/ai/test_agent_serialization.py +0 -229
  20. planar/ai/test_agent_tool_step_display.py +0 -78
  21. planar/data/test_dataset.py +0 -358
  22. planar/files/storage/test_azure_blob.py +0 -435
  23. planar/files/storage/test_local_directory.py +0 -162
  24. planar/files/storage/test_s3.py +0 -299
  25. planar/files/test_files.py +0 -282
  26. planar/human/test_human.py +0 -385
  27. planar/logging/test_formatter.py +0 -327
  28. planar/modeling/mixins/test_auditable.py +0 -97
  29. planar/modeling/mixins/test_timestamp.py +0 -134
  30. planar/modeling/mixins/test_uuid_primary_key.py +0 -52
  31. planar/routers/test_agents_router.py +0 -174
  32. planar/routers/test_dataset_router.py +0 -429
  33. planar/routers/test_files_router.py +0 -49
  34. planar/routers/test_object_config_router.py +0 -367
  35. planar/routers/test_routes_security.py +0 -168
  36. planar/routers/test_rule_router.py +0 -470
  37. planar/routers/test_workflow_router.py +0 -564
  38. planar/rules/test_data/account_dormancy_management.json +0 -223
  39. planar/rules/test_data/airline_loyalty_points_calculator.json +0 -262
  40. planar/rules/test_data/applicant_risk_assessment.json +0 -435
  41. planar/rules/test_data/booking_fraud_detection.json +0 -407
  42. planar/rules/test_data/cellular_data_rollover_system.json +0 -258
  43. planar/rules/test_data/clinical_trial_eligibility_screener.json +0 -437
  44. planar/rules/test_data/customer_lifetime_value.json +0 -143
  45. planar/rules/test_data/import_duties_calculator.json +0 -289
  46. planar/rules/test_data/insurance_prior_authorization.json +0 -443
  47. planar/rules/test_data/online_check_in_eligibility_system.json +0 -254
  48. planar/rules/test_data/order_consolidation_system.json +0 -375
  49. planar/rules/test_data/portfolio_risk_monitor.json +0 -471
  50. planar/rules/test_data/supply_chain_risk.json +0 -253
  51. planar/rules/test_data/warehouse_cross_docking.json +0 -237
  52. planar/rules/test_rules.py +0 -1494
  53. planar/security/tests/test_auth_middleware.py +0 -162
  54. planar/security/tests/test_authorization_context.py +0 -78
  55. planar/security/tests/test_cedar_basics.py +0 -41
  56. planar/security/tests/test_cedar_policies.py +0 -158
  57. planar/security/tests/test_jwt_principal_context.py +0 -179
  58. planar/test_app.py +0 -142
  59. planar/test_cli.py +0 -394
  60. planar/test_config.py +0 -515
  61. planar/test_object_config.py +0 -527
  62. planar/test_object_registry.py +0 -14
  63. planar/test_sqlalchemy.py +0 -193
  64. planar/test_utils.py +0 -105
  65. planar/testing/test_memory_storage.py +0 -143
  66. planar/workflows/test_concurrency_detection.py +0 -120
  67. planar/workflows/test_lock_timeout.py +0 -140
  68. planar/workflows/test_serialization.py +0 -1203
  69. planar/workflows/test_suspend_deserialization.py +0 -231
  70. planar/workflows/test_workflow.py +0 -2005
  71. planar-0.10.0.dist-info/METADATA +0 -323
  72. {planar-0.10.0.dist-info → planar-0.12.0.dist-info}/WHEEL +0 -0
  73. {planar-0.10.0.dist-info → planar-0.12.0.dist-info}/entry_points.txt +0 -0
planar/db/alembic.ini CHANGED
@@ -70,7 +70,7 @@ version_path_separator = os
70
70
  # so it sometimes incorrectly thinks it needs to re-generate things (like indices) that already
71
71
  # exist in the database from a prior migration. Using postgres obviates that issue.
72
72
  # https://github.com/sqlalchemy/alembic/issues/555
73
- sqlalchemy.url = postgresql+psycopg2://postgres:postgres@localhost:5432/postgres
73
+ sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/postgres
74
74
 
75
75
  # [post_write_hooks]
76
76
  # This section defines scripts or Python functions that are run
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING, Annotated, Literal
4
4
 
5
- from pydantic import BaseModel, Field, model_validator
5
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
6
6
 
7
7
  from .local_directory import LocalDirectoryStorage
8
8
  from .s3 import S3Storage
@@ -15,6 +15,8 @@ class LocalDirectoryConfig(BaseModel):
15
15
  backend: Literal["localdir"]
16
16
  directory: str
17
17
 
18
+ model_config = ConfigDict(frozen=True)
19
+
18
20
 
19
21
  class S3Config(BaseModel):
20
22
  backend: Literal["s3"]
@@ -25,6 +27,8 @@ class S3Config(BaseModel):
25
27
  endpoint_url: str | None = None
26
28
  presigned_url_ttl: int = 3600
27
29
 
30
+ model_config = ConfigDict(frozen=True)
31
+
28
32
 
29
33
  class AzureBlobConfig(BaseModel):
30
34
  backend: Literal["azure_blob"]
@@ -39,6 +43,8 @@ class AzureBlobConfig(BaseModel):
39
43
  # Common settings
40
44
  sas_ttl: int = 3600 # SAS URL expiry time in seconds
41
45
 
46
+ model_config = ConfigDict(frozen=True)
47
+
42
48
  @model_validator(mode="after")
43
49
  def validate_auth_config(self):
44
50
  """Ensure exactly one valid authentication configuration."""
@@ -12,6 +12,7 @@ from planar.data.exceptions import DatasetNotFoundError
12
12
  from planar.data.utils import (
13
13
  get_dataset,
14
14
  get_dataset_metadata,
15
+ get_datasets_metadata,
15
16
  list_datasets,
16
17
  list_schemas,
17
18
  )
@@ -51,9 +52,12 @@ def create_dataset_router() -> APIRouter:
51
52
  validate_authorization_for(DatasetResource(), DatasetAction.DATASET_LIST)
52
53
  datasets = await list_datasets(limit, offset)
53
54
 
55
+ dataset_names = [dataset.name for dataset in datasets]
56
+ metadata_by_dataset = await get_datasets_metadata(dataset_names, schema_name)
57
+
54
58
  response = []
55
59
  for dataset in datasets:
56
- metadata = await get_dataset_metadata(dataset.name, schema_name)
60
+ metadata = metadata_by_dataset.get(dataset.name)
57
61
 
58
62
  if not metadata:
59
63
  continue
planar/routers/info.py CHANGED
@@ -1,16 +1,34 @@
1
+ from typing import Literal, TypedDict
2
+
1
3
  from fastapi import APIRouter, Depends
2
4
  from pydantic import BaseModel
3
- from sqlalchemy.ext.asyncio import AsyncSession
4
- from sqlmodel import col, distinct, func, select
5
+ from sqlmodel import col, func, select
6
+ from sqlmodel.ext.asyncio.session import AsyncSession
5
7
 
8
+ from planar.config import PlanarConfig, get_environment
9
+ from planar.data.config import DataConfig
10
+ from planar.files.storage.config import StorageConfig
6
11
  from planar.human.models import HumanTask, HumanTaskStatus
7
12
  from planar.logging import get_logger
8
- from planar.object_config import ConfigurableObjectType, ObjectConfiguration
13
+ from planar.object_registry import ObjectRegistry
9
14
  from planar.session import get_session
15
+ from planar.version import FALLBACK_VERSION, get_version
10
16
  from planar.workflows.models import Workflow, WorkflowStatus
11
17
 
12
18
  logger = get_logger(__name__)
13
19
 
20
+ StorageInfo = Literal["s3", "localdir", "azure_blob"]
21
+
22
+
23
+ class DatasetsInfo(BaseModel):
24
+ catalog: Literal["duckdb", "postgres", "sqlite"]
25
+ storage: StorageInfo
26
+
27
+
28
+ class SystemFeatures(BaseModel):
29
+ storage: StorageInfo | None = None
30
+ datasets: DatasetsInfo | None = None
31
+
14
32
 
15
33
  class SystemInfo(BaseModel):
16
34
  """Combined application information and system statistics"""
@@ -19,6 +37,11 @@ class SystemInfo(BaseModel):
19
37
  title: str
20
38
  description: str
21
39
 
40
+ version: str
41
+ environment: str
42
+
43
+ features: SystemFeatures
44
+
22
45
  # System stats
23
46
  total_workflow_runs: int = 0
24
47
  completed_runs: int = 0
@@ -27,7 +50,18 @@ class SystemInfo(BaseModel):
27
50
  active_agents: int = 0
28
51
 
29
52
 
30
- async def get_system_stats(session: AsyncSession = Depends(get_session)) -> dict:
53
+ class SystemStats(TypedDict):
54
+ total_workflow_runs: int
55
+ completed_runs: int
56
+ in_progress_runs: int
57
+ pending_human_tasks: int
58
+ active_agents: int
59
+
60
+
61
+ async def get_system_stats(
62
+ registry: ObjectRegistry,
63
+ session: AsyncSession = Depends(get_session),
64
+ ) -> SystemStats:
31
65
  """
32
66
  Get system-wide statistics directly from the database.
33
67
 
@@ -35,8 +69,10 @@ async def get_system_stats(session: AsyncSession = Depends(get_session)) -> dict
35
69
  rather than fetching all records and calculating in the application.
36
70
  """
37
71
  try:
72
+ agent_count = len(registry.get_agents())
73
+
38
74
  # Get workflow run counts
39
- workflow_stats = await session.execute(
75
+ workflow_stats = await session.exec(
40
76
  select(
41
77
  func.count().label("total_runs"),
42
78
  func.count(col(Workflow.id))
@@ -47,42 +83,21 @@ async def get_system_stats(session: AsyncSession = Depends(get_session)) -> dict
47
83
  .label("in_progress_runs"),
48
84
  ).select_from(Workflow)
49
85
  )
50
- workflow_row = workflow_stats.one()
86
+ total_runs, completed_runs, in_progress_runs = workflow_stats.one()
51
87
 
52
88
  # Get pending human task count
53
- human_task_query = await session.execute(
89
+ human_task_query = await session.exec(
54
90
  select(func.count())
55
91
  .select_from(HumanTask)
56
92
  .where(HumanTask.status == HumanTaskStatus.PENDING)
57
93
  )
58
- pending_tasks = human_task_query.scalar() or 0
59
-
60
- # Get agent count from the registry or count distinct agent configs
61
- agent_count = 0
62
- try:
63
- # Count distinct agent names in the AgentConfig table
64
- agent_query = await session.execute(
65
- select(
66
- func.count(distinct(ObjectConfiguration.object_name))
67
- ).select_from(
68
- select(ObjectConfiguration)
69
- .where(
70
- ObjectConfiguration.object_type == ConfigurableObjectType.AGENT
71
- )
72
- .subquery()
73
- )
74
- )
75
- agent_count = agent_query.scalar() or 0
76
- except Exception:
77
- logger.exception("error counting agents")
78
- # Fallback to 0
79
- agent_count = 0
94
+ pending_tasks = human_task_query.one()
80
95
 
81
96
  # Return stats dict
82
97
  return {
83
- "total_workflow_runs": workflow_row.total_runs or 0,
84
- "completed_runs": workflow_row.completed_runs or 0,
85
- "in_progress_runs": workflow_row.in_progress_runs or 0,
98
+ "total_workflow_runs": total_runs,
99
+ "completed_runs": completed_runs,
100
+ "in_progress_runs": in_progress_runs,
86
101
  "pending_human_tasks": pending_tasks,
87
102
  "active_agents": agent_count,
88
103
  }
@@ -98,13 +113,23 @@ async def get_system_stats(session: AsyncSession = Depends(get_session)) -> dict
98
113
  }
99
114
 
100
115
 
101
- def create_info_router(title: str, description: str) -> APIRouter:
116
+ def get_storage_info(cfg: StorageConfig) -> StorageInfo:
117
+ return cfg.backend
118
+
119
+
120
+ def get_datasets_info(cfg: DataConfig) -> DatasetsInfo | None:
121
+ return DatasetsInfo(catalog=cfg.catalog.type, storage=get_storage_info(cfg.storage))
122
+
123
+
124
+ def create_info_router(
125
+ title: str, description: str, config: PlanarConfig, registry: ObjectRegistry
126
+ ) -> APIRouter:
102
127
  """
103
128
  Create a router for serving combined application information and system statistics.
104
129
 
105
130
  This router provides a single endpoint to retrieve the application's title,
106
131
  description, and system-wide statistics on workflow runs, human tasks,
107
- and registered agents.
132
+ and registered agents, as well as the application's features and configuration.
108
133
 
109
134
  Args:
110
135
  title: The application title
@@ -125,7 +150,25 @@ def create_info_router(title: str, description: str) -> APIRouter:
125
150
  Returns:
126
151
  SystemInfo object containing app details and system stats
127
152
  """
128
- stats = await get_system_stats(session)
129
- return SystemInfo(title=title, description=description, **stats)
153
+ stats = await get_system_stats(registry, session)
154
+ version = get_version()
155
+ if version == FALLBACK_VERSION:
156
+ logger.warning(
157
+ "planar package version not found",
158
+ package_name="planar",
159
+ fallback_version=FALLBACK_VERSION,
160
+ )
161
+
162
+ return SystemInfo(
163
+ title=title,
164
+ description=description,
165
+ version=version,
166
+ environment=get_environment(),
167
+ features=SystemFeatures(
168
+ storage=get_storage_info(config.storage) if config.storage else None,
169
+ datasets=get_datasets_info(config.data) if config.data else None,
170
+ ),
171
+ **stats,
172
+ )
130
173
 
131
174
  return router
@@ -3,7 +3,7 @@ name = "{{ name }}"
3
3
  version = "0.1.0"
4
4
  requires-python = ">=3.12"
5
5
  dependencies = [
6
- "planar>=0.9.0",
6
+ "planar[data]>=0.10.0",
7
7
  ]
8
8
 
9
9
  [[tool.uv.index]]
@@ -36,7 +36,7 @@ from pathlib import Path
36
36
  import pytest
37
37
 
38
38
  from planar.app import PlanarApp
39
- from planar.config import load_config
39
+ from planar.config import load_config, load_environment_aware_config
40
40
  from planar.data.config import DataConfig, SQLiteCatalogConfig
41
41
  from planar.db import DatabaseManager, new_session
42
42
  from planar.files.storage.config import LocalDirectoryConfig
@@ -138,9 +138,12 @@ def data_config(tmp_path):
138
138
  @pytest.fixture(name="app_with_data")
139
139
  def app_with_data_fixture(data_config):
140
140
  """Create a PlanarApp with data configuration."""
141
- app = PlanarApp()
142
- # Add data config to the app's config
143
- app.config.data = data_config
141
+ config = load_environment_aware_config()
142
+
143
+ config.data = data_config
144
+
145
+ app = PlanarApp(config=config)
146
+
144
147
  return app
145
148
 
146
149
 
@@ -41,6 +41,14 @@ async def planar_test_client(
41
41
  session_var.reset(token)
42
42
  await wait_all_event_loop_tasks()
43
43
 
44
+ if getattr(app.config, "data", None):
45
+ try:
46
+ from planar.data.connection import reset_connection_cache
47
+ except ImportError:
48
+ pass
49
+ else:
50
+ await reset_connection_cache()
51
+
44
52
 
45
53
  async def wait_all_event_loop_tasks():
46
54
  # Workaround prevent the event loop from exiting before aiosqlite
planar/version.py ADDED
@@ -0,0 +1,27 @@
1
+ """Utilities for working with Planar package version information."""
2
+
3
+ from functools import lru_cache
4
+ from importlib import metadata
5
+ from typing import Final
6
+
7
+ PACKAGE_NAME: Final[str] = "planar"
8
+ FALLBACK_VERSION: Final[str] = "0.0.0.dev0"
9
+
10
+
11
+ @lru_cache(maxsize=1)
12
+ def get_version(
13
+ fallback: str = FALLBACK_VERSION,
14
+ ) -> str:
15
+ """Return the installed Planar version or a fallback value.
16
+
17
+ Args:
18
+ fallback: Value returned if the package metadata is unavailable.
19
+
20
+ Returns:
21
+ The package version string if available, otherwise the fallback value.
22
+ """
23
+
24
+ try:
25
+ return metadata.version(PACKAGE_NAME)
26
+ except metadata.PackageNotFoundError:
27
+ return fallback
@@ -0,0 +1,202 @@
1
+ Metadata-Version: 2.4
2
+ Name: planar
3
+ Version: 0.12.0
4
+ Summary: Batteries-included framework for building durable agentic workflows and business applications.
5
+ License-Expression: LicenseRef-Proprietary
6
+ Requires-Dist: aiofiles>=24.1.0
7
+ Requires-Dist: aiosqlite>=0.21.0
8
+ Requires-Dist: alembic>=1.14.1
9
+ Requires-Dist: anthropic>=0.49.0
10
+ Requires-Dist: asyncpg
11
+ Requires-Dist: boto3>=1.39.15
12
+ Requires-Dist: cedarpy>=4.1.0
13
+ Requires-Dist: fastapi[standard]>=0.115.7
14
+ Requires-Dist: inflection>=0.5.1
15
+ Requires-Dist: openai>=1.75
16
+ Requires-Dist: pydantic-ai-slim[anthropic,bedrock,google,openai]>=0.7.5
17
+ Requires-Dist: pygments>=2.19.1
18
+ Requires-Dist: pyjwt[crypto]
19
+ Requires-Dist: python-multipart>=0.0.20
20
+ Requires-Dist: sqlalchemy[asyncio]>=2.0.37
21
+ Requires-Dist: sqlmodel>=0.0.22
22
+ Requires-Dist: typer>=0.15.2
23
+ Requires-Dist: typing-extensions>=4.12.2
24
+ Requires-Dist: zen-engine>=0.40.0
25
+ Requires-Dist: azure-storage-blob>=12.19.0 ; extra == 'azure'
26
+ Requires-Dist: azure-identity>=1.15.0 ; extra == 'azure'
27
+ Requires-Dist: aiohttp>=3.8.0 ; extra == 'azure'
28
+ Requires-Dist: ducklake>=0.1.1 ; extra == 'data'
29
+ Requires-Dist: ibis-framework[duckdb]>=10.8.0 ; extra == 'data'
30
+ Requires-Dist: polars>=1.31.0 ; extra == 'data'
31
+ Requires-Dist: opentelemetry-api>=1.34.1 ; extra == 'otel'
32
+ Requires-Dist: opentelemetry-exporter-otlp>=1.34.1 ; extra == 'otel'
33
+ Requires-Dist: opentelemetry-instrumentation-logging>=0.55b1 ; extra == 'otel'
34
+ Requires-Dist: opentelemetry-sdk>=1.34.1 ; extra == 'otel'
35
+ Requires-Python: >=3.12
36
+ Provides-Extra: azure
37
+ Provides-Extra: data
38
+ Provides-Extra: otel
39
+ Description-Content-Type: text/markdown
40
+
41
+ # Planar
42
+
43
+ Planar is a batteries-included Python framework for building durable workflows, agent automations, and stateful APIs. Built on FastAPI and SQLModel, it combines orchestration, data modeling, and file management into a cohesive developer experience.
44
+
45
+ ## Feature Highlights
46
+
47
+ - Durable workflow engine with resumable async steps, automatic retries, and suspension points
48
+ - Agent step framework with first-class support for OpenAI, Anthropic, and other providers
49
+ - Human task assignments and rule engine tooling baked into workflow execution
50
+ - SQLModel-powered data layer with Alembic migrations and CRUD scaffolding out of the box
51
+ - Built-in file management and storage adapters for local disk, Amazon S3, and Azure Blob Storage
52
+ - CLI-driven developer workflow with templated scaffolding, hot reload, and environment-aware configuration
53
+
54
+ ## Installation
55
+
56
+ Planar is published on PyPI. Add it to an existing project with `uv`:
57
+
58
+ ```bash
59
+ uv add planar
60
+ ```
61
+
62
+ To explore the CLI without updating `pyproject.toml`, use the ephemeral uvx runner:
63
+
64
+ ```bash
65
+ uvx planar --help
66
+ ```
67
+
68
+ ## Quickstart
69
+
70
+ Generate a new service, start up the dev server, and inspect the auto-generated APIs:
71
+
72
+ ```bash
73
+ uvx planar scaffold --name my_service
74
+ cd my_service
75
+ uv run planar dev src/main.py
76
+ ```
77
+
78
+ Open `http://127.0.0.1:8000/docs` to explore your service's routes and workflow endpoints. The scaffold prints the exact app path if it differs from `src/main.py`.
79
+
80
+ ## Define a Durable Workflow
81
+
82
+ ```python
83
+ from datetime import timedelta
84
+
85
+ from planar import PlanarApp
86
+ from planar.workflows import step, suspend, workflow
87
+
88
+ @step
89
+ async def charge_customer(order_id: str) -> None:
90
+ ...
91
+
92
+ @step
93
+ async def notify_success(order_id: str) -> None:
94
+ ...
95
+
96
+ @workflow
97
+ async def process_order(order_id: str) -> None:
98
+ await charge_customer(order_id)
99
+ await suspend(interval=timedelta(hours=1))
100
+ await notify_success(order_id)
101
+
102
+
103
+ app = PlanarApp()
104
+ app.register_workflow(process_order)
105
+ ```
106
+
107
+ Workflows are async functions composed of resumable steps. Planar persists every step, applies configurable retry policies, and resumes suspended workflows even after process restarts. Check `docs/workflows.md` for deeper concepts including event-driven waits, human steps, and agent integrations.
108
+
109
+ ## Core Capabilities
110
+
111
+ - **Workflow orchestration**: Compose async steps with guaranteed persistence, scheduling, and concurrency control.
112
+ - **Agent steps**: Run LLM-powered actions durably with provider-agnostic adapters and structured prompts.
113
+ - **Human tasks and rules**: Build human-in-the-loop approvals and declarative rule evaluations alongside automated logic.
114
+ - **Stateful data and files**: Model entities with SQLModel, manage migrations through Alembic, and store files using pluggable backends.
115
+ - **Observability**: Structured logging and OpenTelemetry hooks surface workflow progress and performance metrics.
116
+
117
+ ## Command Line Interface
118
+
119
+ ```bash
120
+ uvx planar scaffold --help # generate a new project from the official template
121
+ uv run planar dev [PATH] # run with hot reload and development defaults
122
+ uv run planar prod [PATH] # run with production defaults
123
+ ```
124
+
125
+ `[PATH]` points to the module that exports a `PlanarApp` instance (defaults to `app.py` or `main.py`). Use `--config PATH` to load a specific configuration file and `--app NAME` if your application variable is not named `app`.
126
+
127
+ ## Configuration
128
+
129
+ Planar merges environment defaults with an optional YAML override. By convention it looks for `planar.dev.yaml`, `planar.prod.yaml`, or `planar.yaml` in your project directory, but you can supply a path explicitly via `--config` or the `PLANAR_CONFIG` environment variable.
130
+
131
+ Example minimal override:
132
+
133
+ ```yaml
134
+ ai_providers:
135
+ openai:
136
+ api_key: ${OPENAI_API_KEY}
137
+
138
+ storage:
139
+ directory: .files
140
+ ```
141
+
142
+ For more configuration patterns and workflow design guidance, browse the documents in `docs/`.
143
+
144
+ ## Examples
145
+
146
+ - `examples/expense_approval_workflow` — human approvals with AI agent collaboration
147
+ - `examples/event_based_workflow` — event-driven orchestration and external wakeups
148
+ - `examples/simple_service` — CRUD service paired with workflows
149
+
150
+ Run any example with `uv run planar dev path/to/main.py`.
151
+
152
+ ## Local Development
153
+
154
+ Planar is built with `uv`. Clone the repository and install dev dependencies:
155
+
156
+ ```bash
157
+ uv sync --extra otel
158
+ ```
159
+
160
+ Useful commands:
161
+
162
+ - `uv run ruff check --fix` and `uv run ruff format` to lint and format
163
+ - `uv run pyright` for static type checking
164
+ - `uv run pytest` to run the test suite (use `-n auto` for parallel execution)
165
+ - `uv run pytest --cov=planar` to collect coverage
166
+ - `uv tool install pre-commit && uv tool run pre-commit install` to enable git hooks
167
+
168
+ ### PostgreSQL Test Suite
169
+
170
+ ```bash
171
+ docker run --restart=always --name planar-postgres \
172
+ -e POSTGRES_PASSWORD=postgres \
173
+ -p 127.0.0.1:5432:5432 \
174
+ -d docker.io/library/postgres
175
+
176
+ PLANAR_TEST_POSTGRESQL=1 PLANAR_TEST_POSTGRESQL_CONTAINER=planar-postgres \
177
+ uv run pytest -s
178
+ ```
179
+
180
+ Disable SQLite with `PLANAR_TEST_SQLITE=0`.
181
+
182
+ ### Cairo SVG Dependencies
183
+
184
+ Some AI integration tests convert SVG assets using `cairosvg`. Install Cairo libraries locally before running those tests:
185
+
186
+ ```bash
187
+ brew install cairo libffi pkg-config
188
+ export DYLD_FALLBACK_LIBRARY_PATH="/opt/homebrew/lib:${DYLD_FALLBACK_LIBRARY_PATH}"
189
+ ```
190
+
191
+ Most Linux distributions ship the required libraries via their package manager.
192
+
193
+ ## Documentation
194
+
195
+ Dive deeper into Planar's design and APIs in the `docs/` directory:
196
+
197
+ - `docs/workflows.md`
198
+ - `docs/design/event_based_waiting.md`
199
+ - `docs/design/human_step.md`
200
+ - `docs/design/agent_step.md`
201
+
202
+ For agentic coding tools, use `docs/llm_prompt.md` as a drop-in reference document in whatever tool you are using.