planar 0.9.0__py3-none-any.whl → 0.9.1__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/_version.py CHANGED
@@ -1 +1 @@
1
- VERSION = "0.9.0"
1
+ VERSION = "0.9.1"
planar/app.py CHANGED
@@ -80,7 +80,10 @@ class PlanarApp:
80
80
  )
81
81
  self.policy_service: PolicyService | None = None
82
82
 
83
- self.db_manager = DatabaseManager(db_url=self.config.connection_url())
83
+ self.db_manager = DatabaseManager(
84
+ db_url=self.config.connection_url(),
85
+ entity_schema=self.config.app.entity_schema,
86
+ )
84
87
 
85
88
  if self.config.storage:
86
89
  self.storage = create_from_config(self.config.storage)
@@ -220,9 +223,7 @@ class PlanarApp:
220
223
 
221
224
  # Begin the normal lifespan logic
222
225
  self.db_manager.connect()
223
- await self.db_manager.migrate(
224
- self.config.use_alembic if self.config.use_alembic is not None else True
225
- )
226
+ await self.db_manager.migrate()
226
227
 
227
228
  self.orchestrator = WorkflowOrchestrator(self.db_manager.get_engine())
228
229
  config_tok = config_var.set(self.config)
planar/config.py CHANGED
@@ -127,6 +127,10 @@ DatabaseConfig = Annotated[
127
127
  class AppConfig(BaseModel):
128
128
  db_connection: str
129
129
  max_db_conflict_retries: int | None = None
130
+ # Default schema for user-defined entities (PlanarBaseEntity)
131
+ # Postgres: used as the target schema for user tables
132
+ # SQLite: ignored (SQLite has no schemas)
133
+ entity_schema: str = "planar_entity"
130
134
 
131
135
 
132
136
  def default_storage_config() -> StorageConfig:
@@ -224,7 +228,6 @@ class PlanarConfig(BaseModel):
224
228
  environment: Environment = Environment.DEV
225
229
  security: SecurityConfig = SecurityConfig()
226
230
  logging: dict[str, LoggerConfig] | None = None
227
- use_alembic: bool | None = True
228
231
  otel: OtelConfig | None = None
229
232
  data: DataConfig | None = None
230
233
 
planar/data/dataset.py CHANGED
@@ -212,8 +212,21 @@ class PlanarDataset(BaseModel):
212
212
  else:
213
213
  raise ValueError(f"Unsupported catalog type: {catalog_config.type}")
214
214
 
215
+ try:
216
+ await asyncio.to_thread(con.raw_sql, "INSTALL ducklake")
217
+ match catalog_config.type:
218
+ case "sqlite":
219
+ await asyncio.to_thread(con.raw_sql, "INSTALL sqlite;")
220
+ case "postgres":
221
+ await asyncio.to_thread(con.raw_sql, "INSTALL postgres;")
222
+ logger.debug(
223
+ "installed Ducklake extensions", catalog_type=catalog_config.type
224
+ )
225
+ except Exception as e:
226
+ raise DataError(f"Failed to install Ducklake extensions: {e}") from e
227
+
215
228
  # Build ATTACH statement
216
- attach_sql = f"ATTACH 'ducklake:{metadata_path}'"
229
+ attach_sql = f"ATTACH 'ducklake:{metadata_path}' AS planar_ducklake"
217
230
 
218
231
  # Add data path from storage config
219
232
  storage = data_config.storage
@@ -227,7 +240,11 @@ class PlanarDataset(BaseModel):
227
240
  storage, "directory", "."
228
241
  )
229
242
 
230
- attach_sql += f" (DATA_PATH '{data_path}')"
243
+ ducklake_catalog = data_config.catalog_name
244
+ attach_sql += f" (DATA_PATH '{data_path}'"
245
+ if catalog_config.type != "sqlite":
246
+ attach_sql += f", METADATA_SCHEMA '{ducklake_catalog}'"
247
+ attach_sql += ");"
231
248
 
232
249
  # Attach to Ducklake
233
250
  try:
@@ -235,38 +252,12 @@ class PlanarDataset(BaseModel):
235
252
  except Exception as e:
236
253
  raise DataError(f"Failed to attach to Ducklake: {e}") from e
237
254
 
238
- # Check available catalogs (what ibis calls schemas) and use the correct one
239
- try:
240
- catalogs = await asyncio.to_thread(con.list_catalogs)
241
- logger.debug("available catalogs", catalogs=catalogs)
242
-
243
- # Find the ducklake catalog (it might have a different name pattern)
244
- ducklake_catalog = None
245
- for cat in catalogs:
246
- if "ducklake" in cat.lower() or cat == data_config.catalog_name:
247
- ducklake_catalog = cat
248
- break
249
-
250
- if ducklake_catalog:
251
- if not ducklake_catalog.replace("_", "").replace("-", "").isalnum():
252
- raise DataError(f"Invalid catalog name format: {ducklake_catalog}")
253
- await asyncio.to_thread(con.raw_sql, f"USE {ducklake_catalog}")
254
- logger.debug("using catalog", catalog=ducklake_catalog)
255
- else:
256
- catalog_name = data_config.catalog_name
257
- if not catalog_name.replace("_", "").replace("-", "").isalnum():
258
- raise DataError(f"Invalid catalog name format: {catalog_name}")
259
- await asyncio.to_thread(
260
- con.raw_sql,
261
- f"CREATE SCHEMA IF NOT EXISTS {catalog_name}",
262
- )
263
- await asyncio.to_thread(con.raw_sql, f"USE {catalog_name}")
264
- logger.debug(
265
- "created and using catalog", catalog=data_config.catalog_name
266
- )
267
-
268
- except Exception as e:
269
- logger.warning("failed to set catalog, using default", error=str(e))
270
- # Continue without setting catalog - will use qualified names
255
+ await asyncio.to_thread(con.raw_sql, "USE planar_ducklake;")
256
+ logger.debug(
257
+ "connection created",
258
+ catalog=ducklake_catalog,
259
+ catalog_type=catalog_config.type,
260
+ attach_sql=attach_sql,
261
+ )
271
262
 
