plexus-python-pulse-demeter 1.1.126__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 (31) hide show
  1. plexus/pulse/demeter/__init__.py +6 -0
  2. plexus/pulse/demeter/app/__init__.py +0 -0
  3. plexus/pulse/demeter/app/api/__init__.py +44 -0
  4. plexus/pulse/demeter/app/api/archive_internal_endpoints.py +316 -0
  5. plexus/pulse/demeter/app/api/archive_public_endpoints.py +255 -0
  6. plexus/pulse/demeter/app/api/dataset_internal_endpoints.py +1043 -0
  7. plexus/pulse/demeter/app/api/dataset_public_endpoints.py +2161 -0
  8. plexus/pulse/demeter/app/api/dataset_workflow_endpoints.py +532 -0
  9. plexus/pulse/demeter/app/api/tagging_endpoints.py +323 -0
  10. plexus/pulse/demeter/app/api/user_endpoints.py +78 -0
  11. plexus/pulse/demeter/app/core/__init__.py +0 -0
  12. plexus/pulse/demeter/app/core/database.py +43 -0
  13. plexus/pulse/demeter/app/core/vault.py +29 -0
  14. plexus/pulse/demeter/app/main.py +16 -0
  15. plexus/pulse/demeter/app/models/__init__.py +33 -0
  16. plexus/pulse/demeter/app/models/local.py +249 -0
  17. plexus/pulse/demeter/app/models/registry/__init__.py +112 -0
  18. plexus/pulse/demeter/app/models/registry/archive.py +529 -0
  19. plexus/pulse/demeter/app/models/registry/dataset.py +1531 -0
  20. plexus/pulse/demeter/app/models/registry/tagging.py +478 -0
  21. plexus/pulse/demeter/app/models/registry/user.py +79 -0
  22. plexus/pulse/demeter/tools/__init__.py +0 -0
  23. plexus/pulse/demeter/tools/main.py +306 -0
  24. plexus/pulse/demeter/utils/__init__.py +0 -0
  25. plexus/pulse/demeter/utils/config.py +25 -0
  26. plexus/pulse/demeter/utils/datautils.py +436 -0
  27. plexus_python_pulse_demeter-1.1.126.dist-info/METADATA +36 -0
  28. plexus_python_pulse_demeter-1.1.126.dist-info/RECORD +31 -0
  29. plexus_python_pulse_demeter-1.1.126.dist-info/WHEEL +5 -0
  30. plexus_python_pulse_demeter-1.1.126.dist-info/entry_points.txt +2 -0
  31. plexus_python_pulse_demeter-1.1.126.dist-info/top_level.txt +1 -0
