deriva-ml 1.17.10__py3-none-any.whl → 1.17.12__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.
- deriva_ml/__init__.py +69 -1
- deriva_ml/asset/__init__.py +17 -0
- deriva_ml/asset/asset.py +357 -0
- deriva_ml/asset/aux_classes.py +100 -0
- deriva_ml/bump_version.py +254 -11
- deriva_ml/catalog/__init__.py +31 -0
- deriva_ml/catalog/clone.py +1939 -0
- deriva_ml/catalog/localize.py +426 -0
- deriva_ml/core/__init__.py +29 -0
- deriva_ml/core/base.py +845 -1067
- deriva_ml/core/config.py +169 -21
- deriva_ml/core/constants.py +120 -19
- deriva_ml/core/definitions.py +123 -13
- deriva_ml/core/enums.py +47 -73
- deriva_ml/core/ermrest.py +226 -193
- deriva_ml/core/exceptions.py +297 -14
- deriva_ml/core/filespec.py +99 -28
- deriva_ml/core/logging_config.py +225 -0
- deriva_ml/core/mixins/__init__.py +42 -0
- deriva_ml/core/mixins/annotation.py +915 -0
- deriva_ml/core/mixins/asset.py +384 -0
- deriva_ml/core/mixins/dataset.py +237 -0
- deriva_ml/core/mixins/execution.py +408 -0
- deriva_ml/core/mixins/feature.py +365 -0
- deriva_ml/core/mixins/file.py +263 -0
- deriva_ml/core/mixins/path_builder.py +145 -0
- deriva_ml/core/mixins/rid_resolution.py +204 -0
- deriva_ml/core/mixins/vocabulary.py +400 -0
- deriva_ml/core/mixins/workflow.py +322 -0
- deriva_ml/core/validation.py +389 -0
- deriva_ml/dataset/__init__.py +2 -1
- deriva_ml/dataset/aux_classes.py +20 -4
- deriva_ml/dataset/catalog_graph.py +575 -0
- deriva_ml/dataset/dataset.py +1242 -1008
- deriva_ml/dataset/dataset_bag.py +1311 -182
- deriva_ml/dataset/history.py +27 -14
- deriva_ml/dataset/upload.py +225 -38
- deriva_ml/demo_catalog.py +126 -110
- deriva_ml/execution/__init__.py +46 -2
- deriva_ml/execution/base_config.py +639 -0
- deriva_ml/execution/execution.py +543 -242
- deriva_ml/execution/execution_configuration.py +26 -11
- deriva_ml/execution/execution_record.py +592 -0
- deriva_ml/execution/find_caller.py +298 -0
- deriva_ml/execution/model_protocol.py +175 -0
- deriva_ml/execution/multirun_config.py +153 -0
- deriva_ml/execution/runner.py +595 -0
- deriva_ml/execution/workflow.py +223 -34
- deriva_ml/experiment/__init__.py +8 -0
- deriva_ml/experiment/experiment.py +411 -0
- deriva_ml/feature.py +6 -1
- deriva_ml/install_kernel.py +143 -6
- deriva_ml/interfaces.py +862 -0
- deriva_ml/model/__init__.py +99 -0
- deriva_ml/model/annotations.py +1278 -0
- deriva_ml/model/catalog.py +286 -60
- deriva_ml/model/database.py +144 -649
- deriva_ml/model/deriva_ml_database.py +308 -0
- deriva_ml/model/handles.py +14 -0
- deriva_ml/run_model.py +319 -0
- deriva_ml/run_notebook.py +507 -38
- deriva_ml/schema/__init__.py +18 -2
- deriva_ml/schema/annotations.py +62 -33
- deriva_ml/schema/create_schema.py +169 -69
- deriva_ml/schema/validation.py +601 -0
- {deriva_ml-1.17.10.dist-info → deriva_ml-1.17.12.dist-info}/METADATA +4 -4
- deriva_ml-1.17.12.dist-info/RECORD +77 -0
- {deriva_ml-1.17.10.dist-info → deriva_ml-1.17.12.dist-info}/WHEEL +1 -1
- {deriva_ml-1.17.10.dist-info → deriva_ml-1.17.12.dist-info}/entry_points.txt +1 -0
- deriva_ml/protocols/dataset.py +0 -19
- deriva_ml/test.py +0 -94
- deriva_ml-1.17.10.dist-info/RECORD +0 -45
- {deriva_ml-1.17.10.dist-info → deriva_ml-1.17.12.dist-info}/licenses/LICENSE +0 -0
- {deriva_ml-1.17.10.dist-info → deriva_ml-1.17.12.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1278 @@
|
|
|
1
|
+
"""Annotation helper classes for DerivaML.
|
|
2
|
+
|
|
3
|
+
This module provides lightweight helper classes for building Deriva catalog
|
|
4
|
+
annotations that control how data is displayed in the Chaise web interface.
|
|
5
|
+
|
|
6
|
+
These builders provide:
|
|
7
|
+
|
|
8
|
+
- **IDE autocompletion** - Type hints for all annotation properties
|
|
9
|
+
- **Validation** - Catches errors at construction time (e.g., mutually exclusive options)
|
|
10
|
+
- **Reduced typos** - Use `Display(name="...")` instead of `{"name": "..."}`
|
|
11
|
+
- **Self-documenting code** - Easier to read than raw dictionaries
|
|
12
|
+
- **Method chaining** - Fluent API for building complex annotations
|
|
13
|
+
|
|
14
|
+
For the full annotation schema, see the Deriva documentation at:
|
|
15
|
+
https://docs.derivacloud.org/chaise/annotation/
|
|
16
|
+
|
|
17
|
+
Quick Start
|
|
18
|
+
-----------
|
|
19
|
+
Basic usage with a TableHandle::
|
|
20
|
+
|
|
21
|
+
from deriva_ml.model import TableHandle, Display, VisibleColumns, TableDisplay
|
|
22
|
+
|
|
23
|
+
# Get a table handle
|
|
24
|
+
handle = TableHandle(table)
|
|
25
|
+
|
|
26
|
+
# Set display name
|
|
27
|
+
handle.set_annotation(Display(name="Research Subjects"))
|
|
28
|
+
|
|
29
|
+
# Configure visible columns
|
|
30
|
+
vc = VisibleColumns()
|
|
31
|
+
vc.compact(["RID", "Name", "Status"])
|
|
32
|
+
vc.detailed(["RID", "Name", "Status", "Description", "Created"])
|
|
33
|
+
handle.set_annotation(vc)
|
|
34
|
+
|
|
35
|
+
# Set row name pattern
|
|
36
|
+
td = TableDisplay()
|
|
37
|
+
td.row_name("{{{Name}}} ({{{RID}}})")
|
|
38
|
+
handle.set_annotation(td)
|
|
39
|
+
|
|
40
|
+
Available Builders
|
|
41
|
+
------------------
|
|
42
|
+
**Main annotation builders:**
|
|
43
|
+
|
|
44
|
+
- `Display` - Basic display properties (name, comment, show_null)
|
|
45
|
+
- `VisibleColumns` - Which columns appear in each UI context
|
|
46
|
+
- `VisibleForeignKeys` - Which related tables appear in detail view
|
|
47
|
+
- `TableDisplay` - Row naming, ordering, and table-level options
|
|
48
|
+
- `ColumnDisplay` - Column value formatting
|
|
49
|
+
|
|
50
|
+
**Helper classes:**
|
|
51
|
+
|
|
52
|
+
- `PseudoColumn` - Computed columns, FK traversals, aggregates
|
|
53
|
+
- `Facet` / `FacetList` - Faceted search configuration
|
|
54
|
+
- `SortKey` - Row ordering specification
|
|
55
|
+
- `InboundFK` / `OutboundFK` - Foreign key path steps
|
|
56
|
+
- `NameStyle` - Display name styling
|
|
57
|
+
- `PreFormat` - Column pre-formatting (printf, boolean display)
|
|
58
|
+
|
|
59
|
+
**Enums:**
|
|
60
|
+
|
|
61
|
+
- `TemplateEngine` - HANDLEBARS or MUSTACHE
|
|
62
|
+
- `Aggregate` - MIN, MAX, CNT, CNT_D, ARRAY, ARRAY_D
|
|
63
|
+
- `ArrayUxMode` - RAW, CSV, OLIST, ULIST
|
|
64
|
+
- `FacetUxMode` - CHOICES, RANGES, CHECK_PRESENCE
|
|
65
|
+
|
|
66
|
+
**Context constants:**
|
|
67
|
+
|
|
68
|
+
- `CONTEXT_DEFAULT` ("*") - Default for all contexts
|
|
69
|
+
- `CONTEXT_COMPACT` - List/table views
|
|
70
|
+
- `CONTEXT_DETAILED` - Single record view
|
|
71
|
+
- `CONTEXT_ENTRY` - Create/edit forms
|
|
72
|
+
- `CONTEXT_FILTER` - Faceted search
|
|
73
|
+
|
|
74
|
+
Examples
|
|
75
|
+
--------
|
|
76
|
+
Display annotation with description::
|
|
77
|
+
|
|
78
|
+
display = Display(
|
|
79
|
+
name="Research Subjects",
|
|
80
|
+
comment="Individuals enrolled in the study"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
Visible columns with FK and pseudo-column::
|
|
84
|
+
|
|
85
|
+
from deriva_ml.model import fk_constraint, PseudoColumn, InboundFK, Aggregate
|
|
86
|
+
|
|
87
|
+
vc = VisibleColumns()
|
|
88
|
+
vc.compact([
|
|
89
|
+
"RID",
|
|
90
|
+
"Name",
|
|
91
|
+
fk_constraint("domain", "Subject_Species_fkey"),
|
|
92
|
+
PseudoColumn(
|
|
93
|
+
source=[InboundFK("domain", "Sample_Subject_fkey"), "RID"],
|
|
94
|
+
aggregate=Aggregate.CNT,
|
|
95
|
+
markdown_name="Samples"
|
|
96
|
+
),
|
|
97
|
+
])
|
|
98
|
+
|
|
99
|
+
Table display with row name and ordering::
|
|
100
|
+
|
|
101
|
+
td = TableDisplay()
|
|
102
|
+
td.row_name("{{{Name}}} ({{{Species}}})")
|
|
103
|
+
td.compact(TableDisplayOptions(
|
|
104
|
+
row_order=[SortKey("Name"), SortKey("Created", descending=True)],
|
|
105
|
+
page_size=50
|
|
106
|
+
))
|
|
107
|
+
|
|
108
|
+
Faceted search configuration::
|
|
109
|
+
|
|
110
|
+
facets = FacetList()
|
|
111
|
+
facets.add(Facet(source="Species", open=True))
|
|
112
|
+
facets.add(Facet(source="Age", ux_mode=FacetUxMode.RANGES))
|
|
113
|
+
|
|
114
|
+
vc = VisibleColumns()
|
|
115
|
+
vc._contexts["filter"] = facets.to_dict()
|
|
116
|
+
|
|
117
|
+
Using Raw Dictionaries
|
|
118
|
+
----------------------
|
|
119
|
+
The builders are optional. You can always use raw dictionaries::
|
|
120
|
+
|
|
121
|
+
table.annotations["tag:isrd.isi.edu,2016:visible-columns"] = {
|
|
122
|
+
"compact": ["RID", "Name"],
|
|
123
|
+
"detailed": ["RID", "Name", "Description"]
|
|
124
|
+
}
|
|
125
|
+
table.apply()
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
from __future__ import annotations
|
|
129
|
+
|
|
130
|
+
from dataclasses import dataclass, field
|
|
131
|
+
from enum import Enum
|
|
132
|
+
from typing import Any, Literal
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# =============================================================================
|
|
136
|
+
# Enums for constrained values
|
|
137
|
+
# =============================================================================
|
|
138
|
+
|
|
139
|
+
class TemplateEngine(str, Enum):
|
|
140
|
+
"""Template engine for markdown patterns.
|
|
141
|
+
|
|
142
|
+
Attributes:
|
|
143
|
+
HANDLEBARS: Use Handlebars.js templating (recommended, more features)
|
|
144
|
+
MUSTACHE: Use Mustache templating (simpler, fewer features)
|
|
145
|
+
|
|
146
|
+
Example:
|
|
147
|
+
>>> display = PseudoColumnDisplay(
|
|
148
|
+
... markdown_pattern="[{{{Name}}}]({{{URL}}})",
|
|
149
|
+
... template_engine=TemplateEngine.HANDLEBARS
|
|
150
|
+
... )
|
|
151
|
+
"""
|
|
152
|
+
HANDLEBARS = "handlebars"
|
|
153
|
+
MUSTACHE = "mustache"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Aggregate(str, Enum):
|
|
157
|
+
"""Aggregation functions for pseudo-columns.
|
|
158
|
+
|
|
159
|
+
Used when a pseudo-column follows an inbound foreign key and returns
|
|
160
|
+
multiple values that need to be aggregated.
|
|
161
|
+
|
|
162
|
+
Attributes:
|
|
163
|
+
MIN: Minimum value
|
|
164
|
+
MAX: Maximum value
|
|
165
|
+
CNT: Count of values
|
|
166
|
+
CNT_D: Count of distinct values
|
|
167
|
+
ARRAY: Array of all values
|
|
168
|
+
ARRAY_D: Array of distinct values
|
|
169
|
+
|
|
170
|
+
Example:
|
|
171
|
+
>>> # Count related records
|
|
172
|
+
>>> pc = PseudoColumn(
|
|
173
|
+
... source=[InboundFK("domain", "Sample_Subject_fkey"), "RID"],
|
|
174
|
+
... aggregate=Aggregate.CNT,
|
|
175
|
+
... markdown_name="Sample Count"
|
|
176
|
+
... )
|
|
177
|
+
>>>
|
|
178
|
+
>>> # Get distinct values as array
|
|
179
|
+
>>> pc = PseudoColumn(
|
|
180
|
+
... source=[InboundFK("domain", "Tag_Item_fkey"), "Name"],
|
|
181
|
+
... aggregate=Aggregate.ARRAY_D,
|
|
182
|
+
... markdown_name="Tags"
|
|
183
|
+
... )
|
|
184
|
+
"""
|
|
185
|
+
MIN = "min"
|
|
186
|
+
MAX = "max"
|
|
187
|
+
CNT = "cnt"
|
|
188
|
+
CNT_D = "cnt_d"
|
|
189
|
+
ARRAY = "array"
|
|
190
|
+
ARRAY_D = "array_d"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
class ArrayUxMode(str, Enum):
|
|
194
|
+
"""Display modes for array values in pseudo-columns.
|
|
195
|
+
|
|
196
|
+
Controls how arrays of values are rendered in the UI.
|
|
197
|
+
|
|
198
|
+
Attributes:
|
|
199
|
+
RAW: Raw array display
|
|
200
|
+
CSV: Comma-separated values
|
|
201
|
+
OLIST: Ordered (numbered) list
|
|
202
|
+
ULIST: Unordered (bulleted) list
|
|
203
|
+
|
|
204
|
+
Example:
|
|
205
|
+
>>> pc = PseudoColumn(
|
|
206
|
+
... source=[InboundFK("domain", "Tag_Item_fkey"), "Name"],
|
|
207
|
+
... aggregate=Aggregate.ARRAY,
|
|
208
|
+
... display=PseudoColumnDisplay(array_ux_mode=ArrayUxMode.CSV)
|
|
209
|
+
... )
|
|
210
|
+
"""
|
|
211
|
+
RAW = "raw"
|
|
212
|
+
CSV = "csv"
|
|
213
|
+
OLIST = "olist"
|
|
214
|
+
ULIST = "ulist"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class FacetUxMode(str, Enum):
|
|
218
|
+
"""UX modes for facet filters in the search panel.
|
|
219
|
+
|
|
220
|
+
Controls how users interact with a facet filter.
|
|
221
|
+
|
|
222
|
+
Attributes:
|
|
223
|
+
CHOICES: Checkbox list for selecting values
|
|
224
|
+
RANGES: Range slider/inputs for numeric or date ranges
|
|
225
|
+
CHECK_PRESENCE: Check if value exists or is null
|
|
226
|
+
|
|
227
|
+
Example:
|
|
228
|
+
>>> # Choice-based facet
|
|
229
|
+
>>> Facet(source="Status", ux_mode=FacetUxMode.CHOICES)
|
|
230
|
+
>>>
|
|
231
|
+
>>> # Range-based facet for numeric values
|
|
232
|
+
>>> Facet(source="Age", ux_mode=FacetUxMode.RANGES)
|
|
233
|
+
>>>
|
|
234
|
+
>>> # Check presence (has value / no value)
|
|
235
|
+
>>> Facet(source="Notes", ux_mode=FacetUxMode.CHECK_PRESENCE)
|
|
236
|
+
"""
|
|
237
|
+
CHOICES = "choices"
|
|
238
|
+
RANGES = "ranges"
|
|
239
|
+
CHECK_PRESENCE = "check_presence"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# =============================================================================
|
|
243
|
+
# Context Names
|
|
244
|
+
# =============================================================================
|
|
245
|
+
|
|
246
|
+
# Standard context names
|
|
247
|
+
CONTEXT_DEFAULT = "*"
|
|
248
|
+
CONTEXT_COMPACT = "compact"
|
|
249
|
+
CONTEXT_COMPACT_BRIEF = "compact/brief"
|
|
250
|
+
CONTEXT_COMPACT_BRIEF_INLINE = "compact/brief/inline"
|
|
251
|
+
CONTEXT_COMPACT_SELECT = "compact/select"
|
|
252
|
+
CONTEXT_DETAILED = "detailed"
|
|
253
|
+
CONTEXT_ENTRY = "entry"
|
|
254
|
+
CONTEXT_ENTRY_CREATE = "entry/create"
|
|
255
|
+
CONTEXT_ENTRY_EDIT = "entry/edit"
|
|
256
|
+
CONTEXT_EXPORT = "export"
|
|
257
|
+
CONTEXT_FILTER = "filter"
|
|
258
|
+
CONTEXT_ROW_NAME = "row_name"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# =============================================================================
|
|
262
|
+
# Annotation Tag URIs
|
|
263
|
+
# =============================================================================
|
|
264
|
+
|
|
265
|
+
TAG_DISPLAY = "tag:isrd.isi.edu,2015:display"
|
|
266
|
+
TAG_VISIBLE_COLUMNS = "tag:isrd.isi.edu,2016:visible-columns"
|
|
267
|
+
TAG_VISIBLE_FOREIGN_KEYS = "tag:isrd.isi.edu,2016:visible-foreign-keys"
|
|
268
|
+
TAG_TABLE_DISPLAY = "tag:isrd.isi.edu,2016:table-display"
|
|
269
|
+
TAG_COLUMN_DISPLAY = "tag:isrd.isi.edu,2016:column-display"
|
|
270
|
+
TAG_SOURCE_DEFINITIONS = "tag:isrd.isi.edu,2019:source-definitions"
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
# =============================================================================
|
|
274
|
+
# Base Protocol for Annotations
|
|
275
|
+
# =============================================================================
|
|
276
|
+
|
|
277
|
+
class AnnotationBuilder:
|
|
278
|
+
"""Base class for annotation builders.
|
|
279
|
+
|
|
280
|
+
Subclasses must implement:
|
|
281
|
+
- tag: The annotation tag URI
|
|
282
|
+
- to_dict(): Convert to dictionary representation
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
tag: str
|
|
286
|
+
|
|
287
|
+
def to_dict(self) -> dict[str, Any]:
|
|
288
|
+
"""Convert to dictionary suitable for catalog annotation."""
|
|
289
|
+
raise NotImplementedError
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# =============================================================================
|
|
293
|
+
# Display Annotation
|
|
294
|
+
# =============================================================================
|
|
295
|
+
|
|
296
|
+
@dataclass
|
|
297
|
+
class NameStyle:
|
|
298
|
+
"""Styling options for automatic display name formatting.
|
|
299
|
+
|
|
300
|
+
Applied to table or column names when no explicit display name is set.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
underline_space: Replace underscores with spaces (e.g., "First_Name" -> "First Name")
|
|
304
|
+
title_case: Apply title case formatting (e.g., "firstname" -> "Firstname")
|
|
305
|
+
markdown: Render the name as markdown
|
|
306
|
+
|
|
307
|
+
Example:
|
|
308
|
+
>>> # Transform "Subject_ID" to "Subject Id" with title case
|
|
309
|
+
>>> display = Display(
|
|
310
|
+
... name_style=NameStyle(underline_space=True, title_case=True)
|
|
311
|
+
... )
|
|
312
|
+
"""
|
|
313
|
+
underline_space: bool | None = None
|
|
314
|
+
title_case: bool | None = None
|
|
315
|
+
markdown: bool | None = None
|
|
316
|
+
|
|
317
|
+
def to_dict(self) -> dict[str, bool]:
|
|
318
|
+
"""Convert to dictionary, excluding None values."""
|
|
319
|
+
result = {}
|
|
320
|
+
if self.underline_space is not None:
|
|
321
|
+
result["underline_space"] = self.underline_space
|
|
322
|
+
if self.title_case is not None:
|
|
323
|
+
result["title_case"] = self.title_case
|
|
324
|
+
if self.markdown is not None:
|
|
325
|
+
result["markdown"] = self.markdown
|
|
326
|
+
return result
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@dataclass
|
|
330
|
+
class Display(AnnotationBuilder):
|
|
331
|
+
"""Display annotation for tables and columns.
|
|
332
|
+
|
|
333
|
+
Controls the display name, description/tooltip, and how null values
|
|
334
|
+
and foreign key links are rendered. Can be applied to both tables
|
|
335
|
+
and columns.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
name: Display name shown in the UI (mutually exclusive with markdown_name)
|
|
339
|
+
markdown_name: Markdown-formatted display name (mutually exclusive with name)
|
|
340
|
+
name_style: Styling options for automatic name formatting
|
|
341
|
+
comment: Description text shown as tooltip/help text
|
|
342
|
+
show_null: How to display null values, per context
|
|
343
|
+
show_foreign_key_link: Whether to show FK values as links, per context
|
|
344
|
+
|
|
345
|
+
Raises:
|
|
346
|
+
ValueError: If both name and markdown_name are provided
|
|
347
|
+
|
|
348
|
+
Example:
|
|
349
|
+
Basic display name::
|
|
350
|
+
|
|
351
|
+
>>> display = Display(name="Research Subjects")
|
|
352
|
+
>>> handle.set_annotation(display)
|
|
353
|
+
|
|
354
|
+
With description/tooltip::
|
|
355
|
+
|
|
356
|
+
>>> display = Display(
|
|
357
|
+
... name="Subjects",
|
|
358
|
+
... comment="Individuals enrolled in research studies"
|
|
359
|
+
... )
|
|
360
|
+
|
|
361
|
+
Markdown-formatted name::
|
|
362
|
+
|
|
363
|
+
>>> display = Display(markdown_name="**Bold** _Italic_ Name")
|
|
364
|
+
|
|
365
|
+
Context-specific null display::
|
|
366
|
+
|
|
367
|
+
>>> from deriva_ml.model import CONTEXT_COMPACT, CONTEXT_DETAILED
|
|
368
|
+
>>> display = Display(
|
|
369
|
+
... name="Value",
|
|
370
|
+
... show_null={
|
|
371
|
+
... CONTEXT_COMPACT: False, # Hide nulls in lists
|
|
372
|
+
... CONTEXT_DETAILED: '"N/A"' # Show "N/A" string
|
|
373
|
+
... }
|
|
374
|
+
... )
|
|
375
|
+
|
|
376
|
+
Control foreign key link display::
|
|
377
|
+
|
|
378
|
+
>>> display = Display(
|
|
379
|
+
... name="Subject",
|
|
380
|
+
... show_foreign_key_link={CONTEXT_COMPACT: False}
|
|
381
|
+
... )
|
|
382
|
+
"""
|
|
383
|
+
tag = TAG_DISPLAY
|
|
384
|
+
|
|
385
|
+
name: str | None = None
|
|
386
|
+
markdown_name: str | None = None
|
|
387
|
+
name_style: NameStyle | None = None
|
|
388
|
+
comment: str | None = None
|
|
389
|
+
show_null: dict[str, bool | str] | None = None
|
|
390
|
+
show_foreign_key_link: dict[str, bool] | None = None
|
|
391
|
+
|
|
392
|
+
def __post_init__(self):
|
|
393
|
+
if self.name and self.markdown_name:
|
|
394
|
+
raise ValueError("name and markdown_name are mutually exclusive")
|
|
395
|
+
|
|
396
|
+
def to_dict(self) -> dict[str, Any]:
|
|
397
|
+
result = {}
|
|
398
|
+
if self.name is not None:
|
|
399
|
+
result["name"] = self.name
|
|
400
|
+
if self.markdown_name is not None:
|
|
401
|
+
result["markdown_name"] = self.markdown_name
|
|
402
|
+
if self.name_style is not None:
|
|
403
|
+
style_dict = self.name_style.to_dict()
|
|
404
|
+
if style_dict:
|
|
405
|
+
result["name_style"] = style_dict
|
|
406
|
+
if self.comment is not None:
|
|
407
|
+
result["comment"] = self.comment
|
|
408
|
+
if self.show_null is not None:
|
|
409
|
+
result["show_null"] = self.show_null
|
|
410
|
+
if self.show_foreign_key_link is not None:
|
|
411
|
+
result["show_foreign_key_link"] = self.show_foreign_key_link
|
|
412
|
+
return result
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
# =============================================================================
|
|
416
|
+
# Sort Key
|
|
417
|
+
# =============================================================================
|
|
418
|
+
|
|
419
|
+
@dataclass
|
|
420
|
+
class SortKey:
|
|
421
|
+
"""A sort key for row ordering.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
column: Column name to sort by
|
|
425
|
+
descending: Sort in descending order (default False)
|
|
426
|
+
|
|
427
|
+
Example:
|
|
428
|
+
>>> SortKey("Name") # Ascending
|
|
429
|
+
>>> SortKey("Created", descending=True) # Descending
|
|
430
|
+
"""
|
|
431
|
+
column: str
|
|
432
|
+
descending: bool = False
|
|
433
|
+
|
|
434
|
+
def to_dict(self) -> dict[str, Any] | str:
|
|
435
|
+
"""Convert to dict or string (if ascending)."""
|
|
436
|
+
if self.descending:
|
|
437
|
+
return {"column": self.column, "descending": True}
|
|
438
|
+
return self.column
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# =============================================================================
|
|
442
|
+
# Foreign Key Path Components
|
|
443
|
+
# =============================================================================
|
|
444
|
+
|
|
445
|
+
@dataclass
|
|
446
|
+
class InboundFK:
|
|
447
|
+
"""An inbound foreign key path step for pseudo-column source paths.
|
|
448
|
+
|
|
449
|
+
Use this when following a foreign key FROM another table TO the current table.
|
|
450
|
+
This is common when counting or aggregating related records.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
schema: Schema name containing the FK constraint
|
|
454
|
+
constraint: Foreign key constraint name
|
|
455
|
+
|
|
456
|
+
Example:
|
|
457
|
+
Count images related to a subject (Image has FK to Subject)::
|
|
458
|
+
|
|
459
|
+
>>> # In Subject table, count related images
|
|
460
|
+
>>> pc = PseudoColumn(
|
|
461
|
+
... source=[InboundFK("domain", "Image_Subject_fkey"), "RID"],
|
|
462
|
+
... aggregate=Aggregate.CNT,
|
|
463
|
+
... markdown_name="Image Count"
|
|
464
|
+
... )
|
|
465
|
+
"""
|
|
466
|
+
schema: str
|
|
467
|
+
constraint: str
|
|
468
|
+
|
|
469
|
+
def to_dict(self) -> dict[str, list[str]]:
|
|
470
|
+
return {"inbound": [self.schema, self.constraint]}
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
@dataclass
|
|
474
|
+
class OutboundFK:
|
|
475
|
+
"""An outbound foreign key path step for pseudo-column source paths.
|
|
476
|
+
|
|
477
|
+
Use this when following a foreign key FROM the current table TO another table.
|
|
478
|
+
This is common when displaying values from referenced tables.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
schema: Schema name containing the FK constraint
|
|
482
|
+
constraint: Foreign key constraint name
|
|
483
|
+
|
|
484
|
+
Example:
|
|
485
|
+
Show species name from a related Species table::
|
|
486
|
+
|
|
487
|
+
>>> # Subject has FK to Species, display Species.Name
|
|
488
|
+
>>> pc = PseudoColumn(
|
|
489
|
+
... source=[OutboundFK("domain", "Subject_Species_fkey"), "Name"],
|
|
490
|
+
... markdown_name="Species"
|
|
491
|
+
... )
|
|
492
|
+
|
|
493
|
+
Chain multiple outbound FKs::
|
|
494
|
+
|
|
495
|
+
>>> # Image -> Subject -> Species
|
|
496
|
+
>>> pc = PseudoColumn(
|
|
497
|
+
... source=[
|
|
498
|
+
... OutboundFK("domain", "Image_Subject_fkey"),
|
|
499
|
+
... OutboundFK("domain", "Subject_Species_fkey"),
|
|
500
|
+
... "Name"
|
|
501
|
+
... ],
|
|
502
|
+
... markdown_name="Species"
|
|
503
|
+
... )
|
|
504
|
+
"""
|
|
505
|
+
schema: str
|
|
506
|
+
constraint: str
|
|
507
|
+
|
|
508
|
+
def to_dict(self) -> dict[str, list[str]]:
|
|
509
|
+
return {"outbound": [self.schema, self.constraint]}
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def fk_constraint(schema: str, constraint: str) -> list[str]:
|
|
513
|
+
"""Create a foreign key constraint reference for visible-columns.
|
|
514
|
+
|
|
515
|
+
Use this in visible-columns to include a foreign key column (showing the
|
|
516
|
+
referenced row's name/link). This is different from InboundFK/OutboundFK
|
|
517
|
+
which are used inside PseudoColumn source paths.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
schema: Schema name containing the FK constraint
|
|
521
|
+
constraint: Foreign key constraint name
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
[schema, constraint] list for use in visible-columns
|
|
525
|
+
|
|
526
|
+
Example:
|
|
527
|
+
Include a foreign key in visible columns::
|
|
528
|
+
|
|
529
|
+
>>> vc = VisibleColumns()
|
|
530
|
+
>>> vc.compact([
|
|
531
|
+
... "RID",
|
|
532
|
+
... "Name",
|
|
533
|
+
... fk_constraint("domain", "Subject_Species_fkey"), # Shows Species
|
|
534
|
+
... ])
|
|
535
|
+
|
|
536
|
+
This is equivalent to the raw format::
|
|
537
|
+
|
|
538
|
+
>>> vc.compact(["RID", "Name", ["domain", "Subject_Species_fkey"]])
|
|
539
|
+
"""
|
|
540
|
+
return [schema, constraint]
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# =============================================================================
|
|
544
|
+
# Pseudo-Column Display Options
|
|
545
|
+
# =============================================================================
|
|
546
|
+
|
|
547
|
+
@dataclass
|
|
548
|
+
class PseudoColumnDisplay:
|
|
549
|
+
"""Display options for a pseudo-column.
|
|
550
|
+
|
|
551
|
+
Args:
|
|
552
|
+
markdown_pattern: Handlebars/mustache template
|
|
553
|
+
template_engine: Template engine to use
|
|
554
|
+
show_foreign_key_link: Show as clickable link
|
|
555
|
+
array_ux_mode: How to render array values
|
|
556
|
+
column_order: Sort order for the column, or False to disable
|
|
557
|
+
wait_for: Template variables to wait for before rendering
|
|
558
|
+
"""
|
|
559
|
+
markdown_pattern: str | None = None
|
|
560
|
+
template_engine: TemplateEngine | None = None
|
|
561
|
+
show_foreign_key_link: bool | None = None
|
|
562
|
+
array_ux_mode: ArrayUxMode | None = None
|
|
563
|
+
column_order: list[SortKey] | Literal[False] | None = None
|
|
564
|
+
wait_for: list[str] | None = None
|
|
565
|
+
|
|
566
|
+
def to_dict(self) -> dict[str, Any]:
|
|
567
|
+
result = {}
|
|
568
|
+
if self.markdown_pattern is not None:
|
|
569
|
+
result["markdown_pattern"] = self.markdown_pattern
|
|
570
|
+
if self.template_engine is not None:
|
|
571
|
+
result["template_engine"] = self.template_engine.value
|
|
572
|
+
if self.show_foreign_key_link is not None:
|
|
573
|
+
result["show_foreign_key_link"] = self.show_foreign_key_link
|
|
574
|
+
if self.array_ux_mode is not None:
|
|
575
|
+
result["array_ux_mode"] = self.array_ux_mode.value
|
|
576
|
+
if self.column_order is not None:
|
|
577
|
+
if self.column_order is False:
|
|
578
|
+
result["column_order"] = False
|
|
579
|
+
else:
|
|
580
|
+
result["column_order"] = [
|
|
581
|
+
k.to_dict() if isinstance(k, SortKey) else k
|
|
582
|
+
for k in self.column_order
|
|
583
|
+
]
|
|
584
|
+
if self.wait_for is not None:
|
|
585
|
+
result["wait_for"] = self.wait_for
|
|
586
|
+
return result
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# =============================================================================
|
|
590
|
+
# Pseudo-Column (for visible-columns and visible-foreign-keys)
|
|
591
|
+
# =============================================================================
|
|
592
|
+
|
|
593
|
+
@dataclass
|
|
594
|
+
class PseudoColumn:
|
|
595
|
+
"""A pseudo-column definition for visible columns and foreign keys.
|
|
596
|
+
|
|
597
|
+
Pseudo-columns display computed values, values from related tables,
|
|
598
|
+
or custom markdown patterns. They appear as columns in table views
|
|
599
|
+
but are not actual database columns.
|
|
600
|
+
|
|
601
|
+
Args:
|
|
602
|
+
source: Path to source data. Can be:
|
|
603
|
+
- A column name (string)
|
|
604
|
+
- A list of FK path steps ending with a column name
|
|
605
|
+
sourcekey: Reference to a named source in source-definitions annotation
|
|
606
|
+
markdown_name: Display name for the column (supports markdown)
|
|
607
|
+
comment: Description/tooltip text (or False to hide)
|
|
608
|
+
entity: Whether this represents an entity (affects rendering)
|
|
609
|
+
aggregate: Aggregation function when source returns multiple values
|
|
610
|
+
self_link: Make the value a link to the current row
|
|
611
|
+
display: Display formatting options
|
|
612
|
+
array_options: Options for array aggregates (max_length, order)
|
|
613
|
+
|
|
614
|
+
Note:
|
|
615
|
+
source and sourcekey are mutually exclusive. Use source for inline
|
|
616
|
+
definitions, sourcekey to reference pre-defined sources.
|
|
617
|
+
|
|
618
|
+
Raises:
|
|
619
|
+
ValueError: If both source and sourcekey are provided
|
|
620
|
+
|
|
621
|
+
Example:
|
|
622
|
+
Simple column with custom display name::
|
|
623
|
+
|
|
624
|
+
>>> PseudoColumn(source="Internal_ID", markdown_name="ID")
|
|
625
|
+
|
|
626
|
+
Outbound FK traversal (display value from referenced table)::
|
|
627
|
+
|
|
628
|
+
>>> # Subject has FK to Species - show Species.Name
|
|
629
|
+
>>> PseudoColumn(
|
|
630
|
+
... source=[OutboundFK("domain", "Subject_Species_fkey"), "Name"],
|
|
631
|
+
... markdown_name="Species"
|
|
632
|
+
... )
|
|
633
|
+
|
|
634
|
+
Inbound FK with aggregation (count related records)::
|
|
635
|
+
|
|
636
|
+
>>> # Count images pointing to this subject
|
|
637
|
+
>>> PseudoColumn(
|
|
638
|
+
... source=[InboundFK("domain", "Image_Subject_fkey"), "RID"],
|
|
639
|
+
... aggregate=Aggregate.CNT,
|
|
640
|
+
... markdown_name="Images"
|
|
641
|
+
... )
|
|
642
|
+
|
|
643
|
+
Multi-hop FK path::
|
|
644
|
+
|
|
645
|
+
>>> # Image -> Subject -> Species
|
|
646
|
+
>>> PseudoColumn(
|
|
647
|
+
... source=[
|
|
648
|
+
... OutboundFK("domain", "Image_Subject_fkey"),
|
|
649
|
+
... OutboundFK("domain", "Subject_Species_fkey"),
|
|
650
|
+
... "Name"
|
|
651
|
+
... ],
|
|
652
|
+
... markdown_name="Species"
|
|
653
|
+
... )
|
|
654
|
+
|
|
655
|
+
With custom display formatting::
|
|
656
|
+
|
|
657
|
+
>>> PseudoColumn(
|
|
658
|
+
... source="URL",
|
|
659
|
+
... display=PseudoColumnDisplay(
|
|
660
|
+
... markdown_pattern="[Download]({{{_value}}})",
|
|
661
|
+
... show_foreign_key_link=False
|
|
662
|
+
... )
|
|
663
|
+
... )
|
|
664
|
+
|
|
665
|
+
Array aggregate with display options::
|
|
666
|
+
|
|
667
|
+
>>> PseudoColumn(
|
|
668
|
+
... source=[InboundFK("domain", "Tag_Item_fkey"), "Name"],
|
|
669
|
+
... aggregate=Aggregate.ARRAY_D,
|
|
670
|
+
... display=PseudoColumnDisplay(array_ux_mode=ArrayUxMode.CSV),
|
|
671
|
+
... markdown_name="Tags"
|
|
672
|
+
... )
|
|
673
|
+
"""
|
|
674
|
+
source: str | list[str | InboundFK | OutboundFK] | None = None
|
|
675
|
+
sourcekey: str | None = None
|
|
676
|
+
markdown_name: str | None = None
|
|
677
|
+
comment: str | Literal[False] | None = None
|
|
678
|
+
entity: bool | None = None
|
|
679
|
+
aggregate: Aggregate | None = None
|
|
680
|
+
self_link: bool | None = None
|
|
681
|
+
display: PseudoColumnDisplay | None = None
|
|
682
|
+
array_options: dict[str, Any] | None = None # Can be complex
|
|
683
|
+
|
|
684
|
+
def __post_init__(self):
|
|
685
|
+
if self.source is not None and self.sourcekey is not None:
|
|
686
|
+
raise ValueError("source and sourcekey are mutually exclusive")
|
|
687
|
+
|
|
688
|
+
def to_dict(self) -> dict[str, Any]:
|
|
689
|
+
result = {}
|
|
690
|
+
|
|
691
|
+
if self.source is not None:
|
|
692
|
+
if isinstance(self.source, str):
|
|
693
|
+
result["source"] = self.source
|
|
694
|
+
else:
|
|
695
|
+
# Convert path elements
|
|
696
|
+
result["source"] = [
|
|
697
|
+
item.to_dict() if hasattr(item, "to_dict") else item
|
|
698
|
+
for item in self.source
|
|
699
|
+
]
|
|
700
|
+
|
|
701
|
+
if self.sourcekey is not None:
|
|
702
|
+
result["sourcekey"] = self.sourcekey
|
|
703
|
+
if self.markdown_name is not None:
|
|
704
|
+
result["markdown_name"] = self.markdown_name
|
|
705
|
+
if self.comment is not None:
|
|
706
|
+
result["comment"] = self.comment
|
|
707
|
+
if self.entity is not None:
|
|
708
|
+
result["entity"] = self.entity
|
|
709
|
+
if self.aggregate is not None:
|
|
710
|
+
result["aggregate"] = self.aggregate.value
|
|
711
|
+
if self.self_link is not None:
|
|
712
|
+
result["self_link"] = self.self_link
|
|
713
|
+
if self.display is not None:
|
|
714
|
+
result["display"] = self.display.to_dict()
|
|
715
|
+
if self.array_options is not None:
|
|
716
|
+
result["array_options"] = self.array_options
|
|
717
|
+
|
|
718
|
+
return result
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
# =============================================================================
|
|
722
|
+
# Visible Columns Annotation
|
|
723
|
+
# =============================================================================
|
|
724
|
+
|
|
725
|
+
# Type for a single column entry
|
|
726
|
+
ColumnEntry = str | list[str] | PseudoColumn
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
@dataclass
|
|
730
|
+
class VisibleColumns(AnnotationBuilder):
|
|
731
|
+
"""Visible-columns annotation builder.
|
|
732
|
+
|
|
733
|
+
Controls which columns appear in different UI contexts and their order.
|
|
734
|
+
This is one of the most commonly used annotations for customizing the
|
|
735
|
+
Chaise interface.
|
|
736
|
+
|
|
737
|
+
Column entries can be:
|
|
738
|
+
- Column names (strings): "Name", "RID", "Description"
|
|
739
|
+
- Foreign key references: fk_constraint("schema", "constraint_name")
|
|
740
|
+
- Pseudo-columns: PseudoColumn(...) for computed/derived values
|
|
741
|
+
|
|
742
|
+
Contexts:
|
|
743
|
+
- ``compact``: Table/list views (search results, data browser)
|
|
744
|
+
- ``detailed``: Single record view (full record page)
|
|
745
|
+
- ``entry``: Create/edit forms
|
|
746
|
+
- ``entry/create``: Create form only
|
|
747
|
+
- ``entry/edit``: Edit form only
|
|
748
|
+
- ``*``: Default for all contexts
|
|
749
|
+
|
|
750
|
+
Example:
|
|
751
|
+
Basic column lists for different contexts::
|
|
752
|
+
|
|
753
|
+
>>> vc = VisibleColumns()
|
|
754
|
+
>>> vc.compact(["RID", "Name", "Status"])
|
|
755
|
+
>>> vc.detailed(["RID", "Name", "Status", "Description", "Created"])
|
|
756
|
+
>>> vc.entry(["Name", "Status", "Description"])
|
|
757
|
+
>>> handle.set_annotation(vc)
|
|
758
|
+
|
|
759
|
+
Method chaining::
|
|
760
|
+
|
|
761
|
+
>>> vc = (VisibleColumns()
|
|
762
|
+
... .compact(["RID", "Name"])
|
|
763
|
+
... .detailed(["RID", "Name", "Description"])
|
|
764
|
+
... .entry(["Name", "Description"]))
|
|
765
|
+
|
|
766
|
+
Including foreign key references::
|
|
767
|
+
|
|
768
|
+
>>> vc = VisibleColumns()
|
|
769
|
+
>>> vc.compact([
|
|
770
|
+
... "RID",
|
|
771
|
+
... "Name",
|
|
772
|
+
... fk_constraint("domain", "Subject_Species_fkey"),
|
|
773
|
+
... ])
|
|
774
|
+
|
|
775
|
+
With pseudo-columns for computed values::
|
|
776
|
+
|
|
777
|
+
>>> vc = VisibleColumns()
|
|
778
|
+
>>> vc.compact([
|
|
779
|
+
... "RID",
|
|
780
|
+
... "Name",
|
|
781
|
+
... PseudoColumn(
|
|
782
|
+
... source=[InboundFK("domain", "Sample_Subject_fkey"), "RID"],
|
|
783
|
+
... aggregate=Aggregate.CNT,
|
|
784
|
+
... markdown_name="Samples"
|
|
785
|
+
... ),
|
|
786
|
+
... ])
|
|
787
|
+
|
|
788
|
+
Context inheritance (reference another context)::
|
|
789
|
+
|
|
790
|
+
>>> vc = VisibleColumns()
|
|
791
|
+
>>> vc.compact(["RID", "Name"])
|
|
792
|
+
>>> vc.set_context("compact/brief", "compact") # Inherit from compact
|
|
793
|
+
|
|
794
|
+
With faceted search (filter context)::
|
|
795
|
+
|
|
796
|
+
>>> vc = VisibleColumns()
|
|
797
|
+
>>> vc.compact(["RID", "Name", "Status"])
|
|
798
|
+
>>> facets = FacetList()
|
|
799
|
+
>>> facets.add(Facet(source="Status", open=True))
|
|
800
|
+
>>> vc._contexts["filter"] = facets.to_dict()
|
|
801
|
+
"""
|
|
802
|
+
tag = TAG_VISIBLE_COLUMNS
|
|
803
|
+
|
|
804
|
+
_contexts: dict[str, list[ColumnEntry] | str] = field(default_factory=dict)
|
|
805
|
+
|
|
806
|
+
def set_context(
|
|
807
|
+
self,
|
|
808
|
+
context: str,
|
|
809
|
+
columns: list[ColumnEntry] | str
|
|
810
|
+
) -> "VisibleColumns":
|
|
811
|
+
"""Set columns for a context.
|
|
812
|
+
|
|
813
|
+
Args:
|
|
814
|
+
context: Context name (e.g., "compact", "detailed", "*")
|
|
815
|
+
columns: List of columns, or string referencing another context
|
|
816
|
+
|
|
817
|
+
Returns:
|
|
818
|
+
Self for chaining
|
|
819
|
+
"""
|
|
820
|
+
self._contexts[context] = columns
|
|
821
|
+
return self
|
|
822
|
+
|
|
823
|
+
def compact(self, columns: list[ColumnEntry]) -> "VisibleColumns":
|
|
824
|
+
"""Set columns for compact (list) view."""
|
|
825
|
+
return self.set_context(CONTEXT_COMPACT, columns)
|
|
826
|
+
|
|
827
|
+
def detailed(self, columns: list[ColumnEntry]) -> "VisibleColumns":
|
|
828
|
+
"""Set columns for detailed (record) view."""
|
|
829
|
+
return self.set_context(CONTEXT_DETAILED, columns)
|
|
830
|
+
|
|
831
|
+
def entry(self, columns: list[ColumnEntry]) -> "VisibleColumns":
|
|
832
|
+
"""Set columns for entry (create/edit) forms."""
|
|
833
|
+
return self.set_context(CONTEXT_ENTRY, columns)
|
|
834
|
+
|
|
835
|
+
def entry_create(self, columns: list[ColumnEntry]) -> "VisibleColumns":
|
|
836
|
+
"""Set columns for create form only."""
|
|
837
|
+
return self.set_context(CONTEXT_ENTRY_CREATE, columns)
|
|
838
|
+
|
|
839
|
+
def entry_edit(self, columns: list[ColumnEntry]) -> "VisibleColumns":
|
|
840
|
+
"""Set columns for edit form only."""
|
|
841
|
+
return self.set_context(CONTEXT_ENTRY_EDIT, columns)
|
|
842
|
+
|
|
843
|
+
def default(self, columns: list[ColumnEntry]) -> "VisibleColumns":
|
|
844
|
+
"""Set default columns for all contexts."""
|
|
845
|
+
return self.set_context(CONTEXT_DEFAULT, columns)
|
|
846
|
+
|
|
847
|
+
def to_dict(self) -> dict[str, Any]:
|
|
848
|
+
result = {}
|
|
849
|
+
for context, columns in self._contexts.items():
|
|
850
|
+
if isinstance(columns, str):
|
|
851
|
+
result[context] = columns
|
|
852
|
+
else:
|
|
853
|
+
result[context] = [
|
|
854
|
+
c.to_dict() if isinstance(c, PseudoColumn) else c
|
|
855
|
+
for c in columns
|
|
856
|
+
]
|
|
857
|
+
return result
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
# =============================================================================
|
|
861
|
+
# Visible Foreign Keys Annotation
|
|
862
|
+
# =============================================================================
|
|
863
|
+
|
|
864
|
+
# Type for a single FK entry
|
|
865
|
+
ForeignKeyEntry = list[str] | PseudoColumn
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
@dataclass
|
|
869
|
+
class VisibleForeignKeys(AnnotationBuilder):
|
|
870
|
+
"""Visible-foreign-keys annotation builder.
|
|
871
|
+
|
|
872
|
+
Controls which related tables appear in the UI via inbound foreign keys.
|
|
873
|
+
|
|
874
|
+
Example:
|
|
875
|
+
>>> vfk = VisibleForeignKeys()
|
|
876
|
+
>>> vfk.detailed([
|
|
877
|
+
... fk_constraint("domain", "Image_Subject_fkey"),
|
|
878
|
+
... fk_constraint("domain", "Diagnosis_Subject_fkey")
|
|
879
|
+
... ])
|
|
880
|
+
"""
|
|
881
|
+
tag = TAG_VISIBLE_FOREIGN_KEYS
|
|
882
|
+
|
|
883
|
+
_contexts: dict[str, list[ForeignKeyEntry] | str] = field(default_factory=dict)
|
|
884
|
+
|
|
885
|
+
def set_context(
|
|
886
|
+
self,
|
|
887
|
+
context: str,
|
|
888
|
+
foreign_keys: list[ForeignKeyEntry] | str
|
|
889
|
+
) -> "VisibleForeignKeys":
|
|
890
|
+
"""Set foreign keys for a context."""
|
|
891
|
+
self._contexts[context] = foreign_keys
|
|
892
|
+
return self
|
|
893
|
+
|
|
894
|
+
def detailed(self, foreign_keys: list[ForeignKeyEntry]) -> "VisibleForeignKeys":
|
|
895
|
+
"""Set foreign keys for detailed view."""
|
|
896
|
+
return self.set_context(CONTEXT_DETAILED, foreign_keys)
|
|
897
|
+
|
|
898
|
+
def default(self, foreign_keys: list[ForeignKeyEntry]) -> "VisibleForeignKeys":
|
|
899
|
+
"""Set default foreign keys for all contexts."""
|
|
900
|
+
return self.set_context(CONTEXT_DEFAULT, foreign_keys)
|
|
901
|
+
|
|
902
|
+
def to_dict(self) -> dict[str, Any]:
|
|
903
|
+
result = {}
|
|
904
|
+
for context, fkeys in self._contexts.items():
|
|
905
|
+
if isinstance(fkeys, str):
|
|
906
|
+
result[context] = fkeys
|
|
907
|
+
else:
|
|
908
|
+
result[context] = [
|
|
909
|
+
fk.to_dict() if isinstance(fk, PseudoColumn) else fk
|
|
910
|
+
for fk in fkeys
|
|
911
|
+
]
|
|
912
|
+
return result
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
# =============================================================================
|
|
916
|
+
# Table Display Annotation
|
|
917
|
+
# =============================================================================
|
|
918
|
+
|
|
919
|
+
@dataclass
|
|
920
|
+
class TableDisplayOptions:
|
|
921
|
+
"""Options for a single table display context.
|
|
922
|
+
|
|
923
|
+
Args:
|
|
924
|
+
row_order: Sort order for rows
|
|
925
|
+
page_size: Number of rows per page
|
|
926
|
+
row_markdown_pattern: Template for row names
|
|
927
|
+
page_markdown_pattern: Template for page header
|
|
928
|
+
separator_markdown: Template between rows
|
|
929
|
+
prefix_markdown: Template before rows
|
|
930
|
+
suffix_markdown: Template after rows
|
|
931
|
+
template_engine: Template engine for patterns
|
|
932
|
+
collapse_toc_panel: Collapse TOC panel
|
|
933
|
+
hide_column_headers: Hide column headers
|
|
934
|
+
"""
|
|
935
|
+
row_order: list[SortKey] | None = None
|
|
936
|
+
page_size: int | None = None
|
|
937
|
+
row_markdown_pattern: str | None = None
|
|
938
|
+
page_markdown_pattern: str | None = None
|
|
939
|
+
separator_markdown: str | None = None
|
|
940
|
+
prefix_markdown: str | None = None
|
|
941
|
+
suffix_markdown: str | None = None
|
|
942
|
+
template_engine: TemplateEngine | None = None
|
|
943
|
+
collapse_toc_panel: bool | None = None
|
|
944
|
+
hide_column_headers: bool | None = None
|
|
945
|
+
|
|
946
|
+
def to_dict(self) -> dict[str, Any]:
|
|
947
|
+
result = {}
|
|
948
|
+
if self.row_order is not None:
|
|
949
|
+
result["row_order"] = [
|
|
950
|
+
k.to_dict() if isinstance(k, SortKey) else k
|
|
951
|
+
for k in self.row_order
|
|
952
|
+
]
|
|
953
|
+
if self.page_size is not None:
|
|
954
|
+
result["page_size"] = self.page_size
|
|
955
|
+
if self.row_markdown_pattern is not None:
|
|
956
|
+
result["row_markdown_pattern"] = self.row_markdown_pattern
|
|
957
|
+
if self.page_markdown_pattern is not None:
|
|
958
|
+
result["page_markdown_pattern"] = self.page_markdown_pattern
|
|
959
|
+
if self.separator_markdown is not None:
|
|
960
|
+
result["separator_markdown"] = self.separator_markdown
|
|
961
|
+
if self.prefix_markdown is not None:
|
|
962
|
+
result["prefix_markdown"] = self.prefix_markdown
|
|
963
|
+
if self.suffix_markdown is not None:
|
|
964
|
+
result["suffix_markdown"] = self.suffix_markdown
|
|
965
|
+
if self.template_engine is not None:
|
|
966
|
+
result["template_engine"] = self.template_engine.value
|
|
967
|
+
if self.collapse_toc_panel is not None:
|
|
968
|
+
result["collapse_toc_panel"] = self.collapse_toc_panel
|
|
969
|
+
if self.hide_column_headers is not None:
|
|
970
|
+
result["hide_column_headers"] = self.hide_column_headers
|
|
971
|
+
return result
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
@dataclass
|
|
975
|
+
class TableDisplay(AnnotationBuilder):
|
|
976
|
+
"""Table-display annotation builder.
|
|
977
|
+
|
|
978
|
+
Controls table-level display options like row naming and ordering.
|
|
979
|
+
|
|
980
|
+
Example:
|
|
981
|
+
>>> td = TableDisplay()
|
|
982
|
+
>>> td.row_name(row_markdown_pattern="{{{Name}}} ({{{Species}}})")
|
|
983
|
+
>>> td.compact(row_order=[SortKey("Name")])
|
|
984
|
+
"""
|
|
985
|
+
tag = TAG_TABLE_DISPLAY
|
|
986
|
+
|
|
987
|
+
_contexts: dict[str, TableDisplayOptions | str | None] = field(default_factory=dict)
|
|
988
|
+
|
|
989
|
+
def set_context(
|
|
990
|
+
self,
|
|
991
|
+
context: str,
|
|
992
|
+
options: TableDisplayOptions | str | None
|
|
993
|
+
) -> "TableDisplay":
|
|
994
|
+
"""Set options for a context."""
|
|
995
|
+
self._contexts[context] = options
|
|
996
|
+
return self
|
|
997
|
+
|
|
998
|
+
def row_name(
|
|
999
|
+
self,
|
|
1000
|
+
row_markdown_pattern: str,
|
|
1001
|
+
template_engine: TemplateEngine | None = None
|
|
1002
|
+
) -> "TableDisplay":
|
|
1003
|
+
"""Set row name pattern (used in foreign key dropdowns, etc.)."""
|
|
1004
|
+
return self.set_context(
|
|
1005
|
+
CONTEXT_ROW_NAME,
|
|
1006
|
+
TableDisplayOptions(
|
|
1007
|
+
row_markdown_pattern=row_markdown_pattern,
|
|
1008
|
+
template_engine=template_engine
|
|
1009
|
+
)
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
def compact(self, options: TableDisplayOptions) -> "TableDisplay":
|
|
1013
|
+
"""Set options for compact (list) view."""
|
|
1014
|
+
return self.set_context(CONTEXT_COMPACT, options)
|
|
1015
|
+
|
|
1016
|
+
def detailed(self, options: TableDisplayOptions) -> "TableDisplay":
|
|
1017
|
+
"""Set options for detailed (record) view."""
|
|
1018
|
+
return self.set_context(CONTEXT_DETAILED, options)
|
|
1019
|
+
|
|
1020
|
+
def default(self, options: TableDisplayOptions) -> "TableDisplay":
|
|
1021
|
+
"""Set default options."""
|
|
1022
|
+
return self.set_context(CONTEXT_DEFAULT, options)
|
|
1023
|
+
|
|
1024
|
+
def to_dict(self) -> dict[str, Any]:
|
|
1025
|
+
result = {}
|
|
1026
|
+
for context, options in self._contexts.items():
|
|
1027
|
+
if options is None:
|
|
1028
|
+
result[context] = None
|
|
1029
|
+
elif isinstance(options, str):
|
|
1030
|
+
result[context] = options
|
|
1031
|
+
else:
|
|
1032
|
+
result[context] = options.to_dict()
|
|
1033
|
+
return result
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
# =============================================================================
|
|
1037
|
+
# Column Display Annotation
|
|
1038
|
+
# =============================================================================
|
|
1039
|
+
|
|
1040
|
+
@dataclass
|
|
1041
|
+
class PreFormat:
|
|
1042
|
+
"""Pre-formatting options for column values.
|
|
1043
|
+
|
|
1044
|
+
Args:
|
|
1045
|
+
format: Printf-style format string (e.g., "%.2f")
|
|
1046
|
+
bool_true_value: Display value for True
|
|
1047
|
+
bool_false_value: Display value for False
|
|
1048
|
+
"""
|
|
1049
|
+
format: str | None = None
|
|
1050
|
+
bool_true_value: str | None = None
|
|
1051
|
+
bool_false_value: str | None = None
|
|
1052
|
+
|
|
1053
|
+
def to_dict(self) -> dict[str, Any]:
|
|
1054
|
+
result = {}
|
|
1055
|
+
if self.format is not None:
|
|
1056
|
+
result["format"] = self.format
|
|
1057
|
+
if self.bool_true_value is not None:
|
|
1058
|
+
result["bool_true_value"] = self.bool_true_value
|
|
1059
|
+
if self.bool_false_value is not None:
|
|
1060
|
+
result["bool_false_value"] = self.bool_false_value
|
|
1061
|
+
return result
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
@dataclass
|
|
1065
|
+
class ColumnDisplayOptions:
|
|
1066
|
+
"""Options for displaying a column in a specific context.
|
|
1067
|
+
|
|
1068
|
+
Args:
|
|
1069
|
+
pre_format: Pre-formatting options
|
|
1070
|
+
markdown_pattern: Template for rendering
|
|
1071
|
+
template_engine: Template engine to use
|
|
1072
|
+
column_order: Sort order, or False to disable
|
|
1073
|
+
"""
|
|
1074
|
+
pre_format: PreFormat | None = None
|
|
1075
|
+
markdown_pattern: str | None = None
|
|
1076
|
+
template_engine: TemplateEngine | None = None
|
|
1077
|
+
column_order: list[SortKey] | Literal[False] | None = None
|
|
1078
|
+
|
|
1079
|
+
def to_dict(self) -> dict[str, Any]:
|
|
1080
|
+
result = {}
|
|
1081
|
+
if self.pre_format is not None:
|
|
1082
|
+
result["pre_format"] = self.pre_format.to_dict()
|
|
1083
|
+
if self.markdown_pattern is not None:
|
|
1084
|
+
result["markdown_pattern"] = self.markdown_pattern
|
|
1085
|
+
if self.template_engine is not None:
|
|
1086
|
+
result["template_engine"] = self.template_engine.value
|
|
1087
|
+
if self.column_order is not None:
|
|
1088
|
+
if self.column_order is False:
|
|
1089
|
+
result["column_order"] = False
|
|
1090
|
+
else:
|
|
1091
|
+
result["column_order"] = [
|
|
1092
|
+
k.to_dict() if isinstance(k, SortKey) else k
|
|
1093
|
+
for k in self.column_order
|
|
1094
|
+
]
|
|
1095
|
+
return result
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
@dataclass
|
|
1099
|
+
class ColumnDisplay(AnnotationBuilder):
|
|
1100
|
+
"""Column-display annotation builder.
|
|
1101
|
+
|
|
1102
|
+
Controls how column values are rendered.
|
|
1103
|
+
|
|
1104
|
+
Example:
|
|
1105
|
+
>>> cd = ColumnDisplay()
|
|
1106
|
+
>>> cd.default(ColumnDisplayOptions(
|
|
1107
|
+
... pre_format=PreFormat(format="%.2f")
|
|
1108
|
+
... ))
|
|
1109
|
+
>>>
|
|
1110
|
+
>>> # Markdown link
|
|
1111
|
+
>>> cd = ColumnDisplay()
|
|
1112
|
+
>>> cd.default(ColumnDisplayOptions(
|
|
1113
|
+
... markdown_pattern="[Link]({{{_value}}})"
|
|
1114
|
+
... ))
|
|
1115
|
+
"""
|
|
1116
|
+
tag = TAG_COLUMN_DISPLAY
|
|
1117
|
+
|
|
1118
|
+
_contexts: dict[str, ColumnDisplayOptions | str] = field(default_factory=dict)
|
|
1119
|
+
|
|
1120
|
+
def set_context(
|
|
1121
|
+
self,
|
|
1122
|
+
context: str,
|
|
1123
|
+
options: ColumnDisplayOptions | str
|
|
1124
|
+
) -> "ColumnDisplay":
|
|
1125
|
+
"""Set options for a context."""
|
|
1126
|
+
self._contexts[context] = options
|
|
1127
|
+
return self
|
|
1128
|
+
|
|
1129
|
+
def default(self, options: ColumnDisplayOptions) -> "ColumnDisplay":
|
|
1130
|
+
"""Set default options."""
|
|
1131
|
+
return self.set_context(CONTEXT_DEFAULT, options)
|
|
1132
|
+
|
|
1133
|
+
def compact(self, options: ColumnDisplayOptions) -> "ColumnDisplay":
|
|
1134
|
+
"""Set options for compact view."""
|
|
1135
|
+
return self.set_context(CONTEXT_COMPACT, options)
|
|
1136
|
+
|
|
1137
|
+
def detailed(self, options: ColumnDisplayOptions) -> "ColumnDisplay":
|
|
1138
|
+
"""Set options for detailed view."""
|
|
1139
|
+
return self.set_context(CONTEXT_DETAILED, options)
|
|
1140
|
+
|
|
1141
|
+
def to_dict(self) -> dict[str, Any]:
|
|
1142
|
+
result = {}
|
|
1143
|
+
for context, options in self._contexts.items():
|
|
1144
|
+
if isinstance(options, str):
|
|
1145
|
+
result[context] = options
|
|
1146
|
+
else:
|
|
1147
|
+
result[context] = options.to_dict()
|
|
1148
|
+
return result
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
# =============================================================================
|
|
1152
|
+
# Facet Entry (for filter context)
|
|
1153
|
+
# =============================================================================
|
|
1154
|
+
|
|
1155
|
+
@dataclass
|
|
1156
|
+
class FacetRange:
|
|
1157
|
+
"""A range for facet filtering.
|
|
1158
|
+
|
|
1159
|
+
Args:
|
|
1160
|
+
min: Minimum value
|
|
1161
|
+
max: Maximum value
|
|
1162
|
+
min_exclusive: Exclude min value
|
|
1163
|
+
max_exclusive: Exclude max value
|
|
1164
|
+
"""
|
|
1165
|
+
min: float | None = None
|
|
1166
|
+
max: float | None = None
|
|
1167
|
+
min_exclusive: bool | None = None
|
|
1168
|
+
max_exclusive: bool | None = None
|
|
1169
|
+
|
|
1170
|
+
def to_dict(self) -> dict[str, Any]:
|
|
1171
|
+
result = {}
|
|
1172
|
+
if self.min is not None:
|
|
1173
|
+
result["min"] = self.min
|
|
1174
|
+
if self.max is not None:
|
|
1175
|
+
result["max"] = self.max
|
|
1176
|
+
if self.min_exclusive is not None:
|
|
1177
|
+
result["min_exclusive"] = self.min_exclusive
|
|
1178
|
+
if self.max_exclusive is not None:
|
|
1179
|
+
result["max_exclusive"] = self.max_exclusive
|
|
1180
|
+
return result
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
@dataclass
|
|
1184
|
+
class Facet:
|
|
1185
|
+
"""A facet definition for filtering.
|
|
1186
|
+
|
|
1187
|
+
Args:
|
|
1188
|
+
source: Path to source data
|
|
1189
|
+
sourcekey: Reference to named source
|
|
1190
|
+
markdown_name: Display name
|
|
1191
|
+
comment: Description
|
|
1192
|
+
entity: Whether this is an entity facet
|
|
1193
|
+
open: Start expanded
|
|
1194
|
+
ux_mode: UI mode (choices, ranges, check_presence)
|
|
1195
|
+
bar_plot: Show bar plot
|
|
1196
|
+
choices: Preset choice values
|
|
1197
|
+
ranges: Preset range values
|
|
1198
|
+
not_null: Filter to non-null values
|
|
1199
|
+
hide_null_choice: Hide "null" option
|
|
1200
|
+
hide_not_null_choice: Hide "not null" option
|
|
1201
|
+
n_bins: Number of bins for histogram
|
|
1202
|
+
"""
|
|
1203
|
+
source: str | list[str | InboundFK | OutboundFK] | None = None
|
|
1204
|
+
sourcekey: str | None = None
|
|
1205
|
+
markdown_name: str | None = None
|
|
1206
|
+
comment: str | None = None
|
|
1207
|
+
entity: bool | None = None
|
|
1208
|
+
open: bool | None = None
|
|
1209
|
+
ux_mode: FacetUxMode | None = None
|
|
1210
|
+
bar_plot: bool | None = None
|
|
1211
|
+
choices: list[Any] | None = None
|
|
1212
|
+
ranges: list[FacetRange] | None = None
|
|
1213
|
+
not_null: bool | None = None
|
|
1214
|
+
hide_null_choice: bool | None = None
|
|
1215
|
+
hide_not_null_choice: bool | None = None
|
|
1216
|
+
n_bins: int | None = None
|
|
1217
|
+
|
|
1218
|
+
def to_dict(self) -> dict[str, Any]:
|
|
1219
|
+
result = {}
|
|
1220
|
+
|
|
1221
|
+
if self.source is not None:
|
|
1222
|
+
if isinstance(self.source, str):
|
|
1223
|
+
result["source"] = self.source
|
|
1224
|
+
else:
|
|
1225
|
+
result["source"] = [
|
|
1226
|
+
item.to_dict() if hasattr(item, "to_dict") else item
|
|
1227
|
+
for item in self.source
|
|
1228
|
+
]
|
|
1229
|
+
|
|
1230
|
+
if self.sourcekey is not None:
|
|
1231
|
+
result["sourcekey"] = self.sourcekey
|
|
1232
|
+
if self.markdown_name is not None:
|
|
1233
|
+
result["markdown_name"] = self.markdown_name
|
|
1234
|
+
if self.comment is not None:
|
|
1235
|
+
result["comment"] = self.comment
|
|
1236
|
+
if self.entity is not None:
|
|
1237
|
+
result["entity"] = self.entity
|
|
1238
|
+
if self.open is not None:
|
|
1239
|
+
result["open"] = self.open
|
|
1240
|
+
if self.ux_mode is not None:
|
|
1241
|
+
result["ux_mode"] = self.ux_mode.value
|
|
1242
|
+
if self.bar_plot is not None:
|
|
1243
|
+
result["bar_plot"] = self.bar_plot
|
|
1244
|
+
if self.choices is not None:
|
|
1245
|
+
result["choices"] = self.choices
|
|
1246
|
+
if self.ranges is not None:
|
|
1247
|
+
result["ranges"] = [r.to_dict() for r in self.ranges]
|
|
1248
|
+
if self.not_null is not None:
|
|
1249
|
+
result["not_null"] = self.not_null
|
|
1250
|
+
if self.hide_null_choice is not None:
|
|
1251
|
+
result["hide_null_choice"] = self.hide_null_choice
|
|
1252
|
+
if self.hide_not_null_choice is not None:
|
|
1253
|
+
result["hide_not_null_choice"] = self.hide_not_null_choice
|
|
1254
|
+
if self.n_bins is not None:
|
|
1255
|
+
result["n_bins"] = self.n_bins
|
|
1256
|
+
|
|
1257
|
+
return result
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
@dataclass
|
|
1261
|
+
class FacetList:
|
|
1262
|
+
"""A list of facets for filtering (visible_columns.filter).
|
|
1263
|
+
|
|
1264
|
+
Example:
|
|
1265
|
+
>>> facets = FacetList([
|
|
1266
|
+
... Facet(source="Species", open=True),
|
|
1267
|
+
... Facet(source="Age", ux_mode=FacetUxMode.RANGES)
|
|
1268
|
+
... ])
|
|
1269
|
+
"""
|
|
1270
|
+
facets: list[Facet] = field(default_factory=list)
|
|
1271
|
+
|
|
1272
|
+
def add(self, facet: Facet) -> "FacetList":
|
|
1273
|
+
"""Add a facet to the list."""
|
|
1274
|
+
self.facets.append(facet)
|
|
1275
|
+
return self
|
|
1276
|
+
|
|
1277
|
+
def to_dict(self) -> dict[str, list[dict]]:
|
|
1278
|
+
return {"and": [f.to_dict() for f in self.facets]}
|