deriva-ml 1.17.9__py3-none-any.whl → 1.17.11__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 (74) hide show
  1. deriva_ml/__init__.py +43 -1
  2. deriva_ml/asset/__init__.py +17 -0
  3. deriva_ml/asset/asset.py +357 -0
  4. deriva_ml/asset/aux_classes.py +100 -0
  5. deriva_ml/bump_version.py +254 -11
  6. deriva_ml/catalog/__init__.py +21 -0
  7. deriva_ml/catalog/clone.py +1199 -0
  8. deriva_ml/catalog/localize.py +426 -0
  9. deriva_ml/core/__init__.py +29 -0
  10. deriva_ml/core/base.py +817 -1067
  11. deriva_ml/core/config.py +169 -21
  12. deriva_ml/core/constants.py +120 -19
  13. deriva_ml/core/definitions.py +123 -13
  14. deriva_ml/core/enums.py +47 -73
  15. deriva_ml/core/ermrest.py +226 -193
  16. deriva_ml/core/exceptions.py +297 -14
  17. deriva_ml/core/filespec.py +99 -28
  18. deriva_ml/core/logging_config.py +225 -0
  19. deriva_ml/core/mixins/__init__.py +42 -0
  20. deriva_ml/core/mixins/annotation.py +915 -0
  21. deriva_ml/core/mixins/asset.py +384 -0
  22. deriva_ml/core/mixins/dataset.py +237 -0
  23. deriva_ml/core/mixins/execution.py +408 -0
  24. deriva_ml/core/mixins/feature.py +365 -0
  25. deriva_ml/core/mixins/file.py +263 -0
  26. deriva_ml/core/mixins/path_builder.py +145 -0
  27. deriva_ml/core/mixins/rid_resolution.py +204 -0
  28. deriva_ml/core/mixins/vocabulary.py +400 -0
  29. deriva_ml/core/mixins/workflow.py +322 -0
  30. deriva_ml/core/validation.py +389 -0
  31. deriva_ml/dataset/__init__.py +2 -1
  32. deriva_ml/dataset/aux_classes.py +20 -4
  33. deriva_ml/dataset/catalog_graph.py +575 -0
  34. deriva_ml/dataset/dataset.py +1242 -1008
  35. deriva_ml/dataset/dataset_bag.py +1311 -182
  36. deriva_ml/dataset/history.py +27 -14
  37. deriva_ml/dataset/upload.py +225 -38
  38. deriva_ml/demo_catalog.py +186 -105
  39. deriva_ml/execution/__init__.py +46 -2
  40. deriva_ml/execution/base_config.py +639 -0
  41. deriva_ml/execution/execution.py +545 -244
  42. deriva_ml/execution/execution_configuration.py +26 -11
  43. deriva_ml/execution/execution_record.py +592 -0
  44. deriva_ml/execution/find_caller.py +298 -0
  45. deriva_ml/execution/model_protocol.py +175 -0
  46. deriva_ml/execution/multirun_config.py +153 -0
  47. deriva_ml/execution/runner.py +595 -0
  48. deriva_ml/execution/workflow.py +224 -35
  49. deriva_ml/experiment/__init__.py +8 -0
  50. deriva_ml/experiment/experiment.py +411 -0
  51. deriva_ml/feature.py +6 -1
  52. deriva_ml/install_kernel.py +143 -6
  53. deriva_ml/interfaces.py +862 -0
  54. deriva_ml/model/__init__.py +99 -0
  55. deriva_ml/model/annotations.py +1278 -0
  56. deriva_ml/model/catalog.py +286 -60
  57. deriva_ml/model/database.py +144 -649
  58. deriva_ml/model/deriva_ml_database.py +308 -0
  59. deriva_ml/model/handles.py +14 -0
  60. deriva_ml/run_model.py +319 -0
  61. deriva_ml/run_notebook.py +507 -38
  62. deriva_ml/schema/__init__.py +18 -2
  63. deriva_ml/schema/annotations.py +62 -33
  64. deriva_ml/schema/create_schema.py +169 -69
  65. deriva_ml/schema/validation.py +601 -0
  66. {deriva_ml-1.17.9.dist-info → deriva_ml-1.17.11.dist-info}/METADATA +4 -5
  67. deriva_ml-1.17.11.dist-info/RECORD +77 -0
  68. {deriva_ml-1.17.9.dist-info → deriva_ml-1.17.11.dist-info}/WHEEL +1 -1
  69. {deriva_ml-1.17.9.dist-info → deriva_ml-1.17.11.dist-info}/entry_points.txt +2 -0
  70. deriva_ml/protocols/dataset.py +0 -19
  71. deriva_ml/test.py +0 -94
  72. deriva_ml-1.17.9.dist-info/RECORD +0 -45
  73. {deriva_ml-1.17.9.dist-info → deriva_ml-1.17.11.dist-info}/licenses/LICENSE +0 -0
  74. {deriva_ml-1.17.9.dist-info → deriva_ml-1.17.11.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]}