@@ -0,0 +1,323 @@
1
+ import dataclasses
2
+ import datetime
3
+ import tempfile
4
+ import uuid as py_uuid
5
+ from collections.abc import Callable
6
+
7
+ import pydantic as pdt
8
+ import pyparsing as pp
9
+ import sqlalchemy as sa
10
+ import sqlalchemy.orm as sa_orm
11
+ from fastapi import APIRouter, Depends, File, UploadFile
12
+ from fastapi.params import Query
13
+ from plexus.common.utils.dtutils import dt_utc_now, td_to_us
14
+ from plexus.common.utils.funcutils import singleton
15
+ from plexus.pulse.common.utils.apiutils import managed_db_session
16
+ from plexus.pulse.common.utils.ormutils import (
17
+ db_create_changing_model,
18
+ db_create_changing_models,
19
+ db_create_sequence_models,
20
+ )
21
+ from plexus.pulse.common.utils.tagutils import TagCache
22
+ from plexus.pulse.common.utils.tagutils import populate_clip_ranges, predefined_tagsets, standard_clip_duration_us
23
+ from plexus.pulse.common.utils.tagutils import versioned_identifier
24
+
25
+ from plexus.pulse.demeter.app.core.database import make_session
26
+ from plexus.pulse.demeter.app.models import Pagination
27
+ from plexus.pulse.demeter.app.models.registry.tagging import (
28
+ DataTagClip,
29
+ DataTagClipTable,
30
+ DataTagRecord,
31
+ DataTagRecordTable,
32
+ DataTagTarget,
33
+ DataTagTargetTable,
34
+ )
35
+
36
+ router = APIRouter(prefix="/pulse_demeter/tagging", tags=["tagging"])
37
+
38
+
39
+ @singleton
40
+ def tag_query_interpreter() -> Callable[[str], sa.ColumnElement[bool]]:
41
+ # AST node classes
42
+ @dataclasses.dataclass(frozen=True)
43
+ class TagQueryExpr:
44
+ tag_name: str
45
+ features_query: str | None = None
46
+
47
+ @dataclasses.dataclass(frozen=True)
48
+ class NotExpr:
49
+ operand: "Expr"
50
+
51
+ @dataclasses.dataclass(frozen=True)
52
+ class AndExpr:
53
+ left: "Expr"
54
+ right: "Expr"
55
+
56
+ @dataclasses.dataclass(frozen=True)
57
+ class OrExpr:
58
+ left: "Expr"
59
+ right: "Expr"
60
+
61
+ Expr = TagQueryExpr | NotExpr | AndExpr | OrExpr
62
+
63
+ # Parse actions
64
+ def make_tag_query_expr(tokens):
65
+ if len(tokens) == 1:
66
+ tag_name, = tokens
67
+ return TagQueryExpr(tag_name=tag_name, features_query=None)
68
+ elif len(tokens) == 2:
69
+ tag_name, features_query = tokens
70
+ return TagQueryExpr(tag_name=tag_name, features_query=features_query)
71
+ else:
72
+ raise ValueError(f"invalid tag query expression: {tokens}")
73
+
74
+ def make_not(tokens):
75
+ if len(tokens) == 1:
76
+ operand, *_ = tokens
77
+ return NotExpr(operand=operand)
78
+ raise ValueError(f"invalid NOT expression: {tokens}")
79
+
80
+ def make_and(tokens):
81
+ left, *operands = tokens[0][::2]
82
+ if len(operands) == 0:
83
+ raise ValueError(f"invalid AND expression: {tokens}")
84
+ for right in operands:
85
+ left = AndExpr(left=left, right=right)
86
+ return left
87
+
88
+ def make_or(tokens):
89
+ left, *operands = tokens[0][::2]
90
+ if len(operands) == 0:
91
+ raise ValueError(f"invalid OR expression: {tokens}")
92
+ for right in operands:
93
+ left = OrExpr(left=left, right=right)
94
+ return left
95
+
96
+ # Grammar
97
+ ident_token = pp.Regex(r"[a-zA-Z0-9]+(_[a-zA-Z0-9]+)*")
98
+ colon_token = pp.Literal(":")
99
+
100
+ tag_name_expr = (ident_token + (pp.Suppress(colon_token) + ident_token)[...])
101
+ tag_name_expr.set_parse_action(lambda tokens: ":".join(tokens))
102
+
103
+ features_query_expr = pp.nested_expr("{", "}", )
104
+ features_query_expr.set_parse_action(lambda tokens: "".join(map(str, tokens[0])))
105
+
106
+ tag_query_expr = (tag_name_expr("tag_name") + pp.Optional(features_query_expr("features_query")))
107
+ tag_query_expr.set_parse_action(make_tag_query_expr)
108
+
109
+ expr = pp.Forward()
110
+ expr <<= pp.infix_notation(
111
+ tag_query_expr,
112
+ [
113
+ (pp.Keyword("not"), 1, pp.OpAssoc.RIGHT, make_not),
114
+ (pp.Keyword("and"), 2, pp.OpAssoc.LEFT, make_and),
115
+ (pp.Keyword("or"), 2, pp.OpAssoc.LEFT, make_or),
116
+ ],
117
+ )
118
+
119
+ def ast_to_sa_filter(ast: Expr) -> sa.ColumnElement[bool]:
120
+ """
121
+ Converts the AST into an SQLAlchemy filter.
122
+ """
123
+ if isinstance(ast, TagQueryExpr):
124
+ if ast.features_query is None:
125
+ return DataTagRecordTable.tag == ast.tag_name
126
+ return sa.and_(
127
+ DataTagRecordTable.tag == ast.tag_name,
128
+ sa.text(f"jsonb_path_exists({DataTagRecordTable.__table__.c.features.name}, '{ast.features_query}')"),
129
+ )
130
+
131
+ if isinstance(ast, NotExpr):
132
+ return sa.not_(ast_to_sa_filter(ast.operand))
133
+
134
+ if isinstance(ast, AndExpr):
135
+ left_filter = ast_to_sa_filter(ast.left)
136
+ right_filter = ast_to_sa_filter(ast.right)
137
+ return sa.and_(left_filter, right_filter)
138
+
139
+ if isinstance(ast, OrExpr):
140
+ left_filter = ast_to_sa_filter(ast.left)
141
+ right_filter = ast_to_sa_filter(ast.right)
142
+ return sa.or_(left_filter, right_filter)
143
+
144
+ raise ValueError(f"unknown AST node type: {type(ast)}")
145
+
146
+ def converter(query: str) -> sa.ColumnElement[bool]:
147
+ ast, *_ = expr.parse_string(query, parse_all=True)
148
+ return ast_to_sa_filter(ast)
149
+
150
+ return converter
151
+
152
+
153
+ class TagTargetsAndPagination(pdt.BaseModel):
154
+ targets: list[DataTagTargetTable]
155
+ pagination: Pagination
156
+
157
+
158
+ @router.get("/targets", response_model=TagTargetsAndPagination)
159
+ def list_targets(
160
+ tagger_name: str | None = None,
161
+ tagger_version: str | None = None,
162
+ vehicle_name: str | None = None,
163
+ begin_dt: datetime.datetime | None = None,
164
+ end_dt: datetime.datetime | None = None,
165
+ skip: int = Query(0),
166
+ limit: int = Query(1000),
167
+ db: sa_orm.Session = Depends(make_session),
168
+ ) -> TagTargetsAndPagination:
169
+ with managed_db_session(db):
170
+ query_stmt = db.query(DataTagTargetTable)
171
+ if begin_dt is not None:
172
+ query_stmt = query_stmt.filter(DataTagTargetTable.end_dt >= begin_dt)
173
+ if end_dt is not None:
174
+ query_stmt = query_stmt.filter(DataTagTargetTable.begin_dt <= end_dt)
175
+ if tagger_name is not None:
176
+ query_stmt = query_stmt.filter(DataTagTargetTable.tagger_name == tagger_name)
177
+ if tagger_version is not None:
178
+ query_stmt = query_stmt.filter(DataTagTargetTable.tagger_version == tagger_version)
179
+ if vehicle_name is not None:
180
+ query_stmt = query_stmt.filter(DataTagTargetTable.vehicle_name == vehicle_name)
181
+
182
+ query_stmt = query_stmt.order_by(DataTagTargetTable.sqn.desc())
183
+
184
+ total = query_stmt.count()
185
+ if total == 0:
186
+ return TagTargetsAndPagination(targets=[], pagination=Pagination(total=0, skip=skip, limit=limit))
187
+
188
+ db_targets = query_stmt.offset(skip).limit(limit).all()
189
+
190
+ return TagTargetsAndPagination(targets=db_targets, pagination=Pagination(total=total, skip=skip, limit=limit))
191
+
192
+
193
+ class TagRecordsAndPagination(pdt.BaseModel):
194
+ records: list[DataTagRecordTable]
195
+ pagination: Pagination
196
+
197
+
198
+ @router.get("/records", response_model=TagRecordsAndPagination)
199
+ def list_records(
200
+ begin_dt: datetime.datetime | None = None,
201
+ end_dt: datetime.datetime | None = None,
202
+ tagset_namespace: str | None = None,
203
+ tagset_version: str | None = None,
204
+ query: str | None = Query(None, description="Tag query string (e.g. 'tag1 and tag2 {features_query} or not tag3')"),
205
+ tagger_name: str | None = Query(None),
206
+ tagger_version: str | None = Query(None),
207
+ vehicle_name: str | None = Query(None),
208
+ skip: int = Query(0),
209
+ limit: int = Query(1000),
210
+ db: sa_orm.Session = Depends(make_session),
211
+ ) -> TagRecordsAndPagination:
212
+ with managed_db_session(db):
213
+ query_stmt = db.query(DataTagRecordTable)
214
+ if begin_dt is not None:
215
+ query_stmt = query_stmt.filter(DataTagRecordTable.end_dt >= begin_dt)
216
+ if end_dt is not None:
217
+ query_stmt = query_stmt.filter(DataTagRecordTable.begin_dt <= end_dt)
218
+ if tagset_namespace is not None:
219
+ query_stmt = query_stmt.filter(DataTagRecordTable.tagset_namespace == tagset_namespace)
220
+ if tagset_version is not None:
221
+ query_stmt = query_stmt.filter(DataTagRecordTable.tagset_version == tagset_version)
222
+ if query is not None:
223
+ try:
224
+ interpreter = tag_query_interpreter()
225
+ query_filter = interpreter(query)
226
+ query_stmt = query_stmt.filter(query_filter)
227
+ except pp.ParseException as e:
228
+ raise ValueError(f"failed to parse tag query: {e}")
229
+ if tagger_name is not None or tagger_version is not None:
230
+ query_stmt = query_stmt.join(DataTagTargetTable, DataTagRecordTable.target_uuid == DataTagTargetTable.uuid)
231
+ if tagger_name is not None:
232
+ query_stmt = query_stmt.filter(DataTagTargetTable.tagger_name == tagger_name)
233
+ if tagger_version is not None:
234
+ query_stmt = query_stmt.filter(DataTagTargetTable.tagger_version == tagger_version)
235
+ if vehicle_name is not None:
236
+ query_stmt = query_stmt.filter(DataTagRecordTable.vehicle_name == vehicle_name)
237
+
238
+ query_stmt = query_stmt.order_by(DataTagRecordTable.sqn.desc())
239
+
240
+ total = query_stmt.count()
241
+ if total == 0:
242
+ return TagRecordsAndPagination(records=[], pagination=Pagination(total=0, skip=skip, limit=limit))
243
+
244
+ db_records = query_stmt.offset(skip).limit(limit).all()
245
+
246
+ return TagRecordsAndPagination(records=db_records, pagination=Pagination(total=total, skip=skip, limit=limit))
247
+
248
+
249
+ class TagCacheSummary(pdt.BaseModel):
250
+ targets_count: int
251
+ records_count: int
252
+
253
+
254
+ @router.post("/upload_tag_cache/", response_model=TagCacheSummary)
255
+ def upload_tag_cache(
256
+ tag_cache_file: UploadFile = File(...),
257
+ created_at: datetime.datetime = Query(default_factory=dt_utc_now),
258
+ db: sa_orm.Session = Depends(make_session),
259
+ ) -> TagCacheSummary:
260
+ with managed_db_session(db), tempfile.NamedTemporaryFile() as temp_db_file:
261
+ temp_db_file.write(tag_cache_file.file.read())
262
+ temp_db_file.flush()
263
+
264
+ tag_cache = TagCache(file_path=temp_db_file.name)
265
+
266
+ for record in tag_cache.iter_records():
267
+ versioned_namespace = versioned_identifier(record.tagset_namespace, record.tagset_version)
268
+ if versioned_namespace not in predefined_tagsets():
269
+ raise ValueError(f"Tagset '{versioned_namespace}' is not predefined")
270
+
271
+ targets_count = 0
272
+ records_count = 0
273
+
274
+ for target in tag_cache.query_targets():
275
+ tag_target = DataTagTarget(
276
+ uuid=py_uuid.uuid4(),
277
+ tagger_name=target.tagger_name,
278
+ tagger_version=target.tagger_version,
279
+ vehicle_name=target.vehicle_name,
280
+ begin_dt=target.begin_dt,
281
+ end_dt=target.end_dt,
282
+ )
283
+
284
+ tag_records = [
285
+ DataTagRecord(
286
+ target_uuid=tag_target.uuid,
287
+ vehicle_name=tag_target.vehicle_name,
288
+ begin_dt=record.begin_dt,
289
+ end_dt=record.end_dt,
290
+ tagset_namespace=record.tagset_namespace,
291
+ tagset_version=record.tagset_version or predefined_tagsets().get(record.tagset_namespace).version,
292
+ tag=record.tag,
293
+ features=record.features,
294
+ props=record.props,
295
+ )
296
+ for record in tag_cache.with_target(target.identifier).iter_records()
297
+ ]
298
+
299
+ targets_count += 1
300
+ records_count += len(tag_records)
301
+
302
+ db_create_changing_model(db, DataTagTargetTable, tag_target, created_at=created_at)
303
+ db_tag_records = db_create_changing_models(db, DataTagRecordTable, tag_records, created_at=created_at)
304
+
305
+ tag_clips = [
306
+ DataTagClip(
307
+ source_sqn=db_tag_record.sqn,
308
+ vehicle_name=db_tag_record.vehicle_name,
309
+ clip_dt=clip_dt,
310
+ clip_duration_us=standard_clip_duration_us(),
311
+ begin_offset_us=td_to_us(begin_dt - clip_dt),
312
+ end_offset_us=td_to_us(end_dt - clip_dt),
313
+ tagset_namespace=db_tag_record.tagset_namespace,
314
+ tagset_version=db_tag_record.tagset_version,
315
+ tag=db_tag_record.tag,
316
+ )
317
+ for db_tag_record in db_tag_records
318
+ for clip_dt, _, begin_dt, end_dt in populate_clip_ranges(db_tag_record.begin_dt, db_tag_record.end_dt)
319
+ ]
320
+
321
+ db_create_sequence_models(db, DataTagClipTable, tag_clips)
322
+
323
+ return TagCacheSummary(targets_count=targets_count, records_count=records_count)
@@ -0,0 +1,78 @@
1
+ import datetime
2
+
3
+ import sqlalchemy.orm as sa_orm
4
+ from fastapi import APIRouter, Depends
5
+ from fastapi.params import Query
6
+ from plexus.common.utils.dtutils import dt_utc_now
7
+ from plexus.pulse.common.utils.apiutils import managed_db_session
8
+ from plexus.pulse.common.utils.ormutils import (
9
+ db_create_revision_model,
10
+ db_create_revision_models,
11
+ db_expire_revision_model,
12
+ db_read_active_revision_model_of_record,
13
+ db_read_active_revision_models,
14
+ db_update_revision_model,
15
+ )
16
+
17
+ from plexus.pulse.demeter.app.core.database import make_session
18
+ from plexus.pulse.demeter.app.models.registry.user import User
19
+ from plexus.pulse.demeter.app.models.registry.user import UserTable
20
+
21
+ router = APIRouter(prefix="/pulse_demeter/user", tags=["user"])
22
+
23
+
24
+ @router.post("/user/", response_model=UserTable)
25
+ def create_user(
26
+ user: User,
27
+ created_at: datetime.datetime = Query(default_factory=dt_utc_now),
28
+ db: sa_orm.Session = Depends(make_session),
29
+ ) -> UserTable:
30
+ with managed_db_session(db):
31
+ return db_create_revision_model(db, UserTable, user, created_at)
32
+
33
+
34
+ @router.post("/users/", response_model=list[UserTable])
35
+ def create_users(
36
+ users: list[User],
37
+ created_at: datetime.datetime = Query(default_factory=dt_utc_now),
38
+ db: sa_orm.Session = Depends(make_session),
39
+ ) -> list[UserTable]:
40
+ with managed_db_session(db):
41
+ return db_create_revision_models(db, UserTable, users, created_at)
42
+
43
+
44
+ @router.get("/user/{record_sqn}", response_model=UserTable)
45
+ def read_user(record_sqn: int, db: sa_orm.Session = Depends(make_session)) -> UserTable:
46
+ with managed_db_session(db):
47
+ return db_read_active_revision_model_of_record(db, UserTable, record_sqn)
48
+
49
+
50
+ @router.get("/users/", response_model=list[UserTable])
51
+ def read_users(
52
+ skip: int = Query(0),
53
+ limit: int = Query(1000),
54
+ db: sa_orm.Session = Depends(make_session),
55
+ ) -> list[UserTable]:
56
+ with managed_db_session(db):
57
+ return db_read_active_revision_models(db, UserTable, skip, limit)
58
+
59
+
60
+ @router.put("/user/{record_sqn}", response_model=UserTable)
61
+ def update_user(
62
+ record_sqn: int,
63
+ user: User,
64
+ updated_at: datetime.datetime = Query(default_factory=dt_utc_now),
65
+ db: sa_orm.Session = Depends(make_session),
66
+ ) -> UserTable:
67
+ with managed_db_session(db):
68
+ return db_update_revision_model(db, UserTable, user, record_sqn, updated_at)
69
+
70
+
71
+ @router.delete("/user/{record_sqn}", response_model=UserTable)
72
+ def delete_user(
73
+ record_sqn: int,
74
+ updated_at: datetime.datetime = Query(default_factory=dt_utc_now),
75
+ db: sa_orm.Session = Depends(make_session),
76
+ ) -> UserTable:
77
+ with managed_db_session(db):
78
+ return db_expire_revision_model(db, UserTable, record_sqn, updated_at)
File without changes
@@ -0,0 +1,43 @@
1
+ from collections.abc import Generator
2
+
3
+ import sqlalchemy as sa
4
+ import sqlalchemy.orm as sa_orm
5
+ from pydantic_settings import BaseSettings
6
+
7
+ from plexus.pulse.demeter.utils.config import config
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ host: str = config().get("pulse.apps.database", "host", "localhost")
12
+ port: int = config().getint("pulse.apps.database", "port", 5432)
13
+ username: str = config().get("pulse.apps.database", "username", "pulse")
14
+ password: str = config().get("pulse.apps.database", "password", "pulse")
15
+ database: str = config().get("pulse.apps.database", "database", "pulse-apps")
16
+
17
+ class Config:
18
+ env_prefix = "PULSE_APPS_DATABASE_"
19
+ env_file = ".env"
20
+
21
+
22
+ settings = Settings()
23
+
24
+ url = sa.URL.create(
25
+ drivername="postgresql+psycopg",
26
+ host=settings.host,
27
+ port=settings.port,
28
+ username=settings.username,
29
+ password=settings.password,
30
+ database=settings.database,
31
+ )
32
+
33
+ engine = sa.create_engine(url, echo=True, future=True)
34
+ session_maker = sa_orm.sessionmaker(autocommit=False, autoflush=False, bind=engine, future=True, expire_on_commit=False)
35
+
36
+
37
+ def make_session() -> Generator[sa_orm.Session, None, None]:
38
+ db = session_maker()
39
+ db.execute(sa.sql.text("SET TIMEZONE TO 'UTC'"))
40
+ try:
41
+ yield db
42
+ finally:
43
+ db.close()
@@ -0,0 +1,29 @@
1
+ from plexus.pulse.common.utils.s3utils import s3_make_client, s3_make_progressed_client
2
+ from pydantic_settings import BaseSettings
3
+
4
+ from plexus.pulse.demeter.utils.config import config
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ access_key_id: str = config().get("pulse.apps.vault", "access_key_id", "")
9
+ secret_access_key: str = config().get("pulse.apps.vault", "secret_access_key", "")
10
+ endpoint_url: str = config().get("pulse.apps.vault", "endpoint_url", "http://localhost:9000")
11
+
12
+ class Config:
13
+ env_prefix = "PULSE_APPS_VAULT_"
14
+ env_file = ".env"
15
+
16
+
17
+ settings = Settings()
18
+
19
+
20
+ def make_client():
21
+ return s3_make_client(access_key_id=settings.access_key_id,
22
+ secret_access_key=settings.secret_access_key,
23
+ endpoint_url=settings.endpoint_url)
24
+
25
+
26
+ def make_progressed_client():
27
+ return s3_make_progressed_client(access_key_id=settings.access_key_id,
28
+ secret_access_key=settings.secret_access_key,
29
+ endpoint_url=settings.endpoint_url)
@@ -0,0 +1,16 @@
1
+ from fastapi import Response
2
+ from fastapi import status
3
+
4
+ from plexus.pulse.demeter.app.api import make_api
5
+
6
+ app = make_api()
7
+
8
+
9
+ @app.get("/health")
10
+ async def check_health():
11
+ return Response(status_code=status.HTTP_200_OK)
12
+
13
+
14
+ @app.get("/live")
15
+ async def check_liveness():
16
+ return Response(status_code=status.HTTP_200_OK)
@@ -0,0 +1,33 @@
1
+ from typing import Any, Literal
2
+
3
+ import pydantic as pdt
4
+
5
+ __all__ = [
6
+ "Pagination",
7
+ "TableColumn",
8
+ "ColumnOrdering",
9
+ "ColumnFormatter",
10
+ ]
11
+
12
+
13
+ class Pagination(pdt.BaseModel):
14
+ skip: int
15
+ limit: int
16
+ total: int
17
+
18
+
19
+ class ColumnOrdering(pdt.BaseModel):
20
+ semantic: Literal["lexical", "numeric", "datetime", "semver"] = "lexical"
21
+ direction: Literal["ascending", "descending"] = "ascending"
22
+
23
+
24
+ class ColumnFormatter(pdt.BaseModel):
25
+ syntax: Literal["python_print", "python_format", "python_fstring", "python_strftime"] = "python_print"
26
+ template: str
27
+ args: list[Any] | None = None
28
+
29
+
30
+ class TableColumn(pdt.BaseModel):
31
+ name: str
32
+ ordering: ColumnOrdering | None = None
33
+ formatter: ColumnFormatter | None = None