planar 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (289) hide show
  1. planar/.__init__.py.un~ +0 -0
  2. planar/._version.py.un~ +0 -0
  3. planar/.app.py.un~ +0 -0
  4. planar/.cli.py.un~ +0 -0
  5. planar/.config.py.un~ +0 -0
  6. planar/.context.py.un~ +0 -0
  7. planar/.db.py.un~ +0 -0
  8. planar/.di.py.un~ +0 -0
  9. planar/.engine.py.un~ +0 -0
  10. planar/.files.py.un~ +0 -0
  11. planar/.log_context.py.un~ +0 -0
  12. planar/.log_metadata.py.un~ +0 -0
  13. planar/.logging.py.un~ +0 -0
  14. planar/.object_registry.py.un~ +0 -0
  15. planar/.otel.py.un~ +0 -0
  16. planar/.server.py.un~ +0 -0
  17. planar/.session.py.un~ +0 -0
  18. planar/.sqlalchemy.py.un~ +0 -0
  19. planar/.task_local.py.un~ +0 -0
  20. planar/.test_app.py.un~ +0 -0
  21. planar/.test_config.py.un~ +0 -0
  22. planar/.test_object_config.py.un~ +0 -0
  23. planar/.test_sqlalchemy.py.un~ +0 -0
  24. planar/.test_utils.py.un~ +0 -0
  25. planar/.util.py.un~ +0 -0
  26. planar/.utils.py.un~ +0 -0
  27. planar/__init__.py +26 -0
  28. planar/_version.py +1 -0
  29. planar/ai/.__init__.py.un~ +0 -0
  30. planar/ai/._models.py.un~ +0 -0
  31. planar/ai/.agent.py.un~ +0 -0
  32. planar/ai/.agent_utils.py.un~ +0 -0
  33. planar/ai/.events.py.un~ +0 -0
  34. planar/ai/.files.py.un~ +0 -0
  35. planar/ai/.models.py.un~ +0 -0
  36. planar/ai/.providers.py.un~ +0 -0
  37. planar/ai/.pydantic_ai.py.un~ +0 -0
  38. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  39. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  40. planar/ai/.step.py.un~ +0 -0
  41. planar/ai/.test_agent.py.un~ +0 -0
  42. planar/ai/.test_agent_serialization.py.un~ +0 -0
  43. planar/ai/.test_providers.py.un~ +0 -0
  44. planar/ai/.utils.py.un~ +0 -0
  45. planar/ai/__init__.py +15 -0
  46. planar/ai/agent.py +457 -0
  47. planar/ai/agent_utils.py +205 -0
  48. planar/ai/models.py +140 -0
  49. planar/ai/providers.py +1088 -0
  50. planar/ai/test_agent.py +1298 -0
  51. planar/ai/test_agent_serialization.py +229 -0
  52. planar/ai/test_providers.py +463 -0
  53. planar/ai/utils.py +102 -0
  54. planar/app.py +494 -0
  55. planar/cli.py +282 -0
  56. planar/config.py +544 -0
  57. planar/db/.db.py.un~ +0 -0
  58. planar/db/__init__.py +17 -0
  59. planar/db/alembic/env.py +136 -0
  60. planar/db/alembic/script.py.mako +28 -0
  61. planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
  62. planar/db/alembic.ini +128 -0
  63. planar/db/db.py +318 -0
  64. planar/files/.config.py.un~ +0 -0
  65. planar/files/.local.py.un~ +0 -0
  66. planar/files/.local_filesystem.py.un~ +0 -0
  67. planar/files/.model.py.un~ +0 -0
  68. planar/files/.models.py.un~ +0 -0
  69. planar/files/.s3.py.un~ +0 -0
  70. planar/files/.storage.py.un~ +0 -0
  71. planar/files/.test_files.py.un~ +0 -0
  72. planar/files/__init__.py +2 -0
  73. planar/files/models.py +162 -0
  74. planar/files/storage/.__init__.py.un~ +0 -0
  75. planar/files/storage/.base.py.un~ +0 -0
  76. planar/files/storage/.config.py.un~ +0 -0
  77. planar/files/storage/.context.py.un~ +0 -0
  78. planar/files/storage/.local_directory.py.un~ +0 -0
  79. planar/files/storage/.test_local_directory.py.un~ +0 -0
  80. planar/files/storage/.test_s3.py.un~ +0 -0
  81. planar/files/storage/base.py +61 -0
  82. planar/files/storage/config.py +44 -0
  83. planar/files/storage/context.py +15 -0
  84. planar/files/storage/local_directory.py +188 -0
  85. planar/files/storage/s3.py +220 -0
  86. planar/files/storage/test_local_directory.py +162 -0
  87. planar/files/storage/test_s3.py +299 -0
  88. planar/files/test_files.py +283 -0
  89. planar/human/.human.py.un~ +0 -0
  90. planar/human/.test_human.py.un~ +0 -0
  91. planar/human/__init__.py +2 -0
  92. planar/human/human.py +458 -0
  93. planar/human/models.py +80 -0
  94. planar/human/test_human.py +385 -0
  95. planar/logging/.__init__.py.un~ +0 -0
  96. planar/logging/.attributes.py.un~ +0 -0
  97. planar/logging/.formatter.py.un~ +0 -0
  98. planar/logging/.logger.py.un~ +0 -0
  99. planar/logging/.otel.py.un~ +0 -0
  100. planar/logging/.tracer.py.un~ +0 -0
  101. planar/logging/__init__.py +10 -0
  102. planar/logging/attributes.py +54 -0
  103. planar/logging/context.py +14 -0
  104. planar/logging/formatter.py +113 -0
  105. planar/logging/logger.py +114 -0
  106. planar/logging/otel.py +51 -0
  107. planar/modeling/.mixin.py.un~ +0 -0
  108. planar/modeling/.storage.py.un~ +0 -0
  109. planar/modeling/__init__.py +0 -0
  110. planar/modeling/field_helpers.py +59 -0
  111. planar/modeling/json_schema_generator.py +94 -0
  112. planar/modeling/mixins/__init__.py +10 -0
  113. planar/modeling/mixins/auditable.py +52 -0
  114. planar/modeling/mixins/test_auditable.py +97 -0
  115. planar/modeling/mixins/test_timestamp.py +134 -0
  116. planar/modeling/mixins/test_uuid_primary_key.py +52 -0
  117. planar/modeling/mixins/timestamp.py +53 -0
  118. planar/modeling/mixins/uuid_primary_key.py +19 -0
  119. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  120. planar/modeling/orm/__init__.py +18 -0
  121. planar/modeling/orm/planar_base_entity.py +29 -0
  122. planar/modeling/orm/query_filter_builder.py +122 -0
  123. planar/modeling/orm/reexports.py +15 -0
  124. planar/object_config/.object_config.py.un~ +0 -0
  125. planar/object_config/__init__.py +11 -0
  126. planar/object_config/models.py +114 -0
  127. planar/object_config/object_config.py +378 -0
  128. planar/object_registry.py +100 -0
  129. planar/registry_items.py +65 -0
  130. planar/routers/.__init__.py.un~ +0 -0
  131. planar/routers/.agents_router.py.un~ +0 -0
  132. planar/routers/.crud.py.un~ +0 -0
  133. planar/routers/.decision.py.un~ +0 -0
  134. planar/routers/.event.py.un~ +0 -0
  135. planar/routers/.file_attachment.py.un~ +0 -0
  136. planar/routers/.files.py.un~ +0 -0
  137. planar/routers/.files_router.py.un~ +0 -0
  138. planar/routers/.human.py.un~ +0 -0
  139. planar/routers/.info.py.un~ +0 -0
  140. planar/routers/.models.py.un~ +0 -0
  141. planar/routers/.object_config_router.py.un~ +0 -0
  142. planar/routers/.rule.py.un~ +0 -0
  143. planar/routers/.test_object_config_router.py.un~ +0 -0
  144. planar/routers/.test_workflow_router.py.un~ +0 -0
  145. planar/routers/.workflow.py.un~ +0 -0
  146. planar/routers/__init__.py +13 -0
  147. planar/routers/agents_router.py +197 -0
  148. planar/routers/entity_router.py +143 -0
  149. planar/routers/event.py +91 -0
  150. planar/routers/files.py +142 -0
  151. planar/routers/human.py +151 -0
  152. planar/routers/info.py +131 -0
  153. planar/routers/models.py +170 -0
  154. planar/routers/object_config_router.py +133 -0
  155. planar/routers/rule.py +108 -0
  156. planar/routers/test_agents_router.py +174 -0
  157. planar/routers/test_object_config_router.py +367 -0
  158. planar/routers/test_routes_security.py +169 -0
  159. planar/routers/test_rule_router.py +470 -0
  160. planar/routers/test_workflow_router.py +274 -0
  161. planar/routers/workflow.py +468 -0
  162. planar/rules/.decorator.py.un~ +0 -0
  163. planar/rules/.runner.py.un~ +0 -0
  164. planar/rules/.test_rules.py.un~ +0 -0
  165. planar/rules/__init__.py +23 -0
  166. planar/rules/decorator.py +184 -0
  167. planar/rules/models.py +355 -0
  168. planar/rules/rule_configuration.py +191 -0
  169. planar/rules/runner.py +64 -0
  170. planar/rules/test_rules.py +750 -0
  171. planar/scaffold_templates/app/__init__.py.j2 +0 -0
  172. planar/scaffold_templates/app/db/entities.py.j2 +11 -0
  173. planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
  174. planar/scaffold_templates/main.py.j2 +13 -0
  175. planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
  176. planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
  177. planar/scaffold_templates/pyproject.toml.j2 +10 -0
  178. planar/security/.jwt_middleware.py.un~ +0 -0
  179. planar/security/auth_context.py +148 -0
  180. planar/security/authorization.py +388 -0
  181. planar/security/default_policies.cedar +77 -0
  182. planar/security/jwt_middleware.py +116 -0
  183. planar/security/security_context.py +18 -0
  184. planar/security/tests/test_authorization_context.py +78 -0
  185. planar/security/tests/test_cedar_basics.py +41 -0
  186. planar/security/tests/test_cedar_policies.py +158 -0
  187. planar/security/tests/test_jwt_principal_context.py +179 -0
  188. planar/session.py +40 -0
  189. planar/sse/.constants.py.un~ +0 -0
  190. planar/sse/.example.html.un~ +0 -0
  191. planar/sse/.hub.py.un~ +0 -0
  192. planar/sse/.model.py.un~ +0 -0
  193. planar/sse/.proxy.py.un~ +0 -0
  194. planar/sse/constants.py +1 -0
  195. planar/sse/example.html +126 -0
  196. planar/sse/hub.py +216 -0
  197. planar/sse/model.py +8 -0
  198. planar/sse/proxy.py +257 -0
  199. planar/task_local.py +37 -0
  200. planar/test_app.py +51 -0
  201. planar/test_cli.py +372 -0
  202. planar/test_config.py +512 -0
  203. planar/test_object_config.py +527 -0
  204. planar/test_object_registry.py +14 -0
  205. planar/test_sqlalchemy.py +158 -0
  206. planar/test_utils.py +105 -0
  207. planar/testing/.client.py.un~ +0 -0
  208. planar/testing/.memory_storage.py.un~ +0 -0
  209. planar/testing/.planar_test_client.py.un~ +0 -0
  210. planar/testing/.predictable_tracer.py.un~ +0 -0
  211. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  212. planar/testing/.test_memory_storage.py.un~ +0 -0
  213. planar/testing/.workflow_observer.py.un~ +0 -0
  214. planar/testing/__init__.py +0 -0
  215. planar/testing/memory_storage.py +78 -0
  216. planar/testing/planar_test_client.py +54 -0
  217. planar/testing/synchronizable_tracer.py +153 -0
  218. planar/testing/test_memory_storage.py +143 -0
  219. planar/testing/workflow_observer.py +73 -0
  220. planar/utils.py +70 -0
  221. planar/workflows/.__init__.py.un~ +0 -0
  222. planar/workflows/.builtin_steps.py.un~ +0 -0
  223. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  224. planar/workflows/.context.py.un~ +0 -0
  225. planar/workflows/.contrib.py.un~ +0 -0
  226. planar/workflows/.decorators.py.un~ +0 -0
  227. planar/workflows/.durable_test.py.un~ +0 -0
  228. planar/workflows/.errors.py.un~ +0 -0
  229. planar/workflows/.events.py.un~ +0 -0
  230. planar/workflows/.exceptions.py.un~ +0 -0
  231. planar/workflows/.execution.py.un~ +0 -0
  232. planar/workflows/.human.py.un~ +0 -0
  233. planar/workflows/.lock.py.un~ +0 -0
  234. planar/workflows/.misc.py.un~ +0 -0
  235. planar/workflows/.model.py.un~ +0 -0
  236. planar/workflows/.models.py.un~ +0 -0
  237. planar/workflows/.notifications.py.un~ +0 -0
  238. planar/workflows/.orchestrator.py.un~ +0 -0
  239. planar/workflows/.runtime.py.un~ +0 -0
  240. planar/workflows/.serialization.py.un~ +0 -0
  241. planar/workflows/.step.py.un~ +0 -0
  242. planar/workflows/.step_core.py.un~ +0 -0
  243. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  244. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  245. planar/workflows/.test_concurrency.py.un~ +0 -0
  246. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  247. planar/workflows/.test_human.py.un~ +0 -0
  248. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  249. planar/workflows/.test_orchestrator.py.un~ +0 -0
  250. planar/workflows/.test_race_conditions.py.un~ +0 -0
  251. planar/workflows/.test_serialization.py.un~ +0 -0
  252. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  253. planar/workflows/.test_workflow.py.un~ +0 -0
  254. planar/workflows/.tracing.py.un~ +0 -0
  255. planar/workflows/.types.py.un~ +0 -0
  256. planar/workflows/.util.py.un~ +0 -0
  257. planar/workflows/.utils.py.un~ +0 -0
  258. planar/workflows/.workflow.py.un~ +0 -0
  259. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  260. planar/workflows/.wrappers.py.un~ +0 -0
  261. planar/workflows/__init__.py +42 -0
  262. planar/workflows/context.py +44 -0
  263. planar/workflows/contrib.py +190 -0
  264. planar/workflows/decorators.py +217 -0
  265. planar/workflows/events.py +185 -0
  266. planar/workflows/exceptions.py +34 -0
  267. planar/workflows/execution.py +198 -0
  268. planar/workflows/lock.py +229 -0
  269. planar/workflows/misc.py +5 -0
  270. planar/workflows/models.py +154 -0
  271. planar/workflows/notifications.py +96 -0
  272. planar/workflows/orchestrator.py +383 -0
  273. planar/workflows/query.py +256 -0
  274. planar/workflows/serialization.py +409 -0
  275. planar/workflows/step_core.py +373 -0
  276. planar/workflows/step_metadata.py +357 -0
  277. planar/workflows/step_testing_utils.py +86 -0
  278. planar/workflows/sub_workflow_runner.py +191 -0
  279. planar/workflows/test_concurrency_detection.py +120 -0
  280. planar/workflows/test_lock_timeout.py +140 -0
  281. planar/workflows/test_serialization.py +1195 -0
  282. planar/workflows/test_suspend_deserialization.py +231 -0
  283. planar/workflows/test_workflow.py +1967 -0
  284. planar/workflows/tracing.py +106 -0
  285. planar/workflows/wrappers.py +41 -0
  286. planar-0.5.0.dist-info/METADATA +285 -0
  287. planar-0.5.0.dist-info/RECORD +289 -0
  288. planar-0.5.0.dist-info/WHEEL +4 -0
  289. planar-0.5.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,53 @@
