planar 0.11.0__py3-none-any.whl → 0.13.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
planar/ai/__init__.py CHANGED
@@ -6,10 +6,12 @@ from .agent_utils import (
6
6
  agent_configuration,
7
7
  get_agent_config,
8
8
  )
9
+ from .tool_context import get_tool_context
9
10
 
10
11
  __all__ = [
11
12
  "Agent",
12
13
  "AgentRunResult",
13
14
  "agent_configuration",
14
15
  "get_agent_config",
16
+ "get_tool_context",
15
17
  ]
planar/ai/agent.py CHANGED
@@ -50,8 +50,8 @@ class AgentWorkflowNotifier(AgentEventEmitter):
50
50
  class Agent[
51
51
  TInput: BaseModel | str,
52
52
  TOutput: BaseModel | str,
53
- TDeps,
54
- ](AgentBase[TInput, TOutput, TDeps]):
53
+ TToolContext,
54
+ ](AgentBase[TInput, TOutput, TToolContext]):
55
55
  model: models.KnownModelName | models.Model = "openai:gpt-4o"
56
56
 
57
57
  async def run_step(
planar/ai/agent_base.py CHANGED
@@ -1,5 +1,3 @@
1
- from __future__ import annotations
2
-
3
1
  import abc
4
2
  from dataclasses import dataclass, field
5
3
  from typing import (
@@ -15,7 +13,7 @@ from pydantic import BaseModel
15
13
  from pydantic_ai.settings import ModelSettings
16
14
 
17
15
  from planar.ai.models import AgentConfig, AgentEventEmitter, AgentRunResult
18
- from planar.ai.state import delete_state, set_state
16
+ from planar.ai.tool_context import clear_tool_context, set_tool_context
19
17
  from planar.logging import get_logger
20
18
  from planar.modeling.field_helpers import JsonSchema
21
19
  from planar.utils import P, R, T, U
@@ -30,7 +28,7 @@ class AgentBase[
30
28
  # TODO: add `= str` default when we upgrade to 3.13
31
29
  TInput: BaseModel | str,
32
30
  TOutput: BaseModel | str,
33
- TState,
31
+ TToolContext,
34
32
  ](abc.ABC):
35
33
  """An LLM-powered agent that can be called directly within workflows."""
36
34
 
@@ -47,7 +45,7 @@ class AgentBase[
47
45
  )
48
46
  event_emitter: AgentEventEmitter | None = None
49
47
  durable: bool = True
50
- state_type: Type[TState] | None = None
48
+ tool_context_type: Type[TToolContext] | None = None
51
49
 
52
50
  # TODO: move here to serialize to frontend
53
51
  #
@@ -94,16 +92,16 @@ class AgentBase[
94
92
 
95
93
  @overload
96
94
  async def __call__(
97
- self: "AgentBase[TInput, str, TState]",
95
+ self: "AgentBase[TInput, str, TToolContext]",
98
96
  input_value: TInput,
99
- state: TState | None = None,
97
+ tool_context: TToolContext | None = None,
100
98
  ) -> AgentRunResult[str]: ...
101
99
 
102
100
  @overload
103
101
  async def __call__(
104
- self: "AgentBase[TInput, TOutput, TState]",
102
+ self: "AgentBase[TInput, TOutput, TToolContext]",
105
103
  input_value: TInput,
106
- state: TState | None = None,
104
+ tool_context: TToolContext | None = None,
107
105
  ) -> AgentRunResult[TOutput]: ...
108
106
 
109
107
  def as_step_if_durable(
@@ -125,7 +123,7 @@ class AgentBase[
125
123
  async def __call__(
126
124
  self,
127
125
  input_value: TInput,
128
- state: TState | None = None,
126
+ tool_context: TToolContext | None = None,
129
127
  ) -> AgentRunResult[Any]:
130
128
  if self.input_type is not None and not isinstance(input_value, self.input_type):
131
129
  raise ValueError(
@@ -153,22 +151,25 @@ class AgentBase[
153
151
  return_type=AgentRunResult[self.output_type],
154
152
  )
155
153
 
156
- if state is not None:
157
- if self.state_type is None:
158
- raise ValueError("state cannot be provided when state_type is not set")
159
- if not isinstance(state, self.state_type):
154
+ if tool_context is not None:
155
+ if self.tool_context_type is None:
156
+ raise ValueError(
157
+ "tool_context cannot be provided when tool_context_type is not set"
158
+ )
159
+ if not isinstance(tool_context, self.tool_context_type):
160
160
  raise ValueError(
161
- f"state must be of type {self.state_type}, but got {type(state)}"
161
+ f"tool_context must be of type {self.tool_context_type}, "
162
+ f"but got {type(tool_context)}"
162
163
  )
163
- set_state(cast(TState, state))
164
+ set_tool_context(cast(TToolContext, tool_context))
164
165
 
165
166
  try:
166
167
  result = await run_step(input_value=input_value)
167
168
  # Cast the result to ensure type compatibility
168
169
  return cast(AgentRunResult[TOutput], result)
169
170
  finally:
170
- if state is not None:
171
- delete_state()
171
+ if tool_context is not None:
172
+ clear_tool_context()
172
173
 
173
174
  @abc.abstractmethod
174
175
  async def run_step(
@@ -5,13 +5,13 @@ from planar.task_local import TaskLocal
5
5
  data: TaskLocal[Any] = TaskLocal()
6
6
 
7
7
 
8
- def set_state(ctx: Any):
8
+ def set_tool_context(ctx: Any):
9
9
  return data.set(ctx)
10
10
 
11
11
 
12
- def get_state[T](_: Type[T]) -> T:
12
+ def get_tool_context[T](_: Type[T]) -> T:
13
13
  return cast(T, data.get())
14
14
 
15
15
 
16
- def delete_state():
16
+ def clear_tool_context():
17
17
  return data.clear()
planar/app.py CHANGED
@@ -274,6 +274,14 @@ class PlanarApp:
274
274
  # Reset the config in the context
275
275
  config_var.reset(config_tok)
276
276
 
277
+ if self.config.data:
278
+ try:
279
+ from planar.data.connection import reset_connection_cache
280
+ except ImportError as exc: # pragma: no cover - optional dependency
281
+ logger.debug("skipping data connection cleanup", error=str(exc))
282
+ else:
283
+ await reset_connection_cache()
284
+
277
285
  await self.db_manager.disconnect()
278
286
 
279
287
  if self.storage:
planar/cli.py CHANGED
@@ -2,6 +2,7 @@ import os
2
2
  import subprocess
3
3
  import sys
4
4
  from pathlib import Path
5
+ from typing import Annotated
5
6
 
6
7
  import typer
7
8
  import uvicorn
@@ -9,10 +10,35 @@ from jinja2 import Environment as JinjaEnvironment
9
10
  from jinja2 import FileSystemLoader
10
11
 
11
12
  from planar.config import Environment
13
+ from planar.version import get_version
12
14
 
13
15
  app = typer.Typer(help="Planar CLI tool")
14
16
 
15
17
 
18
+ def version_callback(value: bool) -> bool:
19
+ if value:
20
+ typer.echo(f"planar {get_version()}")
21
+ raise typer.Exit()
22
+ return value
23
+
24
+
25
+ @app.callback()
26
+ def root_callback(
27
+ version: Annotated[
28
+ bool | None,
29
+ typer.Option(
30
+ "--version",
31
+ "-v",
32
+ help="Show Planar version and exit.",
33
+ callback=version_callback,
34
+ is_flag=True,
35
+ is_eager=True,
36
+ ),
37
+ ] = None,
38
+ ) -> None:
39
+ """Entry point for Planar CLI with shared options."""
40
+
41
+
16
42
  def find_default_app_path() -> Path:
17
43
  """Checks for default app file paths (app.py, then main.py)."""
18
44
  for filename in ["app.py", "main.py"]:
@@ -236,6 +262,11 @@ def run_command(
236
262
  def scaffold_project(
237
263
  name: str = typer.Option(None, "--name", help="Name of the new project"),
238
264
  directory: Path = typer.Option(Path("."), "--directory", help="Target directory"),
265
+ use_local_source: bool = typer.Option(
266
+ False,
267
+ "--use-local-source",
268
+ help="Use local planar source instead of published package (for development)",
269
+ ),
239
270
  ):
240
271
  """
241
272
  Creates a new Planar project with a basic structure and example workflow.
@@ -252,8 +283,19 @@ def scaffold_project(
252
283
  template_dir = Path(__file__).parent / "scaffold_templates"
253
284
  jinja_env = JinjaEnvironment(loader=FileSystemLoader(template_dir))
254
285
 
286
+ planar_source_path = None
287
+ if use_local_source:
288
+ # Assume we're running from the planar package itself
289
+ # Go up from planar/cli.py -> planar/ -> planar_repo/
290
+ planar_source_path = Path(__file__).parent.parent.resolve()
291
+ typer.echo(f"Using local planar source: {planar_source_path}")
292
+
255
293
  # Template context
256
- context = {"name": name}
294
+ context = {
295
+ "name": name,
296
+ "local_source": use_local_source,
297
+ "planar_source_path": planar_source_path,
298
+ }
257
299
 
258
300
  # Create project structure
259
301
  try:
planar/config.py CHANGED
@@ -3,6 +3,7 @@ import logging
3
3
  import logging.config
4
4
  import os
5
5
  import sys
6
+ from contextlib import contextmanager
6
7
  from enum import Enum
7
8
  from pathlib import Path
8
9
  from typing import Annotated, Any, Dict, Literal
@@ -24,6 +25,7 @@ from sqlalchemy import URL, make_url
24
25
  from planar.data.config import DataConfig
25
26
  from planar.files.storage.config import LocalDirectoryConfig, StorageConfig
26
27
  from planar.logging import get_logger
28
+ from planar.logging.formatter import StructuredFormatter
27
29
 
28
30
  logger = get_logger(__name__)
29
31
 
@@ -144,12 +146,12 @@ class CorsConfig(BaseModel):
144
146
  allow_headers: list[str]
145
147
 
146
148
  @model_validator(mode="after")
147
- def validate_allow_origins(cls, instance):
148
- if instance.allow_credentials and "*" in instance.allow_origins:
149
+ def validate_allow_origins(self):
150
+ if self.allow_credentials and "*" in self.allow_origins:
149
151
  raise ValueError(
150
152
  "allow_credentials cannot be True if allow_origins includes '*'. Must explicitly specify allowed origins."
151
153
  )
152
- return instance
154
+ return self
153
155
 
154
156
 
155
157
  LOCAL_CORS_CONFIG = CorsConfig(
@@ -173,10 +175,10 @@ class JWTConfig(BaseModel):
173
175
  additional_exclusion_paths: list[str] | None = Field(default_factory=list)
174
176
 
175
177
  @model_validator(mode="after")
176
- def validate_client_id(cls, instance):
177
- if not instance.client_id or not instance.org_id:
178
+ def validate_client_id(self):
179
+ if not self.client_id or not self.org_id:
178
180
  raise ValueError("Both client_id and org_id required to enable JWT")
179
- return instance
181
+ return self
180
182
 
181
183
 
182
184
  # Coplane ORG JWT config
@@ -235,12 +237,12 @@ class PlanarConfig(BaseModel):
235
237
  model_config = ConfigDict(extra="forbid")
236
238
 
237
239
  @model_validator(mode="after")
238
- def validate_db_connection_reference(cls, instance):
239
- if instance.app.db_connection not in instance.db_connections:
240
+ def validate_db_connection_reference(self):
241
+ if self.app.db_connection not in self.db_connections:
240
242
  raise ValueError(
241
- f"Invalid db_connection reference: {instance.app.db_connection}"
243
+ f"Invalid db_connection reference: {self.app.db_connection}"
242
244
  )
243
- return instance
245
+ return self
244
246
 
245
247
  def connection_url(self) -> URL:
246
248
  connection = self.db_connections[self.app.db_connection]
@@ -460,6 +462,28 @@ def load_environment_aware_env_vars() -> None:
460
462
  return
461
463
 
462
464
 
465
+ @contextmanager
466
+ def _temporary_config_logging():
467
+ """Install a console handler so config loading logs are visible before configuration."""
468
+ root_logger = logging.getLogger()
469
+ handler = logging.StreamHandler(sys.stderr)
470
+ handler.setFormatter(StructuredFormatter(use_colors=sys.stderr.isatty()))
471
+ handler.setLevel(logging.NOTSET)
472
+
473
+ previous_level = root_logger.level
474
+ if root_logger.getEffectiveLevel() > logging.INFO:
475
+ root_logger.setLevel(logging.INFO)
476
+
477
+ root_logger.addHandler(handler)
478
+
479
+ try:
480
+ yield
481
+ finally:
482
+ root_logger.removeHandler(handler)
483
+ handler.close()
484
+ root_logger.setLevel(previous_level)
485
+
486
+
463
487
  def load_environment_aware_config[ConfigClass]() -> PlanarConfig:
464
488
  """
465
489
  Load configuration based on environment settings, using environment variables
@@ -476,83 +500,87 @@ def load_environment_aware_config[ConfigClass]() -> PlanarConfig:
476
500
  Raises:
477
501
  InvalidConfigurationError: If configuration loading or validation fails.
478
502
  """
479
- load_environment_aware_env_vars()
480
- env = get_environment()
481
-
482
- if env == "dev":
483
- base_config = sqlite_config(db_path="planar_dev.db")
484
- base_config.security = SecurityConfig(cors=LOCAL_CORS_CONFIG)
485
- base_config.environment = Environment.DEV
486
- else:
487
- base_config = sqlite_config(db_path="planar.db")
488
- base_config.environment = Environment.PROD
489
- base_config.security = SecurityConfig(
490
- cors=PROD_CORS_CONFIG, jwt=JWT_COPLANE_CONFIG
491
- )
503
+ with _temporary_config_logging():
504
+ load_environment_aware_env_vars()
505
+ env = get_environment()
506
+
507
+ if env == "dev":
508
+ base_config = sqlite_config(db_path="planar_dev.db")
509
+ base_config.security = SecurityConfig(cors=LOCAL_CORS_CONFIG)
510
+ base_config.environment = Environment.DEV
511
+ else:
512
+ base_config = sqlite_config(db_path="planar.db")
513
+ base_config.environment = Environment.PROD
514
+ base_config.security = SecurityConfig(
515
+ cors=PROD_CORS_CONFIG, jwt=JWT_COPLANE_CONFIG
516
+ )
492
517
 
493
- # Convert base config to dict for merging
494
- # Use by_alias=False to work with Python field names before validation
495
- base_dict = base_config.model_dump(mode="python", by_alias=False)
518
+ # Convert base config to dict for merging
519
+ # Use by_alias=False to work with Python field names before validation
520
+ base_dict = base_config.model_dump(mode="python", by_alias=False)
496
521
 
497
- override_config_path = get_config_path()
498
- if override_config_path:
499
- if not override_config_path.exists():
500
- raise InvalidConfigurationError(
501
- f"Configuration file not found: {override_config_path}"
522
+ override_config_path = get_config_path()
523
+ if override_config_path:
524
+ if not override_config_path.exists():
525
+ raise InvalidConfigurationError(
526
+ f"Configuration file not found: {override_config_path}"
527
+ )
528
+ else:
529
+ paths_to_check = []
530
+ if os.environ.get("PLANAR_ENTRY_POINT"):
531
+ # Extract the directory from the entry point path
532
+ entry_point_dir = Path(os.environ["PLANAR_ENTRY_POINT"]).parent
533
+ paths_to_check = [
534
+ entry_point_dir / f"planar.{env}.yaml",
535
+ entry_point_dir / "planar.yaml",
536
+ ]
537
+ paths_to_check.append(Path(f"planar.{env}.yaml"))
538
+ paths_to_check.append(Path("planar.yaml"))
539
+
540
+ override_config_path = next(
541
+ (path for path in paths_to_check if path.exists()), None
502
542
  )
503
- else:
504
- paths_to_check = []
505
- if os.environ.get("PLANAR_ENTRY_POINT"):
506
- # Extract the directory from the entry point path
507
- entry_point_dir = Path(os.environ["PLANAR_ENTRY_POINT"]).parent
508
- paths_to_check = [
509
- entry_point_dir / f"planar.{env}.yaml",
510
- entry_point_dir / "planar.yaml",
511
- ]
512
- paths_to_check.append(Path(f"planar.{env}.yaml"))
513
- paths_to_check.append(Path("planar.yaml"))
514
-
515
- override_config_path = next(
516
- (path for path in paths_to_check if path.exists()), None
517
- )
518
- if override_config_path is None:
519
- logger.warning(
520
- "no override config file found, using default config",
521
- search_paths=[str(p) for p in paths_to_check],
522
- env=env,
543
+ if override_config_path is None:
544
+ logger.warning(
545
+ "no override config file found, using default config",
546
+ search_paths=[str(p) for p in paths_to_check],
547
+ env=env,
548
+ )
549
+
550
+ merged_dict = base_dict
551
+ if override_config_path and override_config_path.exists():
552
+ logger.info(
553
+ "using override config file", override_config_path=override_config_path
523
554
  )
555
+ try:
556
+ # We can't use load_config_from_file here because we expect
557
+ # the override config to not be a fully validated PlanarConfig object,
558
+ # and we need to merge it onto the base default config.
559
+ with open(override_config_path, "r") as f:
560
+ override_yaml_str = f.read()
561
+
562
+ # Expand environment variables in the YAML string
563
+ processed_yaml_str = os.path.expandvars(override_yaml_str)
564
+ logger.debug(
565
+ "processed override yaml string",
566
+ processed_yaml_str=processed_yaml_str,
567
+ )
568
+
569
+ override_dict = yaml.safe_load(processed_yaml_str) or {}
570
+ logger.debug("loaded override config", override_dict=override_dict)
571
+
572
+ # Deep merge the override onto the base dictionary
573
+ merged_dict = deep_merge_dicts(override_dict, base_dict)
574
+ logger.debug("merged config dict", merged_dict=merged_dict)
575
+ except yaml.YAMLError as e:
576
+ raise InvalidConfigurationError(
577
+ f"Error parsing override configuration file {override_config_path}: {e}"
578
+ ) from e
524
579
 
525
- merged_dict = base_dict
526
- if override_config_path and override_config_path.exists():
527
- logger.info(
528
- "using override config file", override_config_path=override_config_path
529
- )
530
580
  try:
531
- # We can't use load_config_from_file here because we expect
532
- # the override config to not be a fully validated PlanarConfig object,
533
- # and we need to merge it onto the base default config.
534
- with open(override_config_path, "r") as f:
535
- override_yaml_str = f.read()
536
-
537
- # Expand environment variables in the YAML string
538
- processed_yaml_str = os.path.expandvars(override_yaml_str)
539
- logger.debug(
540
- "processed override yaml string", processed_yaml_str=processed_yaml_str
541
- )
542
-
543
- override_dict = yaml.safe_load(processed_yaml_str) or {}
544
- logger.debug("loaded override config", override_dict=override_dict)
545
-
546
- # Deep merge the override onto the base dictionary
547
- merged_dict = deep_merge_dicts(override_dict, base_dict)
548
- logger.debug("merged config dict", merged_dict=merged_dict)
549
- except yaml.YAMLError as e:
581
+ final_config = PlanarConfig.model_validate(merged_dict)
582
+ return final_config
583
+ except ValidationError as e:
550
584
  raise InvalidConfigurationError(
551
- f"Error parsing override configuration file {override_config_path}: {e}"
585
+ f"Configuration validation error: {e}"
552
586
  ) from e
553
-
554
- try:
555
- final_config = PlanarConfig.model_validate(merged_dict)
556
- return final_config
557
- except ValidationError as e:
558
- raise InvalidConfigurationError(f"Configuration validation error: {e}") from e
planar/data/__init__.py CHANGED
@@ -6,6 +6,7 @@ lazy_exports(
6
6
  __name__,
7
7
  {
8
8
  "PlanarDataset": (".dataset", "PlanarDataset"),
9
+ "reset_connection_cache": (".connection", "reset_connection_cache"),
9
10
  },
10
11
  )
11
12
 
planar/data/config.py CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  from typing import Annotated, Literal
4
4
 
5
- from pydantic import BaseModel, Field
5
+ from pydantic import BaseModel, ConfigDict, Field
6
6
 
7
7
  from planar.files.storage.config import StorageConfig
8
8
 
@@ -13,6 +13,8 @@ class DuckDBCatalogConfig(BaseModel):
13
13
  type: Literal["duckdb"]
14
14
  path: str # Path to .ducklake file
15
15
 
16
+ model_config = ConfigDict(frozen=True)
17
+
16
18
 
17
19
  class PostgresCatalogConfig(BaseModel):
18
20
  """Configuration for PostgreSQL catalog backend."""
@@ -24,6 +26,8 @@ class PostgresCatalogConfig(BaseModel):
24
26
  password: str | None = None
25
27
  db: str
26
28
 
29
+ model_config = ConfigDict(frozen=True)
30
+
27
31
 
28
32
  class SQLiteCatalogConfig(BaseModel):
29
33
  """Configuration for SQLite catalog backend."""
@@ -31,6 +35,8 @@ class SQLiteCatalogConfig(BaseModel):
31
35
  type: Literal["sqlite"]
32
36
  path: str # Path to .sqlite file
33
37
 
38
+ model_config = ConfigDict(frozen=True)
39
+
34
40
 
35
41
  # Discriminated union for catalog configurations
36
42
  CatalogConfig = Annotated[
@@ -47,3 +53,8 @@ class DataConfig(BaseModel):
47
53
 
48
54
  # Optional settings
49
55
  catalog_name: str = "planar_data" # Default catalog name in Ducklake
56
+
57
+ model_config = ConfigDict(frozen=True)
58
+
59
+ def is_sqlite_catalog(self) -> bool:
60
+ return self.catalog.type == "sqlite"