tracdap-runtime 0.6.4__py3-none-any.whl → 0.6.5__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 (33) hide show
  1. tracdap/rt/_exec/context.py +382 -29
  2. tracdap/rt/_exec/dev_mode.py +123 -94
  3. tracdap/rt/_exec/engine.py +120 -9
  4. tracdap/rt/_exec/functions.py +125 -20
  5. tracdap/rt/_exec/graph.py +38 -13
  6. tracdap/rt/_exec/graph_builder.py +120 -9
  7. tracdap/rt/_impl/data.py +115 -49
  8. tracdap/rt/_impl/grpc/tracdap/metadata/job_pb2.py +74 -30
  9. tracdap/rt/_impl/grpc/tracdap/metadata/job_pb2.pyi +120 -2
  10. tracdap/rt/_impl/grpc/tracdap/metadata/model_pb2.py +12 -10
  11. tracdap/rt/_impl/grpc/tracdap/metadata/model_pb2.pyi +14 -2
  12. tracdap/rt/_impl/grpc/tracdap/metadata/resource_pb2.py +29 -0
  13. tracdap/rt/_impl/grpc/tracdap/metadata/resource_pb2.pyi +16 -0
  14. tracdap/rt/_impl/models.py +8 -0
  15. tracdap/rt/_impl/static_api.py +16 -0
  16. tracdap/rt/_impl/storage.py +37 -25
  17. tracdap/rt/_impl/validation.py +76 -7
  18. tracdap/rt/_plugins/repo_git.py +1 -1
  19. tracdap/rt/_version.py +1 -1
  20. tracdap/rt/api/experimental.py +220 -0
  21. tracdap/rt/api/hook.py +4 -0
  22. tracdap/rt/api/model_api.py +48 -6
  23. tracdap/rt/config/__init__.py +2 -2
  24. tracdap/rt/config/common.py +6 -0
  25. tracdap/rt/metadata/__init__.py +25 -20
  26. tracdap/rt/metadata/job.py +54 -0
  27. tracdap/rt/metadata/model.py +18 -0
  28. tracdap/rt/metadata/resource.py +24 -0
  29. {tracdap_runtime-0.6.4.dist-info → tracdap_runtime-0.6.5.dist-info}/METADATA +3 -1
  30. {tracdap_runtime-0.6.4.dist-info → tracdap_runtime-0.6.5.dist-info}/RECORD +33 -29
  31. {tracdap_runtime-0.6.4.dist-info → tracdap_runtime-0.6.5.dist-info}/LICENSE +0 -0
  32. {tracdap_runtime-0.6.4.dist-info → tracdap_runtime-0.6.5.dist-info}/WHEEL +0 -0
  33. {tracdap_runtime-0.6.4.dist-info → tracdap_runtime-0.6.5.dist-info}/top_level.txt +0 -0
@@ -23,6 +23,7 @@ import tracdap.rt.api as _api
23
23
  import tracdap.rt.config as _config
24
24
  import tracdap.rt.exceptions as _ex
25
25
  import tracdap.rt._exec.context as _ctx
26
+ import tracdap.rt._exec.graph_builder as _graph
26
27
  import tracdap.rt._impl.config_parser as _cfg_p # noqa
27
28
  import tracdap.rt._impl.type_system as _types # noqa
28
29
  import tracdap.rt._impl.data as _data # noqa
@@ -59,6 +60,13 @@ class NodeContext:
59
60
  pass
60
61
 
61
62
 
63
+ class NodeCallback:
64
+
65
+ @abc.abstractmethod
66
+ def send_graph_updates(self, new_nodes: tp.Dict[NodeId, Node], new_deps: tp.Dict[NodeId, tp.List[Dependency]]):
67
+ pass
68
+
69
+
62
70
  # Helper functions to access the node context (in case the NodeContext interface needs to change)
63
71
 
64
72
  def _ctx_lookup(node_id: NodeId[_T], ctx: NodeContext) -> _T:
@@ -89,8 +97,15 @@ class NodeFunction(tp.Generic[_T]):
89
97
  :py:class:`NodeContext <NodeContext>`