1
+ from datetime import datetime
2
+ from typing import Any, Callable
3
+
4
+ from sqlmodel import Field, SQLModel
5
+
6
+ from planar.utils import utc_now
7
+
8
+
9
+ def timestamp_column(
10
+ index: bool = False,
11
+ nullable: bool = False,
12
+ onupdate: Callable[[], datetime] | bool | None = None,
13
+ default: Callable[[], datetime] | None = utc_now,
14
+ ):
15
+ if onupdate is True:
16
+ onupdate = utc_now
17
+ return Field(
18
+ default_factory=default,
19
+ nullable=nullable,
20
+ index=index,
21
+ sa_column_kwargs={"onupdate": onupdate},
22
+ )
23
+
24
+
25
+ class TimestampMixin(SQLModel, table=False):
26
+ """
27
+ Mixin that adds created_at and updated_at fields to a model.
28
+
29
+ This standardizes timestamp handling across all internal models.
30
+
31
+ Attributes:
32
+ created_at: Timestamp when the record was created
33
+ updated_at: Timestamp that updates whenever the record is modified
34
+ """
35
+
36
+ __abstract__ = True
37
+
38
+ created_at: datetime = timestamp_column()
39
+ updated_at: datetime = timestamp_column(onupdate=utc_now)
40
+
41
+ def __init__(self, **kwargs: Any):
42
+ """
43
+ Initializes the TimestampMixin.
44
+ Ensures that `updated_at` is the same as `created_at` if `updated_at`
45
+ is not explicitly provided during instantiation.
46
+ """
47
+ super().__init__(**kwargs)
48
+ # If 'updated_at' was not passed during construction,
49
+ # set it to the value of 'created_at'.
50
+ # 'created_at' itself would have been set by super().__init__()
51
+ # either from kwargs or its default_factory.
52
+ if "updated_at" not in kwargs and self.created_at is not None:
53
+ self.updated_at = self.created_at
@@ -0,0 +1,19 @@
1
+ from uuid import UUID, uuid4
2
+
3
+ from sqlmodel import Field, SQLModel
4
+
5
+
6
+ class UUIDPrimaryKeyMixin(SQLModel, table=False):
7
+ """
8
+ Mixin that provides a UUID primary key field.
9
+
10
+ This standardizes primary key handling across all models that need
11
+ a UUID-based primary key.
12
+
13
+ Attributes:
14
+ id: UUID primary key field with automatic generation
15
+ """
16
+
17
+ __abstract__ = True
18
+
19
+ id: UUID = Field(default_factory=uuid4, primary_key=True)
@@ -0,0 +1,18 @@
1
+ from .planar_base_entity import PLANAR_APPLICATION_METADATA, PlanarBaseEntity
2
+ from .reexports import (
3
+ Field,
4
+ Relationship,
5
+ Session,
6
+ SQLModel,
7
+ create_engine,
8
+ )
9
+
10
+ __all__ = [
11
+ "Field",
12
+ "Relationship",
13
+ "Session",
14
+ "SQLModel",
15
+ "create_engine",
16
+ "PlanarBaseEntity",
17
+ "PLANAR_APPLICATION_METADATA",
18
+ ]
@@ -0,0 +1,29 @@
1
+ from pydantic import ConfigDict
2
+ from sqlalchemy import MetaData, event
3
+ from sqlalchemy.engine import Connection
4
+ from sqlalchemy.orm import Mapper
5
+
6
+ from planar.logging import get_logger
7
+ from planar.modeling.mixins.auditable import AuditableMixin
8
+ from planar.modeling.mixins.uuid_primary_key import UUIDPrimaryKeyMixin
9
+
10
+ from .reexports import SQLModel
11
+
12
+ logger = get_logger("orm.PlanarBaseEntity")
13
+
14
+
15
+ PLANAR_APPLICATION_METADATA = MetaData()
16
+
17
+
18
+ class PlanarBaseEntity(UUIDPrimaryKeyMixin, AuditableMixin, SQLModel, table=False):
19
+ __abstract__ = True
20
+ model_config = ConfigDict(validate_assignment=True) # type: ignore
21
+ metadata = PLANAR_APPLICATION_METADATA
22
+
23
+
24
+ @event.listens_for(PlanarBaseEntity, "before_delete", propagate=True)
25
+ def log_deletion(
26
+ mapper: Mapper, connection: Connection, target: PlanarBaseEntity
27
+ ) -> None:
28
+ """Logs the deletion of the entity."""
29
+ logger.info("deleting entity", table_name=target.__tablename__, key=target.id)
@@ -0,0 +1,122 @@
1
+ from sqlalchemy.sql import func as sql_func
2
+ from sqlmodel import desc, select
3
+
4
+ from planar.routers.models import SortDirection
5
+
6
+
7
+ def build_paginated_query(
8
+ query, filters=None, offset=None, limit=None, order_by=None, order_direction=None
9
+ ):
10
+ """
11
+ Helper function to build paginated and filtered queries.
12
+
13
+ Args:
14
+ query: The base SQL query to build upon
15
+ filters: Optional list of filter conditions, where each condition is a tuple of
16
+ (column, operator, value). Operator should be one of:
17
+ '==', '!=', '>', '>=', '<', '<=', 'like', 'ilike', 'in', 'not_in'
18
+ For example: [(User.name, '==', 'John'), (User.age, '>', 18)]
19
+ For date ranges: [(Workflow.created_at, '>=', start_date)]
20
+ offset: Optional offset for pagination
21
+ limit: Optional limit for pagination
22
+ order_by: Optional field or list of fields to order by
23
+ order_direction: Optional direction to order by
24
+ Returns:
25
+ Tuple of (paginated query, total count query)
26
+ The count query is guaranteed to work with the session.exec().one() pattern
27
+ """
28
+ # Create a copy of the query for filtering
29
+ filtered_query = query
30
+
31
+ # Apply filters if provided
32
+ if filters:
33
+ for column, operator, value in filters:
34
+ if value is not None: # Skip None values
35
+ if operator == "==" or operator == "=":
36
+ filtered_query = filtered_query.where(column == value)
37
+ elif operator == "!=":
38
+ filtered_query = filtered_query.where(column != value)
39
+ elif operator == ">":
40
+ filtered_query = filtered_query.where(column > value)
41
+ elif operator == ">=":
42
+ filtered_query = filtered_query.where(column >= value)
43
+ elif operator == "<":
44
+ filtered_query = filtered_query.where(column < value)
45
+ elif operator == "<=":
46
+ filtered_query = filtered_query.where(column <= value)
47
+ elif operator == "like":
48
+ filtered_query = filtered_query.where(column.like(value))
49
+ elif operator == "ilike":
50
+ filtered_query = filtered_query.where(column.ilike(value))
51
+ elif operator == "in":
52
+ filtered_query = filtered_query.where(column.in_(value))
53
+ elif operator == "not_in":
54
+ filtered_query = filtered_query.where(column.not_in(value))
55
+
56
+ # Create a total count query based on the filtered query
57
+ # For select queries with a clear table source
58
+ if hasattr(filtered_query, "whereclause"):
59
+ # For standard select queries, create a count query from the same structure
60
+ count_query = select(sql_func.count())
61
+
62
+ # Try to determine the table to select from
63
+ if (
64
+ hasattr(filtered_query, "get_final_froms")
65
+ and filtered_query.get_final_froms()
66
+ ):
67
+ count_query = count_query.select_from(filtered_query.get_final_froms()[0])
68
+ elif hasattr(filtered_query, "columns") and filtered_query.columns:
69
+ # If we can't get froms, try to extract from columns
70
+ for col in filtered_query.columns:
71
+ if hasattr(col, "table"):
72
+ count_query = count_query.select_from(col.table)
73
+ break
74
+
75
+ # Apply the same where clause if it exists
76
+ if filtered_query.whereclause is not None:
77
+ count_query = count_query.where(filtered_query.whereclause)
78
+ else:
79
+ # For manually constructed queries, extract from first part of query
80
+ # Assuming the query has a clear identifier of the table it's querying
81
+ first_table = None
82
+
83
+ # Try to find the table by examining the query structure
84
+ # This is a simplified approach - adjust as needed based on your query structure
85
+ if hasattr(query, "columns") and query.columns:
86
+ for col in query.columns:
87
+ if hasattr(col, "table"):
88
+ first_table = col.table
89
+ break
90
+
91
+ # Build count query based on the table and where clause
92
+ if first_table is not None:
93
+ count_query = select(sql_func.count()).select_from(first_table)
94
+ if (
95
+ hasattr(filtered_query, "whereclause")
96
+ and filtered_query.whereclause is not None
97
+ ):
98
+ count_query = count_query.where(filtered_query.whereclause)
99
+ else:
100
+ # If we can't determine the table, create a dummy count query that returns 0
101
+ # This ensures we don't need fallback code in the endpoint handlers
102
+ count_query = select(sql_func.lit(0).label("count"))
103
+
104
+ # Apply pagination
105
+ result_query = filtered_query
106
+
107
+ # Apply offset if provided
108
+ if offset is not None:
109
+ result_query = result_query.offset(offset)
110
+
111
+ # Apply limit if provided
112
+ if limit is not None:
113
+ result_query = result_query.limit(limit)
114
+
115
+ # Apply ordering
116
+ if order_by:
117
+ if order_direction == SortDirection.ASC:
118
+ result_query = result_query.order_by(order_by)
119
+ else:
120
+ result_query = result_query.order_by(desc(order_by))
121
+
122
+ return result_query, count_query
@@ -0,0 +1,15 @@
1
+ import sqlmodel as _sqlmodel
2
+
3
+ SQLModel = _sqlmodel.SQLModel
4
+ Field = _sqlmodel.Field
5
+ Relationship = _sqlmodel.Relationship
6
+ Session = _sqlmodel.Session
7
+ create_engine = _sqlmodel.create_engine
8
+
9
+ __all__ = [
10
+ "SQLModel",
11
+ "Field",
12
+ "Relationship",
13
+ "Session",
14
+ "create_engine",
15
+ ]
@@ -0,0 +1,11 @@
1
+ from .models import ConfigurableObjectType, ObjectConfiguration, ObjectConfigurationBase
2
+ from .object_config import DEFAULT_UUID, ConfigNotFoundError, ObjectConfigurationIO
3
+
4
+ __all__ = [
5
+ "ObjectConfiguration",
6
+ "ObjectConfigurationBase",
7
+ "ConfigurableObjectType",
8
+ "DEFAULT_UUID",
9
+ "ObjectConfigurationIO",
10
+ "ConfigNotFoundError",
11
+ ]
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from typing import Any, Generic, TypeVar
7
+ from uuid import UUID, uuid4
8
+
9
+ from pydantic import BaseModel
10
+ from sqlalchemy import VARCHAR, UniqueConstraint
11
+ from sqlalchemy.sql.type_api import TypeDecorator
12
+ from sqlmodel import Column
13
+ from sqlmodel import Field as SQLField
14
+
15
+ from planar.db import PlanarInternalBase
16
+ from planar.modeling.mixins.timestamp import timestamp_column
17
+
18
+ T = TypeVar("T", bound="BaseModel")
19
+ V = TypeVar("V")
20
+
21
+
22
+ class ConfigurableObjectType(str, Enum):
23
+ RULE = "rule"
24
+ AGENT = "agent"
25
+
26
+
27
+ class JSONEncodedDict(TypeDecorator):
28
+ """Create a SQLAlchemy type that converts BaseModel to JSON string on write and to dict on read."""
29
+
30
+ impl = VARCHAR
31
+ cache_ok = True # Required for SQLAlchemy caching mechanism
32
+
33
+ def process_bind_param(self, value, dialect):
34
+ if value is None:
35
+ return None
36
+
37
+ if isinstance(value, BaseModel):
38
+ return value.model_dump_json(by_alias=True)
39
+
40
+ raise ValueError(f"Invalid type: {type(value)}")
41
+
42
+ def process_result_value(self, value, dialect):
43
+ if value is None:
44
+ return None
45
+
46
+ return json.loads(value)
47
+
48
+
49
+ class DiffErrorCode(Enum):
50
+ """Error codes for dictionary comparison diagnostics."""
51
+
52
+ MISSING_FIELD = "MISSING_FIELD"
53
+ VALUE_MISMATCH = "VALUE_MISMATCH"
54
+ EXTRA_FIELD = "EXTRA_FIELD"
55
+ CONFIG_MODEL_CHANGED = "CONFIG_MODEL_CHANGED"
56
+
57
+
58
+ class ConfigDiagnosticIssue(BaseModel):
59
+ """Represents a single diagnostic issue found during dictionary comparison."""
60
+
61
+ error_code: DiffErrorCode
62
+ field_path: str
63
+ message: str
64
+ reference_value: Any | None = None
65
+ current_value: Any | None = None
66
+ for_object: str
67
+
68
+
69
+ class ConfigDiagnostics(BaseModel, Generic[T]):
70
+ is_valid: bool
71
+ suggested_fix: T | None = None
72
+ issues: list[ConfigDiagnosticIssue]
73
+
74
+
75
+ class ObjectConfigurationBase(BaseModel, Generic[T]):
76
+ """Base Pydantic model for object configurations without SQLModel dependencies.
77
+
78
+ This class mirrors the fields in ObjectConfiguration but can be used for
79
+ serialization in FastAPI routes and other places where SQLModel references
80
+ should be avoided.
81
+ """
82
+
83
+ id: UUID
84
+ object_name: str
85
+ object_type: ConfigurableObjectType
86
+ created_at: datetime
87
+ version: int
88
+ data: T
89
+ active: bool
90
+
91
+
92
+ class ObjectConfiguration(PlanarInternalBase, Generic[T], table=True):
93
+ __table_args__ = (
94
+ UniqueConstraint(
95
+ "object_name",
96
+ "object_type",
97
+ "version",
98
+ name="uq_object_config_name_type_version",
99
+ ),
100
+ )
101
+
102
+ __tablename__ = "object_configuration" # type: ignore
103
+
104
+ id: UUID = SQLField(default_factory=uuid4, primary_key=True)
105
+
106
+ object_name: str = SQLField(index=True)
107
+ object_type: ConfigurableObjectType = SQLField(index=True)
108
+ created_at: datetime = timestamp_column()
109
+
110
+ version: int = SQLField(default=1)
111
+
112
+ data: T = SQLField(sa_column=Column(JSONEncodedDict))
113
+
114
+ active: bool = SQLField(default=False)