272
263
  return con
planar/db/db.py CHANGED
@@ -20,7 +20,10 @@ from sqlmodel.ext.asyncio.session import AsyncSession
20
20
 
21
21
  import planar
22
22
  from planar.logging import get_logger
23
- from planar.modeling.orm.planar_base_entity import PLANAR_APPLICATION_METADATA
23
+ from planar.modeling.orm.planar_base_entity import (
24
+ PLANAR_APPLICATION_METADATA,
25
+ PLANAR_ENTITY_SCHEMA,
26
+ )
24
27
  from planar.utils import P, R, T, U, exponential_backoff_with_jitter
25
28
 
26
29
 
@@ -170,9 +173,12 @@ class DatabaseManager:
170
173
  def __init__(
171
174
  self,
172
175
  db_url: str | URL,
176
+ *,
177
+ entity_schema: str = PLANAR_ENTITY_SCHEMA,
173
178
  ):
174
179
  self.db_url = make_url(db_url) if isinstance(db_url, str) else db_url
175
180
  self.engine: AsyncEngine | None = None
181
+ self.entity_schema = entity_schema
176
182
 
177
183
  def _create_sqlite_engine(self, url: URL) -> AsyncEngine:
178
184
  # in practice this high timeout is only use
@@ -189,9 +195,14 @@ class DatabaseManager:
189
195
  # even though it is the default value.
190
196
  autocommit=LEGACY_TRANSACTION_CONTROL,
191
197
  ),
192
- # SQLite doesn't support schemas, so we need to translate the planar schema
193
- # name to None.
194
- execution_options={"schema_translate_map": {"planar": None}},
198
+ # SQLite doesn't support schemas, so we need to translate the planar and user
199
+ # schema names to None.
200
+ execution_options={
201
+ "schema_translate_map": {
202
+ "planar": None,
203
+ PLANAR_ENTITY_SCHEMA: None,
204
+ }
205
+ },
195
206
  )
196
207
 
197
208
  def do_begin(conn: Connection):
@@ -202,7 +213,12 @@ class DatabaseManager:
202
213
  return engine
203
214
 
204
215
  def _create_postgresql_engine(self, url: URL) -> AsyncEngine:
205
- engine = create_async_engine(url)
216
+ # Map default (PLANAR_ENTITY_SCHEMA) schema to the configured entity schema for user tables.
217
+ # Leave the system table schema ('planar') unmapped so system tables are not overridden.
218
+ schema_map = {PLANAR_ENTITY_SCHEMA: self.entity_schema}
219
+ engine = create_async_engine(
220
+ url, execution_options={"schema_translate_map": schema_map}
221
+ )
206
222
 
207
223
  return engine
208
224
 
@@ -214,6 +230,12 @@ class DatabaseManager:
214
230
 
215
231
  db_backend = self.db_url.get_backend_name()
216
232
 
233
+ if self.entity_schema == PLANAR_SCHEMA:
234
+ logger.warning(
235
+ "entity_schema is set to 'planar'; mixing user and system tables in the same schema is discouraged",
236
+ entity_schema=self.entity_schema,
237
+ )
238
+
217
239
  match db_backend:
218
240
  case "sqlite":