90
98
  """
91
99
 
92
- def __call__(self, ctx: NodeContext) -> _T:
93
- return self._execute(ctx)
100
+ def __init__(self):
101
+ self.node_callback: tp.Optional[NodeCallback] = None
102
+
103
+ def __call__(self, ctx: NodeContext, callback: NodeCallback = None) -> _T:
104
+ try:
105
+ self.node_callback = callback
106
+ return self._execute(ctx)
107
+ finally:
108
+ self.node_callback = None
94
109
 
95
110
  @abc.abstractmethod
96
111
  def _execute(self, ctx: NodeContext) -> _T:
@@ -105,6 +120,7 @@ class NodeFunction(tp.Generic[_T]):
105
120
  class NoopFunc(NodeFunction[None]):
106
121
 
107
122
  def __init__(self, node: NoopNode):
123
+ super().__init__()
108
124
  self.node = node
109
125
 
110
126
  def _execute(self, _: NodeContext) -> None:
@@ -114,6 +130,7 @@ class NoopFunc(NodeFunction[None]):
114
130
  class StaticValueFunc(NodeFunction[_T]):
115
131
 
116
132
  def __init__(self, node: StaticValueNode[_T]):
133
+ super().__init__()
117
134
  self.node = node
118
135
 
119
136
  def _execute(self, ctx: NodeContext) -> _T:
@@ -123,6 +140,7 @@ class StaticValueFunc(NodeFunction[_T]):
123
140
  class IdentityFunc(NodeFunction[_T]):
124
141
 
125
142
  def __init__(self, node: IdentityNode[_T]):
143
+ super().__init__()
126
144
  self.node = node
127
145
 
128
146
  def _execute(self, ctx: NodeContext) -> _T:
@@ -138,6 +156,7 @@ class _ContextPushPopFunc(NodeFunction[Bundle[tp.Any]], abc.ABC):
138
156
  _POP = False
139
157
 
140
158
  def __init__(self, node: tp.Union[ContextPushNode, ContextPopNode], direction: bool):
159
+ super().__init__()
141
160
  self.node = node
142
161
  self.direction = direction
143
162
 
@@ -176,6 +195,7 @@ class ContextPopFunc(_ContextPushPopFunc):
176
195
  class KeyedItemFunc(NodeFunction[_T]):
177
196
 
178
197
  def __init__(self, node: KeyedItemNode[_T]):
198
+ super().__init__()
179
199
  self.node = node
180
200
 
181
201
  def _execute(self, ctx: NodeContext) -> _T:
@@ -184,9 +204,20 @@ class KeyedItemFunc(NodeFunction[_T]):
184
204
  return src_item
185
205
 
186
206
 
207
+ class RuntimeOutputsFunc(NodeFunction[JobOutputs]):
208
+
209
+ def __init__(self, node: RuntimeOutputsNode):
210
+ super().__init__()
211
+ self.node = node
212
+
213
+ def _execute(self, ctx: NodeContext) -> JobOutputs:
214
+ return self.node.outputs
215
+
216
+
187
217
  class BuildJobResultFunc(NodeFunction[_config.JobResult]):
188
218
 
189
219
  def __init__(self, node: BuildJobResultNode):
220
+ super().__init__()
190
221
  self.node = node
191
222
 
192
223
  def _execute(self, ctx: NodeContext) -> _config.JobResult:
@@ -197,20 +228,33 @@ class BuildJobResultFunc(NodeFunction[_config.JobResult]):
197
228
 
198
229
  # TODO: Handle individual failed results
199
230
 
200
- for obj_id, node_id in self.node.objects.items():
231
+ for obj_id, node_id in self.node.outputs.objects.items():
201
232
  obj_def = _ctx_lookup(node_id, ctx)
202
233
  job_result.results[obj_id] = obj_def
203
234
 
204
- for bundle_id in self.node.bundles:
235
+ for bundle_id in self.node.outputs.bundles:
205
236
  bundle = _ctx_lookup(bundle_id, ctx)
206
237
  job_result.results.update(bundle.items())
207
238
 
239
+ if self.node.runtime_outputs is not None:
240
+
241
+ runtime_outputs = _ctx_lookup(self.node.runtime_outputs, ctx)
242
+
243
+ for obj_id, node_id in runtime_outputs.objects.items():
244
+ obj_def = _ctx_lookup(node_id, ctx)
245
+ job_result.results[obj_id] = obj_def
246
+
247
+ for bundle_id in runtime_outputs.bundles:
248
+ bundle = _ctx_lookup(bundle_id, ctx)
249
+ job_result.results.update(bundle.items())
250
+
208
251
  return job_result
209
252
 
210
253
 
211
254
  class SaveJobResultFunc(NodeFunction[None]):
212
255
 
213
256
  def __init__(self, node: SaveJobResultNode):
257
+ super().__init__()
214
258
  self.node = node
215
259
 
216
260
  def _execute(self, ctx: NodeContext) -> None:
@@ -241,6 +285,7 @@ class SaveJobResultFunc(NodeFunction[None]):
241
285
  class DataViewFunc(NodeFunction[_data.DataView]):
242
286
 
243
287
  def __init__(self, node: DataViewNode):
288
+ super().__init__()
244
289
  self.node = node
245
290
 
246
291
  def _execute(self, ctx: NodeContext) -> _data.DataView:
@@ -267,6 +312,7 @@ class DataViewFunc(NodeFunction[_data.DataView]):
267
312
  class DataItemFunc(NodeFunction[_data.DataItem]):
268
313
 
269
314
  def __init__(self, node: DataItemNode):
315
+ super().__init__()
270
316
  self.node = node
271
317
 
272
318
  def _execute(self, ctx: NodeContext) -> _data.DataItem:
@@ -290,6 +336,7 @@ class DataItemFunc(NodeFunction[_data.DataItem]):
290
336
  class DataResultFunc(NodeFunction[ObjectBundle]):
291
337
 
292
338
  def __init__(self, node: DataResultNode):
339
+ super().__init__()
293
340
  self.node = node
294
341
 
295
342
  def _execute(self, ctx: NodeContext) -> ObjectBundle:
@@ -324,6 +371,7 @@ class DynamicDataSpecFunc(NodeFunction[_data.DataSpec]):
324
371
  RANDOM.seed()
325
372
 
326
373
  def __init__(self, node: DynamicDataSpecNode, storage: _storage.StorageManager):
374
+ super().__init__()
327
375
  self.node = node
328
376
  self.storage = storage
329
377
 
@@ -434,7 +482,7 @@ class _LoadSaveDataFunc(abc.ABC):
434
482
  return copy_
435
483
 
436
484
 
437
- class LoadDataFunc(NodeFunction[_data.DataItem], _LoadSaveDataFunc):
485
+ class LoadDataFunc( _LoadSaveDataFunc, NodeFunction[_data.DataItem],):
438
486
 
439
487
  def __init__(self, node: LoadDataNode, storage: _storage.StorageManager):
440
488
  super().__init__(storage)
@@ -463,7 +511,7 @@ class LoadDataFunc(NodeFunction[_data.DataItem], _LoadSaveDataFunc):
463
511
  return _data.DataItem(table.schema, table)
464
512
 
465
513
 
466
- class SaveDataFunc(NodeFunction[None], _LoadSaveDataFunc):
514
+ class SaveDataFunc(_LoadSaveDataFunc, NodeFunction[None]):
467
515
 
468
516
  def __init__(self, node: SaveDataNode, storage: _storage.StorageManager):
469
517
  super().__init__(storage)
@@ -518,6 +566,7 @@ def _model_def_for_import(import_details: meta.ImportModelJob):
518
566
  class ImportModelFunc(NodeFunction[meta.ObjectDefinition]):
519
567
 
520
568
  def __init__(self, node: ImportModelNode, models: _models.ModelLoader):
569
+ super().__init__()
521
570
  self.node = node
522
571
  self._models = models
523
572
 
@@ -535,11 +584,17 @@ class ImportModelFunc(NodeFunction[meta.ObjectDefinition]):
535
584
 
536
585
  class RunModelFunc(NodeFunction[Bundle[_data.DataView]]):
537
586
 
538
- def __init__(self, node: RunModelNode, model_class: _api.TracModel.__class__, checkout_directory: pathlib.Path):
587
+ def __init__(
588
+ self, node: RunModelNode,
589
+ model_class: _api.TracModel.__class__,
590
+ checkout_directory: pathlib.Path,
591
+ storage_manager: _storage.StorageManager):
592
+
539
593
  super().__init__()
540
594
  self.node = node
541
595
  self.model_class = model_class
542
596
  self.checkout_directory = checkout_directory
597
+ self.storage_manager = storage_manager
543
598
 
544
599
  def _execute(self, ctx: NodeContext) -> Bundle[_data.DataView]:
545
600
 
@@ -550,23 +605,37 @@ class RunModelFunc(NodeFunction[Bundle[_data.DataView]]):
550
605
  # Still, if any nodes are missing or have the wrong type TracContextImpl will raise ERuntimeValidation
551
606
 
552
607
  local_ctx = {}
608
+ dynamic_outputs = []
553
609
 
554
610
  for node_id, node_result in _ctx_iter_items(ctx):
611
+ if node_id.namespace == self.node.id.namespace:
612
+ if node_id.name in model_def.parameters or node_id.name in model_def.inputs:
613
+ local_ctx[node_id.name] = node_result
555
614
 
556
- if node_id.namespace != self.node.id.namespace:
557
- continue
615
+ # Set up access to external storage if required
558
616
 
559
- if node_id.name in model_def.parameters:
560
- param_name = node_id.name
561
- local_ctx[param_name] = node_result
617
+ storage_map = {}
562
618
 
563
- if node_id.name in model_def.inputs:
564
- input_name = node_id.name
565
- local_ctx[input_name] = node_result
619
+ if self.node.storage_access:
620
+ write_access = True if self.node.model_def.modelType == meta.ModelType.DATA_EXPORT_MODEL else False
621
+ for storage_key in self.node.storage_access:
622
+ if self.storage_manager.has_file_storage(storage_key, external=True):
623
+ storage_impl = self.storage_manager.get_file_storage(storage_key, external=True)
624
+ storage = _ctx.TracFileStorageImpl(storage_key, storage_impl, write_access, self.checkout_directory)
625
+ storage_map[storage_key] = storage
566
626
 
567
627
  # Run the model against the mapped local context
568
628
 
569
- trac_ctx = _ctx.TracContextImpl(self.node.model_def, self.model_class, local_ctx, self.checkout_directory)
629
+ if model_def.modelType in [meta.ModelType.DATA_IMPORT_MODEL, meta.ModelType.DATA_EXPORT_MODEL]:
630
+ trac_ctx = _ctx.TracDataContextImpl(
631
+ self.node.model_def, self.model_class,
632
+ local_ctx, dynamic_outputs, storage_map,
633
+ self.checkout_directory)
634
+ else:
635
+ trac_ctx = _ctx.TracContextImpl(
636
+ self.node.model_def, self.model_class,
637
+ local_ctx, dynamic_outputs,
638
+ self.checkout_directory)
570
639
 
571
640
  try:
572
641
  model = self.model_class()
@@ -580,7 +649,10 @@ class RunModelFunc(NodeFunction[Bundle[_data.DataView]]):
580
649
 
581
650
  # Check required outputs are present and build the results bundle
582
651
 
652
+ model_name = self.model_class.__name__
583
653
  results: Bundle[_data.DataView] = dict()
654
+ new_nodes = dict()
655
+ new_deps = dict()
584
656
 
585
657
  for output_name, output_schema in model_def.outputs.items():
586
658
 
@@ -589,7 +661,6 @@ class RunModelFunc(NodeFunction[Bundle[_data.DataView]]):
589
661
  if result is None or result.is_empty():
590
662
 
591
663
  if not output_schema.optional:
592
- model_name = self.model_class.__name__
593
664
  raise _ex.ERuntimeValidation(f"Missing required output [{output_name}] from model [{model_name}]")
594
665
 
595
666
  # Create a placeholder for optional outputs that were not emitted
@@ -598,6 +669,30 @@ class RunModelFunc(NodeFunction[Bundle[_data.DataView]]):
598
669
 
599
670
  results[output_name] = result
600
671
 
672
+ if dynamic_outputs:
673
+
674
+ for output_name in dynamic_outputs:
675
+
676
+ result: _data.DataView = local_ctx.get(output_name)
677
+
678
+ if result is None or result.is_empty():
679
+ raise _ex.ERuntimeValidation(f"No data provided for [{output_name}] from model [{model_name}]")
680
+
681
+ results[output_name] = result
682
+
683
+ result_node_id = NodeId.of(output_name, self.node.id.namespace, _data.DataView)
684
+ result_node = BundleItemNode(result_node_id, self.node.id, output_name)
685
+
686
+ new_nodes[result_node_id] = result_node
687
+
688
+ output_section = _graph.GraphBuilder.build_runtime_outputs(dynamic_outputs, self.node.id.namespace)
689
+ new_nodes.update(output_section.nodes)
690
+
691
+ ctx_id = NodeId.of("trac_build_result", self.node.id.namespace, result_type=None)
692
+ new_deps[ctx_id] = list(_graph.Dependency(nid, _graph.DependencyType.HARD) for nid in output_section.outputs)
693
+
694
+ self.node_callback.send_graph_updates(new_nodes, new_deps)
695
+
601
696
  return results
602
697
 
603
698
 
@@ -621,6 +716,14 @@ class FunctionResolver:
621
716
  :py:class:`NodeFunction <NodeFunction>`
622
717
  """
