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.
- planar/.__init__.py.un~ +0 -0
- planar/._version.py.un~ +0 -0
- planar/.app.py.un~ +0 -0
- planar/.cli.py.un~ +0 -0
- planar/.config.py.un~ +0 -0
- planar/.context.py.un~ +0 -0
- planar/.db.py.un~ +0 -0
- planar/.di.py.un~ +0 -0
- planar/.engine.py.un~ +0 -0
- planar/.files.py.un~ +0 -0
- planar/.log_context.py.un~ +0 -0
- planar/.log_metadata.py.un~ +0 -0
- planar/.logging.py.un~ +0 -0
- planar/.object_registry.py.un~ +0 -0
- planar/.otel.py.un~ +0 -0
- planar/.server.py.un~ +0 -0
- planar/.session.py.un~ +0 -0
- planar/.sqlalchemy.py.un~ +0 -0
- planar/.task_local.py.un~ +0 -0
- planar/.test_app.py.un~ +0 -0
- planar/.test_config.py.un~ +0 -0
- planar/.test_object_config.py.un~ +0 -0
- planar/.test_sqlalchemy.py.un~ +0 -0
- planar/.test_utils.py.un~ +0 -0
- planar/.util.py.un~ +0 -0
- planar/.utils.py.un~ +0 -0
- planar/__init__.py +26 -0
- planar/_version.py +1 -0
- planar/ai/.__init__.py.un~ +0 -0
- planar/ai/._models.py.un~ +0 -0
- planar/ai/.agent.py.un~ +0 -0
- planar/ai/.agent_utils.py.un~ +0 -0
- planar/ai/.events.py.un~ +0 -0
- planar/ai/.files.py.un~ +0 -0
- planar/ai/.models.py.un~ +0 -0
- planar/ai/.providers.py.un~ +0 -0
- planar/ai/.pydantic_ai.py.un~ +0 -0
- planar/ai/.pydantic_ai_agent.py.un~ +0 -0
- planar/ai/.pydantic_ai_provider.py.un~ +0 -0
- planar/ai/.step.py.un~ +0 -0
- planar/ai/.test_agent.py.un~ +0 -0
- planar/ai/.test_agent_serialization.py.un~ +0 -0
- planar/ai/.test_providers.py.un~ +0 -0
- planar/ai/.utils.py.un~ +0 -0
- planar/ai/__init__.py +15 -0
- planar/ai/agent.py +457 -0
- planar/ai/agent_utils.py +205 -0
- planar/ai/models.py +140 -0
- planar/ai/providers.py +1088 -0
- planar/ai/test_agent.py +1298 -0
- planar/ai/test_agent_serialization.py +229 -0
- planar/ai/test_providers.py +463 -0
- planar/ai/utils.py +102 -0
- planar/app.py +494 -0
- planar/cli.py +282 -0
- planar/config.py +544 -0
- planar/db/.db.py.un~ +0 -0
- planar/db/__init__.py +17 -0
- planar/db/alembic/env.py +136 -0
- planar/db/alembic/script.py.mako +28 -0
- planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
- planar/db/alembic.ini +128 -0
- planar/db/db.py +318 -0
- planar/files/.config.py.un~ +0 -0
- planar/files/.local.py.un~ +0 -0
- planar/files/.local_filesystem.py.un~ +0 -0
- planar/files/.model.py.un~ +0 -0
- planar/files/.models.py.un~ +0 -0
- planar/files/.s3.py.un~ +0 -0
- planar/files/.storage.py.un~ +0 -0
- planar/files/.test_files.py.un~ +0 -0
- planar/files/__init__.py +2 -0
- planar/files/models.py +162 -0
- planar/files/storage/.__init__.py.un~ +0 -0
- planar/files/storage/.base.py.un~ +0 -0
- planar/files/storage/.config.py.un~ +0 -0
- planar/files/storage/.context.py.un~ +0 -0
- planar/files/storage/.local_directory.py.un~ +0 -0
- planar/files/storage/.test_local_directory.py.un~ +0 -0
- planar/files/storage/.test_s3.py.un~ +0 -0
- planar/files/storage/base.py +61 -0
- planar/files/storage/config.py +44 -0
- planar/files/storage/context.py +15 -0
- planar/files/storage/local_directory.py +188 -0
- planar/files/storage/s3.py +220 -0
- planar/files/storage/test_local_directory.py +162 -0
- planar/files/storage/test_s3.py +299 -0
- planar/files/test_files.py +283 -0
- planar/human/.human.py.un~ +0 -0
- planar/human/.test_human.py.un~ +0 -0
- planar/human/__init__.py +2 -0
- planar/human/human.py +458 -0
- planar/human/models.py +80 -0
- planar/human/test_human.py +385 -0
- planar/logging/.__init__.py.un~ +0 -0
- planar/logging/.attributes.py.un~ +0 -0
- planar/logging/.formatter.py.un~ +0 -0
- planar/logging/.logger.py.un~ +0 -0
- planar/logging/.otel.py.un~ +0 -0
- planar/logging/.tracer.py.un~ +0 -0
- planar/logging/__init__.py +10 -0
- planar/logging/attributes.py +54 -0
- planar/logging/context.py +14 -0
- planar/logging/formatter.py +113 -0
- planar/logging/logger.py +114 -0
- planar/logging/otel.py +51 -0
- planar/modeling/.mixin.py.un~ +0 -0
- planar/modeling/.storage.py.un~ +0 -0
- planar/modeling/__init__.py +0 -0
- planar/modeling/field_helpers.py +59 -0
- planar/modeling/json_schema_generator.py +94 -0
- planar/modeling/mixins/__init__.py +10 -0
- planar/modeling/mixins/auditable.py +52 -0
- planar/modeling/mixins/test_auditable.py +97 -0
- planar/modeling/mixins/test_timestamp.py +134 -0
- planar/modeling/mixins/test_uuid_primary_key.py +52 -0
- planar/modeling/mixins/timestamp.py +53 -0
- planar/modeling/mixins/uuid_primary_key.py +19 -0
- planar/modeling/orm/.planar_base_model.py.un~ +0 -0
- planar/modeling/orm/__init__.py +18 -0
- planar/modeling/orm/planar_base_entity.py +29 -0
- planar/modeling/orm/query_filter_builder.py +122 -0
- planar/modeling/orm/reexports.py +15 -0
- planar/object_config/.object_config.py.un~ +0 -0
- planar/object_config/__init__.py +11 -0
- planar/object_config/models.py +114 -0
- planar/object_config/object_config.py +378 -0
- planar/object_registry.py +100 -0
- planar/registry_items.py +65 -0
- planar/routers/.__init__.py.un~ +0 -0
- planar/routers/.agents_router.py.un~ +0 -0
- planar/routers/.crud.py.un~ +0 -0
- planar/routers/.decision.py.un~ +0 -0
- planar/routers/.event.py.un~ +0 -0
- planar/routers/.file_attachment.py.un~ +0 -0
- planar/routers/.files.py.un~ +0 -0
- planar/routers/.files_router.py.un~ +0 -0
- planar/routers/.human.py.un~ +0 -0
- planar/routers/.info.py.un~ +0 -0
- planar/routers/.models.py.un~ +0 -0
- planar/routers/.object_config_router.py.un~ +0 -0
- planar/routers/.rule.py.un~ +0 -0
- planar/routers/.test_object_config_router.py.un~ +0 -0
- planar/routers/.test_workflow_router.py.un~ +0 -0
- planar/routers/.workflow.py.un~ +0 -0
- planar/routers/__init__.py +13 -0
- planar/routers/agents_router.py +197 -0
- planar/routers/entity_router.py +143 -0
- planar/routers/event.py +91 -0
- planar/routers/files.py +142 -0
- planar/routers/human.py +151 -0
- planar/routers/info.py +131 -0
- planar/routers/models.py +170 -0
- planar/routers/object_config_router.py +133 -0
- planar/routers/rule.py +108 -0
- planar/routers/test_agents_router.py +174 -0
- planar/routers/test_object_config_router.py +367 -0
- planar/routers/test_routes_security.py +169 -0
- planar/routers/test_rule_router.py +470 -0
- planar/routers/test_workflow_router.py +274 -0
- planar/routers/workflow.py +468 -0
- planar/rules/.decorator.py.un~ +0 -0
- planar/rules/.runner.py.un~ +0 -0
- planar/rules/.test_rules.py.un~ +0 -0
- planar/rules/__init__.py +23 -0
- planar/rules/decorator.py +184 -0
- planar/rules/models.py +355 -0
- planar/rules/rule_configuration.py +191 -0
- planar/rules/runner.py +64 -0
- planar/rules/test_rules.py +750 -0
- planar/scaffold_templates/app/__init__.py.j2 +0 -0
- planar/scaffold_templates/app/db/entities.py.j2 +11 -0
- planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
- planar/scaffold_templates/main.py.j2 +13 -0
- planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
- planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
- planar/scaffold_templates/pyproject.toml.j2 +10 -0
- planar/security/.jwt_middleware.py.un~ +0 -0
- planar/security/auth_context.py +148 -0
- planar/security/authorization.py +388 -0
- planar/security/default_policies.cedar +77 -0
- planar/security/jwt_middleware.py +116 -0
- planar/security/security_context.py +18 -0
- planar/security/tests/test_authorization_context.py +78 -0
- planar/security/tests/test_cedar_basics.py +41 -0
- planar/security/tests/test_cedar_policies.py +158 -0
- planar/security/tests/test_jwt_principal_context.py +179 -0
- planar/session.py +40 -0
- planar/sse/.constants.py.un~ +0 -0
- planar/sse/.example.html.un~ +0 -0
- planar/sse/.hub.py.un~ +0 -0
- planar/sse/.model.py.un~ +0 -0
- planar/sse/.proxy.py.un~ +0 -0
- planar/sse/constants.py +1 -0
- planar/sse/example.html +126 -0
- planar/sse/hub.py +216 -0
- planar/sse/model.py +8 -0
- planar/sse/proxy.py +257 -0
- planar/task_local.py +37 -0
- planar/test_app.py +51 -0
- planar/test_cli.py +372 -0
- planar/test_config.py +512 -0
- planar/test_object_config.py +527 -0
- planar/test_object_registry.py +14 -0
- planar/test_sqlalchemy.py +158 -0
- planar/test_utils.py +105 -0
- planar/testing/.client.py.un~ +0 -0
- planar/testing/.memory_storage.py.un~ +0 -0
- planar/testing/.planar_test_client.py.un~ +0 -0
- planar/testing/.predictable_tracer.py.un~ +0 -0
- planar/testing/.synchronizable_tracer.py.un~ +0 -0
- planar/testing/.test_memory_storage.py.un~ +0 -0
- planar/testing/.workflow_observer.py.un~ +0 -0
- planar/testing/__init__.py +0 -0
- planar/testing/memory_storage.py +78 -0
- planar/testing/planar_test_client.py +54 -0
- planar/testing/synchronizable_tracer.py +153 -0
- planar/testing/test_memory_storage.py +143 -0
- planar/testing/workflow_observer.py +73 -0
- planar/utils.py +70 -0
- planar/workflows/.__init__.py.un~ +0 -0
- planar/workflows/.builtin_steps.py.un~ +0 -0
- planar/workflows/.concurrency_tracing.py.un~ +0 -0
- planar/workflows/.context.py.un~ +0 -0
- planar/workflows/.contrib.py.un~ +0 -0
- planar/workflows/.decorators.py.un~ +0 -0
- planar/workflows/.durable_test.py.un~ +0 -0
- planar/workflows/.errors.py.un~ +0 -0
- planar/workflows/.events.py.un~ +0 -0
- planar/workflows/.exceptions.py.un~ +0 -0
- planar/workflows/.execution.py.un~ +0 -0
- planar/workflows/.human.py.un~ +0 -0
- planar/workflows/.lock.py.un~ +0 -0
- planar/workflows/.misc.py.un~ +0 -0
- planar/workflows/.model.py.un~ +0 -0
- planar/workflows/.models.py.un~ +0 -0
- planar/workflows/.notifications.py.un~ +0 -0
- planar/workflows/.orchestrator.py.un~ +0 -0
- planar/workflows/.runtime.py.un~ +0 -0
- planar/workflows/.serialization.py.un~ +0 -0
- planar/workflows/.step.py.un~ +0 -0
- planar/workflows/.step_core.py.un~ +0 -0
- planar/workflows/.sub_workflow_runner.py.un~ +0 -0
- planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
- planar/workflows/.test_concurrency.py.un~ +0 -0
- planar/workflows/.test_concurrency_detection.py.un~ +0 -0
- planar/workflows/.test_human.py.un~ +0 -0
- planar/workflows/.test_lock_timeout.py.un~ +0 -0
- planar/workflows/.test_orchestrator.py.un~ +0 -0
- planar/workflows/.test_race_conditions.py.un~ +0 -0
- planar/workflows/.test_serialization.py.un~ +0 -0
- planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
- planar/workflows/.test_workflow.py.un~ +0 -0
- planar/workflows/.tracing.py.un~ +0 -0
- planar/workflows/.types.py.un~ +0 -0
- planar/workflows/.util.py.un~ +0 -0
- planar/workflows/.utils.py.un~ +0 -0
- planar/workflows/.workflow.py.un~ +0 -0
- planar/workflows/.workflow_wrapper.py.un~ +0 -0
- planar/workflows/.wrappers.py.un~ +0 -0
- planar/workflows/__init__.py +42 -0
- planar/workflows/context.py +44 -0
- planar/workflows/contrib.py +190 -0
- planar/workflows/decorators.py +217 -0
- planar/workflows/events.py +185 -0
- planar/workflows/exceptions.py +34 -0
- planar/workflows/execution.py +198 -0
- planar/workflows/lock.py +229 -0
- planar/workflows/misc.py +5 -0
- planar/workflows/models.py +154 -0
- planar/workflows/notifications.py +96 -0
- planar/workflows/orchestrator.py +383 -0
- planar/workflows/query.py +256 -0
- planar/workflows/serialization.py +409 -0
- planar/workflows/step_core.py +373 -0
- planar/workflows/step_metadata.py +357 -0
- planar/workflows/step_testing_utils.py +86 -0
- planar/workflows/sub_workflow_runner.py +191 -0
- planar/workflows/test_concurrency_detection.py +120 -0
- planar/workflows/test_lock_timeout.py +140 -0
- planar/workflows/test_serialization.py +1195 -0
- planar/workflows/test_suspend_deserialization.py +231 -0
- planar/workflows/test_workflow.py +1967 -0
- planar/workflows/tracing.py +106 -0
- planar/workflows/wrappers.py +41 -0
- planar-0.5.0.dist-info/METADATA +285 -0
- planar-0.5.0.dist-info/RECORD +289 -0
- planar-0.5.0.dist-info/WHEEL +4 -0
- planar-0.5.0.dist-info/entry_points.txt +3 -0
planar/logging/logger.py
ADDED
@@ -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"
|