planar 0.7.0__py3-none-any.whl → 0.9.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.
@@ -0,0 +1,166 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ from dataclasses import dataclass, field
5
+ from typing import (
6
+ Any,
7
+ Callable,
8
+ Coroutine,
9
+ Type,
10
+ cast,
11
+ overload,
12
+ )
13
+
14
+ from pydantic import BaseModel
15
+
16
+ from planar.ai.models import AgentConfig, AgentEventEmitter, AgentRunResult
17
+ from planar.logging import get_logger
18
+ from planar.modeling.field_helpers import JsonSchema
19
+ from planar.utils import P, R, T, U
20
+ from planar.workflows import as_step
21
+ from planar.workflows.models import StepType
22
+
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ @dataclass
27
+ class AgentBase[
28
+ # TODO: add `= str` default when we upgrade to 3.13
29
+ TInput: BaseModel | str,
30
+ TOutput: BaseModel | str,
31
+ ](abc.ABC):
32
+ """An LLM-powered agent that can be called directly within workflows."""
33
+
34
+ name: str
35
+ system_prompt: str
36
+ output_type: Type[TOutput] | None = None
37
+ input_type: Type[TInput] | None = None
38
+ user_prompt: str = ""
39
+ tools: list[Callable] = field(default_factory=list)
40
+ max_turns: int = 2
41
+ model_parameters: dict[str, Any] = field(default_factory=dict)
42
+ event_emitter: AgentEventEmitter | None = None
43
+ durable: bool = True
44
+
45
+ # TODO: move here to serialize to frontend
46
+ #
47
+ # built_in_vars: Dict[str, str] = field(default_factory=lambda: {
48
+ # "datetime_now": datetime.datetime.now().isoformat(),
49
+ # "date_today": datetime.date.today().isoformat(),
50
+ # })
51
+
52
+ def __post_init__(self):
53
+ if self.input_type:
54
+ if (
55
+ not issubclass(self.input_type, BaseModel)
56
+ and self.input_type is not str
57
+ ):
58
+ raise ValueError(
59
+ "input_type must be 'str' or a subclass of a Pydantic model"
60
+ )
61
+ if self.max_turns < 1:
62
+ raise ValueError("Max_turns must be greater than or equal to 1.")
63
+ if self.tools and self.max_turns <= 1:
64
+ raise ValueError(
65
+ "For tool calling to work, max_turns must be greater than 1."
66
+ )
67
+
68
+ def input_schema(self) -> JsonSchema | None:
69
+ if self.input_type is None:
70
+ return None
71
+ if self.input_type is str:
72
+ return None
73
+ assert issubclass(self.input_type, BaseModel), (
74
+ "input_type must be a subclass of BaseModel or str"
75
+ )
76
+ return self.input_type.model_json_schema()
77
+
78
+ def output_schema(self) -> JsonSchema | None:
79
+ if self.output_type is None:
80
+ return None
81
+ if self.output_type is str:
82
+ return None
83
+ assert issubclass(self.output_type, BaseModel), (
84
+ "output_type must be a subclass of BaseModel or str"
85
+ )
86
+ return self.output_type.model_json_schema()
87
+
88
+ @overload
89
+ async def __call__(
90
+ self: "AgentBase[TInput, str]",
91
+ input_value: TInput,
92
+ ) -> AgentRunResult[str]: ...
93
+
94
+ @overload
95
+ async def __call__(
96
+ self: "AgentBase[TInput, TOutput]",
97
+ input_value: TInput,
98
+ ) -> AgentRunResult[TOutput]: ...
99
+
100
+ def as_step_if_durable(
101
+ self,
102
+ func: Callable[P, Coroutine[T, U, R]],
103
+ step_type: StepType,
104
+ display_name: str | None = None,
105
+ return_type: Type[R] | None = None,
106
+ ) -> Callable[P, Coroutine[T, U, R]]:
107
+ if not self.durable:
108
+ return func
109
+ return as_step(
110
+ func,
111
+ step_type=step_type,
112
+ display_name=display_name or self.name,
113
+ return_type=return_type,
114
+ )
115
+
116
+ async def __call__(
117
+ self,
118
+ input_value: TInput,
119
+ ) -> AgentRunResult[Any]:
120
+ if self.input_type is not None and not isinstance(input_value, self.input_type):
121
+ raise ValueError(
122
+ f"Input value must be of type {self.input_type}, but got {type(input_value)}"
123
+ )
124
+ elif not isinstance(input_value, (str, BaseModel)):
125
+ # Should not happen based on type constraints, but just in case
126
+ # user does not have type checking enabled
127
+ raise ValueError(
128
+ "Input value must be a string or a Pydantic model if input_type is not provided"
129
+ )
130
+
131
+ if self.output_type is None:
132
+ run_step = self.as_step_if_durable(
133
+ self.run_step,
134
+ step_type=StepType.AGENT,
135
+ display_name=self.name,
136
+ return_type=AgentRunResult[str],
137
+ )
138
+ else:
139
+ run_step = self.as_step_if_durable(
140
+ self.run_step,
141
+ step_type=StepType.AGENT,
142
+ display_name=self.name,
143
+ return_type=AgentRunResult[self.output_type],
144
+ )
145
+
146
+ result = await run_step(input_value=input_value)
147
+ # Cast the result to ensure type compatibility
148
+ return cast(AgentRunResult[TOutput], result)
149
+
150
+ @abc.abstractmethod
151
+ async def run_step(
152
+ self,
153
+ input_value: TInput,
154
+ ) -> AgentRunResult[TOutput]: ...
155
+
156
+ @abc.abstractmethod
157
+ def get_model_str(self) -> str: ...
158
+
159
+ def to_config(self) -> AgentConfig:
160
+ return AgentConfig(
161
+ system_prompt=self.system_prompt,
162
+ user_prompt=self.user_prompt,
163
+ model=self.get_model_str(),
164
+ max_turns=self.max_turns,
165
+ model_parameters=self.model_parameters,
166
+ )
planar/ai/agent_utils.py CHANGED
@@ -1,8 +1,4 @@
1
- import asyncio
2
1
  import inspect