623
718
 
719
+ # TODO: Validate consistency for resource keys
720
+ # Storage key should be validated for load data, save data and run model with storage access
721
+ # Repository key should be validated for import model (and explicitly for run model)
722
+
723
+ # Currently jobs with missing resources will fail at runtime, with a suitable error
724
+ # The resolver is called during graph building
725
+ # Putting the check here will raise a consistency error before the job starts processing
726
+
624
727
  __ResolveFunc = tp.Callable[['FunctionResolver', Node[_T]], NodeFunction[_T]]
625
728
 
626
729
  def __init__(self, models: _models.ModelLoader, storage: _storage.StorageManager):
@@ -655,12 +758,13 @@ class FunctionResolver:
655
758
 
656
759
  def resolve_run_model_node(self, node: RunModelNode) -> NodeFunction:
657
760
 
761
+ # TODO: Verify model_class against model_def
762
+
658
763
  model_class = self._models.load_model_class(node.model_scope, node.model_def)
659
764
  checkout_directory = self._models.model_load_checkout_directory(node.model_scope, node.model_def)
765
+ storage_manager = self._storage if node.storage_access else None
660
766
 
661
- # TODO: Verify model_class against model_def
662
-
663
- return RunModelFunc(node, model_class, checkout_directory)
767
+ return RunModelFunc(node, model_class, checkout_directory, storage_manager)
664
768
 
