QuickLab-MCP 0.1.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.
- ml_mcp/__init__.py +3 -0
- ml_mcp/application/__init__.py +1 -0
- ml_mcp/application/analysis/__init__.py +15 -0
- ml_mcp/application/analysis/diagnostics.py +122 -0
- ml_mcp/application/analysis/service.py +155 -0
- ml_mcp/application/artifacts/__init__.py +5 -0
- ml_mcp/application/artifacts/service.py +149 -0
- ml_mcp/application/datasets/__init__.py +6 -0
- ml_mcp/application/datasets/service.py +178 -0
- ml_mcp/application/datasets/validator.py +156 -0
- ml_mcp/application/experiments/__init__.py +5 -0
- ml_mcp/application/experiments/service.py +254 -0
- ml_mcp/application/models/__init__.py +6 -0
- ml_mcp/application/models/catalog_seed.py +131 -0
- ml_mcp/application/models/service.py +177 -0
- ml_mcp/application/projects/__init__.py +5 -0
- ml_mcp/application/projects/service.py +93 -0
- ml_mcp/config/__init__.py +5 -0
- ml_mcp/config/settings.py +176 -0
- ml_mcp/domain/__init__.py +1 -0
- ml_mcp/domain/errors/__init__.py +39 -0
- ml_mcp/domain/errors/base.py +155 -0
- ml_mcp/domain/errors/codes.py +23 -0
- ml_mcp/domain/policies/__init__.py +16 -0
- ml_mcp/domain/policies/rbac.py +126 -0
- ml_mcp/domain/policies/tenant_isolation.py +34 -0
- ml_mcp/domain/policies/tool_policy.py +185 -0
- ml_mcp/domain/value_objects/__init__.py +41 -0
- ml_mcp/domain/value_objects/dataset.py +60 -0
- ml_mcp/domain/value_objects/experiment.py +131 -0
- ml_mcp/domain/value_objects/model.py +48 -0
- ml_mcp/infrastructure/__init__.py +1 -0
- ml_mcp/infrastructure/object_storage/__init__.py +5 -0
- ml_mcp/infrastructure/object_storage/s3.py +272 -0
- ml_mcp/infrastructure/postgres/__init__.py +14 -0
- ml_mcp/infrastructure/postgres/base.py +40 -0
- ml_mcp/infrastructure/postgres/models.py +356 -0
- ml_mcp/infrastructure/postgres/repositories/__init__.py +21 -0
- ml_mcp/infrastructure/postgres/repositories/artifacts.py +34 -0
- ml_mcp/infrastructure/postgres/repositories/audit.py +34 -0
- ml_mcp/infrastructure/postgres/repositories/datasets.py +85 -0
- ml_mcp/infrastructure/postgres/repositories/experiments.py +134 -0
- ml_mcp/infrastructure/postgres/repositories/metrics.py +61 -0
- ml_mcp/infrastructure/postgres/repositories/models.py +61 -0
- ml_mcp/infrastructure/postgres/repositories/outbox.py +47 -0
- ml_mcp/infrastructure/postgres/repositories/projects.py +83 -0
- ml_mcp/infrastructure/postgres/session.py +117 -0
- ml_mcp/infrastructure/queue/__init__.py +6 -0
- ml_mcp/infrastructure/queue/outbox_processor.py +67 -0
- ml_mcp/infrastructure/queue/task_queue.py +98 -0
- ml_mcp/infrastructure/redis/__init__.py +11 -0
- ml_mcp/infrastructure/redis/client.py +64 -0
- ml_mcp/infrastructure/redis/rate_limiter.py +129 -0
- ml_mcp/infrastructure/telemetry/__init__.py +23 -0
- ml_mcp/infrastructure/telemetry/logging.py +155 -0
- ml_mcp/infrastructure/telemetry/otel.py +141 -0
- ml_mcp/mcp/__init__.py +1 -0
- ml_mcp/mcp/prompts.py +38 -0
- ml_mcp/mcp/resources.py +35 -0
- ml_mcp/mcp/schemas/__init__.py +1 -0
- ml_mcp/mcp/schemas/tools.py +124 -0
- ml_mcp/mcp/tools/__init__.py +63 -0
- ml_mcp/mcp/tools/analysis.py +37 -0
- ml_mcp/mcp/tools/datasets.py +82 -0
- ml_mcp/mcp/tools/discovery.py +82 -0
- ml_mcp/mcp/tools/experiments.py +96 -0
- ml_mcp/mcp/tools/projects.py +39 -0
- ml_mcp/mcp/tools/results.py +59 -0
- ml_mcp/server/__init__.py +1 -0
- ml_mcp/server/app.py +488 -0
- ml_mcp/server/auth.py +146 -0
- ml_mcp/server/context.py +59 -0
- ml_mcp/server/http.py +183 -0
- ml_mcp/server/middleware.py +121 -0
- ml_mcp/server/stdio.py +42 -0
- ml_mcp/workers/__init__.py +1 -0
- ml_mcp/workers/daemon.py +130 -0
- ml_mcp/workers/metrics.py +110 -0
- ml_mcp/workers/preprocessing.py +170 -0
- ml_mcp/workers/runner.py +250 -0
- ml_mcp/workers/sandbox.py +35 -0
- ml_mcp/workers/trainers/__init__.py +52 -0
- ml_mcp/workers/trainers/base.py +52 -0
- ml_mcp/workers/trainers/catboost_model.py +85 -0
- ml_mcp/workers/trainers/lightgbm_model.py +81 -0
- ml_mcp/workers/trainers/sklearn_models.py +266 -0
- ml_mcp/workers/trainers/xgboost_model.py +82 -0
- quicklab_mcp-0.1.0.dist-info/METADATA +540 -0
- quicklab_mcp-0.1.0.dist-info/RECORD +92 -0
- quicklab_mcp-0.1.0.dist-info/WHEEL +5 -0
- quicklab_mcp-0.1.0.dist-info/entry_points.txt +4 -0
- quicklab_mcp-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""S3-compatible object storage service with tenant isolation and presigned URL support."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import boto3
|
|
10
|
+
from botocore.client import Config
|
|
11
|
+
from botocore.exceptions import ClientError
|
|
12
|
+
|
|
13
|
+
from ml_mcp.config import Settings, get_settings
|
|
14
|
+
from ml_mcp.domain.errors import DependencyUnavailableError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class S3StorageService:
|
|
20
|
+
"""Production S3 / MinIO storage adapter managing artifacts and datasets."""
|
|
21
|
+
|
|
22
|
+
_SAFE_COMPONENT_PATTERN = re.compile(r"^[a-zA-Z0-9_\-\.]+$")
|
|
23
|
+
|
|
24
|
+
def __init__(self, settings: Settings | None = None) -> None:
|
|
25
|
+
self.settings = settings or get_settings()
|
|
26
|
+
self.bucket_name = self.settings.object_store.bucket_name
|
|
27
|
+
self._client: Any = None
|
|
28
|
+
self._memory_store: dict[str, tuple[bytes, dict[str, str], str]] = {}
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def _assert_safe_component(cls, component: str, field_name: str = "component") -> str:
|
|
32
|
+
"""Validate that a path component contains no traversal sequences or unsafe characters."""
|
|
33
|
+
if not component or not isinstance(component, str) or not component.strip():
|
|
34
|
+
raise ValueError(f"Invalid {field_name}: empty component")
|
|
35
|
+
if component in (".", "..") or ".." in component or "/" in component or "\\" in component:
|
|
36
|
+
raise ValueError(f"Directory traversal detected in {field_name}: '{component}'")
|
|
37
|
+
if not cls._SAFE_COMPONENT_PATTERN.match(component):
|
|
38
|
+
raise ValueError(f"Invalid characters detected in {field_name}: '{component}'")
|
|
39
|
+
return component
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def _assert_safe_key(cls, key: str) -> str:
|
|
43
|
+
"""Validate full storage key against path traversal and malformed structures."""
|
|
44
|
+
if not key or not isinstance(key, str) or not key.strip():
|
|
45
|
+
raise ValueError("Invalid storage key: empty key")
|
|
46
|
+
if key.startswith("/") or key.endswith("/"):
|
|
47
|
+
raise ValueError(f"Invalid storage key: leading or trailing slash in '{key}'")
|
|
48
|
+
if "//" in key or "\\" in key or ".." in key:
|
|
49
|
+
raise ValueError(f"Directory traversal detected in storage key: '{key}'")
|
|
50
|
+
parts = key.split("/")
|
|
51
|
+
for part in parts:
|
|
52
|
+
cls._assert_safe_component(part, "key component")
|
|
53
|
+
return key
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_testing(self) -> bool:
|
|
57
|
+
app_env = getattr(self.settings, "app_env", None)
|
|
58
|
+
if app_env is None and hasattr(self.settings, "app"):
|
|
59
|
+
app_env = getattr(self.settings.app, "env", None)
|
|
60
|
+
return app_env == "testing" or os.environ.get("APP_ENV") == "testing"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def client(self) -> Any:
|
|
64
|
+
if self._client is None:
|
|
65
|
+
self._client = boto3.client(
|
|
66
|
+
"s3",
|
|
67
|
+
endpoint_url=self.settings.object_store.endpoint_url,
|
|
68
|
+
aws_access_key_id=self.settings.object_store.access_key_id.get_secret_value(),
|
|
69
|
+
aws_secret_access_key=self.settings.object_store.secret_access_key.get_secret_value(),
|
|
70
|
+
region_name=self.settings.object_store.region,
|
|
71
|
+
config=Config(
|
|
72
|
+
signature_version="s3v4",
|
|
73
|
+
s3={"addressing_style": "path"},
|
|
74
|
+
connect_timeout=1,
|
|
75
|
+
read_timeout=1,
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
return self._client
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def compute_sha256(data: bytes) -> str:
|
|
82
|
+
"""Compute standard hex SHA-256 digest of binary content."""
|
|
83
|
+
return hashlib.sha256(data).hexdigest()
|
|
84
|
+
|
|
85
|
+
# Tenant-isolated path generators
|
|
86
|
+
@classmethod
|
|
87
|
+
def get_dataset_key(
|
|
88
|
+
cls, tenant_id: str, dataset_id: str, version: str, filename: str = "data.parquet"
|
|
89
|
+
) -> str:
|
|
90
|
+
cls._assert_safe_component(tenant_id, "tenant_id")
|
|
91
|
+
cls._assert_safe_component(dataset_id, "dataset_id")
|
|
92
|
+
cls._assert_safe_component(version, "version")
|
|
93
|
+
cls._assert_safe_component(filename, "filename")
|
|
94
|
+
return f"tenants/{tenant_id}/datasets/{dataset_id}/{version}/{filename}"
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def get_experiment_artifact_key(
|
|
98
|
+
cls, tenant_id: str, experiment_id: str, artifact_type: str, filename: str
|
|
99
|
+
) -> str:
|
|
100
|
+
cls._assert_safe_component(tenant_id, "tenant_id")
|
|
101
|
+
cls._assert_safe_component(experiment_id, "experiment_id")
|
|
102
|
+
cls._assert_safe_component(artifact_type, "artifact_type")
|
|
103
|
+
cls._assert_safe_component(filename, "filename")
|
|
104
|
+
return f"tenants/{tenant_id}/experiments/{experiment_id}/{artifact_type}/{filename}"
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def get_model_key(
|
|
108
|
+
cls, tenant_id: str, model_id: str, version: str, filename: str = "model.joblib"
|
|
109
|
+
) -> str:
|
|
110
|
+
cls._assert_safe_component(tenant_id, "tenant_id")
|
|
111
|
+
cls._assert_safe_component(model_id, "model_id")
|
|
112
|
+
cls._assert_safe_component(version, "version")
|
|
113
|
+
cls._assert_safe_component(filename, "filename")
|
|
114
|
+
return f"tenants/{tenant_id}/models/{model_id}/{version}/{filename}"
|
|
115
|
+
|
|
116
|
+
def put_object(
|
|
117
|
+
self,
|
|
118
|
+
key: str,
|
|
119
|
+
data: bytes,
|
|
120
|
+
content_type: str = "application/octet-stream",
|
|
121
|
+
metadata: dict[str, str] | None = None,
|
|
122
|
+
) -> tuple[str, str]:
|
|
123
|
+
"""Upload an immutable object. Returns (storage_uri, sha256_digest)."""
|
|
124
|
+
self._assert_safe_key(key)
|
|
125
|
+
content_hash = self.compute_sha256(data)
|
|
126
|
+
meta = metadata or {}
|
|
127
|
+
meta["content_hash"] = content_hash
|
|
128
|
+
|
|
129
|
+
if self.is_testing and self._client is None:
|
|
130
|
+
self._memory_store[key] = (data, meta, content_type)
|
|
131
|
+
storage_uri = f"s3://{self.bucket_name}/{key}"
|
|
132
|
+
logger.info(
|
|
133
|
+
"Testing environment: stored S3 object in memory: %s (size: %d bytes)",
|
|
134
|
+
storage_uri,
|
|
135
|
+
len(data),
|
|
136
|
+
)
|
|
137
|
+
return storage_uri, content_hash
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
self.client.put_object(
|
|
141
|
+
Bucket=self.bucket_name,
|
|
142
|
+
Key=key,
|
|
143
|
+
Body=data,
|
|
144
|
+
ContentType=content_type,
|
|
145
|
+
Metadata=meta,
|
|
146
|
+
)
|
|
147
|
+
storage_uri = f"s3://{self.bucket_name}/{key}"
|
|
148
|
+
logger.info("Successfully wrote S3 object: %s (size: %d bytes)", storage_uri, len(data))
|
|
149
|
+
return storage_uri, content_hash
|
|
150
|
+
except Exception as exc:
|
|
151
|
+
if not self.is_testing:
|
|
152
|
+
raise DependencyUnavailableError("Object Storage") from exc
|
|
153
|
+
logger.warning(
|
|
154
|
+
"Live S3 unavailable (%s), writing object to memory fallback for key '%s'", exc, key
|
|
155
|
+
)
|
|
156
|
+
self._memory_store[key] = (data, meta, content_type)
|
|
157
|
+
storage_uri = f"s3://{self.bucket_name}/{key}"
|
|
158
|
+
return storage_uri, content_hash
|
|
159
|
+
|
|
160
|
+
def get_object(self, key: str) -> bytes:
|
|
161
|
+
"""Retrieve binary content of an object."""
|
|
162
|
+
self._assert_safe_key(key)
|
|
163
|
+
if key in self._memory_store:
|
|
164
|
+
return self._memory_store[key][0]
|
|
165
|
+
if self.is_testing and self._client is None:
|
|
166
|
+
logger.error("Failed to read object from S3 memory store at key '%s'", key)
|
|
167
|
+
raise DependencyUnavailableError("Object Storage")
|
|
168
|
+
try:
|
|
169
|
+
response = self.client.get_object(Bucket=self.bucket_name, Key=key)
|
|
170
|
+
return response["Body"].read()
|
|
171
|
+
except Exception as exc:
|
|
172
|
+
if key in self._memory_store:
|
|
173
|
+
return self._memory_store[key][0]
|
|
174
|
+
logger.error("Failed to read object from S3 at key '%s': %s", key, exc)
|
|
175
|
+
raise DependencyUnavailableError("Object Storage") from exc
|
|
176
|
+
|
|
177
|
+
def head_object(self, key: str) -> dict[str, Any] | None:
|
|
178
|
+
"""Inspect object metadata without downloading full payload."""
|
|
179
|
+
self._assert_safe_key(key)
|
|
180
|
+
if key in self._memory_store:
|
|
181
|
+
data, meta, c_type = self._memory_store[key]
|
|
182
|
+
return {
|
|
183
|
+
"size_bytes": len(data),
|
|
184
|
+
"content_type": c_type,
|
|
185
|
+
"metadata": meta,
|
|
186
|
+
"last_modified": None,
|
|
187
|
+
}
|
|
188
|
+
if self.is_testing and self._client is None:
|
|
189
|
+
return None
|
|
190
|
+
try:
|
|
191
|
+
response = self.client.head_object(Bucket=self.bucket_name, Key=key)
|
|
192
|
+
return {
|
|
193
|
+
"size_bytes": response.get("ContentLength", 0),
|
|
194
|
+
"content_type": response.get("ContentType"),
|
|
195
|
+
"metadata": response.get("Metadata", {}),
|
|
196
|
+
"last_modified": response.get("LastModified"),
|
|
197
|
+
}
|
|
198
|
+
except ClientError as exc:
|
|
199
|
+
if exc.response["Error"]["Code"] in ("404", "NoSuchKey"):
|
|
200
|
+
return None
|
|
201
|
+
raise DependencyUnavailableError("Object Storage") from exc
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
if key in self._memory_store:
|
|
204
|
+
data, meta, c_type = self._memory_store[key]
|
|
205
|
+
return {
|
|
206
|
+
"size_bytes": len(data),
|
|
207
|
+
"content_type": c_type,
|
|
208
|
+
"metadata": meta,
|
|
209
|
+
"last_modified": None,
|
|
210
|
+
}
|
|
211
|
+
raise DependencyUnavailableError("Object Storage") from exc
|
|
212
|
+
|
|
213
|
+
def delete_object(self, key: str) -> bool:
|
|
214
|
+
"""Delete an object."""
|
|
215
|
+
self._assert_safe_key(key)
|
|
216
|
+
self._memory_store.pop(key, None)
|
|
217
|
+
if self.is_testing and self._client is None:
|
|
218
|
+
return True
|
|
219
|
+
try:
|
|
220
|
+
self.client.delete_object(Bucket=self.bucket_name, Key=key)
|
|
221
|
+
return True
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
logger.warning("Failed to delete object from S3: %s", exc)
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
def generate_presigned_get_url(self, key: str, expires_in: int | None = None) -> str:
|
|
227
|
+
"""Generate a short-lived read-only presigned GET URL."""
|
|
228
|
+
self._assert_safe_key(key)
|
|
229
|
+
ttl = expires_in or self.settings.object_store.presigned_ttl_seconds
|
|
230
|
+
if self.is_testing and self._client is None:
|
|
231
|
+
return f"https://s3.local/{self.bucket_name}/{key}?expires={ttl}&sig=mock"
|
|
232
|
+
try:
|
|
233
|
+
url = self.client.generate_presigned_url(
|
|
234
|
+
ClientMethod="get_object",
|
|
235
|
+
Params={"Bucket": self.bucket_name, "Key": key},
|
|
236
|
+
ExpiresIn=ttl,
|
|
237
|
+
)
|
|
238
|
+
return url
|
|
239
|
+
except Exception:
|
|
240
|
+
return f"https://s3.local/{self.bucket_name}/{key}?expires={ttl}&sig=mock"
|
|
241
|
+
|
|
242
|
+
def generate_presigned_put_url(
|
|
243
|
+
self,
|
|
244
|
+
key: str,
|
|
245
|
+
content_type: str = "application/octet-stream",
|
|
246
|
+
expires_in: int | None = None,
|
|
247
|
+
) -> str:
|
|
248
|
+
"""Generate a short-lived presigned PUT URL for direct client uploads."""
|
|
249
|
+
self._assert_safe_key(key)
|
|
250
|
+
ttl = expires_in or self.settings.object_store.presigned_ttl_seconds
|
|
251
|
+
if self.is_testing and self._client is None:
|
|
252
|
+
return f"https://s3.local/{self.bucket_name}/{key}?expires={ttl}&sig=mock"
|
|
253
|
+
try:
|
|
254
|
+
url = self.client.generate_presigned_url(
|
|
255
|
+
ClientMethod="put_object",
|
|
256
|
+
Params={"Bucket": self.bucket_name, "Key": key, "ContentType": content_type},
|
|
257
|
+
ExpiresIn=ttl,
|
|
258
|
+
)
|
|
259
|
+
return url
|
|
260
|
+
except Exception:
|
|
261
|
+
return f"https://s3.local/{self.bucket_name}/{key}?expires={ttl}&sig=mock"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
_storage_service: S3StorageService | None = None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def get_storage_service() -> S3StorageService:
|
|
268
|
+
"""Return singleton S3StorageService instance."""
|
|
269
|
+
global _storage_service
|
|
270
|
+
if _storage_service is None:
|
|
271
|
+
_storage_service = S3StorageService()
|
|
272
|
+
return _storage_service
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""PostgreSQL persistence layer using SQLAlchemy 2."""
|
|
2
|
+
|
|
3
|
+
import ml_mcp.infrastructure.postgres.models as models
|
|
4
|
+
from ml_mcp.infrastructure.postgres.base import Base, generate_uuid7, utc_now
|
|
5
|
+
from ml_mcp.infrastructure.postgres.session import DatabaseManager, get_db_manager
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"Base",
|
|
9
|
+
"generate_uuid7",
|
|
10
|
+
"utc_now",
|
|
11
|
+
"DatabaseManager",
|
|
12
|
+
"get_db_manager",
|
|
13
|
+
"models",
|
|
14
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""SQLAlchemy declarative base and time-sortable identifier generator."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import DateTime
|
|
9
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def generate_uuid7() -> str:
|
|
13
|
+
"""Generate a time-sortable UUID (RFC 9562 UUIDv7 format)."""
|
|
14
|
+
# 48-bit timestamp in milliseconds
|
|
15
|
+
ms = int(time.time() * 1000)
|
|
16
|
+
# 12 bits of rand_a + 62 bits of rand_b
|
|
17
|
+
rand_bytes = os.urandom(10)
|
|
18
|
+
|
|
19
|
+
# Construct 16-byte UUID array
|
|
20
|
+
b = bytearray(16)
|
|
21
|
+
b[0:6] = ms.to_bytes(6, byteorder="big")
|
|
22
|
+
b[6] = 0x70 | (rand_bytes[0] & 0x0F) # version 7
|
|
23
|
+
b[7] = rand_bytes[1]
|
|
24
|
+
b[8] = 0x80 | (rand_bytes[2] & 0x3F) # variant 1
|
|
25
|
+
b[9:16] = rand_bytes[3:10]
|
|
26
|
+
|
|
27
|
+
return str(uuid.UUID(bytes=bytes(b)))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def utc_now() -> datetime:
|
|
31
|
+
"""Return timezone-aware current UTC datetime."""
|
|
32
|
+
return datetime.now(UTC)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Base(DeclarativeBase):
|
|
36
|
+
"""Declarative base class for all ModelLab ORM entities."""
|
|
37
|
+
|
|
38
|
+
type_annotation_map = {
|
|
39
|
+
datetime: DateTime(timezone=True),
|
|
40
|
+
}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""SQLAlchemy 2.x ORM models mapping the complete ModelLab control-plane schema."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqlalchemy import (
|
|
7
|
+
JSON,
|
|
8
|
+
BigInteger,
|
|
9
|
+
Float,
|
|
10
|
+
ForeignKey,
|
|
11
|
+
Index,
|
|
12
|
+
Integer,
|
|
13
|
+
String,
|
|
14
|
+
Text,
|
|
15
|
+
UniqueConstraint,
|
|
16
|
+
)
|
|
17
|
+
from sqlalchemy.dialects.postgresql import JSONB
|
|
18
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
19
|
+
|
|
20
|
+
from ml_mcp.infrastructure.postgres.base import Base, generate_uuid7, utc_now
|
|
21
|
+
|
|
22
|
+
# Universal JSON type: native JSONB for PostgreSQL, automatic JSON serializer for SQLite
|
|
23
|
+
JSON_TYPE = JSON().with_variant(JSONB(), "postgresql")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TenantOrm(Base):
|
|
27
|
+
__tablename__ = "tenants"
|
|
28
|
+
|
|
29
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
|
|
30
|
+
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
|
31
|
+
created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
32
|
+
updated_at: Mapped[datetime] = mapped_column(default=utc_now, onupdate=utc_now, nullable=False)
|
|
33
|
+
|
|
34
|
+
principals: Mapped[list["PrincipalOrm"]] = relationship(
|
|
35
|
+
back_populates="tenant", cascade="all, delete-orphan"
|
|
36
|
+
)
|
|
37
|
+
projects: Mapped[list["ProjectOrm"]] = relationship(
|
|
38
|
+
back_populates="tenant", cascade="all, delete-orphan"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class PrincipalOrm(Base):
|
|
43
|
+
__tablename__ = "principals"
|
|
44
|
+
|
|
45
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
|
|
46
|
+
tenant_id: Mapped[str] = mapped_column(
|
|
47
|
+
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
|
48
|
+
)
|
|
49
|
+
username: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
50
|
+
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
51
|
+
role: Mapped[str] = mapped_column(String(32), nullable=False, default="viewer")
|
|
52
|
+
scopes: Mapped[list[str]] = mapped_column(JSON_TYPE, default=list, nullable=False)
|
|
53
|
+
created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
54
|
+
|
|
55
|
+
tenant: Mapped["TenantOrm"] = relationship(back_populates="principals")
|
|
56
|
+
|
|
57
|
+
__table_args__ = (
|
|
58
|
+
UniqueConstraint("tenant_id", "username", name="uq_principals_tenant_username"),
|
|
59
|
+
Index("ix_principals_tenant_created", "tenant_id", "created_at"),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ProjectOrm(Base):
|
|
64
|
+
__tablename__ = "projects"
|
|
65
|
+
|
|
66
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
|
|
67
|
+
tenant_id: Mapped[str] = mapped_column(
|
|
68
|
+
ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False
|
|
69
|
+
)
|
|
70
|
+
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
71
|
+
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
|
72
|
+
created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
73
|
+
|
|
74
|
+
tenant: Mapped["TenantOrm"] = relationship(back_populates="projects")
|
|
75
|
+
datasets: Mapped[list["DatasetOrm"]] = relationship(
|
|
76
|
+
back_populates="project", cascade="all, delete-orphan"
|
|
77
|
+
)
|
|
78
|
+
experiments: Mapped[list["ExperimentOrm"]] = relationship(
|
|
79
|
+
back_populates="project", cascade="all, delete-orphan"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
__table_args__ = (
|
|
83
|
+
UniqueConstraint("tenant_id", "name", name="uq_projects_tenant_name"),
|
|
84
|
+
Index("ix_projects_tenant_created", "tenant_id", "created_at"),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class ModelOrm(Base):
|
|
89
|
+
__tablename__ = "models"
|
|
90
|
+
|
|
91
|
+
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
|
92
|
+
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, default="system")
|
|
93
|
+
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
94
|
+
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
|
95
|
+
family: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
96
|
+
publisher: Mapped[str] = mapped_column(String(128), default="ModelLab", nullable=False)
|
|
97
|
+
created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
98
|
+
|
|
99
|
+
versions: Mapped[list["ModelVersionOrm"]] = relationship(
|
|
100
|
+
back_populates="model", cascade="all, delete-orphan"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
__table_args__ = (Index("ix_models_tenant_created", "tenant_id", "created_at"),)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ModelVersionOrm(Base):
|
|
107
|
+
__tablename__ = "model_versions"
|
|
108
|
+
|
|
109
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
|
|
110
|
+
model_id: Mapped[str] = mapped_column(
|
|
111
|
+
ForeignKey("models.id", ondelete="CASCADE"), nullable=False
|
|
112
|
+
)
|
|
113
|
+
version: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
114
|
+
container_image_digest: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
115
|
+
artifact_digest: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
116
|
+
supported_task_types: Mapped[list[str]] = mapped_column(JSON_TYPE, nullable=False)
|
|
117
|
+
hyperparameters_schema: Mapped[list[dict[str, Any]]] = mapped_column(
|
|
118
|
+
JSON_TYPE, default=list, nullable=False
|
|
119
|
+
)
|
|
120
|
+
approval_status: Mapped[str] = mapped_column(String(32), default="APPROVED", nullable=False)
|
|
121
|
+
created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
122
|
+
|
|
123
|
+
model: Mapped["ModelOrm"] = relationship(back_populates="versions")
|
|
124
|
+
|
|
125
|
+
__table_args__ = (
|
|
126
|
+
UniqueConstraint("model_id", "version", name="uq_model_versions_model_version"),
|
|
127
|
+
Index("ix_model_versions_created", "created_at"),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class DatasetOrm(Base):
|
|
132
|
+
__tablename__ = "datasets"
|
|
133
|
+
|
|
134
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
|
|
135
|
+
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
|
136
|
+
project_id: Mapped[str] = mapped_column(
|
|
137
|
+
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
|
138
|
+
)
|
|
139
|
+
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
140
|
+
description: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
|
141
|
+
format: Mapped[str] = mapped_column(String(16), nullable=False)
|
|
142
|
+
created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
143
|
+
|
|
144
|
+
project: Mapped["ProjectOrm"] = relationship(back_populates="datasets")
|
|
145
|
+
versions: Mapped[list["DatasetVersionOrm"]] = relationship(
|
|
146
|
+
back_populates="dataset", cascade="all, delete-orphan"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
__table_args__ = (
|
|
150
|
+
UniqueConstraint("project_id", "name", name="uq_datasets_project_name"),
|
|
151
|
+
Index("ix_datasets_tenant_created", "tenant_id", "created_at"),
|
|
152
|
+
Index("ix_datasets_project_created", "project_id", "created_at"),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class DatasetVersionOrm(Base):
|
|
157
|
+
__tablename__ = "dataset_versions"
|
|
158
|
+
|
|
159
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
|
|
160
|
+
dataset_id: Mapped[str] = mapped_column(
|
|
161
|
+
ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False
|
|
162
|
+
)
|
|
163
|
+
version: Mapped[str] = mapped_column(String(32), nullable=False)
|
|
164
|
+
schema_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, nullable=False)
|
|
165
|
+
row_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
166
|
+
column_count: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
167
|
+
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
|
168
|
+
content_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
169
|
+
storage_uri: Mapped[str] = mapped_column(String(512), nullable=False)
|
|
170
|
+
validation_status: Mapped[str] = mapped_column(String(32), default="VALIDATED", nullable=False)
|
|
171
|
+
created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
172
|
+
|
|
173
|
+
dataset: Mapped["DatasetOrm"] = relationship(back_populates="versions")
|
|
174
|
+
|
|
175
|
+
__table_args__ = (
|
|
176
|
+
UniqueConstraint("dataset_id", "version", name="uq_dataset_versions_dataset_version"),
|
|
177
|
+
Index("ix_dataset_versions_hash", "content_hash"),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class ExperimentOrm(Base):
|
|
182
|
+
__tablename__ = "experiments"
|
|
183
|
+
|
|
184
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
|
|
185
|
+
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
|
186
|
+
project_id: Mapped[str] = mapped_column(
|
|
187
|
+
ForeignKey("projects.id", ondelete="CASCADE"), nullable=False
|
|
188
|
+
)
|
|
189
|
+
dataset_version_id: Mapped[str] = mapped_column(
|
|
190
|
+
ForeignKey("dataset_versions.id"), nullable=False
|
|
191
|
+
)
|
|
192
|
+
model_version_id: Mapped[str] = mapped_column(ForeignKey("model_versions.id"), nullable=False)
|
|
193
|
+
spec_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, nullable=False)
|
|
194
|
+
status: Mapped[str] = mapped_column(String(32), default="CREATED", nullable=False)
|
|
195
|
+
idempotency_key: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
196
|
+
created_by: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
197
|
+
created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
198
|
+
updated_at: Mapped[datetime] = mapped_column(default=utc_now, onupdate=utc_now, nullable=False)
|
|
199
|
+
|
|
200
|
+
project: Mapped["ProjectOrm"] = relationship(back_populates="experiments")
|
|
201
|
+
dataset_version: Mapped["DatasetVersionOrm"] = relationship()
|
|
202
|
+
model_version: Mapped["ModelVersionOrm"] = relationship()
|
|
203
|
+
runs: Mapped[list["ExperimentRunOrm"]] = relationship(
|
|
204
|
+
back_populates="experiment", cascade="all, delete-orphan"
|
|
205
|
+
)
|
|
206
|
+
metrics: Mapped[list["MetricOrm"]] = relationship(
|
|
207
|
+
back_populates="experiment", cascade="all, delete-orphan"
|
|
208
|
+
)
|
|
209
|
+
artifacts: Mapped[list["ArtifactOrm"]] = relationship(
|
|
210
|
+
back_populates="experiment", cascade="all, delete-orphan"
|
|
211
|
+
)
|
|
212
|
+
analysis: Mapped["AnalysisRunOrm | None"] = relationship(
|
|
213
|
+
back_populates="experiment", uselist=False, cascade="all, delete-orphan"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
__table_args__ = (
|
|
217
|
+
Index("ix_experiments_tenant_created", "tenant_id", "created_at"),
|
|
218
|
+
Index("ix_experiments_project_created", "project_id", "created_at"),
|
|
219
|
+
Index("ix_experiments_status_created", "status", "created_at"),
|
|
220
|
+
Index("ix_experiments_idempotency", "tenant_id", "project_id", "idempotency_key"),
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class ExperimentRunOrm(Base):
|
|
225
|
+
__tablename__ = "experiment_runs"
|
|
226
|
+
|
|
227
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
|
|
228
|
+
experiment_id: Mapped[str] = mapped_column(
|
|
229
|
+
ForeignKey("experiments.id", ondelete="CASCADE"), nullable=False
|
|
230
|
+
)
|
|
231
|
+
run_number: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
|
232
|
+
status: Mapped[str] = mapped_column(String(32), default="QUEUED", nullable=False)
|
|
233
|
+
worker_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
|
234
|
+
start_time: Mapped[datetime | None] = mapped_column(nullable=True)
|
|
235
|
+
end_time: Mapped[datetime | None] = mapped_column(nullable=True)
|
|
236
|
+
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
237
|
+
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
238
|
+
created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
239
|
+
|
|
240
|
+
experiment: Mapped["ExperimentOrm"] = relationship(back_populates="runs")
|
|
241
|
+
metrics: Mapped[list["MetricOrm"]] = relationship(
|
|
242
|
+
back_populates="run", cascade="all, delete-orphan"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
__table_args__ = (Index("ix_experiment_runs_experiment", "experiment_id"),)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class MetricOrm(Base):
|
|
249
|
+
__tablename__ = "metrics"
|
|
250
|
+
|
|
251
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
|
|
252
|
+
experiment_id: Mapped[str] = mapped_column(
|
|
253
|
+
ForeignKey("experiments.id", ondelete="CASCADE"), nullable=False
|
|
254
|
+
)
|
|
255
|
+
run_id: Mapped[str] = mapped_column(
|
|
256
|
+
ForeignKey("experiment_runs.id", ondelete="CASCADE"), nullable=False
|
|
257
|
+
)
|
|
258
|
+
metric_name: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
259
|
+
metric_value: Mapped[float] = mapped_column(Float, nullable=False)
|
|
260
|
+
step: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
261
|
+
split: Mapped[str] = mapped_column(
|
|
262
|
+
String(32), default="validation", nullable=False
|
|
263
|
+
) # train, validation, test
|
|
264
|
+
created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
265
|
+
|
|
266
|
+
experiment: Mapped["ExperimentOrm"] = relationship(back_populates="metrics")
|
|
267
|
+
run: Mapped["ExperimentRunOrm"] = relationship(back_populates="metrics")
|
|
268
|
+
|
|
269
|
+
__table_args__ = (
|
|
270
|
+
Index("ix_metrics_experiment_name", "experiment_id", "metric_name"),
|
|
271
|
+
Index("ix_metrics_run", "run_id"),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class AnalysisRunOrm(Base):
|
|
276
|
+
__tablename__ = "analysis_runs"
|
|
277
|
+
|
|
278
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
|
|
279
|
+
experiment_id: Mapped[str] = mapped_column(
|
|
280
|
+
ForeignKey("experiments.id", ondelete="CASCADE"), unique=True, nullable=False
|
|
281
|
+
)
|
|
282
|
+
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
|
283
|
+
summary_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, nullable=False)
|
|
284
|
+
diagnostics_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, nullable=False)
|
|
285
|
+
created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
286
|
+
|
|
287
|
+
experiment: Mapped["ExperimentOrm"] = relationship(back_populates="analysis")
|
|
288
|
+
|
|
289
|
+
__table_args__ = (Index("ix_analysis_tenant_created", "tenant_id", "created_at"),)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class ArtifactOrm(Base):
|
|
293
|
+
__tablename__ = "artifacts"
|
|
294
|
+
|
|
295
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
|
|
296
|
+
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
|
297
|
+
experiment_id: Mapped[str] = mapped_column(
|
|
298
|
+
ForeignKey("experiments.id", ondelete="CASCADE"), nullable=False
|
|
299
|
+
)
|
|
300
|
+
artifact_type: Mapped[str] = mapped_column(
|
|
301
|
+
String(64), nullable=False
|
|
302
|
+
) # model, predictions, metrics_report, etc.
|
|
303
|
+
storage_uri: Mapped[str] = mapped_column(String(512), nullable=False)
|
|
304
|
+
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
|
305
|
+
content_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
306
|
+
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, default=dict, nullable=False)
|
|
307
|
+
created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
308
|
+
|
|
309
|
+
experiment: Mapped["ExperimentOrm"] = relationship(back_populates="artifacts")
|
|
310
|
+
|
|
311
|
+
__table_args__ = (
|
|
312
|
+
Index("ix_artifacts_experiment", "experiment_id"),
|
|
313
|
+
Index("ix_artifacts_tenant_created", "tenant_id", "created_at"),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
class OutboxEventOrm(Base):
|
|
318
|
+
__tablename__ = "outbox_events"
|
|
319
|
+
|
|
320
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
|
|
321
|
+
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
322
|
+
aggregate_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
323
|
+
aggregate_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
324
|
+
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON_TYPE, nullable=False)
|
|
325
|
+
status: Mapped[str] = mapped_column(
|
|
326
|
+
String(32), default="PENDING", nullable=False
|
|
327
|
+
) # PENDING, DELIVERED, FAILED
|
|
328
|
+
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
|
329
|
+
scheduled_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
330
|
+
processed_at: Mapped[datetime | None] = mapped_column(nullable=True)
|
|
331
|
+
created_at: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
332
|
+
|
|
333
|
+
__table_args__ = (Index("ix_outbox_status_scheduled", "status", "scheduled_at"),)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
class AuditEventOrm(Base):
|
|
337
|
+
__tablename__ = "audit_events"
|
|
338
|
+
|
|
339
|
+
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=generate_uuid7)
|
|
340
|
+
timestamp: Mapped[datetime] = mapped_column(default=utc_now, nullable=False)
|
|
341
|
+
principal_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
342
|
+
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False)
|
|
343
|
+
project_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
|
344
|
+
action: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
345
|
+
resource_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
346
|
+
resource_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
347
|
+
request_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
348
|
+
trace_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
349
|
+
result: Mapped[str] = mapped_column(String(32), nullable=False) # SUCCESS, DENIED, ERROR
|
|
350
|
+
reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
351
|
+
client_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
352
|
+
|
|
353
|
+
__table_args__ = (
|
|
354
|
+
Index("ix_audit_tenant_timestamp", "tenant_id", "timestamp"),
|
|
355
|
+
Index("ix_audit_principal_timestamp", "principal_id", "timestamp"),
|
|
356
|
+
)
|