219
241
  logger.info(
@@ -293,27 +315,23 @@ class DatabaseManager:
293
315
  else:
294
316
  # Ensure planar schema exists
295
317
  await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {PLANAR_SCHEMA}"))
318
+ # Ensure the configured entity schema exists
319
+ if self.entity_schema != PLANAR_SCHEMA:
320
+ await conn.execute(
321
+ text(f"CREATE SCHEMA IF NOT EXISTS {self.entity_schema}")
322
+ )
296
323
 
297
- async def migrate(self, use_alembic: bool):
324
+ async def migrate(self):
298
325
  """
299
326
  Runs database migrations.
300
327
  By default, uses SQLModel.metadata.create_all.
301
- Set use_alembic=True to use Alembic (requires Alembic setup).
302
328
  """
303
329
  if not self.engine:
304
330
  raise RuntimeError("Database engine not initialized. Call connect() first.")
305
331
 
306
- logger.info("starting database migration")
307
- if use_alembic:
308
- logger.info("using alembic for migrations")
309
- await self._setup_database()
310
- await self._run_system_migrations()
311
- # For now user migrations are not supported, so we fall back to SQLModel.metadata.create_all
312
- async with self.engine.begin() as conn:
313
- await conn.run_sync(PLANAR_APPLICATION_METADATA.create_all)
314
-
315
- else:
316
- async with self.engine.begin() as conn:
317
- await self._setup_database()
318
- await conn.run_sync(PLANAR_FRAMEWORK_METADATA.create_all)
319
- await conn.run_sync(PLANAR_APPLICATION_METADATA.create_all)
332
+ logger.info("starting database migration with alembic")
333
+ await self._setup_database()
334
+ await self._run_system_migrations()
335
+ # For now user migrations are not supported, so we fall back to SQLModel.metadata.create_all
336
+ async with self.engine.begin() as conn:
337
+ await conn.run_sync(PLANAR_APPLICATION_METADATA.create_all)
@@ -19,15 +19,14 @@ from planar.workflows.decorators import workflow
19
19
  from planar.workflows.execution import execute
20
20
  from planar.workflows.models import Workflow
21
21
 
22
- app = PlanarApp(
23
- config=sqlite_config(":memory:"),
24
- title="Planar app for testing file workflows",
25
- description="Testing",
26
- )
27
-
28
22
 
29
23
  @pytest.fixture(name="app")
30
- def app_fixture():
24
+ def app_fixture(tmp_db_path: str):
25
+ app = PlanarApp(
26
+ config=sqlite_config(tmp_db_path),
27
+ title="Planar app for testing file workflows",
28
+ description="Testing",
29
+ )
31
30
  yield app
32
31
 
33
32
 
@@ -37,10 +37,10 @@ class TestAuditableModel(AuditableMixin, SQLModel, table=True):
37
37
 
38
38
 
39
39
  @pytest.fixture
40
- async def session(mem_db_engine):
40
+ async def session(tmp_db_engine):
41
41
  """Create a database session."""
42
42
 
43
- async with new_session(mem_db_engine) as session:
43
+ async with new_session(tmp_db_engine) as session:
44
44
  await (await session.connection()).run_sync(SQLModel.metadata.create_all)
45
45
  yield session
46
46
 
@@ -12,7 +12,10 @@ from .reexports import SQLModel
12
12
  logger = get_logger("orm.PlanarBaseEntity")
13
13
 
14
14
 
15
- PLANAR_APPLICATION_METADATA = MetaData()
15
+ # Default schema for all entity / user tables, but can be overridden by the user
16
+ # in planar configuration, which db.py uses.
17
+ PLANAR_ENTITY_SCHEMA = "planar_entity"
18
+ PLANAR_APPLICATION_METADATA = MetaData(schema=PLANAR_ENTITY_SCHEMA)
16
19
 
17
20
 
18
21
  class PlanarBaseEntity(UUIDPrimaryKeyMixin, AuditableMixin, SQLModel, table=False):
@@ -15,10 +15,10 @@ from planar.testing.planar_test_client import PlanarTestClient
15
15
 
16
16
 
17
17
  @pytest.fixture(name="app")
18
- def app_fixture():
18
+ def app_fixture(tmp_db_path: str):
19
19
  """Create a test app with agents."""
20
20
  app = PlanarApp(
21
- config=sqlite_config(":memory:"),
21
+ config=sqlite_config(tmp_db_path),
22
22
  title="Test app for agent router",
23
23
  description="Testing agent endpoints",
24
24
  )
@@ -10,9 +10,9 @@ from planar.testing.planar_test_client import PlanarTestClient
10
10
 
11
11
 
12
12
  @pytest.fixture(name="app")
13
- def app_fixture():
13
+ def app_fixture(tmp_db_path: str):
14
14
  return PlanarApp(
15
- config=sqlite_config(":memory:"),
15
+ config=sqlite_config(tmp_db_path),
16
16
  title="Test app for files router",
17
17
  description="Testing files endpoints",
18
18
  )
@@ -41,10 +41,10 @@ class OutputFromTestRule(BaseModel):
41
41
 
42
42
 
43
43
  @pytest.fixture(name="app")
44
- def app_fixture():
44
+ def app_fixture(tmp_db_path: str):
45
45
  """Create a test app with agents and rules."""
46
46
  app = PlanarApp(
47
- config=sqlite_config(":memory:"),
47
+ config=sqlite_config(tmp_db_path),
48
48
  title="Test app for object config router",
49
49
  description="Testing object configuration endpoints",
50
50
  )
@@ -83,9 +83,9 @@ def pricing_rule_with_wrong_type(
83
83
 
84
84
 
85
85
  @pytest.fixture(name="app")
86
- def app_fixture():
86
+ def app_fixture(tmp_db_path: str):
87
87
  app = PlanarApp(
88
- config=sqlite_config(":memory:"),
88
+ config=sqlite_config(tmp_db_path),
89
89
  title="Test app for agent router",
90
90
  description="Testing agent endpoints",
91
91
  )
@@ -120,18 +120,16 @@ async def file_processing_workflow(file: PlanarFile):
120
120
  )
121
121
 
122
122
 
123
- app = PlanarApp(
124
- config=sqlite_config("test_workflow_router.db"),
125
- title="Test Workflow Router API",
126
- description="API for testing workflow routers",
127
- )
128
-
129
-
130
123
  # ------ TESTS ------
131
124
 
132
125
 
133
126
  @pytest.fixture(name="app")
134
- def app_fixture():
127
+ def app_fixture(tmp_db_path: str):
128
+ app = PlanarApp(
129
+ config=sqlite_config(tmp_db_path),
130
+ title="Test Workflow Router API",
131
+ description="API for testing workflow routers",
132
+ )
135
133
  # Re-register workflows since ObjectRegistry gets reset before each test
136
134
  app.register_workflow(expense_approval_workflow)
137
135
  app.register_workflow(file_processing_workflow)
@@ -1,5 +1,4 @@
1
1
  from planar.ai import Agent
2
- from planar.ai.providers import OpenAI
3
2
  from planar.files import PlanarFile
4
3
  from planar.human import Human
5
4
  from planar.rules.decorator import rule
@@ -23,7 +22,7 @@ class RuleOutput(BaseModel):
23
22
 
24
23
  invoice_agent = Agent(
25
24
  name="Invoice Agent",
26
- model=OpenAI.gpt_4_1,
25
+ model="openai:gpt-4.1",
27
26
  tools=[],
28
27
  max_turns=1,
29
28
  system_prompt="Extract vendor and amount from invoice text.",
@@ -3,8 +3,8 @@ name = "{{ name }}"
3
3
  version = "0.1.0"
4
4
  requires-python = ">=3.12"
5
5
  dependencies = [
6
- "planar>=0.6.0",
6
+ "planar>=0.9.0",
7
7
  ]
8
8
 
9
9
  [[tool.uv.index]]
10
- url = "https://coplane.github.io/planar/simple/"
10
+ url = "https://coplane.github.io/planar/simple/"
planar/test_sqlalchemy.py CHANGED
@@ -3,7 +3,7 @@ from uuid import uuid4
3
3
  import pytest
4
4
  from sqlalchemy.exc import DBAPIError
5
5
  from sqlalchemy.ext.asyncio import AsyncEngine
6
- from sqlmodel import col, insert, select
6
+ from sqlmodel import col, insert, select, text
7
7
 
8
8
  from planar.db import PlanarSession, new_session
9
9
  from planar.modeling.orm.planar_base_entity import PlanarBaseEntity
@@ -156,3 +156,38 @@ async def test_serializable_transaction_failure_1(tmp_db_engine: AsyncEngine):
156
156
  # Session 2: Commit should fail with serialization error
157
157
  with pytest.raises(DBAPIError, match="could not serialize access"):
158
158
  await session2.commit()
159
+
160
+
161
+ async def test_entity_schema_and_planar_schema_presence(tmp_db_engine: AsyncEngine):
162
+ table_name = SomeModel.__tablename__
163
+
164
+ async with new_session(tmp_db_engine) as session:
165
+ dialect = session.dialect.name
166
+
167
+ if dialect == "postgresql":
168
+ # Verify schemas include 'planar' and the default entity schema 'planar_entity'
169
+ res = await session.exec(
170
+ text("select schema_name from information_schema.schemata") # type: ignore[arg-type]
171
+ )
172
+ schemas = {row[0] for row in res}
173
+ assert "planar" in schemas
174
+ assert "planar_entity" in schemas
175
+
176
+ # Verify SomeModel table is created in the entity schema
177
+ res = await session.exec(
178
+ text(
179
+ "select table_schema from information_schema.tables where table_name = :tn"
180
+ ).bindparams(tn=table_name) # type: ignore[arg-type]
181
+ )
182
+ table_schemas = {row[0] for row in res}
183
+ assert "planar_entity" in table_schemas
184
+ assert "public" not in table_schemas
185
+
186
+ else:
187
+ # SQLite: no schemas; ensure table exists
188
+ res = await session.exec(
189
+ text("select name from sqlite_master where type='table'") # type: ignore[arg-type]
190
+ )
191
+ tables = {row[0] for row in res}
192
+ assert table_name in tables
193
+ assert not any(name.startswith("planar.") for name in tables)
@@ -254,23 +254,9 @@ async def tmp_db_engine(tmp_db_url: str):
254
254
  yield engine
255
255
 
256
256
 
257
- @pytest.fixture()
258
- async def mem_db_engine(tmp_db_engine):
259
- # Memory databases don't work well with aiosqlite due to the "database
260
- # table is locked" error (which doesn't respect timeouts), so just use a
261
- # temporary db file for now until we figure out a fix.
262
- yield tmp_db_engine
263
-
264
- # name = uuid4()
265
- # async with engine_context(
266
- # f"sqlite+aiosqlite:///file:{name}?mode=memory&cache=shared&uri=true"
267
- # ) as engine:
268
- # yield engine
269
-
270
-
271
257
  @pytest.fixture(name="session")
272
- async def session_fixture(mem_db_engine):
273
- async with new_session(mem_db_engine) as session:
258
+ async def session_fixture(tmp_db_engine):
259
+ async with new_session(tmp_db_engine) as session:
274
260
  tok = session_var.set(session)
275
261
  yield session
276
262
  session_var.reset(tok)
@@ -318,7 +304,7 @@ async def tracer_fixture():
318
304
  async def engine_context(url: str):
319
305
  db_manager = DatabaseManager(url)
320
306
  db_manager.connect()
321
- await db_manager.migrate(use_alembic=True)
307
+ await db_manager.migrate()
322
308
  engine = db_manager.get_engine()
323
309
  tok = engine_var.set(engine)
324
310
  yield engine
@@ -26,7 +26,7 @@ async def long_running_workflow():
26
26
  return "finished"
27
27
 
28
28
 
29
- async def test_lock_timer_extension(mem_db_engine):
29
+ async def test_lock_timer_extension(tmp_db_engine):
30
30
  tracer = SynchronizableTracer()
31
31
  tracer_var.set(tracer)
32
32
  lock_acquired = tracer.instrument(
@@ -36,7 +36,7 @@ async def test_lock_timer_extension(mem_db_engine):
36
36
  TraceSpec(function_name="lock_heartbeat", message="commit")
37
37
  )
38
38
 
39
- async with new_session(mem_db_engine) as session:
39
+ async with new_session(tmp_db_engine) as session:
40
40
  # This test verifies that when a workflow is executing, the heartbeat task
41
41
  # (lock_heartbeat) extends the workflow's lock_until field. We run a
42
42
  # long-running workflow (which sleeps for 1 second) with a short lock
@@ -95,7 +95,7 @@ async def crashed_worker_workflow():
95
95
  return "completed"
96
96
 
97
97
 
98
- async def test_orchestrator_resumes_crashed_worker(mem_db_engine):
98
+ async def test_orchestrator_resumes_crashed_worker(tmp_db_engine):
99
99
  # This test simulates the scenario where a worker has “crashed” after
100
100
  # locking a workflow. We start a workflow that suspends. Then we add a LockedResource
101
101
  # record with an expired lock_until time to simulate a crashed
@@ -105,7 +105,7 @@ async def test_orchestrator_resumes_crashed_worker(mem_db_engine):
105
105
  # the workflow to be resumed. Finally, we verify that the workflow
106
106
  # completes successfully. Start the workflow – its first execution will
107
107
  # suspend.
108
- async with new_session(mem_db_engine) as session:
108
+ async with new_session(tmp_db_engine) as session:
109
109
  session_var.set(session)
110
110
  wf = await crashed_worker_workflow.start()
111
111
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: planar
3
- Version: 0.9.0
3
+ Version: 0.9.1
4
4
  Summary: Add your description here
5
5
  License-Expression: LicenseRef-Proprietary
6
6
  Requires-Python: >=3.12
@@ -61,8 +61,8 @@ The workflow system in Planar is a sophisticated orchestration framework that en
61
61
  - Concurrency Control: Uses a locking mechanism to prevent multiple executions
62
62
  - Recovery: Can recover from crashes by detecting stalled workflows
63
63
  3. Main Components:
64
- - @workflow decorator: Marks a function as a workflow with persistence
65
- - @step decorator: Wraps function calls inside a workflow to make them resumable
64
+ - `@workflow` decorator: Marks a function as a workflow with persistence
65
+ - `@step` decorator: Wraps function calls inside a workflow to make them resumable
66
66
  - Suspend class: Allows pausing workflow execution
67
67
  - workflow_orchestrator: Background task that finds and resumes suspended workflows
68
68
  4. REST API Integration:
@@ -78,18 +78,20 @@ The workflow system in Planar is a sophisticated orchestration framework that en
78
78
 
79
79
  Planar builds on Python's async/await system but adds durability. When you create a workflow:
80
80
 
81
+ ```python
81
82
  @workflow
82
83
  async def process_order(order_id: str):
83
84
  # workflow steps
85
+ ```
84
86
 
85
87
  The system:
86
88
 
87
- 1. Enforces that all workflows and steps must be coroutines (async def)
88
- 2. Accesses the underlying generator of the coroutine via coro.__await__()
89
- 3. Manually drives this generator by calling next(gen) and gen.send(result)
89
+ 1. Enforces that all workflows and steps must be coroutines (`async def`)
90
+ 2. Accesses the underlying generator of the coroutine via `coro.__await__()`
91
+ 3. Manually drives this generator by calling `next(gen)` and `gen.send(result)`
90
92
  4. Intercepts any values yielded from the coroutine to implement suspension
91
93
 
92
- The execute() function (lines 278-335) is the core that drives coroutine execution. It:
94
+ The `execute()` function (lines 278-335) is the core that drives coroutine execution. It:
93
95
  - Takes control of the coroutine's generator
94
96
  - Processes each yielded value
95
97
  - Handles regular awaits vs. suspensions differently
@@ -99,6 +101,7 @@ The workflow system in Planar is a sophisticated orchestration framework that en
99
101
 
100
102
  The Suspend class (lines 55-72) enables pausing workflows:
101
103
 
104
+ ```python
102
105
  class Suspend:
103
106
  def __init__(self, *, wakeup_at=None, interval=None):
104
107
  # Set when to wake up
@@ -106,36 +109,42 @@ The workflow system in Planar is a sophisticated orchestration framework that en
106
109
  def __await__(self):
107
110
  result = yield self
108
111
  return result
112
+ ```
109
113
 
110
114
  When you call:
115
+ ```python
111
116
  await suspend(interval=timedelta(minutes=5))
117
+ ```
112
118
 
113
119
  What happens:
114
- 1. The suspend() function uses the @step() decorator to mark it as resumable
120
+ 1. The `suspend()` function uses the `@step()` decorator to mark it as resumable
115
121
  2. Inside it creates and awaits a Suspend object
116
- 3. The __await__ method yields self (the Suspend instance) to the executor
117
- 4. The execute() function detects this is a Suspend object (lines 303-307)
122
+ 3. The `__await__` method yields self (the Suspend instance) to the executor
123
+ 4. The `execute()` function detects this is a Suspend object (lines 303-307)
118
124
  5. It sets the workflow status to SUSPENDED and persists the wake-up time
119
- 6. Later, the orchestrator finds workflows ready to resume based on wakeup_at
125
+ 6. Later, the orchestrator finds workflows ready to resume based on `wakeup_at`
120
126
  7. When resumed, execution continues right after the suspension point
121
127
 
122
128
  YieldWrapper
123
129
 
124
130
  The YieldWrapper class (lines 48-53) is crucial for handling regular async operations:
125
131
 
132
+ ```python
126
133
  class YieldWrapper:
127
134
  def __init__(self, value):
128
135
  self.value = value
129
136
  def __await__(self):
130
137
  return (yield self.value)
138
+ ```
131
139
 
132
140
  For non-Suspend yields (regular awaits), the system:
133
- 1. Wraps the yielded value in YieldWrapper
134
- 2. Awaits it to get the result from asyncio
141
+ 1. Wraps the yielded value in `YieldWrapper`
142
+ 2. Awaits it to get the result from `asyncio`
135
143
  3. Sends the result back to the workflow's generator
136
144
 
137
145
  This allows you to use normal async functions inside workflows:
138
146
 
147
+ ```python
139
148
  @workflow
140
149
  async def my_workflow():
141
150
  # This works because YieldWrapper passes through regular awaits
@@ -144,6 +153,7 @@ The workflow system in Planar is a sophisticated orchestration framework that en
144
153
  await suspend(interval=timedelta(hours=1))
145
154
  # When resumed days later, continues here
146
155
  return process_result(data)
156
+ ```
147
157
 
148
158
  The magic is that the workflow appears to be a normal async function, but the state is persisted across suspensions, allowing workflows to survive
149
159
  process restarts or even server reboots.
@@ -1,8 +1,8 @@
1
1
  planar/__init__.py,sha256=FAYRGjuJOH2Y_XYFA0-BrRFjuKdPzIShNbaYwJbtu6A,499
2
- planar/_version.py,sha256=4bF_N3mS34eGDXoMdYuijyM1JYXxzz7J-NCugplemp0,18
3
- planar/app.py,sha256=1X0ZPW7nmf6RhMzQgjtyKD_WO-E4tjM7ItOP-bjvocQ,18505
2
+ planar/_version.py,sha256=MDn0Ro0DvGxuAuRTGL8IBqcm5nbo1P640CIS7xBBu2k,18
3
+ planar/app.py,sha256=VEs4jDlcisyOy9I9zEGMG_-Qm8ULKT36CSHjqrYit3o,18491
4
4
  planar/cli.py,sha256=2ObR5XkLGbdbnDqp5mrBzDVhSacHCNsVNSHnXkrMQzQ,9593
5
- planar/config.py,sha256=ghZNDqncwPK3LjvkMkv0BNnN1w-7ALpDBZWc1-JanTE,17634
5
+ planar/config.py,sha256=6J42G9rEVUiOyCAY3EwUTU3PPmWthGTnrHMzST9TMcc,17809
6
6
  planar/dependencies.py,sha256=PH78fGk3bQfGnz-AphxH49307Y0XVgl3EY0LdGJnoik,1008
7
7
  planar/object_registry.py,sha256=RMleX5XE8OKDxlnMeyLpJ1Y280duub-tx1smR1zTlDg,3219
8
8
  planar/registry_items.py,sha256=UhZRIpbSoa_CV9OTl17pJfRLxItYp4Pxd9f5ZbJkGaM,2055
@@ -13,7 +13,7 @@ planar/test_cli.py,sha256=faR6CSuooHHyyB5Yt-p8CIr7mGtKrrU2TLQbc4Oe9bA,13834
13
13
  planar/test_config.py,sha256=HcmDu1nwKZZhzHQLGVyP9oxje-_g_XubEsvzRj28QPg,14328
14
14
  planar/test_object_config.py,sha256=izn4s2HmSDWpGtgpOTDmKeUYN2-63WDR1QtVQrT-x00,20135
15
15
  planar/test_object_registry.py,sha256=R7IwbB2GACm2HUuVZTeVY4V12XB9_JgSSeppPxiCdfs,480
16
- planar/test_sqlalchemy.py,sha256=F0aKqm5tStQj_Mqjh50kiLX4r7kphBFDOUDu_Iw7S3s,5573
16
+ planar/test_sqlalchemy.py,sha256=QTloaipWiFmlLTBGH6YCRkwi1R27gmQZnwprO7lPLfU,7058
17
17
  planar/test_utils.py,sha256=gKenXotj36SN_bb3bQpYPfD8t06IjnGBQqEgWpujHcA,3086
18
18
  planar/utils.py,sha256=v7q9AJyWgQWl9VPSN_0qxw3rBvYe-_Pb_KcwqSsjOFU,3103
19
19
  planar/ai/__init__.py,sha256=ABOKvqQOLlVJkptcvXcuLjVZZWEsK8h-1RyFGK7kib8,231
@@ -26,18 +26,18 @@ planar/ai/test_agent_serialization.py,sha256=zYLIxhYdFhOZzBrEBoQNyYLyNcNxWwaMTkj
26
26
  planar/ai/utils.py,sha256=WVBW0TGaoKytC4bNd_a9lXrBf5QsDRut4GBcA53U2Ww,3116
27
27
  planar/data/__init__.py,sha256=LwrWl925w1CN0aW645Wpj_kDp0B8j5SsPzjr9iyrcmI,285
28
28
  planar/data/config.py,sha256=zp6ChI_2MUMbupEVQNY-BxzcdLvejXG33DCp0BujGVU,1209
29
- planar/data/dataset.py,sha256=5SdOh7NzJdTpuleNkoA_UA-C4WJM8mcNLcvGsRIiqIs,10150
29
+ planar/data/dataset.py,sha256=P0NVE2OvJcXMKqVylYczY2lSGR0pSWlPAHM_upKoBWQ,9507
30
30
  planar/data/exceptions.py,sha256=AlhGQ_TReyEzfPSlqoXCjoZ1206Ut7dS4lrukVfGHaw,358
31
31
  planar/data/test_dataset.py,sha256=w2kay2PE-BhkryM3cOKX0nzSr2G0nCJxDuW1QCeFbyk,9985
32
32
  planar/db/__init__.py,sha256=SNgB6unQ1f1E9dB9O-KrsPsYM17KLsgOW1u0ajqs57I,318
33
33
  planar/db/alembic.ini,sha256=8G9IWbmF61Vwp1BXbkNOXTTgCEUMBQhOK_e-nnpnSYY,4309
34
- planar/db/db.py,sha256=dELx4CHZkOAxFgHsW8qPlx2kUZ8J7cTlU6rHljMV_vg,12102
34
+ planar/db/db.py,sha256=VNpHH1R62tdWVLIV1I2ULmw3B8M6-RsM2ALG3VAVjSg,12790
35
35
  planar/db/alembic/env.py,sha256=cowI6O_4BMJPqDAukkbg69lzdsE44soi3ysxKGXbS_w,5207
36
36
  planar/db/alembic/script.py.mako,sha256=Cl7ixgLNtLk1gF5xFNXOnC9YYLX4cpFd8yHtEyY0_dY,699
37
37
  planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py,sha256=1FbzJyfapjegM-Mxd3HMMVA-8zVU6AnrnzEgIoc6eoQ,13204
38
38
  planar/files/__init__.py,sha256=fms64l32M8hPK0SINXxNCykr2EpjBTcdgnezVgaCwkc,120
39
39
  planar/files/models.py,sha256=zbZvMkoqoSnn7yOo26SRtEgtlHJbFIvwSht75APHQXk,6145
40
- planar/files/test_files.py,sha256=2GFAz39dIf6-bhmJaDFeOpS2sZN7VnLI60ky-e170Mc,8921
40
+ planar/files/test_files.py,sha256=nclsbLnbijCWQ-Aj8Yvo06hs72PygL1Wps7uk7716sc,8957
41
41
  planar/files/storage/azure_blob.py,sha256=PzCm8ZpyAMH9-N6VscTlLpud-CBLcQX9qC6YjbOSfZg,12316
42
42
  planar/files/storage/base.py,sha256=KO7jyKwjKg5fNSLvhxJWE-lsypv6LXXf7bgA34aflwY,2495
43
43
  planar/files/storage/config.py,sha256=jE9Dn6cG_a4x9pdaZkasOxjyWkK6hmplLrPjEsRXGLM,3473
@@ -63,13 +63,13 @@ planar/modeling/field_helpers.py,sha256=9SOHTWPzjlaiq7RF88wjug3NvAywFurcHn651YL_
63
63
  planar/modeling/json_schema_generator.py,sha256=NDqPkWQA_I7ywQXCEQfj5ub9u5KAFEcSQpXVkrCluV4,2864
64
64
  planar/modeling/mixins/__init__.py,sha256=Lwg5eL4VFfv61FRBvH5OZqIyfrSogxQlYLUDnWnSorg,320
65
65
  planar/modeling/mixins/auditable.py,sha256=WP7aDWVn1j22ZffKzYRpu23JQJ4vvHCU1qxcbgChwhc,1619
66
- planar/modeling/mixins/test_auditable.py,sha256=zIa63_0Y19wMF7oIvMcOj7RoIVH7ztQzis9kHFEKKR8,2912
66
+ planar/modeling/mixins/test_auditable.py,sha256=RSYesWWBysFEWTD39-yDhww3wCe1OPN9Yt3ywGmx4d8,2912
67
67
  planar/modeling/mixins/test_timestamp.py,sha256=oLKPvr8oUdjJPJRif81nn4YV_uwbxql_ojjXdKI7j7E,4366
68
68
  planar/modeling/mixins/test_uuid_primary_key.py,sha256=t9ZoB0dS4jjJVrHic7EeEh_g3eZeYV3mr0ylv3Kr1Io,1575
69
69
  planar/modeling/mixins/timestamp.py,sha256=-eHndCWztDiOxfCI2UknmphGeoHMJVDJG1Lz4KtkQUA,1641
70
70
  planar/modeling/mixins/uuid_primary_key.py,sha256=O1BtuXk5GdsfpwTS6nGZm1GNi0pdXKQ6Kt2f5ZjiuMc,453
71
71
  planar/modeling/orm/__init__.py,sha256=QwPgToKEf_gCdjAjKKmgh0xLTHGsboK1kH1a1a0tSy0,339
72
- planar/modeling/orm/planar_base_entity.py,sha256=nL1kT2k__F9Lmo1SW2XEfB6vJi_Fg72u-wW1F79eFTs,959
72
+ planar/modeling/orm/planar_base_entity.py,sha256=7X0d86mWABleTWpulnA91P80ZjlUdGd6bv-L_TIhR2Q,1151
73
73
  planar/modeling/orm/query_filter_builder.py,sha256=Q2qWojo1Pzkt3HY1DdkBINlsP7uTOg1OOSUmwlzjTs8,5335
74
74
  planar/modeling/orm/reexports.py,sha256=sP7nw8e1yp1cahpfsefO84P5n4TNnBRk1jVHuCuH4U4,290
75
75
  planar/object_config/__init__.py,sha256=8LbI3teg3jCKoUznZ7cal22C1URnHtJMpBokCHZQUWo,352
@@ -85,12 +85,12 @@ planar/routers/info.py,sha256=HQa-mumw4zitG61V9isJlZ3cMr8pEwlB54Ct_LrpJDo,4473
85
85
  planar/routers/models.py,sha256=RwXjXpJw2uyluM4Fjc34UA0Jm7J95cUjbmTTarD_P9k,4669
86
86
  planar/routers/object_config_router.py,sha256=zA8-gGBQp1-Gm3uCC4WJ6nLicFwt4CsCqCYLFp1lRN8,4802
87
87
  planar/routers/rule.py,sha256=d6giUwYRKzxQFPeoWbe8Ylp2Cxd71_uK8yoS9NrOOBg,3563
88
- planar/routers/test_agents_router.py,sha256=d_d_lZT5zuSxNY2MEu51SmgLRGNZ3yCpGUooAXLpEaY,6082
89
- planar/routers/test_files_router.py,sha256=_uYpRJkxSxyjFJAG7aj3letx25iDSkaOgZDTRHfU8TU,1559
90
- planar/routers/test_object_config_router.py,sha256=HBOsQZXccPuWOLCPxEsduSd93loswUsbSk3eTM6KHRc,11389
88
+ planar/routers/test_agents_router.py,sha256=jzRCLB21YcEfhaFUos_gfRp9WDdP38_cozTQkHbi9b4,6099
89
+ planar/routers/test_files_router.py,sha256=HfZF1zeJ9BD2jhE6s698Jo7sDpO55RJF5g1Ksup4jtM,1576
90
+ planar/routers/test_object_config_router.py,sha256=JpzoNlNONgljmGJYtrXnhtGhKjUT3YQMhP89fnt7dn4,11406
91
91
  planar/routers/test_routes_security.py,sha256=lXHeYg_th4UaDWeANM-dzONF8p2bEtwXJYYUlftE9R8,5556
92
- planar/routers/test_rule_router.py,sha256=gCJO7-NVjkio-0ZHY2hNgPvubN-0NAPA3Hg5Jcrwe70,17203
93
- planar/routers/test_workflow_router.py,sha256=rjK1Eau-YWs3vZbuJ50Ae_8I8_8TfxQA0F8I2HeDl9o,16572
92
+ planar/routers/test_rule_router.py,sha256=08fa4sc7RaXvQzPCQQ4LaftfXuQwoPEDzcS4lesPG2Q,17220
93
+ planar/routers/test_workflow_router.py,sha256=w3Gl1Okr2FgKMIqcum3gD6XzVrYm82oSIj_znbtd-aQ,16592
94
94
  planar/routers/workflow.py,sha256=8R35ENZeB5Mdt7WfH2zZ75BPg2oQ9d8kL47P1qvP-7Q,18045
95
95
  planar/rules/__init__.py,sha256=lF3F8Rdf2ottjiJu0IeBdqhg1bckLhOqZFI2t-8KItM,474
96
96
  planar/rules/decorator.py,sha256=nxT17n9uwfXMOlk5lliw_cRS7Y83gMI6CQdrf_pB5yk,6666
@@ -115,10 +115,10 @@ planar/rules/test_data/warehouse_cross_docking.json,sha256=IPfcgNkY2sds301BeW6Cj
115
115
  planar/scaffold_templates/main.py.j2,sha256=HcV0PVzcyRDaJvNdDQIFiDR1MJlLquNQzNO9oNkCKDQ,322
116
116
  planar/scaffold_templates/planar.dev.yaml.j2,sha256=I5-IqX7GJm6qA91WtUMw43L4hKACqgnER_H2racim4c,998
117
117
  planar/scaffold_templates/planar.prod.yaml.j2,sha256=FahJ2atDtvVH7IUCatGq6h9hmyF8meeiWC8RLfWphOQ,867
118
- planar/scaffold_templates/pyproject.toml.j2,sha256=zD8UaEwK3uUYoGQuFuPZZCNdvqNS0faGrNHbD081DAU,182
118
+ planar/scaffold_templates/pyproject.toml.j2,sha256=nFfHWLp0sFK8cqjkdwBm6Hi6xsPzTNkaBeSgdTWTS-Q,183
119
119
  planar/scaffold_templates/app/__init__.py.j2,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
120
120
  planar/scaffold_templates/app/db/entities.py.j2,sha256=wg9O3JtRaRMKlDtoWHHodyNRL0s1UILvsr9fCQ_O2-4,279
121
- planar/scaffold_templates/app/flows/process_invoice.py.j2,sha256=nVJ5BlhOkHFEGkBuQNEF5G2P5HA1FGx25NSS5RXBUsw,1728
121
+ planar/scaffold_templates/app/flows/process_invoice.py.j2,sha256=R3EII_O2DHV1kvffW_AApZyaS6rR9eikcpxI08XH9dI,1691
122
122
  planar/security/auth_context.py,sha256=i63JkHQ3oXNlTis7GIKRkZJbkcvZhD2jVDuO7blgbSc,5068
123
123
  planar/security/auth_middleware.py,sha256=Grrm0i2bstWZ83ukrNZsHvFbNzffN0rvbbCcb2OxRY0,5746
124
124
  planar/security/authorization.py,sha256=zoej88_VINVNSDXm7u2LJbwOpMqmXBKj_pmCaPTar7M,11721
@@ -135,7 +135,7 @@ planar/sse/hub.py,sha256=5jhfk7zdCivau3TT1MxU2qtvETSskhqEiXzt-t0sRpE,6859
135
135
  planar/sse/model.py,sha256=fU_Fx9LS2ouS6-Dj1TIF-PLGul9YratKWafoWfZR1gc,123
136
136
  planar/sse/proxy.py,sha256=aJGo_-JIeQ0xSmE4HJdulZxIgCVRsBMMXqqSqtPvTvo,9177
137
137
  planar/testing/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
138
- planar/testing/fixtures.py,sha256=UEg12Ohj2Oip94CrC_wQ9rch71wctUZ1fYNryJLsPUY,9149
138
+ planar/testing/fixtures.py,sha256=spK7iL1NSv-d8fd139ep-SDogZR2ZycGkD_voSAPPF4,8662
139
139
  planar/testing/memory_storage.py,sha256=apcuFisC3hW9KiU3kO8zwHQ6oK9Lu20NSX5fJ0LSZUY,2824
140
140
  planar/testing/planar_test_client.py,sha256=qPkI_ZHZho_38PpdSmEjcRBO1iHcIx3dOwo7c02Am10,1979
141
141
  planar/testing/synchronizable_tracer.py,sha256=SWeta1CgwGsN5duC0FR8NyXOQ1b1L8nDpvGdjZVJ9Bg,4938
@@ -160,13 +160,13 @@ planar/workflows/step_metadata.py,sha256=7hwcIm6ot8m-iUXSYCbPmkg6bWegF6_RJ1stInv
160
160
  planar/workflows/step_testing_utils.py,sha256=WiTwxB4mM2y6dW7CJ3PlIR1BkBodSxQV7-S25pQ3Ycs,2361
161
161
  planar/workflows/sub_workflow_runner.py,sha256=EpS7DhhXRbC6ABm-Sho6Uyxh2TqCjcTPDYvcTQN4FjY,8313
162
162
  planar/workflows/test_concurrency_detection.py,sha256=yfgvLOMkPaK7EiW4ihm1KQx82Y-s9pB6uJhBfDi7PwQ,4528
163
- planar/workflows/test_lock_timeout.py,sha256=WAQttTjmetTLiDHb9j0o_ydvF6j4TV1SaY_iNH8Y5vk,5746
163
+ planar/workflows/test_lock_timeout.py,sha256=H78N090wJtiEg6SaJosfRWijpX6HwnyWyNNb7WaGPe0,5746
164
164
  planar/workflows/test_serialization.py,sha256=JfaveBRQTNMkucqkTorIMGcvi8S0j6uRtboFaWpCmes,39586
165
165
  planar/workflows/test_suspend_deserialization.py,sha256=ddw2jToSJ-ebQ0RfT7KWTRMCOs1nis1lprQiGIGuaJ0,7751
166
166
  planar/workflows/test_workflow.py,sha256=KArm9m44IBXKY9j4v_O74MAweFN6jEb7tVRomziaeFU,64011
167
167
  planar/workflows/tracing.py,sha256=E7E_kj2VBQisDqrllviIshbvOmB9QcEeRwMapunqio4,2732
168
168
  planar/workflows/wrappers.py,sha256=KON6RGg1D6yStboNbuMEeTXRpPTEa8S6Elh1tOnMAlM,1149
169
- planar-0.9.0.dist-info/METADATA,sha256=ak3k4S6dRs-2cuotMeuTZ2Egv0syC2rHER_zL706OFk,12199
170
- planar-0.9.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
171
- planar-0.9.0.dist-info/entry_points.txt,sha256=ZtFgrZ0eeoVmhLA51ESipK0nHg2t_prjW0Cm8WhpP54,95
172
- planar-0.9.0.dist-info/RECORD,,
169
+ planar-0.9.1.dist-info/METADATA,sha256=dXMHhfpYKGO-5bwwHaSSJLH2LyKxODUOwj0zTtT6gnY,12303
170
+ planar-0.9.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
171
+ planar-0.9.1.dist-info/entry_points.txt,sha256=ZtFgrZ0eeoVmhLA51ESipK0nHg2t_prjW0Cm8WhpP54,95
172
+ planar-0.9.1.dist-info/RECORD,,
File without changes