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,114 @@
1
+ from datetime import datetime
2
+ from logging import DEBUG, Logger, getLogger
3
+ from typing import Any, Mapping
4
+ from uuid import UUID
5
+
6
+
7
+ def _process_values(values: Mapping[str, Any] | None):
8
+ if not values:
9
+ return None
10
+
11
+ processed = {}
12
+ for k, v in values.items():
13
+ k = f"${k}" # prefix keys to avoid conflicts with LogRecord keys
14
+ if isinstance(v, (UUID, datetime)):
15
+ processed[k] = str(v)
16
+ else:
17
+ processed[k] = v
18
+ return processed
19
+
20
+
21
+ # A wrapper around a standard `logging.Logger` instance. The main
22
+ # difference is that its logging methods accept arbitrary **kwargs which are
23
+ # automatically merged with "extra"
24
+ class PlanarLogger:
25
+ def __init__(self, logger: Logger):
26
+ self._logger = logger
27
+
28
+ def isDebugEnabled(self) -> bool:
29
+ return self._logger.isEnabledFor(DEBUG)
30
+
31
+ def debug(
32
+ self,
33
+ msg: object,
34
+ **kwargs: Any,
35
+ ) -> None:
36
+ return self._logger.debug(
37
+ msg,
38
+ stacklevel=2,
39
+ extra=_process_values(kwargs),
40
+ )
41
+
42
+ def info(
43
+ self,
44
+ msg: object,
45
+ **kwargs: Any,
46
+ ) -> None:
47
+ return self._logger.info(
48
+ msg,
49
+ stacklevel=2,
50
+ extra=_process_values(kwargs),
51
+ )
52
+
53
+ def warning(
54
+ self,
55
+ msg: object,
56
+ **kwargs: Any,
57
+ ) -> None:
58
+ return self._logger.warning(
59
+ msg,
60
+ stacklevel=2,
61
+ extra=_process_values(kwargs),
62
+ )
63
+
64
+ def error(
65
+ self,
66
+ msg: object,
67
+ **kwargs: Any,
68
+ ) -> None:
69
+ return self._logger.error(
70
+ msg,
71
+ stacklevel=2,
72
+ extra=_process_values(kwargs),
73
+ )
74
+
75
+ def critical(
76
+ self,
77
+ msg: object,
78
+ **kwargs: Any,
79
+ ) -> None:
80
+ return self._logger.critical(
81
+ msg,
82
+ stacklevel=2,
83
+ extra=_process_values(kwargs),
84
+ )
85
+
86
+ def exception(
87
+ self,
88
+ msg: object,
89
+ **kwargs: Any,
90
+ ) -> None:
91
+ return self._logger.exception(
92
+ msg,
93
+ exc_info=True,
94
+ stacklevel=2,
95
+ extra=_process_values(kwargs),
96
+ )
97
+
98
+ def setLevel(self, level: int) -> None:
99
+ self._logger.setLevel(level)
100
+
101
+ @property
102
+ def handlers(self):
103
+ return self._logger.handlers
104
+
105
+
106
+ def get_logger(name: str) -> PlanarLogger:
107
+ """
108
+ Get a logger instance.
109
+
110
+ This will be a PlanarLogger instance which supports structured logging
111
+ by passing keyword arguments to logging methods.
112
+ """
113
+ logger = getLogger(name)
114
+ return PlanarLogger(logger)
planar/logging/otel.py ADDED
@@ -0,0 +1,51 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ from opentelemetry._logs import set_logger_provider
5
+ from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
6
+ from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
7
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
8
+ from opentelemetry.sdk.resources import Resource
9
+ from pydantic import HttpUrl
10
+
11
+ from .attributes import ExtraAttributesFilter
12
+
13
+
14
+ def get_otel_collector_handler(
15
+ otel_collector_endpoint: HttpUrl,
16
+ resource_attributes: dict[str, Any] | None = None,
17
+ ) -> logging.Handler:
18
+ logger_provider = LoggerProvider(
19
+ resource=Resource.create(
20
+ resource_attributes
21
+ or {
22
+ "service.name": "planar-app",
23
+ }
24
+ ),
25
+ )
26
+
27
+ otlp_exporter = OTLPLogExporter(
28
+ endpoint=str(otel_collector_endpoint),
29
+ insecure=otel_collector_endpoint.scheme == "http",
30
+ )
31
+ logger_provider.add_log_record_processor(BatchLogRecordProcessor(otlp_exporter))
32
+
33
+ set_logger_provider(logger_provider)
34
+ handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
35
+ handler.addFilter(ExtraAttributesFilter())
36
+ return handler
37
+
38
+
39
+ def setup_otel_logging(
40
+ otel_collector_endpoint: HttpUrl,
41
+ resource_attributes: dict[str, Any] | None = None,
42
+ ) -> None:
43
+ """
44
+ Sets up the OpenTelemetry logging handler and adds it to the root logger.
45
+
46
+ Args:
47
+ otel_collector_endpoint: The endpoint of the OpenTelemetry collector.
48
+ resource_attributes: A dictionary of resource attributes to add to the logs.
49
+ """
50
+ handler = get_otel_collector_handler(otel_collector_endpoint, resource_attributes)
51
+ logging.getLogger().addHandler(handler)
Binary file
Binary file
File without changes
@@ -0,0 +1,59 @@
1
+ """
2
+ Helper functions for field definitions and schema customization.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Annotated, Any, Type
7
+
8
+ from pydantic import GetJsonSchemaHandler
9
+ from pydantic.json_schema import JsonSchemaValue
10
+ from pydantic_core import core_schema
11
+
12
+ from planar.modeling.orm import PlanarBaseEntity
13
+
14
+
15
+ class JsonSchemaJson:
16
+ @classmethod
17
+ def __get_pydantic_json_schema__(
18
+ cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
19
+ ) -> JsonSchemaValue:
20
+ return {"$ref": "https://json-schema.org/draft/2020-12/schema"}
21
+
22
+
23
+ JsonSchema = Annotated[dict[str, Any], JsonSchemaJson]
24
+
25
+
26
+ @dataclass
27
+ class EntityField:
28
+ entity: Type[PlanarBaseEntity]
29
+ description: str | None = None
30
+ display_field: str | None = None
31
+ """
32
+ Create a field that references an entity, with metadata for UI rendering.
33
+
34
+ Args:
35
+ entity: The entity class this field references
36
+ display_field: Field to display in dropdowns (defaults to best guess)
37
+ description: Field description
38
+
39
+ Use this by annotating a field with:
40
+ Annotated[str, EntityField(entity=MyEntity)]
41
+ """
42
+
43
+ def __get_pydantic_json_schema__(
44
+ self, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler
45
+ ) -> JsonSchemaValue:
46
+ json_schema = handler(core_schema)
47
+ json_schema["description"] = self.description
48
+ display_field = self.display_field
49
+ if display_field is None:
50
+ for field_name in ["name", "title", "username", "label", "display_name"]:
51
+ if hasattr(self.entity, field_name):
52
+ display_field = field_name
53
+ break
54
+ json_schema["x-planar-presentation"] = {
55
+ "inputType": "entity-select",
56
+ "entity": self.entity.__name__,
57
+ "displayField": display_field,
58
+ }
59
+ return json_schema.copy()
@@ -0,0 +1,94 @@
1
+ import inspect
2
+ from typing import Any, Callable, Optional, get_args, get_origin, get_type_hints
3
+
4
+ from pydantic import create_model
5
+
6
+ from planar.logging import get_logger
7
+ from planar.modeling.field_helpers import JsonSchema
8
+
9
+ logger = get_logger(__name__)
10
+
11
+
12
+ def generate_json_schema_for_input_parameters(func: Callable[..., Any]) -> JsonSchema:
13
+ """Generate a Pydantic model from a function's parameters and return it as JSON schema."""
14
+ logger.debug(
15
+ "generating input json schema for function", function_name=func.__name__
16
+ )
17
+ class_name = "DynamicInputModel"
18
+
19
+ sig = inspect.signature(func)
20
+ type_hints = get_type_hints(func)
21
+
22
+ fields = {}
23
+ for param_name, param in sig.parameters.items():
24
+ # Skip self/cls for methods
25
+ if param_name in ("self", "cls") and param.kind == param.POSITIONAL_OR_KEYWORD:
26
+ continue
27
+
28
+ param_type = type_hints.get(param_name, Any)
29
+
30
+ is_optional = False
31
+ if get_origin(param_type) is Optional:
32
+ param_type = get_args(param_type)[0]
33
+ is_optional = True
34
+
35
+ if param.default is not param.empty:
36
+ default = param.default
37
+ elif is_optional:
38
+ default = None
39
+ else:
40
+ default = ... # Required field with no default
41
+
42
+ fields[param_name] = (param_type, default)
43
+
44
+ logger.debug(
45
+ "fields for input model",
46
+ function_name=func.__name__,
47
+ fields=list(fields.keys()),
48
+ )
49
+ model_class = create_model(class_name, **fields)
50
+ schema = model_class.model_json_schema()
51
+ logger.debug(
52
+ "generated input schema",
53
+ function_name=func.__name__,
54
+ title=schema.get("title", class_name),
55
+ )
56
+ return schema
57
+
58
+
59
+ def generate_json_schema_for_output_parameters(func: Callable[..., Any]) -> JsonSchema:
60
+ """Generate a Pydantic model from a function's output parameters and return it as JSON schema."""
61
+ logger.debug(
62
+ "generating output json schema for function", function_name=func.__name__
63
+ )
64
+ class_name = "DynamicOutputModel"
65
+
66
+ type_hints = get_type_hints(func)
67
+ return_type = type_hints.get("return", Any)
68
+
69
+ is_optional = False
70
+ if get_origin(return_type) is Optional:
71
+ return_type = get_args(return_type)[0]
72
+ is_optional = True
73
+
74
+ if is_optional:
75
+ default = None
76
+ else:
77
+ default = ... # Required field with no default
78
+
79
+ fields = {}
80
+ fields["output_type"] = (return_type, default)
81
+
82
+ logger.debug(
83
+ "field for output model",
84
+ function_name=func.__name__,
85
+ fields=list(fields.keys()),
86
+ )
87
+ model_class = create_model(class_name, **fields)
88
+ schema = model_class.model_json_schema()
89
+ logger.debug(
90
+ "generated output schema",
91
+ function_name=func.__name__,
92
+ title=schema.get("title", class_name),
93
+ )
94
+ return schema
@@ -0,0 +1,10 @@
1
+ from planar.modeling.mixins.auditable import AuditableMixin
2
+ from planar.modeling.mixins.timestamp import TimestampMixin, timestamp_column
3
+ from planar.modeling.mixins.uuid_primary_key import UUIDPrimaryKeyMixin
4
+
5
+ __all__ = [
6
+ "TimestampMixin",
7
+ "timestamp_column",
8
+ "AuditableMixin",
9
+ "UUIDPrimaryKeyMixin",
10
+ ]
@@ -0,0 +1,52 @@
1
+ from sqlalchemy import event
2
+ from sqlalchemy.engine import Connection
3
+ from sqlalchemy.orm import Mapper
4
+ from sqlmodel import Field, SQLModel
5
+
6
+ from planar.logging import get_logger
7
+ from planar.security.auth_context import get_current_principal
8
+
9
+ logger = get_logger("orm.AuditableMixin")
10
+
11
+ SYSTEM_USER = "system"
12
+
13
+
14
+ class AuditableMixin(SQLModel, table=False):
15
+ """
16
+ Mixin that provides audit trail fields for tracking who created and updated records.
17
+
18
+ This standardizes audit trail handling across all models that need to track
19
+ user actions.
20
+
21
+ Attributes:
22
+ created_by: User who created the record
23
+ updated_by: User who last updated the record
24
+ """
25
+
26
+ __abstract__ = True
27
+
28
+ created_by: str = Field(default=SYSTEM_USER)
29
+ updated_by: str = Field(default=SYSTEM_USER)
30
+
31
+
32
+ @event.listens_for(AuditableMixin, "before_insert", propagate=True)
33
+ def set_auditable_values(
34
+ mapper: Mapper, connection: Connection, target: AuditableMixin
35
+ ) -> None:
36
+ """Set created_by, updated_by before insert."""
37
+ principal = get_current_principal()
38
+ email = principal.user_email if principal else None
39
+ user_str: str = email or SYSTEM_USER
40
+ target.created_by = user_str
41
+ target.updated_by = user_str
42
+
43
+
44
+ @event.listens_for(AuditableMixin, "before_update", propagate=True)
45
+ def update_auditable_values(
46
+ mapper: Mapper, connection: Connection, target: AuditableMixin
47
+ ) -> None:
48
+ """Set updated_by before update."""
49
+ principal = get_current_principal()
50
+ email = principal.user_email if principal else None
51
+ user_str: str = email or SYSTEM_USER
52
+ target.updated_by = user_str
@@ -0,0 +1,97 @@
1
+ import pytest
2
+ from sqlmodel import Field, SQLModel
3
+
4
+ from planar.db import PlanarSession, new_session
5
+ from planar.modeling.mixins.auditable import AuditableMixin
6
+ from planar.security.auth_context import (
7
+ Principal,
8
+ as_principal,
9
+ get_current_principal,
10
+ )
11
+
12
+ TEST_PRINCIPAL = Principal(
13
+ sub="test_user",
14
+ iss="test",
15
+ exp=1000,
16
+ iat=1000,
17
+ sid="test",
18
+ jti="test",
19
+ org_id="test",
20
+ org_name="test",
21
+ user_first_name="test",
22
+ user_last_name="test",
23
+ user_email="test@test.com",
24
+ role="test",
25
+ permissions=["test"],
26
+ extra_claims={"test": "test"},
27
+ )
28
+
29
+
30
+ class TestAuditableModel(AuditableMixin, SQLModel, table=True):
31
+ """Test model using AuditableMixin."""
32
+
33
+ __test__ = False
34
+
35
+ id: int | None = Field(default=None, primary_key=True)
36
+ name: str = Field()
37
+
38
+
39
+ @pytest.fixture
40
+ async def session(mem_db_engine):
41
+ """Create a database session."""
42
+
43
+ async with new_session(mem_db_engine) as session:
44
+ await (await session.connection()).run_sync(SQLModel.metadata.create_all)
45
+ yield session
46
+
47
+
48
+ def test_auditable_mixin_has_audit_fields():
49
+ """Test that AuditableMixin provides default audit fields."""
50
+ model = TestAuditableModel(name="test")
51
+
52
+ assert hasattr(model, "created_by")
53
+ assert hasattr(model, "updated_by")
54
+ assert model.created_by == "system"
55
+ assert model.updated_by == "system"
56
+
57
+
58
+ async def test_auditable_mixin_sets_values_on_insert(session: PlanarSession):
59
+ """Test that audit fields are set from SecurityContext on insert."""
60
+ with as_principal(TEST_PRINCIPAL):
61
+ model = TestAuditableModel(name="test_insert")
62
+ session.add(model)
63
+ await session.commit()
64
+
65
+ # Refresh to get the updated values
66
+ await session.refresh(model)
67
+
68
+ assert model.created_by == "test@test.com"
69
+ assert model.updated_by == "test@test.com"
70
+
71
+
72
+ async def test_auditable_mixin_sets_updated_by_on_update(session: PlanarSession):
73
+ """Test that updated_by is set from SecurityContext on update."""
74
+ # First insert with initial user
75
+ with as_principal(TEST_PRINCIPAL):
76
+ model = TestAuditableModel(name="test_update")
77
+ session.add(model)
78
+ await session.commit()
79
+ await session.refresh(model)
80
+
81
+ assert model.created_by == "test@test.com"
82
+ assert model.updated_by == "test@test.com"
83
+
84
+ # Now update with different user
85
+ updating_principal = TEST_PRINCIPAL.model_copy(
86
+ update={"user_email": "updating@test.com"}
87
+ )
88
+ with as_principal(updating_principal):
89
+ assert get_current_principal() == updating_principal
90
+ model.name = "updated_name"
91
+ session.add(model)
92
+ await session.commit()
93
+ await session.refresh(model)
94
+
95
+ # created_by should remain the same, updated_by should change
96
+ assert model.created_by == "test@test.com"
97
+ assert model.updated_by == "updating@test.com"
@@ -0,0 +1,134 @@
1
+ import asyncio
2
+ from datetime import timedelta
3
+
4
+ from sqlalchemy.ext.asyncio import AsyncEngine
5
+ from sqlmodel import select
6
+
7
+ from planar.db import new_session
8
+ from planar.modeling.mixins import TimestampMixin
9
+ from planar.modeling.orm.planar_base_entity import PlanarBaseEntity
10
+ from planar.utils import utc_now
11
+
12
+
13
+ class TimestampTestModel(TimestampMixin, PlanarBaseEntity, table=True):
14
+ """Test model that uses the TimestampMixin."""
15
+
16
+ name: str
17
+ value: int = 0
18
+
19
+
20
+ async def test_timestamp_fields_set_on_creation(tmp_db_engine: AsyncEngine):
21
+ """Test that created_at and updated_at are set when a model is created."""
22
+ # Record time before the operation
23
+ before_creation = utc_now()
24
+
25
+ # Create and insert model
26
+ model_id = None
27
+ async with new_session(tmp_db_engine) as session:
28
+ model = TimestampTestModel(name="test_item", value=42)
29
+ session.add(model)
30
+ await session.commit()
31
+ model_id = model.id
32
+
33
+ # Record time after the operation
34
+ after_creation = utc_now()
35
+
36
+ # Fetch model to verify timestamps
37
+ async with new_session(tmp_db_engine) as session:
38
+ created_model = (
39
+ await session.exec(
40
+ select(TimestampTestModel).where(TimestampTestModel.id == model_id)
41
+ )
42
+ ).one()
43
+
44
+ # Verify created_at is set and within the expected time range
45
+ # and that it equals updated_at
46
+ assert created_model.created_at is not None
47
+ assert before_creation <= created_model.created_at <= after_creation
48
+ assert created_model.created_at == created_model.updated_at
49
+
50
+
51
+ async def test_updated_at_reflects_changes(tmp_db_engine: AsyncEngine):
52
+ """Test that updated_at is updated when a model is modified."""
53
+ # Create and insert model
54
+ model_id = None
55
+ async with new_session(tmp_db_engine) as session:
56
+ model = TimestampTestModel(name="test_item", value=42)
57
+ session.add(model)
58
+ await session.commit()
59
+ model_id = model.id
60
+
61
+ # Get initial created_at and updated_at values
62
+ initial_model = (
63
+ await session.exec(
64
+ select(TimestampTestModel).where(TimestampTestModel.id == model_id)
65
+ )
66
+ ).one()
67
+ await session.commit()
68
+ initial_created_at = initial_model.created_at
69
+ initial_updated_at = initial_model.updated_at
70
+
71
+ # Wait a moment to ensure timestamp will be different
72
+ await asyncio.sleep(0.01)
73
+
74
+ # Record time before update
75
+ before_update = utc_now()
76
+
77
+ # Update the model
78
+ async with new_session(tmp_db_engine) as session:
79
+ model_to_update = (
80
+ await session.exec(
81
+ select(TimestampTestModel).where(TimestampTestModel.id == model_id)
82
+ )
83
+ ).one()
84
+ model_to_update.value = 99
85
+ await session.commit()
86
+
87
+ # Record time after update
88
+ after_update = utc_now()
89
+
90
+ # Verify the timestamps
91
+ async with new_session(tmp_db_engine) as session:
92
+ updated_model = (
93
+ await session.exec(
94
+ select(TimestampTestModel).where(TimestampTestModel.id == model_id)
95
+ )
96
+ ).one()
97
+ await session.commit()
98
+
99
+ # created_at should not change
100
+ assert updated_model.created_at == initial_created_at
101
+
102
+ # updated_at should be newer than before
103
+ assert updated_model.updated_at is not None
104
+ assert initial_updated_at is not None
105
+ assert updated_model.updated_at > initial_updated_at
106
+ assert before_update <= updated_model.updated_at <= after_update
107
+
108
+
109
+ async def test_timestamp_init_with_explicit_values():
110
+ """Test initializing a model with explicit timestamp values."""
111
+ # Create a specific timestamp
112
+ now = utc_now()
113
+ past = now - timedelta(days=1)
114
+
115
+ # Initialize model with explicit timestamps
116
+ model = TimestampTestModel(
117
+ name="test_explicit_timestamps",
118
+ value=200,
119
+ created_at=past,
120
+ updated_at=now,
121
+ )
122
+
123
+ # Verify timestamps match what we provided
124
+ assert model.created_at == past
125
+ assert model.updated_at == now
126
+
127
+ # Initialize model with only created_at
128
+ model2 = TimestampTestModel(
129
+ name="test_partial_timestamps", value=300, created_at=past
130
+ )
131
+
132
+ # Verify updated_at equals created_at when only created_at is provided
133
+ assert model2.created_at == past
134
+ assert model2.updated_at == past
@@ -0,0 +1,52 @@
1
+ from uuid import UUID
2
+
3
+ from sqlmodel import Field
4
+
5
+ from planar.db import PlanarSession
6
+ from planar.modeling.mixins.uuid_primary_key import UUIDPrimaryKeyMixin
7
+ from planar.modeling.orm.planar_base_entity import PlanarBaseEntity
8
+
9
+
10
+ class UUIDModelTest(PlanarBaseEntity, UUIDPrimaryKeyMixin, table=True):
11
+ """Test model using UUIDPrimaryKeyMixin."""
12
+
13
+ name: str = Field()
14
+
15
+
16
+ def test_uuid_primary_key_mixin_creates_uuid_id():
17
+ """Test that UUIDPrimaryKeyMixin provides a UUID id field."""
18
+ model = UUIDModelTest(name="test")
19
+
20
+ assert hasattr(model, "id")
21
+ assert isinstance(model.id, UUID)
22
+ assert model.id is not None
23
+
24
+
25
+ def test_uuid_primary_key_mixin_allows_custom_id():
26
+ """Test that a custom UUID can be provided."""
27
+ custom_uuid = UUID("12345678-1234-5678-1234-123456789abc")
28
+ model = UUIDModelTest(id=custom_uuid, name="test")
29
+
30
+ assert model.id == custom_uuid
31
+
32
+
33
+ async def test_uuid_primary_key_mixin_is_primary_key(session: PlanarSession):
34
+ """Test that the id field works as a primary key."""
35
+ model1 = UUIDModelTest(name="test1")
36
+ model2 = UUIDModelTest(name="test2")
37
+
38
+ session.add(model1)
39
+ session.add(model2)
40
+ await session.commit()
41
+
42
+ # Both should have different IDs
43
+ assert model1.id != model2.id
44
+
45
+ # Both should be retrievable by their IDs
46
+ retrieved1 = await session.get(UUIDModelTest, model1.id)
47
+ retrieved2 = await session.get(UUIDModelTest, model2.id)
48
+
49
+ assert retrieved1 is not None
50
+ assert retrieved1.name == "test1"
51
+ assert retrieved2 is not None
52
+ assert retrieved2.name == "test2"