665
769
  __basic_node_mapping: tp.Dict[Node.__class__, NodeFunction.__class__] = {
666
770
 
@@ -674,6 +778,7 @@ class FunctionResolver:
674
778
  SaveJobResultNode: SaveJobResultFunc,
675
779
  DataResultNode: DataResultFunc,
676
780
  StaticValueNode: StaticValueFunc,
781
+ RuntimeOutputsNode: RuntimeOutputsFunc,
677
782
  BundleItemNode: NoopFunc,
678
783
  NoopNode: NoopFunc,
679
784
  RunModelResultNode: NoopFunc
tracdap/rt/_exec/graph.py CHANGED
@@ -12,8 +12,6 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
- from __future__ import annotations
16
-
17
15
  import pathlib
18
16
  import typing as tp
19
17
  import dataclasses as dc
@@ -38,7 +36,7 @@ class NodeNamespace:
38
36
  return cls.__ROOT
39
37
 
40
38
  name: str
41
- parent: tp.Optional[NodeNamespace] = dc.field(default_factory=lambda: NodeNamespace.root())
39
+ parent: "tp.Optional[NodeNamespace]" = dc.field(default_factory=lambda: NodeNamespace.root())
42
40
 
43
41
  def __str__(self):
44
42
  if self is self.__ROOT:
@@ -62,7 +60,7 @@ class NodeNamespace:
62
60
  class NodeId(tp.Generic[_T]):
63
61
 
64
62
  @staticmethod
65
- def of(name: str, namespace: NodeNamespace, result_type: tp.Type[_T]) -> NodeId[_T]:
63
+ def of(name: str, namespace: NodeNamespace, result_type: tp.Type[_T]) -> "NodeId[_T]":
66
64
  return NodeId(name, namespace, result_type)
67
65
 
68
66
  name: str
@@ -83,8 +81,8 @@ class DependencyType:
83
81
  immediate: bool = True
84
82
  tolerant: bool = False
85
83
 
86
- HARD: tp.ClassVar[DependencyType]
87
- TOLERANT: tp.ClassVar[DependencyType]
84
+ HARD: "tp.ClassVar[DependencyType]"
85
+ TOLERANT: "tp.ClassVar[DependencyType]"
88
86
 
89
87
 
90
88
  DependencyType.HARD = DependencyType(immediate=True, tolerant=False)
@@ -93,6 +91,13 @@ DependencyType.TOLERANT = DependencyType(immediate=True, tolerant=True)
93
91
  DependencyType.DELAYED = DependencyType(immediate=False, tolerant=False)
94
92
 
95
93
 
94
+ @dc.dataclass(frozen=True)
95
+ class Dependency:
96
+
97
+ node_id: NodeId
98
+ dependency_type: DependencyType
99
+
100
+
96
101
  @dc.dataclass(frozen=True)
97
102
  class Node(tp.Generic[_T]):
98
103
 
@@ -165,6 +170,17 @@ class GraphSection:
165
170
  must_run: tp.List[NodeId] = dc.field(default_factory=list)
166
171
 
167
172
 
173
+ Bundle: tp.Generic[_T] = tp.Dict[str, _T]
174
+ ObjectBundle = Bundle[meta.ObjectDefinition]
175
+
176
+
177
+ @dc.dataclass(frozen=True)
178
+ class JobOutputs:
179
+
180
+ objects: tp.Dict[str, NodeId[meta.ObjectDefinition]] = dc.field(default_factory=dict)
181
+ bundles: tp.List[NodeId[ObjectBundle]] = dc.field(default_factory=list)
182
+
183
+
168
184
  # TODO: Where does this go?
169
185
  @dc.dataclass(frozen=True)
170
186
  class JobResultSpec:
@@ -179,10 +195,6 @@ class JobResultSpec:
179
195
  # ----------------------------------------------------------------------------------------------------------------------
180
196
 
181
197
 
182
- Bundle: tp.Generic[_T] = tp.Dict[str, _T]
183
- ObjectBundle = Bundle[meta.ObjectDefinition]
184
-
185
-
186
198
  @_node_type
187
199
  class NoopNode(Node):
188
200
  pass
@@ -354,6 +366,7 @@ class RunModelNode(Node[Bundle[_data.DataView]]):
354
366
  model_def: meta.ModelDefinition
355
367
  parameter_ids: tp.FrozenSet[NodeId]
356
368
  input_ids: tp.FrozenSet[NodeId]
369
+ storage_access: tp.Optional[tp.List[str]] = None
357
370
 
358
371
  def _node_dependencies(self) -> tp.Dict[NodeId, DependencyType]:
359
372
  return {dep_id: DependencyType.HARD for dep_id in [*self.parameter_ids, *self.input_ids]}
@@ -368,16 +381,28 @@ class RunModelResultNode(Node[None]):
368
381
  return {self.model_id: DependencyType.HARD}
369
382
 
370
383
 
384
+ @_node_type
385
+ class RuntimeOutputsNode(Node[JobOutputs]):
386
+
387
+ outputs: JobOutputs
388
+
389
+ def _node_dependencies(self) -> tp.Dict[NodeId, DependencyType]:
390
+ dep_ids = [*self.outputs.bundles, *self.outputs.objects.values()]
391
+ return {node_id: DependencyType.HARD for node_id in dep_ids}
392
+
393
+
371
394
  @_node_type
372
395
  class BuildJobResultNode(Node[cfg.JobResult]):
373
396
 
374
397
  job_id: meta.TagHeader
375
398
 
376
- objects: tp.Dict[str, NodeId[meta.ObjectDefinition]] = dc.field(default_factory=dict)
377
- bundles: tp.List[NodeId[ObjectBundle]] = dc.field(default_factory=list)
399
+ outputs: JobOutputs
400
+ runtime_outputs: tp.Optional[NodeId[JobOutputs]] = None
378
401
 
379
402
  def _node_dependencies(self) -> tp.Dict[NodeId, DependencyType]:
380
- dep_ids = [*self.bundles, *self.objects.values()]
403
+ dep_ids = [*self.outputs.bundles, *self.outputs.objects.values()]
404
+ if self.runtime_outputs is not None:
405
+ dep_ids.append(self.runtime_outputs)
381
406
  return {node_id: DependencyType.HARD for node_id in dep_ids}
382
407
 
383
408
 
@@ -12,8 +12,6 @@
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
14
 
15
- from __future__ import annotations
16
-
17
15
  import tracdap.rt.config as config
18
16
  import tracdap.rt.exceptions as _ex
19
17
  import tracdap.rt._impl.data as _data # noqa
@@ -42,6 +40,9 @@ class GraphBuilder:
42
40
  if job_config.job.jobType == meta.JobType.RUN_FLOW:
43
41
  return cls.build_standard_job(job_config, result_spec, cls.build_run_flow_job)
44
42
 
43
+ if job_config.job.jobType in [meta.JobType.IMPORT_DATA, meta.JobType.EXPORT_DATA]:
44
+ return cls.build_standard_job(job_config, result_spec, cls.build_import_export_data_job)
45
+
45
46
  raise _ex.EConfigParse(f"Job type [{job_config.job.jobType}] is not supported yet")
46
47
 
47
48
  @classmethod
@@ -114,6 +115,28 @@ class GraphBuilder:
114
115
 
115
116
  return cls._join_sections(main_section, result_section)
116
117
 
118
+ @classmethod
119
+ def build_import_export_data_job(
120
+ cls, job_config: config.JobConfig, result_spec: JobResultSpec,
121
+ job_namespace: NodeNamespace, job_push_id: NodeId) \
122
+ -> GraphSection:
123
+
124
+ # TODO: These are processed as regular calculation jobs for now
125
+ # That might be ok, but is worth reviewing
126
+
127
+ if job_config.job.jobType == meta.JobType.IMPORT_DATA:
128
+ job_def = job_config.job.importData
129
+ else:
130
+ job_def = job_config.job.exportData
131
+
132
+ target_selector = job_def.model
133
+ target_obj = _util.get_job_resource(target_selector, job_config)
134
+ target_def = target_obj.model
135
+
136
+ return cls.build_calculation_job(
137
+ job_config, result_spec, job_namespace, job_push_id,
138
+ target_selector, target_def, job_def)
139
+
117
140
  @classmethod
118
141
  def build_run_model_job(
119
142
  cls, job_config: config.JobConfig, result_spec: JobResultSpec,
@@ -380,6 +403,65 @@ class GraphBuilder:
380
403
 
381
404
  return GraphSection(nodes, inputs=inputs)
382
405
 
406
+ @classmethod
407
+ def build_runtime_outputs(cls, output_names: tp.List[str], job_namespace: NodeNamespace):
408
+
409
+ # TODO: Factor out common logic with regular job outputs (including static / dynamic)
410
+
411
+ nodes = {}
412
+ inputs = set()
413
+ outputs = list()
414
+
415
+ for output_name in output_names:
416
+
417
+ # Output data view must already exist in the namespace
418
+ data_view_id = NodeId.of(output_name, job_namespace, _data.DataView)
419
+ data_spec_id = NodeId.of(f"{output_name}:SPEC", job_namespace, _data.DataSpec)
420
+
421
+ data_key = output_name + ":DATA"
422
+ data_id = _util.new_object_id(meta.ObjectType.DATA)
423
+ storage_key = output_name + ":STORAGE"
424
+ storage_id = _util.new_object_id(meta.ObjectType.STORAGE)
425
+
426
+ data_spec_node = DynamicDataSpecNode(
427
+ data_spec_id, data_view_id,
428
+ data_id, storage_id,
429
+ prior_data_spec=None)
430
+
431
+ output_data_key = _util.object_key(data_id)
432
+ output_storage_key = _util.object_key(storage_id)
433
+
434
+ # Map one data item from each view, since outputs are single part/delta
435
+ data_item_id = NodeId(f"{output_name}:ITEM", job_namespace, _data.DataItem)
436
+ data_item_node = DataItemNode(data_item_id, data_view_id)
437
+
438
+ # Create a physical save operation for the data item
439
+ data_save_id = NodeId.of(f"{output_name}:SAVE", job_namespace, None)
440
+ data_save_node = SaveDataNode(data_save_id, data_spec_id, data_item_id)
441
+
442
+ data_result_id = NodeId.of(f"{output_name}:RESULT", job_namespace, ObjectBundle)
443
+ data_result_node = DataResultNode(
444
+ data_result_id, output_name,
445
+ data_item_id, data_spec_id, data_save_id,
446
+ output_data_key, output_storage_key)
447
+
448
+ nodes[data_spec_id] = data_spec_node
449
+ nodes[data_item_id] = data_item_node
450
+ nodes[data_save_id] = data_save_node
451
+ nodes[data_result_id] = data_result_node
452
+
453
+ # Job-level data view is an input to the save operation
454
+ inputs.add(data_view_id)
455
+ outputs.append(data_result_id)
456
+
457
+ runtime_outputs = JobOutputs(bundles=outputs)
458
+ runtime_outputs_id = NodeId.of("trac_runtime_outputs", job_namespace, JobOutputs)
459
+ runtime_outputs_node = RuntimeOutputsNode(runtime_outputs_id, runtime_outputs)
460
+
461
+ nodes[runtime_outputs_id] = runtime_outputs_node
462
+
463
+ return GraphSection(nodes, inputs=inputs, outputs={runtime_outputs_id})
464
+
383
465
  @classmethod
384
466
  def build_job_results(
385
467
  cls, job_config: cfg.JobConfig, job_namespace: NodeNamespace, result_spec: JobResultSpec,
@@ -396,7 +478,8 @@ class GraphBuilder:
396
478
 
397
479
  build_result_node = BuildJobResultNode(
398
480
  build_result_id, job_config.jobId,
399
- objects=objects, explicit_deps=explicit_deps)
481
+ outputs = JobOutputs(objects=objects),
482
+ explicit_deps=explicit_deps)
400
483
 
401
484
  elif bundles is not None:
402
485
 
@@ -404,7 +487,8 @@ class GraphBuilder:
404
487
 
405
488
  build_result_node = BuildJobResultNode(
406
489
  build_result_id, job_config.jobId,
407
- bundles=bundles, explicit_deps=explicit_deps)
490
+ outputs = JobOutputs(bundles=bundles),
491
+ explicit_deps=explicit_deps)
408
492
 
409
493
  else:
410
494
  raise _ex.EUnexpected()
@@ -459,7 +543,7 @@ class GraphBuilder:
459
543
  -> GraphSection:
460
544
 
461
545
  if model_or_flow.objectType == meta.ObjectType.MODEL:
462
- return cls.build_model(namespace, model_or_flow.model, explicit_deps)
546
+ return cls.build_model(job_config, namespace, model_or_flow.model, explicit_deps)
463
547
 
464
548
  elif model_or_flow.objectType == meta.ObjectType.FLOW:
465
549
  return cls.build_flow(job_config, namespace, model_or_flow.flow)
@@ -469,11 +553,13 @@ class GraphBuilder:
469
553
 
470
554
  @classmethod
471
555
  def build_model(
472
- cls, namespace: NodeNamespace,
556
+ cls, job_config: config.JobConfig, namespace: NodeNamespace,
473
557
  model_def: meta.ModelDefinition,
474
558
  explicit_deps: tp.Optional[tp.List[NodeId]] = None) \
475
559
  -> GraphSection:
476
560
 
561
+ cls.check_model_type(job_config, model_def)
562
+
477
563
  def param_id(node_name):
478
564
  return NodeId(node_name, namespace, meta.Value)
479
565
 
@@ -485,6 +571,14 @@ class GraphBuilder:
485
571
  input_ids = set(map(data_id, model_def.inputs))
486
572
  output_ids = set(map(data_id, model_def.outputs))
487
573
 
574
+ # Set up storage access for import / export data jobs
575
+ if job_config.job.jobType == meta.JobType.IMPORT_DATA:
576
+ storage_access = job_config.job.importData.storageAccess
577
+ elif job_config.job.jobType == meta.JobType.EXPORT_DATA:
578
+ storage_access = job_config.job.exportData.storageAccess
579
+ else:
580
+ storage_access = None
581
+
488
582
  # Create the model node
489
583
  # Always add the prior graph root ID as a dependency
490
584
  # This is to ensure dependencies are still pulled in for models with no inputs!
@@ -500,7 +594,8 @@ class GraphBuilder:
500
594
  model_node = RunModelNode(
501
595
  model_id, model_scope, model_def,
502
596
  frozenset(parameter_ids), frozenset(input_ids),
503
- explicit_deps=explicit_deps, bundle=model_id.namespace)
597
+ explicit_deps=explicit_deps, bundle=model_id.namespace,
598
+ storage_access=storage_access)
504
599
 
