deriva-ml 1.17.10__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 +126 -110
  39. deriva_ml/execution/__init__.py +46 -2
  40. deriva_ml/execution/base_config.py +639 -0
  41. deriva_ml/execution/execution.py +543 -242
  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 +223 -34
  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.10.dist-info → deriva_ml-1.17.11.dist-info}/METADATA +4 -4
  67. deriva_ml-1.17.11.dist-info/RECORD +77 -0
  68. {deriva_ml-1.17.10.dist-info → deriva_ml-1.17.11.dist-info}/WHEEL +1 -1
  69. {deriva_ml-1.17.10.dist-info → deriva_ml-1.17.11.dist-info}/entry_points.txt +1 -0
  70. deriva_ml/protocols/dataset.py +0 -19
  71. deriva_ml/test.py +0 -94
  72. deriva_ml-1.17.10.dist-info/RECORD +0 -45
  73. {deriva_ml-1.17.10.dist-info → deriva_ml-1.17.11.dist-info}/licenses/LICENSE +0 -0
  74. {deriva_ml-1.17.10.dist-info → deriva_ml-1.17.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,915 @@
1
+ """Annotation management mixin for DerivaML.
2
+
3
+ This module provides the AnnotationMixin class which handles
4
+ Deriva catalog annotation operations for controlling how data
5
+ is displayed in the Chaise web interface.
6
+
7
+ Annotation Tags:
8
+ - display: tag:isrd.isi.edu,2015:display
9
+ - visible-columns: tag:isrd.isi.edu,2016:visible-columns
10
+ - visible-foreign-keys: tag:isrd.isi.edu,2016:visible-foreign-keys
11
+ - table-display: tag:isrd.isi.edu,2016:table-display
12
+ - column-display: tag:isrd.isi.edu,2016:column-display
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING, Any, Callable
18
+
19
+ # Deriva imports - use importlib to avoid shadowing by local 'deriva.py' files
20
+ import importlib
21
+ _ermrest_model = importlib.import_module("deriva.core.ermrest_model")
22
+ Column = _ermrest_model.Column
23
+ Table = _ermrest_model.Table
24
+
25
+ from pydantic import ConfigDict, validate_call
26
+
27
+ from deriva_ml.core.exceptions import DerivaMLException
28
+
29
+ if TYPE_CHECKING:
30
+ from deriva_ml.model.catalog import DerivaModel
31
+
32
+
33
+ # Annotation tag URIs
34
+ DISPLAY_TAG = "tag:isrd.isi.edu,2015:display"
35
+ VISIBLE_COLUMNS_TAG = "tag:isrd.isi.edu,2016:visible-columns"
36
+ VISIBLE_FOREIGN_KEYS_TAG = "tag:isrd.isi.edu,2016:visible-foreign-keys"
37
+ TABLE_DISPLAY_TAG = "tag:isrd.isi.edu,2016:table-display"
38
+ COLUMN_DISPLAY_TAG = "tag:isrd.isi.edu,2016:column-display"
39
+
40
+
41
+ class AnnotationMixin:
42
+ """Mixin providing annotation management operations.
43
+
44
+ This mixin requires the host class to have:
45
+ - model: DerivaModel instance
46
+ - pathBuilder(): method returning catalog path builder
47
+
48
+ Methods:
49
+ get_table_annotations: Get all display-related annotations for a table
50
+ get_column_annotations: Get all display-related annotations for a column
51
+ set_display_annotation: Set display annotation on table or column
52
+ set_visible_columns: Set visible-columns annotation on a table
53
+ set_visible_foreign_keys: Set visible-foreign-keys annotation on a table
54
+ set_table_display: Set table-display annotation on a table
55
+ set_column_display: Set column-display annotation on a column
56
+ list_foreign_keys: List all foreign keys related to a table
57
+ add_visible_column: Add a column to visible-columns list
58
+ remove_visible_column: Remove a column from visible-columns list
59
+ reorder_visible_columns: Reorder columns in visible-columns list
60
+ add_visible_foreign_key: Add a foreign key to visible-foreign-keys list
61
+ remove_visible_foreign_key: Remove a foreign key from visible-foreign-keys list
62
+ reorder_visible_foreign_keys: Reorder foreign keys in visible-foreign-keys list
63
+ apply_annotations: Apply staged annotation changes to the catalog
64
+ """
65
+
66
+ # Type hints for IDE support - actual attributes/methods from host class
67
+ model: "DerivaModel"
68
+ pathBuilder: Callable[[], Any]
69
+
70
+ # =========================================================================
71
+ # Core Annotation Operations
72
+ # =========================================================================
73
+
74
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
75
+ def get_table_annotations(self, table: str | Table) -> dict[str, Any]:
76
+ """Get all display-related annotations for a table.
77
+
78
+ Returns the current values of display, visible-columns, visible-foreign-keys,
79
+ and table-display annotations for the specified table.
80
+
81
+ Args:
82
+ table: Table name or Table object.
83
+
84
+ Returns:
85
+ Dictionary with keys: table, schema, display, visible_columns,
86
+ visible_foreign_keys, table_display. Missing annotations are None.
87
+
88
+ Example:
89
+ >>> annotations = ml.get_table_annotations("Image")
90
+ >>> print(annotations["visible_columns"])
91
+ """
92
+ table_obj = self.model.name_to_table(table)
93
+ return {
94
+ "table": table_obj.name,
95
+ "schema": table_obj.schema.name,
96
+ "display": table_obj.annotations.get(DISPLAY_TAG),
97
+ "visible_columns": table_obj.annotations.get(VISIBLE_COLUMNS_TAG),
98
+ "visible_foreign_keys": table_obj.annotations.get(VISIBLE_FOREIGN_KEYS_TAG),
99
+ "table_display": table_obj.annotations.get(TABLE_DISPLAY_TAG),
100
+ }
101
+
102
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
103
+ def get_column_annotations(self, table: str | Table, column_name: str) -> dict[str, Any]:
104
+ """Get all display-related annotations for a column.
105
+
106
+ Returns the current values of display and column-display annotations
107
+ for the specified column.
108
+
109
+ Args:
110
+ table: Table name or Table object containing the column.
111
+ column_name: Name of the column.
112
+
113
+ Returns:
114
+ Dictionary with keys: table, column, display, column_display.
115
+ Missing annotations are None.
116
+
117
+ Example:
118
+ >>> annotations = ml.get_column_annotations("Image", "Filename")
119
+ >>> print(annotations["display"])
120
+ """
121
+ table_obj = self.model.name_to_table(table)
122
+ column = table_obj.columns[column_name]
123
+ return {
124
+ "table": table_obj.name,
125
+ "column": column.name,
126
+ "display": column.annotations.get(DISPLAY_TAG),
127
+ "column_display": column.annotations.get(COLUMN_DISPLAY_TAG),
128
+ }
129
+
130
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
131
+ def set_display_annotation(
132
+ self,
133
+ table: str | Table,
134
+ annotation: dict[str, Any] | None,
135
+ column_name: str | None = None,
136
+ ) -> str:
137
+ """Set the display annotation on a table or column.
138
+
139
+ The display annotation controls basic naming and display options.
140
+ Changes are staged locally until apply_annotations() is called.
141
+
142
+ Args:
143
+ table: Table name or Table object.
144
+ annotation: The display annotation value. Set to None to remove.
145
+ column_name: If provided, sets annotation on the column; otherwise on the table.
146
+
147
+ Returns:
148
+ Target identifier (table name or table.column).
149
+
150
+ Example:
151
+ >>> ml.set_display_annotation("Image", {"name": "Images"})
152
+ >>> ml.set_display_annotation("Image", {"name": "File Name"}, column_name="Filename")
153
+ >>> ml.apply_annotations() # Commit changes
154
+ """
155
+ table_obj = self.model.name_to_table(table)
156
+
157
+ if column_name:
158
+ column = table_obj.columns[column_name]
159
+ if annotation is None:
160
+ column.annotations.pop(DISPLAY_TAG, None)
161
+ else:
162
+ column.annotations[DISPLAY_TAG] = annotation
163
+ return f"{table_obj.name}.{column_name}"
164
+ else:
165
+ if annotation is None:
166
+ table_obj.annotations.pop(DISPLAY_TAG, None)
167
+ else:
168
+ table_obj.annotations[DISPLAY_TAG] = annotation
169
+ return table_obj.name
170
+
171
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
172
+ def set_visible_columns(
173
+ self,
174
+ table: str | Table,
175
+ annotation: dict[str, Any] | None,
176
+ ) -> str:
177
+ """Set the visible-columns annotation on a table.
178
+
179
+ Controls which columns appear in different UI contexts and their order.
180
+ Changes are staged locally until apply_annotations() is called.
181
+
182
+ Args:
183
+ table: Table name or Table object.
184
+ annotation: The visible-columns annotation value. Set to None to remove.
185
+
186
+ Returns:
187
+ Table name.
188
+
189
+ Example:
190
+ >>> ml.set_visible_columns("Image", {
191
+ ... "compact": ["RID", "Filename", "Subject"],
192
+ ... "detailed": ["RID", "Filename", "Subject", "Description"]
193
+ ... })
194
+ >>> ml.apply_annotations()
195
+ """
196
+ table_obj = self.model.name_to_table(table)
197
+
198
+ if annotation is None:
199
+ table_obj.annotations.pop(VISIBLE_COLUMNS_TAG, None)
200
+ else:
201
+ table_obj.annotations[VISIBLE_COLUMNS_TAG] = annotation
202
+
203
+ return table_obj.name
204
+
205
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
206
+ def set_visible_foreign_keys(
207
+ self,
208
+ table: str | Table,
209
+ annotation: dict[str, Any] | None,
210
+ ) -> str:
211
+ """Set the visible-foreign-keys annotation on a table.
212
+
213
+ Controls which related tables (via inbound foreign keys) appear in
214
+ different UI contexts and their order.
215
+ Changes are staged locally until apply_annotations() is called.
216
+
217
+ Args:
218
+ table: Table name or Table object.
219
+ annotation: The visible-foreign-keys annotation value. Set to None to remove.
220
+
221
+ Returns:
222
+ Table name.
223
+
224
+ Example:
225
+ >>> ml.set_visible_foreign_keys("Subject", {
226
+ ... "detailed": [
227
+ ... ["domain", "Image_Subject_fkey"],
228
+ ... ["domain", "Diagnosis_Subject_fkey"]
229
+ ... ]
230
+ ... })
231
+ >>> ml.apply_annotations()
232
+ """
233
+ table_obj = self.model.name_to_table(table)
234
+
235
+ if annotation is None:
236
+ table_obj.annotations.pop(VISIBLE_FOREIGN_KEYS_TAG, None)
237
+ else:
238
+ table_obj.annotations[VISIBLE_FOREIGN_KEYS_TAG] = annotation
239
+
240
+ return table_obj.name
241
+
242
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
243
+ def set_table_display(
244
+ self,
245
+ table: str | Table,
246
+ annotation: dict[str, Any] | None,
247
+ ) -> str:
248
+ """Set the table-display annotation on a table.
249
+
250
+ Controls table-level display options like row naming patterns,
251
+ page size, and row ordering.
252
+ Changes are staged locally until apply_annotations() is called.
253
+
254
+ Args:
255
+ table: Table name or Table object.
256
+ annotation: The table-display annotation value. Set to None to remove.
257
+
258
+ Returns:
259
+ Table name.
260
+
261
+ Example:
262
+ >>> ml.set_table_display("Subject", {
263
+ ... "row_name": {
264
+ ... "row_markdown_pattern": "{{{Name}}} ({{{Species}}})"
265
+ ... }
266
+ ... })
267
+ >>> ml.apply_annotations()
268
+ """
269
+ table_obj = self.model.name_to_table(table)
270
+
271
+ if annotation is None:
272
+ table_obj.annotations.pop(TABLE_DISPLAY_TAG, None)
273
+ else:
274
+ table_obj.annotations[TABLE_DISPLAY_TAG] = annotation
275
+
276
+ return table_obj.name
277
+
278
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
279
+ def set_column_display(
280
+ self,
281
+ table: str | Table,
282
+ column_name: str,
283
+ annotation: dict[str, Any] | None,
284
+ ) -> str:
285
+ """Set the column-display annotation on a column.
286
+
287
+ Controls how a column's values are rendered, including custom
288
+ formatting and markdown patterns.
289
+ Changes are staged locally until apply_annotations() is called.
290
+
291
+ Args:
292
+ table: Table name or Table object containing the column.
293
+ column_name: Name of the column.
294
+ annotation: The column-display annotation value. Set to None to remove.
295
+
296
+ Returns:
297
+ Column identifier (table.column).
298
+
299
+ Example:
300
+ >>> ml.set_column_display("Measurement", "Value", {
301
+ ... "*": {"pre_format": {"format": "%.2f"}}
302
+ ... })
303
+ >>> ml.apply_annotations()
304
+ """
305
+ table_obj = self.model.name_to_table(table)
306
+ column = table_obj.columns[column_name]
307
+
308
+ if annotation is None:
309
+ column.annotations.pop(COLUMN_DISPLAY_TAG, None)
310
+ else:
311
+ column.annotations[COLUMN_DISPLAY_TAG] = annotation
312
+
313
+ return f"{table_obj.name}.{column_name}"
314
+
315
+ def apply_annotations(self) -> None:
316
+ """Apply all staged annotation changes to the catalog.
317
+
318
+ Commits any annotation changes made via set_display_annotation,
319
+ set_visible_columns, set_visible_foreign_keys, set_table_display,
320
+ or set_column_display to the remote catalog.
321
+
322
+ Example:
323
+ >>> ml.set_display_annotation("Image", {"name": "Images"})
324
+ >>> ml.set_visible_columns("Image", {"compact": ["RID", "Filename"]})
325
+ >>> ml.apply_annotations() # Commit all changes
326
+ """
327
+ self.model.apply()
328
+
329
+ # =========================================================================
330
+ # Foreign Key Information
331
+ # =========================================================================
332
+
333
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
334
+ def list_foreign_keys(self, table: str | Table) -> dict[str, Any]:
335
+ """List all foreign keys related to a table.
336
+
337
+ Returns both outbound foreign keys (from this table to others) and
338
+ inbound foreign keys (from other tables to this one). Useful for
339
+ determining valid constraint names for visible-columns and
340
+ visible-foreign-keys annotations.
341
+
342
+ Args:
343
+ table: Table name or Table object.
344
+
345
+ Returns:
346
+ Dictionary with:
347
+ - table: Table name
348
+ - outbound: List of outbound foreign keys
349
+ - inbound: List of inbound foreign keys
350
+ Each foreign key contains constraint_name, from_table, from_columns,
351
+ to_table, to_columns.
352
+
353
+ Example:
354
+ >>> fkeys = ml.list_foreign_keys("Image")
355
+ >>> for fk in fkeys["outbound"]:
356
+ ... print(f"{fk['constraint_name']} -> {fk['to_table']}")
357
+ """
358
+ table_obj = self.model.name_to_table(table)
359
+
360
+ outbound = []
361
+ for fkey in table_obj.foreign_keys:
362
+ outbound.append({
363
+ "constraint_name": [fkey.constraint_schema.name, fkey.constraint_name],
364
+ "from_table": table_obj.name,
365
+ "from_columns": [col.name for col in fkey.columns],
366
+ "to_table": fkey.pk_table.name,
367
+ "to_columns": [col.name for col in fkey.referenced_columns],
368
+ })
369
+
370
+ inbound = []
371
+ for fkey in table_obj.referenced_by:
372
+ inbound.append({
373
+ "constraint_name": [fkey.constraint_schema.name, fkey.constraint_name],
374
+ "from_table": fkey.table.name,
375
+ "from_columns": [col.name for col in fkey.columns],
376
+ "to_table": table_obj.name,
377
+ "to_columns": [col.name for col in fkey.referenced_columns],
378
+ })
379
+
380
+ return {
381
+ "table": table_obj.name,
382
+ "outbound": outbound,
383
+ "inbound": inbound,
384
+ }
385
+
386
+ # =========================================================================
387
+ # Visible Columns Convenience Methods
388
+ # =========================================================================
389
+
390
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
391
+ def add_visible_column(
392
+ self,
393
+ table: str | Table,
394
+ context: str,
395
+ column: str | list[str] | dict[str, Any],
396
+ position: int | None = None,
397
+ ) -> list[Any]:
398
+ """Add a column to the visible-columns list for a specific context.
399
+
400
+ Convenience method for adding columns without replacing the entire
401
+ visible-columns annotation. Changes are staged until apply_annotations()
402
+ is called.
403
+
404
+ Args:
405
+ table: Table name or Table object.
406
+ context: The context to modify (e.g., "compact", "detailed", "entry").
407
+ column: Column to add. Can be:
408
+ - String: column name (e.g., "Filename")
409
+ - List: foreign key reference (e.g., ["schema", "fkey_name"])
410
+ - Dict: pseudo-column definition
411
+ position: Position to insert at (0-indexed). If None, appends to end.
412
+
413
+ Returns:
414
+ The updated column list for the context.
415
+
416
+ Raises:
417
+ DerivaMLException: If context references another context.
418
+
419
+ Example:
420
+ >>> ml.add_visible_column("Image", "compact", "Description")
421
+ >>> ml.add_visible_column("Image", "detailed", ["domain", "Image_Subject_fkey"], 1)
422
+ >>> ml.apply_annotations()
423
+ """
424
+ table_obj = self.model.name_to_table(table)
425
+
426
+ # Get or create visible_columns annotation
427
+ visible_cols = table_obj.annotations.get(VISIBLE_COLUMNS_TAG, {})
428
+ if visible_cols is None:
429
+ visible_cols = {}
430
+
431
+ # Get or create the context list
432
+ context_list = visible_cols.get(context, [])
433
+ if isinstance(context_list, str):
434
+ raise DerivaMLException(
435
+ f"Context '{context}' references another context '{context_list}'. "
436
+ "Set it explicitly first with set_visible_columns()."
437
+ )
438
+
439
+ # Make a copy to avoid modifying in place
440
+ context_list = list(context_list)
441
+
442
+ # Insert at position or append
443
+ if position is not None:
444
+ context_list.insert(position, column)
445
+ else:
446
+ context_list.append(column)
447
+
448
+ # Update the annotation
449
+ visible_cols[context] = context_list
450
+ table_obj.annotations[VISIBLE_COLUMNS_TAG] = visible_cols
451
+
452
+ return context_list
453
+
454
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
455
+ def remove_visible_column(
456
+ self,
457
+ table: str | Table,
458
+ context: str,
459
+ column: str | list[str] | int,
460
+ ) -> list[Any]:
461
+ """Remove a column from the visible-columns list for a specific context.
462
+
463
+ Convenience method for removing columns without replacing the entire
464
+ visible-columns annotation. Changes are staged until apply_annotations()
465
+ is called.
466
+
467
+ Args:
468
+ table: Table name or Table object.
469
+ context: The context to modify (e.g., "compact", "detailed").
470
+ column: Column to remove. Can be:
471
+ - String: column name to find and remove
472
+ - List: foreign key reference [schema, constraint] to find and remove
473
+ - Integer: index position to remove (0-indexed)
474
+
475
+ Returns:
476
+ The updated column list for the context.
477
+
478
+ Raises:
479
+ DerivaMLException: If annotation or context doesn't exist, or column not found.
480
+
481
+ Example:
482
+ >>> ml.remove_visible_column("Image", "compact", "Description")
483
+ >>> ml.remove_visible_column("Image", "compact", 0) # Remove first column
484
+ >>> ml.apply_annotations()
485
+ """
486
+ table_obj = self.model.name_to_table(table)
487
+
488
+ # Get visible_columns annotation
489
+ visible_cols = table_obj.annotations.get(VISIBLE_COLUMNS_TAG, {})
490
+ if not visible_cols:
491
+ raise DerivaMLException(f"Table '{table_obj.name}' has no visible-columns annotation.")
492
+
493
+ # Get the context list
494
+ context_list = visible_cols.get(context)
495
+ if context_list is None:
496
+ raise DerivaMLException(f"Context '{context}' not found in visible-columns annotation.")
497
+ if isinstance(context_list, str):
498
+ raise DerivaMLException(
499
+ f"Context '{context}' references another context '{context_list}'. "
500
+ "Set it explicitly first with set_visible_columns()."
501
+ )
502
+
503
+ # Make a copy
504
+ context_list = list(context_list)
505
+ removed = None
506
+
507
+ # Remove by index or by value
508
+ if isinstance(column, int):
509
+ if 0 <= column < len(context_list):
510
+ removed = context_list.pop(column)
511
+ else:
512
+ raise DerivaMLException(
513
+ f"Index {column} out of range (list has {len(context_list)} items)."
514
+ )
515
+ else:
516
+ # Find and remove the column
517
+ for i, item in enumerate(context_list):
518
+ if item == column:
519
+ removed = context_list.pop(i)
520
+ break
521
+ # Also check if it's a pseudo-column with matching source
522
+ if isinstance(item, dict) and isinstance(column, str):
523
+ if item.get("source") == column:
524
+ removed = context_list.pop(i)
525
+ break
526
+
527
+ if removed is None:
528
+ raise DerivaMLException(f"Column {column!r} not found in context '{context}'.")
529
+
530
+ # Update the annotation
531
+ visible_cols[context] = context_list
532
+ table_obj.annotations[VISIBLE_COLUMNS_TAG] = visible_cols
533
+
534
+ return context_list
535
+
536
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
537
+ def reorder_visible_columns(
538
+ self,
539
+ table: str | Table,
540
+ context: str,
541
+ new_order: list[int] | list[str | list[str] | dict[str, Any]],
542
+ ) -> list[Any]:
543
+ """Reorder columns in the visible-columns list for a specific context.
544
+
545
+ Convenience method for reordering columns without manually reconstructing
546
+ the list. Changes are staged until apply_annotations() is called.
547
+
548
+ Args:
549
+ table: Table name or Table object.
550
+ context: The context to modify (e.g., "compact", "detailed").
551
+ new_order: The new order specification. Can be:
552
+ - List of indices: [2, 0, 1, 3] reorders by current positions
553
+ - List of column specs: ["Name", "RID", ...] specifies exact order
554
+
555
+ Returns:
556
+ The reordered column list.
557
+
558
+ Raises:
559
+ DerivaMLException: If annotation or context doesn't exist, or invalid order.
560
+
561
+ Example:
562
+ >>> ml.reorder_visible_columns("Image", "compact", [2, 0, 1, 3, 4])
563
+ >>> ml.reorder_visible_columns("Image", "compact", ["Filename", "Subject", "RID"])
564
+ >>> ml.apply_annotations()
565
+ """
566
+ table_obj = self.model.name_to_table(table)
567
+
568
+ # Get visible_columns annotation
569
+ visible_cols = table_obj.annotations.get(VISIBLE_COLUMNS_TAG, {})
570
+ if not visible_cols:
571
+ raise DerivaMLException(f"Table '{table_obj.name}' has no visible-columns annotation.")
572
+
573
+ # Get the context list
574
+ context_list = visible_cols.get(context)
575
+ if context_list is None:
576
+ raise DerivaMLException(f"Context '{context}' not found in visible-columns annotation.")
577
+ if isinstance(context_list, str):
578
+ raise DerivaMLException(
579
+ f"Context '{context}' references another context '{context_list}'. "
580
+ "Set it explicitly first with set_visible_columns()."
581
+ )
582
+
583
+ original_list = list(context_list)
584
+
585
+ # Determine if new_order is indices or column specs
586
+ if new_order and isinstance(new_order[0], int):
587
+ # Reorder by indices
588
+ if len(new_order) != len(original_list):
589
+ raise DerivaMLException(
590
+ f"Index list length ({len(new_order)}) must match "
591
+ f"current list length ({len(original_list)})."
592
+ )
593
+ if set(new_order) != set(range(len(original_list))):
594
+ raise DerivaMLException("Index list must contain each index exactly once.")
595
+ new_list = [original_list[i] for i in new_order]
596
+ else:
597
+ # new_order is the exact new column list
598
+ new_list = list(new_order)
599
+
600
+ # Update the annotation
601
+ visible_cols[context] = new_list
602
+ table_obj.annotations[VISIBLE_COLUMNS_TAG] = visible_cols
603
+
604
+ return new_list
605
+
606
+ # =========================================================================
607
+ # Visible Foreign Keys Convenience Methods
608
+ # =========================================================================
609
+
610
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
611
+ def add_visible_foreign_key(
612
+ self,
613
+ table: str | Table,
614
+ context: str,
615
+ foreign_key: list[str] | dict[str, Any],
616
+ position: int | None = None,
617
+ ) -> list[Any]:
618
+ """Add a foreign key to the visible-foreign-keys list for a specific context.
619
+
620
+ Convenience method for adding related tables without replacing the entire
621
+ visible-foreign-keys annotation. Changes are staged until apply_annotations()
622
+ is called.
623
+
624
+ Args:
625
+ table: Table name or Table object.
626
+ context: The context to modify (typically "detailed" or "*").
627
+ foreign_key: Foreign key to add. Can be:
628
+ - List: inbound foreign key reference (e.g., ["schema", "Other_Table_fkey"])
629
+ - Dict: pseudo-column definition for complex relationships
630
+ position: Position to insert at (0-indexed). If None, appends to end.
631
+
632
+ Returns:
633
+ The updated foreign key list for the context.
634
+
635
+ Raises:
636
+ DerivaMLException: If context references another context.
637
+
638
+ Example:
639
+ >>> ml.add_visible_foreign_key("Subject", "detailed", ["domain", "Image_Subject_fkey"])
640
+ >>> ml.apply_annotations()
641
+ """
642
+ table_obj = self.model.name_to_table(table)
643
+
644
+ # Get or create visible_foreign_keys annotation
645
+ visible_fkeys = table_obj.annotations.get(VISIBLE_FOREIGN_KEYS_TAG, {})
646
+ if visible_fkeys is None:
647
+ visible_fkeys = {}
648
+
649
+ # Get or create the context list
650
+ context_list = visible_fkeys.get(context, [])
651
+ if isinstance(context_list, str):
652
+ raise DerivaMLException(
653
+ f"Context '{context}' references another context '{context_list}'. "
654
+ "Set it explicitly first with set_visible_foreign_keys()."
655
+ )
656
+
657
+ # Make a copy to avoid modifying in place
658
+ context_list = list(context_list)
659
+
660
+ # Insert at position or append
661
+ if position is not None:
662
+ context_list.insert(position, foreign_key)
663
+ else:
664
+ context_list.append(foreign_key)
665
+
666
+ # Update the annotation
667
+ visible_fkeys[context] = context_list
668
+ table_obj.annotations[VISIBLE_FOREIGN_KEYS_TAG] = visible_fkeys
669
+
670
+ return context_list
671
+
672
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
673
+ def remove_visible_foreign_key(
674
+ self,
675
+ table: str | Table,
676
+ context: str,
677
+ foreign_key: list[str] | int,
678
+ ) -> list[Any]:
679
+ """Remove a foreign key from the visible-foreign-keys list for a specific context.
680
+
681
+ Convenience method for removing related tables without replacing the entire
682
+ visible-foreign-keys annotation. Changes are staged until apply_annotations()
683
+ is called.
684
+
685
+ Args:
686
+ table: Table name or Table object.
687
+ context: The context to modify (e.g., "detailed", "*").
688
+ foreign_key: Foreign key to remove. Can be:
689
+ - List: foreign key reference [schema, constraint] to find and remove
690
+ - Integer: index position to remove (0-indexed)
691
+
692
+ Returns:
693
+ The updated foreign key list for the context.
694
+
695
+ Raises:
696
+ DerivaMLException: If annotation or context doesn't exist, or foreign key not found.
697
+
698
+ Example:
699
+ >>> ml.remove_visible_foreign_key("Subject", "detailed", ["domain", "Image_Subject_fkey"])
700
+ >>> ml.remove_visible_foreign_key("Subject", "detailed", 0) # Remove first
701
+ >>> ml.apply_annotations()
702
+ """
703
+ table_obj = self.model.name_to_table(table)
704
+
705
+ # Get visible_foreign_keys annotation
706
+ visible_fkeys = table_obj.annotations.get(VISIBLE_FOREIGN_KEYS_TAG, {})
707
+ if not visible_fkeys:
708
+ raise DerivaMLException(
709
+ f"Table '{table_obj.name}' has no visible-foreign-keys annotation."
710
+ )
711
+
712
+ # Get the context list
713
+ context_list = visible_fkeys.get(context)
714
+ if context_list is None:
715
+ raise DerivaMLException(
716
+ f"Context '{context}' not found in visible-foreign-keys annotation."
717
+ )
718
+ if isinstance(context_list, str):
719
+ raise DerivaMLException(
720
+ f"Context '{context}' references another context '{context_list}'. "
721
+ "Set it explicitly first with set_visible_foreign_keys()."
722
+ )
723
+
724
+ # Make a copy
725
+ context_list = list(context_list)
726
+ removed = None
727
+
728
+ # Remove by index or by value
729
+ if isinstance(foreign_key, int):
730
+ if 0 <= foreign_key < len(context_list):
731
+ removed = context_list.pop(foreign_key)
732
+ else:
733
+ raise DerivaMLException(
734
+ f"Index {foreign_key} out of range (list has {len(context_list)} items)."
735
+ )
736
+ else:
737
+ # Find and remove the foreign key
738
+ for i, item in enumerate(context_list):
739
+ if item == foreign_key:
740
+ removed = context_list.pop(i)
741
+ break
742
+
743
+ if removed is None:
744
+ raise DerivaMLException(
745
+ f"Foreign key {foreign_key!r} not found in context '{context}'."
746
+ )
747
+
748
+ # Update the annotation
749
+ visible_fkeys[context] = context_list
750
+ table_obj.annotations[VISIBLE_FOREIGN_KEYS_TAG] = visible_fkeys
751
+
752
+ return context_list
753
+
754
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
755
+ def reorder_visible_foreign_keys(
756
+ self,
757
+ table: str | Table,
758
+ context: str,
759
+ new_order: list[int] | list[list[str] | dict[str, Any]],
760
+ ) -> list[Any]:
761
+ """Reorder foreign keys in the visible-foreign-keys list for a specific context.
762
+
763
+ Convenience method for reordering related tables without manually
764
+ reconstructing the list. Changes are staged until apply_annotations()
765
+ is called.
766
+
767
+ Args:
768
+ table: Table name or Table object.
769
+ context: The context to modify (e.g., "detailed", "*").
770
+ new_order: The new order specification. Can be:
771
+ - List of indices: [2, 0, 1] reorders by current positions
772
+ - List of foreign key refs: [["schema", "fkey1"], ...] specifies exact order
773
+
774
+ Returns:
775
+ The reordered foreign key list.
776
+
777
+ Raises:
778
+ DerivaMLException: If annotation or context doesn't exist, or invalid order.
779
+
780
+ Example:
781
+ >>> ml.reorder_visible_foreign_keys("Subject", "detailed", [2, 0, 1])
782
+ >>> ml.apply_annotations()
783
+ """
784
+ table_obj = self.model.name_to_table(table)
785
+
786
+ # Get visible_foreign_keys annotation
787
+ visible_fkeys = table_obj.annotations.get(VISIBLE_FOREIGN_KEYS_TAG, {})
788
+ if not visible_fkeys:
789
+ raise DerivaMLException(
790
+ f"Table '{table_obj.name}' has no visible-foreign-keys annotation."
791
+ )
792
+
793
+ # Get the context list
794
+ context_list = visible_fkeys.get(context)
795
+ if context_list is None:
796
+ raise DerivaMLException(
797
+ f"Context '{context}' not found in visible-foreign-keys annotation."
798
+ )
799
+ if isinstance(context_list, str):
800
+ raise DerivaMLException(
801
+ f"Context '{context}' references another context '{context_list}'. "
802
+ "Set it explicitly first with set_visible_foreign_keys()."
803
+ )
804
+
805
+ original_list = list(context_list)
806
+
807
+ # Determine if new_order is indices or foreign key specs
808
+ if new_order and isinstance(new_order[0], int):
809
+ # Reorder by indices
810
+ if len(new_order) != len(original_list):
811
+ raise DerivaMLException(
812
+ f"Index list length ({len(new_order)}) must match "
813
+ f"current list length ({len(original_list)})."
814
+ )
815
+ if set(new_order) != set(range(len(original_list))):
816
+ raise DerivaMLException("Index list must contain each index exactly once.")
817
+ new_list = [original_list[i] for i in new_order]
818
+ else:
819
+ # new_order is the exact new foreign key list
820
+ new_list = list(new_order)
821
+
822
+ # Update the annotation
823
+ visible_fkeys[context] = new_list
824
+ table_obj.annotations[VISIBLE_FOREIGN_KEYS_TAG] = visible_fkeys
825
+
826
+ return new_list
827
+
828
+ # =========================================================================
829
+ # Template Helpers
830
+ # =========================================================================
831
+
832
+ @validate_call(config=ConfigDict(arbitrary_types_allowed=True))
833
+ def get_handlebars_template_variables(self, table: str | Table) -> dict[str, Any]:
834
+ """Get all available template variables for a table.
835
+
836
+ Returns the columns, foreign keys, and special variables that can be
837
+ used in Handlebars templates (row_markdown_pattern, markdown_pattern, etc.)
838
+ for the specified table.
839
+
840
+ Args:
841
+ table: Table name or Table object.
842
+
843
+ Returns:
844
+ Dictionary with columns, foreign_keys, special_variables, and helper_examples.
845
+
846
+ Example:
847
+ >>> vars = ml.get_handlebars_template_variables("Image")
848
+ >>> for col in vars["columns"]:
849
+ ... print(f"{col['name']}: {col['template']}")
850
+ """
851
+ table_obj = self.model.name_to_table(table)
852
+
853
+ # Get columns
854
+ columns = []
855
+ for col in table_obj.columns:
856
+ columns.append({
857
+ "name": col.name,
858
+ "type": str(col.type.typename),
859
+ "template": "{{{" + col.name + "}}}",
860
+ "row_template": "{{{_row." + col.name + "}}}",
861
+ })
862
+
863
+ # Get foreign keys (outbound)
864
+ foreign_keys = []
865
+ for fkey in table_obj.foreign_keys:
866
+ schema_name = fkey.constraint_schema.name
867
+ constraint_name = fkey.constraint_name
868
+ fk_path = f"$fkeys.{schema_name}.{constraint_name}"
869
+
870
+ # Get columns from referenced table
871
+ ref_columns = [col.name for col in fkey.pk_table.columns]
872
+
873
+ foreign_keys.append({
874
+ "constraint": [schema_name, constraint_name],
875
+ "from_columns": [col.name for col in fkey.columns],
876
+ "to_table": fkey.pk_table.name,
877
+ "to_columns": ref_columns,
878
+ "values_template": "{{{" + fk_path + ".values.COLUMN}}}",
879
+ "row_name_template": "{{{" + fk_path + ".rowName}}}",
880
+ "example_column_templates": [
881
+ "{{{" + fk_path + ".values." + c + "}}}"
882
+ for c in ref_columns[:3] # Show first 3 as examples
883
+ ]
884
+ })
885
+
886
+ return {
887
+ "table": table_obj.name,
888
+ "columns": columns,
889
+ "foreign_keys": foreign_keys,
890
+ "special_variables": {
891
+ "_value": {
892
+ "description": "Current column value (in column_display)",
893
+ "template": "{{{_value}}}"
894
+ },
895
+ "_row": {
896
+ "description": "Object with all row columns",
897
+ "template": "{{{_row.column_name}}}"
898
+ },
899
+ "$catalog.id": {
900
+ "description": "Catalog ID",
901
+ "template": "{{{$catalog.id}}}"
902
+ },
903
+ "$catalog.snapshot": {
904
+ "description": "Current snapshot ID",
905
+ "template": "{{{$catalog.snapshot}}}"
906
+ },
907
+ },
908
+ "helper_examples": {
909
+ "conditional": "{{#if column}}...{{else}}...{{/if}}",
910
+ "iteration": "{{#each array}}{{{this}}}{{/each}}",
911
+ "comparison": "{{#ifCond val1 '==' val2}}...{{/ifCond}}",
912
+ "date_format": "{{formatDate RCT 'YYYY-MM-DD'}}",
913
+ "json_output": "{{toJSON object}}"
914
+ }
915
+ }