3
- import json
4
- from collections.abc import AsyncGenerator
5
- from enum import Enum
6
2
  from typing import (
7
3
  Any,
8
4
  Callable,
@@ -20,77 +16,16 @@ from planar.ai.models import (
20
16
  from planar.files.models import PlanarFile
21
17
  from planar.logging import get_logger
22
18
  from planar.object_config import ConfigurableObjectType, ObjectConfigurationIO
23
- from planar.utils import utc_now
24
19
  from planar.workflows import step
25
20
 
26
21
  logger = get_logger(__name__)
27
22
 
28
23
 
29
- class AgentEventType(str, Enum):
30
- """Valid event types that can be emitted by an Agent."""
24
+ class ModelSpec(BaseModel):
25
+ """Pydantic model for AI model specifications."""
31
26
 
32
- RESPONSE = "response"
33
- TOOL_RESPONSE = "tool_response"
34
- COMPLETED = "completed"
35
- ERROR = "error"
36
- THINK = "think"
37
- TEXT = "text"
38
-
39
-
40
- class AgentEvent:
41
- def __init__(
42
- self,
43
- event_type: AgentEventType,
44
- data: BaseModel | str | None,
45
- ):
46
- self.event_type = event_type
47
- self.data = data
48
- self.timestamp = utc_now().isoformat()
49
-
50
-
51
- class AgentEventEmitter:
52
- def __init__(self):
53
- self.queue: asyncio.Queue[AgentEvent] = asyncio.Queue()
54
-
55
- def emit(self, event_type: AgentEventType, data: BaseModel | str | None):
56
- event = AgentEvent(event_type, data)
57
- self.queue.put_nowait(event)
58
-
59
- async def get_events(self) -> AsyncGenerator[str, None]:
60
- while True:
61
- event = await self.queue.get()
62
-
63
- if isinstance(event.data, BaseModel):
64
- data = {
65
- "data": event.data.model_dump(),
66
- "event_type": event.event_type,
67
- }
68
- else:
69
- data = {
70
- "data": event.data,
71
- "event_type": event.event_type,
72
- }
73
-
74
- yield f"data: {json.dumps(data)}\n\n"
75
-
76
- self.queue.task_done()
77
-
78
- if event.event_type in (AgentEventType.COMPLETED, AgentEventType.ERROR):
79
- break
80
-
81
- def is_empty(self) -> bool:
82
- """Check if the queue is empty."""
83
- return self.queue.empty()
84
-
85
-
86
- # Define JsonData type as a union of valid JSON values
87
- JsonData = str | int | float | bool | None | dict[str, Any] | list[Any]
88
-
89
-
90
- class ToolCallResult(BaseModel):
91
- tool_call_id: str
92
- tool_call_name: str
93
- content: BaseModel | JsonData
27
+ model_id: str
28
+ parameters: dict[str, Any] = {}
94
29
 
95
30
 
96
31
  def extract_files_from_model(
planar/ai/models.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from enum import Enum
3
4
  from typing import (
4
5
  Annotated,
5
6
  Any,
@@ -7,6 +8,7 @@ from typing import (
7
8
  List,
8
9
  Literal,
9
10
  Optional,
11
+ Protocol,
10
12
  TypeVar,
11
13
  Union,
12
14
  )
@@ -88,10 +90,23 @@ class ToolMessage(ModelMessage):
88
90
  tool_call_id: str # ID of the tool call this is responding to
89
91
 
90
92
 
93
+ # Define JsonData type as a union of valid JSON values
94
+ JsonData = str | int | float | bool | None | dict[str, Any] | list[Any]
95
+
96
+
97
+ class ToolCallResult(BaseModel):
98
+ tool_call_id: str
99
+ tool_call_name: str
100
+ content: BaseModel | JsonData
101
+
102
+
91
103
  class CompletionResponse[T: BaseModel | str](BaseModel):
92
104
  """Response object that may contain content or tool calls."""
93
105
 
94
106
  content: Optional[T] = None # Content as str or parsed Pydantic model
107
+ text_content: Optional[str] = (
108
+ None # Optional text content, if separate from structured output
109
+ )
95
110
  reasoning_content: Optional[str] = None # Optional reasoning content
96
111
  tool_calls: Optional[List[ToolCall]] = None # List of tool calls, if any
97
112
 
@@ -138,3 +153,18 @@ class AgentSerializeable(BaseModel):
138
153
 
139
154
  # TODO: actually fetch built_in_vars from agent object
140
155
  built_in_vars: dict[str, str] = Field(default_factory=dict)
156
+
157
+
158
+ class AgentEventType(str, Enum):
159
+ """Valid event types that can be emitted by an Agent."""
160
+
161
+ RESPONSE = "response"
162
+ TOOL_RESPONSE = "tool_response"
163
+ COMPLETED = "completed"
164
+ ERROR = "error"
165
+ THINK = "think"
166
+ TEXT = "text"
167
+
168
+
169
+ class AgentEventEmitter(Protocol):
170
+ def emit(self, event_type: AgentEventType, data: BaseModel | str | None): ...
planar/ai/pydantic_ai.py CHANGED
@@ -1,8 +1,9 @@
1
1
  import base64
2
2
  import json
3
+ import os
3
4
  import re
4
5
  import textwrap
5
- from typing import Any, Literal, Protocol, Type, cast
6
+ from typing import Any, Type, cast
6
7
 
7
8
  from pydantic import BaseModel, ValidationError
8
9
  from pydantic_ai import BinaryContent
@@ -28,12 +29,13 @@ from pydantic_ai.messages import (
28
29
  UserContent,
29
30
  UserPromptPart,
30
31
  )
31
- from pydantic_ai.models import Model, ModelRequestParameters
32
+ from pydantic_ai.models import KnownModelName, Model, ModelRequestParameters
32
33
  from pydantic_ai.settings import ModelSettings
33
34
  from pydantic_ai.tools import ToolDefinition
34
35
  from pydantic_core import ErrorDetails
35
36
 
36
37
  from planar.ai import models as m
38
+ from planar.files.models import PlanarFile
37
39
  from planar.logging import get_logger
38
40
  from planar.utils import partition
39
41
 
@@ -67,7 +69,65 @@ def format_validation_errors(errors: list[ErrorDetails], function: bool) -> str:
67
69
  return "\n".join(lines)
68
70
 
69
71
 
70
- async def build_file_map(messages: list[m.ModelMessage]) -> m.FileMap:
72
+ async def openai_try_upload_file(
73
+ model: KnownModelName | Model, file: PlanarFile
74
+ ) -> m.FileIdContent | None:
75
+ # Currently pydanticAI doesn't support passing file_ids, but leaving the
76
+ # implementation here for when they add support.
77
+ return None
78
+
79
+ if file.content_type != "application/pdf":
80
+ # old implementation only does this for pdf files, so keep the behavior for now
81
+ return None
82
+
83
+ if isinstance(model, str) and not model.startswith("openai:"):
84
+ # not using openai provider
85
+ return None
86
+
87
+ try:
88
+ # make this code work with openai as optional dependency
89
+ from pydantic_ai.models.openai import OpenAIModel
90
+ except ImportError:
91
+ return None
92
+
93
+ if os.getenv("OPENAI_BASE_URL", None) is not None:
94
+ # cannot use OpenAI file upload if using a custom base url
95
+ return None
96
+
97
+ if (
98
+ isinstance(model, OpenAIModel)
99
+ and model.client.base_url.host != "api.openai.com"
100
+ ):
101
+ # same as above
102
+ return None
103
+
104
+ logger.debug("uploading pdf file to openai", filename=file.filename)
105
+
106
+ # use a separate AsyncClient instance since the model might be provided as a string
107
+ from openai import AsyncClient
108
+
109
+ client = AsyncClient()
110
+
111
+ # upload the file to the provider
112
+ openai_file = await client.files.create(
113
+ file=(
114
+ file.filename,
115
+ await file.get_content(),
116
+ file.content_type,
117
+ ),
118
+ purpose="user_data",
119
+ )
120
+ logger.info(
121
+ "uploaded pdf file to openai",
122
+ filename=file.filename,
123
+ openai_file_id=openai_file.id,
124
+ )
125
+ return m.FileIdContent(content=openai_file.id)
126
+
127
+
128
+ async def build_file_map(
129
+ model: KnownModelName | Model, messages: list[m.ModelMessage]
130
+ ) -> m.FileMap:
71
131
  logger.debug("building file map", num_messages=len(messages))
72
132
  file_dict = {}
73
133
 
@@ -86,6 +146,12 @@ async def build_file_map(messages: list[m.ModelMessage]) -> m.FileMap:
86
146
  content_type=file.content_type,
87
147
  )
88
148
 
149
+ file_content_id = await openai_try_upload_file(model, file)
150
+ # TODO: add more `try_upload_file` implementations for other providers that support
151
+ if file_content_id is not None:
152
+ file_dict[str(file.id)] = file_content_id
153
+ continue
154
+
89
155
  # For now we are not using uploaded files with Gemini, so convert all to base64
90
156
  if file.content_type.startswith(
91
157
  ("image/", "audio/", "video/", "application/pdf")
@@ -107,7 +173,9 @@ async def build_file_map(messages: list[m.ModelMessage]) -> m.FileMap:
107
173
  return m.FileMap(mapping=file_dict)
108
174
 
109
175
 
110
- async def prepare_messages(messages: list[m.ModelMessage]) -> list[Any]:
176
+ async def prepare_messages(
177
+ model: KnownModelName | Model, messages: list[m.ModelMessage]
178
+ ) -> list[Any]:
111
179
  """Prepare messages from Planar representations into the format expected by PydanticAI.
112
180
 
113
181
  Args:
@@ -118,7 +186,7 @@ async def prepare_messages(messages: list[m.ModelMessage]) -> list[Any]:
118
186
  List of messages in PydanticAI format
119
187
  """
120
188
  pydantic_messages: list[ModelMessage] = []
121
- file_map = await build_file_map(messages)
189
+ file_map = await build_file_map(model, messages)
122
190
 
123
191
  def append_request_part(part: ModelRequestPart):
124
192
  last = (
@@ -197,10 +265,6 @@ async def prepare_messages(messages: list[m.ModelMessage]) -> list[Any]:
197
265
  return pydantic_messages
198
266
 
199
267
 
200
- class StreamEventHandler(Protocol):
201
- def emit(self, event: Literal["text", "think"], data: str) -> None: ...
202
-
203
-
204
268
  def setup_native_structured_output(
205
269
  request_params: ModelRequestParameters,
206
270
  output_type: Type[BaseModel],
@@ -262,12 +326,14 @@ def return_native_structured_output[TOutput: BaseModel](
262
326
  result = m.CompletionResponse(
263
327
  content=output_type.model_validate_json(content),
264
328
  tool_calls=final_tool_calls,
329
+ text_content=content,
265
330
  reasoning_content=thinking,
266
331
  )
267
332
  logger.info(
268
333
  "model run completed with structured output",
269
334
  content=result.content,
270
335
  reasoning_content=result.reasoning_content,
336
+ text_content=content,
271
337
  tool_calls=result.tool_calls,
272
338
  )
273
339
  return result
@@ -291,6 +357,7 @@ def return_tool_structured_output[TOutput: BaseModel](
291
357
  result = m.CompletionResponse(
292
358
  content=output_type.model_validate(final_result_tc.arguments),
293
359
  tool_calls=tool_calls,
360
+ text_content=content,
294
361
  reasoning_content=thinking,
295
362
  )
296
363
  logger.info(
@@ -315,12 +382,12 @@ class ModelRunResponse[TOutput: BaseModel | str](BaseModel):
315
382
 
316
383
 
317
384
  async def model_run[TOutput: BaseModel | str](
318
- model: Model | str,
385
+ model: Model | KnownModelName,
319
386
  max_extra_turns: int,
320
387
  model_settings: dict[str, Any] | None = None,
321
388
  messages: list[m.ModelMessage] = [],
322
389
  tools: list[m.ToolDefinition] = [],
323
- event_handler: StreamEventHandler | None = None,
390
+ event_handler: m.AgentEventEmitter | None = None,
324
391
  output_type: Type[TOutput] = str,
325
392
  ) -> ModelRunResponse[TOutput]:
326
393
  # assert that the caller doesn't provide a tool called "final_result"
@@ -350,11 +417,11 @@ async def model_run[TOutput: BaseModel | str](
350
417
 
351
418
  structured_output = issubclass(output_type, BaseModel)
352
419
 
353
- def emit(event_type: Literal["text", "think"], content: str):
420
+ def emit(event_type: m.AgentEventType, content: str):
354
421
  if event_handler:
355
422
  event_handler.emit(event_type, content)
356
423
 
357
- history = await prepare_messages(messages=messages)
424
+ history = await prepare_messages(model, messages=messages)
358
425
 
359
426
  if structured_output:
360
427
  if supports_native_structured_output:
@@ -383,10 +450,10 @@ async def model_run[TOutput: BaseModel | str](
383
450
  case PartStartEvent(part=part):
384
451
  response_parts.append(part)
385
452
  if isinstance(part, TextPart):
386
- emit("text", part.content)
453
+ emit(m.AgentEventType.TEXT, part.content)
387
454
  text_buffer.append(part.content)
388
455
  elif isinstance(part, ThinkingPart):
389
- emit("think", part.content)
456
+ emit(m.AgentEventType.THINK, part.content)
390
457
  think_buffer.append(part.content)
391
458
  elif isinstance(part, ToolCallPart):
392
459
  if current_tool_call is not None:
@@ -412,14 +479,14 @@ async def model_run[TOutput: BaseModel | str](
412
479
  current = response_parts[-1]
413
480
  if isinstance(delta, TextPartDelta):
414
481
  assert isinstance(current, TextPart)
415
- emit("text", delta.content_delta)
482
+ emit(m.AgentEventType.TEXT, delta.content_delta)
416
483
  text_buffer.append(delta.content_delta)
417
484
  current.content += delta.content_delta
418
485
  elif (
419
486
  isinstance(delta, ThinkingPartDelta) and delta.content_delta
420
487
  ):
421
488
  assert isinstance(current, ThinkingPart)
422
- emit("think", delta.content_delta)
489
+ emit(m.AgentEventType.THINK, delta.content_delta)
423
490
  think_buffer.append(delta.content_delta)
424
491
  current.content += delta.content_delta
425
492
  elif isinstance(delta, ToolCallPartDelta):
@@ -479,6 +546,7 @@ async def model_run[TOutput: BaseModel | str](
479
546
  return ModelRunResponse(
480
547
  response=m.CompletionResponse(
481
548
  tool_calls=final_tool_calls,
549
+ text_content=content,
482
550
  reasoning_content=thinking,
483
551
  ),
484
552
  extra_turns_used=extra_turns_used,
@@ -555,6 +623,7 @@ async def model_run[TOutput: BaseModel | str](
555
623
  m.CompletionResponse(
556
624
  content=content,
557
625
  tool_calls=final_tool_calls,
626
+ text_content=content,
558
627
  reasoning_content=thinking,
559
628
  ),
560
629
  )
@@ -56,7 +56,7 @@ def test_agent_with_tools():
56
56
  name="test_agent_with_tools",
57
57
  system_prompt="System with tools",
58
58
  user_prompt="User: {input}",
59
- model="anthropic:claude-3-sonnet",
59
+ model="anthropic:claude-3-5-sonnet-latest",
60
60
  max_turns=5,
61
61
  tools=[test_tool],
62
62
  )
planar/app.py CHANGED
@@ -37,7 +37,6 @@ from planar.security.authorization import PolicyService, policy_service_context
37
37
  from planar.session import config_var, session_context
38
38
  from planar.sse.proxy import SSEProxy
39
39
  from planar.workflows import (
40
- Workflow,
41
40
  WorkflowNotification,
42
41
  WorkflowNotificationCallback,
43
42
  WorkflowOrchestrator,
@@ -169,13 +168,8 @@ class PlanarApp:
169
168
  return
170
169
 
171
170
  def on_workflow_notification(notification: WorkflowNotification):
172
- workflow_id = (
173
- notification.data.id
174
- if isinstance(notification.data, Workflow)
175
- else notification.data.workflow_id
176
- )
177
171
  self.sse_proxy.push(
178
- f"{notification.kind.value}:{workflow_id}",
172
+ f"{notification.kind.value}:{notification.workflow_id}",
179
173
  notification.data.model_dump(mode="json"),
180
174
  )
181
175
 
planar/config.py CHANGED
@@ -21,6 +21,7 @@ from pydantic import (
21
21
  )
22
22
  from sqlalchemy import URL, make_url
23
23
 
24
+ from planar.data.config import DataConfig
24
25
  from planar.files.storage.config import LocalDirectoryConfig, StorageConfig
25
26
  from planar.logging import get_logger
26
27
 
@@ -225,6 +226,7 @@ class PlanarConfig(BaseModel):
225
226
  logging: dict[str, LoggerConfig] | None = None
226
227
  use_alembic: bool | None = True
227
228
  otel: OtelConfig | None = None
229
+ data: DataConfig | None = None
228
230
 
229
231
  # forbid extra keys in the config to prevent accidental misconfiguration
230
232
  model_config = ConfigDict(extra="forbid")
@@ -0,0 +1,17 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from planar.dependencies import lazy_exports
4
+
5
+ lazy_exports(
6
+ __name__,
7
+ {
8
+ "PlanarDataset": (".dataset", "PlanarDataset"),
9
+ },
10
+ )
11
+
12
+ if TYPE_CHECKING:
13
+ from .dataset import PlanarDataset
14
+
15
+ __all__ = [
16
+ "PlanarDataset",
17
+ ]
planar/data/config.py ADDED
@@ -0,0 +1,49 @@
1
+ """Configuration for Planar data module."""
2
+
3
+ from typing import Annotated, Literal
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ from planar.files.storage.config import StorageConfig
8
+
9
+
10
+ class DuckDBCatalogConfig(BaseModel):
11
+ """Configuration for DuckDB catalog backend."""
12
+
13
+ type: Literal["duckdb"]
14
+ path: str # Path to .ducklake file
15
+
16
+
17
+ class PostgresCatalogConfig(BaseModel):
18
+ """Configuration for PostgreSQL catalog backend."""
19
+
20
+ type: Literal["postgres"]
21
+ host: str | None = None
22
+ port: int | None = None
23
+ user: str | None = None
24
+ password: str | None = None
25
+ db: str
26
+
27
+
28
+ class SQLiteCatalogConfig(BaseModel):
29
+ """Configuration for SQLite catalog backend."""
30
+
31
+ type: Literal["sqlite"]
32
+ path: str # Path to .sqlite file
33
+
34
+
35
+ # Discriminated union for catalog configurations
36
+ CatalogConfig = Annotated[
37
+ DuckDBCatalogConfig | PostgresCatalogConfig | SQLiteCatalogConfig,
38
+ Field(discriminator="type"),
39
+ ]
40
+
41
+
42
+ class DataConfig(BaseModel):
43
+ """Configuration for data features."""
44
+
45
+ catalog: CatalogConfig
46
+ storage: StorageConfig # Reuse existing StorageConfig from files
47
+
48
+ # Optional settings
49
+ catalog_name: str = "planar_data" # Default catalog name in Ducklake