505
600
  model_result_id = NodeId(f"{model_name}:RESULT", namespace)
506
601
  model_result_node = RunModelResultNode(model_result_id, model_id)
@@ -637,6 +732,7 @@ class GraphBuilder:
637
732
 
638
733
  # Explicit check for model compatibility - report an error now, do not try build_model()
639
734
  cls.check_model_compatibility(model_selector, model_obj.model, node_name, node)
735
+ cls.check_model_type(job_config, model_obj.model)
640
736
 
641
737
  return cls.build_model_or_flow_with_context(
642
738
  job_config, namespace, node_name, model_obj,
@@ -647,8 +743,8 @@ class GraphBuilder:
647
743
 
648
744
  @classmethod
649
745
  def check_model_compatibility(
650
- cls, model_selector: meta.TagSelector, model_def: meta.ModelDefinition,
651
- node_name: str, flow_node: meta.FlowNode):
746
+ cls, model_selector: meta.TagSelector,
747
+ model_def: meta.ModelDefinition, node_name: str, flow_node: meta.FlowNode):
652
748
 
653
749
  model_params = list(sorted(model_def.parameters.keys()))
654
750
  model_inputs = list(sorted(model_def.inputs.keys()))
@@ -662,6 +758,21 @@ class GraphBuilder:
662
758
  model_key = _util.object_key(model_selector)
663
759
  raise _ex.EJobValidation(f"Incompatible model for flow node [{node_name}] (Model: [{model_key}])")
664
760
 
761
+ @classmethod
762
+ def check_model_type(cls, job_config: config.JobConfig, model_def: meta.ModelDefinition):
763
+
764
+ if job_config.job.jobType == meta.JobType.IMPORT_DATA:
765
+ allowed_model_types = [meta.ModelType.DATA_IMPORT_MODEL]
766
+ elif job_config.job.jobType == meta.JobType.EXPORT_DATA:
767
+ allowed_model_types = [meta.ModelType.DATA_EXPORT_MODEL]
768
+ else:
769
+ allowed_model_types = [meta.ModelType.STANDARD_MODEL]
770
+
771
+ if model_def.modelType not in allowed_model_types:
772
+ job_type = job_config.job.jobType.name
773
+ model_type = model_def.modelType.name
774
+ raise _ex.EJobValidation(f"Job type [{job_type}] cannot use model type [{model_type}]")
775
+
665
776
  @staticmethod
666
777
  def build_context_push(
667
778
  namespace: NodeNamespace, input_mapping: tp.Dict[str, NodeId],