luminarycloud 0.22.1__py3-none-any.whl → 0.22.3__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 (78) hide show
  1. luminarycloud/_client/client.py +5 -3
  2. luminarycloud/_client/retry_interceptor.py +13 -2
  3. luminarycloud/_helpers/__init__.py +9 -0
  4. luminarycloud/_helpers/_inference_jobs.py +227 -0
  5. luminarycloud/_helpers/_parse_iso_datetime.py +54 -0
  6. luminarycloud/_helpers/proto_decorator.py +38 -7
  7. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.py +45 -25
  8. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2.pyi +30 -0
  9. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2_grpc.py +34 -0
  10. luminarycloud/_proto/api/v0/luminarycloud/geometry/geometry_pb2_grpc.pyi +12 -0
  11. luminarycloud/_proto/api/v0/luminarycloud/named_variable_set/named_variable_set_pb2.py +25 -3
  12. luminarycloud/_proto/api/v0/luminarycloud/named_variable_set/named_variable_set_pb2.pyi +30 -0
  13. luminarycloud/_proto/api/v0/luminarycloud/named_variable_set/named_variable_set_pb2_grpc.py +34 -0
  14. luminarycloud/_proto/api/v0/luminarycloud/named_variable_set/named_variable_set_pb2_grpc.pyi +12 -0
  15. luminarycloud/_proto/api/v0/luminarycloud/physics_ai/physics_ai_pb2.py +140 -45
  16. luminarycloud/_proto/api/v0/luminarycloud/physics_ai/physics_ai_pb2.pyi +322 -8
  17. luminarycloud/_proto/api/v0/luminarycloud/physics_ai/physics_ai_pb2_grpc.py +68 -0
  18. luminarycloud/_proto/api/v0/luminarycloud/physics_ai/physics_ai_pb2_grpc.pyi +24 -0
  19. luminarycloud/_proto/api/v0/luminarycloud/physicsaiinference/physicsaiinference_pb2.py +93 -33
  20. luminarycloud/_proto/api/v0/luminarycloud/physicsaiinference/physicsaiinference_pb2.pyi +105 -0
  21. luminarycloud/_proto/api/v0/luminarycloud/physicsaiinference/physicsaiinference_pb2_grpc.py +70 -0
  22. luminarycloud/_proto/api/v0/luminarycloud/physicsaiinference/physicsaiinference_pb2_grpc.pyi +29 -0
  23. luminarycloud/_proto/api/v0/luminarycloud/simulation/simulation_pb2.py +29 -7
  24. luminarycloud/_proto/api/v0/luminarycloud/simulation/simulation_pb2.pyi +39 -0
  25. luminarycloud/_proto/api/v0/luminarycloud/simulation/simulation_pb2_grpc.py +36 -0
  26. luminarycloud/_proto/api/v0/luminarycloud/simulation/simulation_pb2_grpc.pyi +18 -0
  27. luminarycloud/_proto/api/v0/luminarycloud/thirdpartyintegration/onshape/onshape_pb2.py +70 -70
  28. luminarycloud/_proto/api/v0/luminarycloud/thirdpartyintegration/onshape/onshape_pb2.pyi +5 -5
  29. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2.py +163 -153
  30. luminarycloud/_proto/api/v0/luminarycloud/vis/vis_pb2.pyi +37 -3
  31. luminarycloud/_proto/client/simulation_pb2.py +356 -337
  32. luminarycloud/_proto/client/simulation_pb2.pyi +89 -3
  33. luminarycloud/_proto/lcstatus/details/geometry/geometry_pb2.py +256 -0
  34. luminarycloud/_proto/lcstatus/details/geometry/geometry_pb2.pyi +472 -0
  35. luminarycloud/_proto/physicsaiinferenceservice/physicsaiinferenceservice_pb2.py +9 -4
  36. luminarycloud/_proto/physicsaitrainingservice/physicsaitrainingservice_pb2.py +6 -3
  37. luminarycloud/_proto/physicsaitrainingservice/physicsaitrainingservice_pb2_grpc.py +68 -0
  38. luminarycloud/_proto/physicsaitrainingservice/physicsaitrainingservice_pb2_grpc.pyi +24 -0
  39. luminarycloud/_proto/quantity/quantity_pb2.pyi +1 -1
  40. luminarycloud/_wrapper.py +53 -7
  41. luminarycloud/feature_modification.py +25 -32
  42. luminarycloud/geometry.py +6 -6
  43. luminarycloud/outputs/__init__.py +2 -0
  44. luminarycloud/outputs/output_definitions.py +3 -3
  45. luminarycloud/outputs/stopping_conditions.py +94 -0
  46. luminarycloud/params/enum/_enum_wrappers.py +16 -0
  47. luminarycloud/params/geometry/shapes.py +33 -33
  48. luminarycloud/params/simulation/adaptive_mesh_refinement/__init__.py +1 -0
  49. luminarycloud/params/simulation/adaptive_mesh_refinement/active_region_.py +83 -0
  50. luminarycloud/params/simulation/adaptive_mesh_refinement/boundary_layer_profile_.py +1 -1
  51. luminarycloud/params/simulation/adaptive_mesh_refinement_.py +8 -1
  52. luminarycloud/physics_ai/__init__.py +15 -0
  53. luminarycloud/physics_ai/architectures.py +1 -1
  54. luminarycloud/physics_ai/datasets.py +246 -0
  55. luminarycloud/physics_ai/inference.py +166 -199
  56. luminarycloud/physics_ai/models.py +22 -0
  57. luminarycloud/pipelines/__init__.py +11 -0
  58. luminarycloud/pipelines/api.py +106 -9
  59. luminarycloud/pipelines/core.py +358 -45
  60. luminarycloud/pipelines/flowables.py +138 -0
  61. luminarycloud/pipelines/stages.py +7 -31
  62. luminarycloud/project.py +56 -2
  63. luminarycloud/simulation.py +25 -0
  64. luminarycloud/types/__init__.py +2 -0
  65. luminarycloud/types/ids.py +2 -0
  66. luminarycloud/vis/__init__.py +1 -0
  67. luminarycloud/vis/filters.py +97 -0
  68. luminarycloud/vis/visualization.py +3 -0
  69. luminarycloud/volume_selection.py +6 -6
  70. luminarycloud/workflow_utils.py +149 -0
  71. {luminarycloud-0.22.1.dist-info → luminarycloud-0.22.3.dist-info}/METADATA +1 -1
  72. {luminarycloud-0.22.1.dist-info → luminarycloud-0.22.3.dist-info}/RECORD +73 -70
  73. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2.py +0 -61
  74. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2.pyi +0 -85
  75. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2_grpc.py +0 -67
  76. luminarycloud/_proto/api/v0/luminarycloud/inference/inference_pb2_grpc.pyi +0 -26
  77. luminarycloud/_proto/inferenceservice/inferenceservice_pb2.py +0 -69
  78. {luminarycloud-0.22.1.dist-info → luminarycloud-0.22.3.dist-info}/WHEEL +0 -0
@@ -2,8 +2,12 @@
2
2
  from .core import (
3
3
  Pipeline as Pipeline,
4
4
  PipelineParameter as PipelineParameter,
5
+ stage as stage,
5
6
  # Stage base class, mainly exported for testing
6
7
  Stage as Stage,
8
+ # RunScript stage lives in core because it's a special snowflake
9
+ RunScript as RunScript,
10
+ StopRun as StopRun,
7
11
  )
8
12
 
9
13
  from .parameters import (
@@ -36,6 +40,11 @@ from .arguments import (
36
40
  ArgNamedVariableSet as ArgNamedVariableSet,
37
41
  )
38
42
 
43
+ from .flowables import (
44
+ FlowableType as FlowableType,
45
+ FlowableIOSchema as FlowableIOSchema,
46
+ )
47
+
39
48
  from .api import (
40
49
  create_pipeline as create_pipeline,
41
50
  list_pipelines as list_pipelines,
@@ -46,5 +55,7 @@ from .api import (
46
55
  PipelineJobRecord as PipelineJobRecord,
47
56
  PipelineRecord as PipelineRecord,
48
57
  PipelineJobRunRecord as PipelineJobRunRecord,
58
+ PipelineTaskRecord as PipelineTaskRecord,
59
+ StageDefinition as StageDefinition,
49
60
  LogLine as LogLine,
50
61
  )
@@ -10,6 +10,7 @@ from .arguments import PipelineArgValueType
10
10
  from .core import Stage
11
11
  from ..pipelines import Pipeline, PipelineArgs
12
12
  from .._client import get_default_client
13
+ from .._helpers import parse_iso_datetime
13
14
 
14
15
  logger = logging.getLogger(__name__)
15
16
 
@@ -23,12 +24,56 @@ class LogLine:
23
24
  @classmethod
24
25
  def from_json(cls, json: dict) -> "LogLine":
25
26
  return cls(
26
- timestamp=datetime.fromisoformat(json["timestamp"]),
27
+ timestamp=parse_iso_datetime(json["timestamp"]),
27
28
  level=json["level"],
28
29
  message=json["message"],
29
30
  )
30
31
 
31
32
 
33
+ @dataclass
34
+ class StageDefinition:
35
+ """
36
+ Represents a stage definition from a pipeline.
37
+ """
38
+
39
+ id: str
40
+ name: str
41
+ stage_type: str
42
+
43
+ @classmethod
44
+ def from_json(cls, json: dict) -> "StageDefinition":
45
+ return cls(
46
+ id=json["id"],
47
+ name=json["name"],
48
+ stage_type=json["stage_type"],
49
+ )
50
+
51
+
52
+ @dataclass
53
+ class PipelineTaskRecord:
54
+ """
55
+ A PipelineTaskRecord represents a task within a pipeline job run.
56
+ """
57
+
58
+ status: Literal["pending", "running", "completed", "failed", "upstream_failed", "cancelled"]
59
+ artifacts: dict[str, dict]
60
+ stage: StageDefinition | None
61
+ created_at: datetime
62
+ updated_at: datetime
63
+ error_messages: list[str] | None
64
+
65
+ @classmethod
66
+ def from_json(cls, json: dict) -> "PipelineTaskRecord":
67
+ return cls(
68
+ status=json["status"],
69
+ artifacts=json["artifacts"],
70
+ stage=StageDefinition.from_json(json["stage"]) if json.get("stage") else None,
71
+ created_at=parse_iso_datetime(json["created_at"]),
72
+ updated_at=parse_iso_datetime(json["updated_at"]),
73
+ error_messages=json.get("error_messages"),
74
+ )
75
+
76
+
32
77
  @dataclass
33
78
  class PipelineRecord:
34
79
  """
@@ -54,8 +99,8 @@ class PipelineRecord:
54
99
  name=json["name"],
55
100
  description=json["description"],
56
101
  definition_yaml=json["definition_yaml"],
57
- created_at=datetime.fromisoformat(json["created_at"]),
58
- updated_at=datetime.fromisoformat(json["updated_at"]),
102
+ created_at=parse_iso_datetime(json["created_at"]),
103
+ updated_at=parse_iso_datetime(json["updated_at"]),
59
104
  )
60
105
 
61
106
  def pipeline_jobs(self) -> "list[PipelineJobRecord]":
@@ -100,11 +145,12 @@ class PipelineJobRecord:
100
145
  pipeline_id: str
101
146
  name: str
102
147
  description: str | None
103
- status: Literal["pending", "running", "completed", "failed", "cancelled"]
148
+ status: Literal["pending", "running", "completed", "failed", "cancelled", "paused"]
104
149
  created_at: datetime
105
150
  updated_at: datetime
106
151
  started_at: datetime | None
107
152
  completed_at: datetime | None
153
+ paused_at: datetime | None
108
154
 
109
155
  @classmethod
110
156
  def from_json(cls, json: dict) -> "PipelineJobRecord":
@@ -112,14 +158,15 @@ class PipelineJobRecord:
112
158
  id=json["id"],
113
159
  pipeline_id=json["pipeline_id"],
114
160
  name=json["name"],
115
- description=json["description"],
161
+ description=json.get("description"),
116
162
  status=json["status"],
117
- created_at=datetime.fromisoformat(json["created_at"]),
118
- updated_at=datetime.fromisoformat(json["updated_at"]),
119
- started_at=datetime.fromisoformat(json["started_at"]) if json["started_at"] else None,
163
+ created_at=parse_iso_datetime(json["created_at"]),
164
+ updated_at=parse_iso_datetime(json["updated_at"]),
165
+ started_at=(parse_iso_datetime(json["started_at"]) if json.get("started_at") else None),
120
166
  completed_at=(
121
- datetime.fromisoformat(json["completed_at"]) if json["completed_at"] else None
167
+ parse_iso_datetime(json["completed_at"]) if json.get("completed_at") else None
122
168
  ),
169
+ paused_at=(parse_iso_datetime(json["paused_at"]) if json.get("paused_at") else None),
123
170
  )
124
171
 
125
172
  def pipeline(self) -> PipelineRecord:
@@ -315,6 +362,39 @@ class PipelineJobRecord:
315
362
  get_default_client().http.post(f"/rest/v0/pipeline_jobs/{self.id}/cancel", {})
316
363
  logger.info(f"Cancelled pipeline job {self.id}")
317
364
 
365
+ def pause(self) -> None:
366
+ """Pause this running pipeline job.
367
+
368
+ This will prevent new tasks from being scheduled while allowing
369
+ in-progress tasks to complete. The job status will be set to PAUSED
370
+ and all stage concurrency limits will be temporarily set to 0.
371
+
372
+ Call resume() to continue execution.
373
+
374
+ Raises
375
+ ------
376
+ HTTPError
377
+ If the pipeline job cannot be paused (e.g., not found or not in
378
+ RUNNING state).
379
+ """
380
+ get_default_client().http.post(f"/rest/v0/pipeline_jobs/{self.id}/pause", {})
381
+ logger.info(f"Paused pipeline job {self.id}")
382
+
383
+ def resume(self) -> None:
384
+ """Resume this paused pipeline job.
385
+
386
+ This will restore the job status to RUNNING and restore the original
387
+ concurrency limits, allowing new tasks to be scheduled again.
388
+
389
+ Raises
390
+ ------
391
+ HTTPError
392
+ If the pipeline job cannot be resumed (e.g., not found or not in
393
+ PAUSED state).
394
+ """
395
+ get_default_client().http.post(f"/rest/v0/pipeline_jobs/{self.id}/resume", {})
396
+ logger.info(f"Resumed pipeline job {self.id}")
397
+
318
398
 
319
399
  @dataclass
320
400
  class PipelineJobRunRecord:
@@ -378,6 +458,23 @@ class PipelineJobRunRecord:
378
458
  )
379
459
  return res["data"]
380
460
 
461
+ def tasks(self) -> list[PipelineTaskRecord]:
462
+ """
463
+ Returns a list of tasks for this pipeline job run.
464
+
465
+ Each task represents an execution of a stage of the pipeline, with its own
466
+ status, artifacts, and stage information.
467
+
468
+ Returns
469
+ -------
470
+ list[PipelineTaskRecord]
471
+ The tasks associated with this pipeline job run.
472
+ """
473
+ res = get_default_client().http.get(
474
+ f"/rest/v0/pipeline_jobs/{self.pipeline_job_id}/runs/{self.idx}/tasks"
475
+ )
476
+ return [PipelineTaskRecord.from_json(t) for t in res["data"]]
477
+
381
478
 
382
479
  def create_pipeline(
383
480
  name: str, pipeline: Pipeline | str, description: str | None = None
@@ -1,12 +1,26 @@
1
1
  # Copyright 2025 Luminary Cloud, Inc. All Rights Reserved.
2
+ from __future__ import annotations
2
3
  from abc import ABC, abstractmethod
3
4
  from dataclasses import is_dataclass, fields
4
- from typing import Any, Type, TypeVar, Generic
5
+ from typing import Any, Callable, Mapping, Type, TypeVar, Generic, TYPE_CHECKING
5
6
  from typing_extensions import Self
7
+ import inspect
6
8
  import re
9
+ import textwrap
7
10
  import yaml
8
11
 
9
12
  from ..pipeline_util.yaml import ensure_yamlizable
13
+ from .flowables import (
14
+ PipelineOutput,
15
+ PipelineInput,
16
+ FlowableType,
17
+ flowable_class_to_name,
18
+ flowable_name_to_class,
19
+ FlowableIOSchema,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from .arguments import PipelineArgValueType
10
24
 
11
25
 
12
26
  class PipelineParameterRegistry:
@@ -86,40 +100,12 @@ class PipelineParameter(ABC):
86
100
  return self.__hash__() == other.__hash__()
87
101
 
88
102
 
89
- class PipelineInput:
90
- """
91
- A named input for a Stage. Explicitly connected to a PipelineOutput.
92
- """
93
-
94
- def __init__(self, upstream_output: "PipelineOutput", owner: "Stage", name: str):
95
- self.upstream_output = upstream_output
96
- self.owner = owner
97
- self.name = name
98
-
99
- def _to_dict(self, id_for_stage: dict) -> dict:
100
- if self.upstream_output.owner not in id_for_stage:
101
- raise ValueError(
102
- f"Stage {self.owner} depends on a stage, {self.upstream_output.owner}, that isn't in the Pipeline. Did you forget to add it?"
103
- )
104
- upstream_stage_id = id_for_stage[self.upstream_output.owner]
105
- upstream_output_name = self.upstream_output.name
106
- return {self.name: f"{upstream_stage_id}.{upstream_output_name}"}
107
-
108
-
109
- class PipelineOutput(ABC):
103
+ class StopRun(RuntimeError):
110
104
  """
111
- A named output for a Stage. Can be used to spawn any number of connected PipelineInputs.
105
+ Raised by RunScript code to indicate that the pipeline run should stop intentionally.
112
106
  """
113
107
 
114
- def __init__(self, owner: "Stage", name: str):
115
- self.owner = owner
116
- self.name = name
117
- self.downstream_inputs: list[PipelineInput] = []
118
-
119
- def _spawn_input(self, owner: "Stage", name: str) -> PipelineInput:
120
- input = PipelineInput(self, owner, name)
121
- self.downstream_inputs.append(input)
122
- return input
108
+ pass
123
109
 
124
110
 
125
111
  class StageInputs:
@@ -187,11 +173,52 @@ class StageOutputs(ABC):
187
173
  return inputs
188
174
 
189
175
 
176
+ class DynamicStageOutputs(StageOutputs):
177
+ def __init__(self, owner: "RunScript", output_types: dict[str, FlowableType]):
178
+ self.owner = owner
179
+ self._order = list(output_types.keys())
180
+ self.outputs: dict[str, PipelineOutput] = {}
181
+ for name in self._order:
182
+ output_type = output_types[name]
183
+ output_cls = flowable_name_to_class(output_type)
184
+ self.outputs[name] = output_cls(owner, name)
185
+
186
+ def downstream_inputs(self) -> list[PipelineInput]:
187
+ inputs = []
188
+ for output in self.outputs.values():
189
+ inputs.extend(output.downstream_inputs)
190
+ return inputs
191
+
192
+ def __getattr__(self, name: str) -> PipelineOutput:
193
+ return self.outputs[name]
194
+
195
+ def __getitem__(self, key: int | str) -> PipelineOutput:
196
+ if isinstance(key, int):
197
+ name = self._order[key]
198
+ return self.outputs[name]
199
+ return self.outputs[key]
200
+
201
+ def __iter__(self):
202
+ return iter(self._order)
203
+
204
+ def __len__(self) -> int:
205
+ return len(self.outputs)
206
+
207
+ def keys(self):
208
+ return self.outputs.keys()
209
+
210
+ def values(self):
211
+ return self.outputs.values()
212
+
213
+ def items(self):
214
+ return self.outputs.items()
215
+
216
+
190
217
  class StageRegistry:
191
218
  def __init__(self):
192
219
  self.stages = {}
193
220
 
194
- def register(self, stage_class: Type["Stage"]) -> None:
221
+ def register(self, stage_class: Type["StandardStage"] | Type["RunScript"]) -> None:
195
222
  self.stages[stage_class.__name__] = stage_class
196
223
 
197
224
  def get(self, stage_type_name: str) -> Type["Stage"]:
@@ -203,7 +230,7 @@ class StageRegistry:
203
230
  TOutputs = TypeVar("TOutputs", bound=StageOutputs)
204
231
 
205
232
 
206
- class Stage(Generic[TOutputs], ABC):
233
+ class StandardStage(Generic[TOutputs], ABC):
207
234
  def __init__(
208
235
  self,
209
236
  stage_name: str | None,
@@ -268,7 +295,7 @@ class Stage(Generic[TOutputs], ABC):
268
295
 
269
296
  def __init_subclass__(cls, **kwargs):
270
297
  super().__init_subclass__(**kwargs)
271
- Stage._registry.register(cls)
298
+ StandardStage._registry.register(cls)
272
299
 
273
300
  @classmethod
274
301
  def _get_subclass(cls, stage_type_name: str) -> Type["Stage"]:
@@ -281,6 +308,276 @@ class Stage(Generic[TOutputs], ABC):
281
308
  return params
282
309
 
283
310
 
311
+ class RunScript:
312
+ """
313
+ RunScript is a stage that runs a user-provided Python function.
314
+
315
+ While you can instantiate a RunScript stage directly, the usual way to construct one is to
316
+ decorate a function with the `@stage` decorator.
317
+
318
+ Examples
319
+ --------
320
+ >>> @pipelines.stage(
321
+ ... inputs={"geometry": read_geo.outputs.geometry},
322
+ ... outputs={"geometry": pipelines.PipelineOutputGeometry},
323
+ ... )
324
+ ... def ensure_single_volume(geometry: lc.Geometry):
325
+ ... _, volumes = geometry.list_entities()
326
+ ... if len(volumes) != 1:
327
+ ... raise pipelines.StopRun("expected exactly one volume")
328
+ ... return {"geometry": geometry}
329
+ """
330
+
331
+ def __init__(
332
+ self,
333
+ script: Callable[..., dict[str, Any]] | str,
334
+ *,
335
+ stage_name: str | None = None,
336
+ inputs: dict[str, PipelineOutput] | None = None,
337
+ outputs: Mapping[str, type[PipelineOutput] | str] | None = None,
338
+ entrypoint: str | None = None,
339
+ params: dict[str, Any] | None = None,
340
+ ):
341
+ inputs = inputs or {}
342
+ params = params or {}
343
+ outputs = outputs or {}
344
+ overlapping = set(inputs.keys()).intersection(params.keys())
345
+ if overlapping:
346
+ overlap = ", ".join(sorted(overlapping))
347
+ raise ValueError(f"RunScript params and inputs cannot share names: {overlap}")
348
+
349
+ inputs_and_params = set(inputs.keys()).union(params.keys())
350
+ script_source, callable_entrypoint = self._get_script_source(script, inputs_and_params)
351
+ self._stage_type_name = "RunScript"
352
+ self._entrypoint = (
353
+ entrypoint or callable_entrypoint or self._infer_entrypoint(script_source)
354
+ )
355
+ self._name = (
356
+ stage_name if stage_name is not None else self._default_stage_name(self._entrypoint)
357
+ )
358
+
359
+ for input_name, upstream_output in inputs.items():
360
+ if not isinstance(upstream_output, PipelineOutput):
361
+ raise TypeError(
362
+ f"Input '{input_name}' must be a PipelineOutput, got {type(upstream_output).__name__}"
363
+ )
364
+
365
+ stage_inputs_kwargs = {
366
+ input_name: (PipelineOutput, upstream_output)
367
+ for input_name, upstream_output in inputs.items()
368
+ }
369
+ self._inputs = StageInputs(self, **stage_inputs_kwargs)
370
+
371
+ input_types = {
372
+ input_name: flowable_class_to_name(type(upstream_output))
373
+ for input_name, upstream_output in inputs.items()
374
+ }
375
+ output_flowable_types = self._normalize_output_types(outputs)
376
+ self._io_schema = FlowableIOSchema(
377
+ inputs=input_types,
378
+ outputs=output_flowable_types,
379
+ )
380
+
381
+ self.outputs = DynamicStageOutputs(self, output_flowable_types)
382
+
383
+ reserved_params = {
384
+ "$script": script_source,
385
+ "$output_types": {name: ft.value for name, ft in output_flowable_types.items()},
386
+ "$entrypoint": self._entrypoint,
387
+ }
388
+ user_params = dict(params or {})
389
+ invalid_param_names = ({"context"} | reserved_params.keys()).intersection(
390
+ user_params.keys()
391
+ )
392
+ if invalid_param_names:
393
+ invalid = ", ".join(sorted(invalid_param_names))
394
+ raise ValueError(f"RunScript params cannot use reserved names: {invalid}")
395
+ overlapping_input_names = set(inputs.keys()).intersection(user_params.keys())
396
+ if overlapping_input_names:
397
+ overlap = ", ".join(sorted(overlapping_input_names))
398
+ raise ValueError(f"RunScript params and inputs cannot share names: {overlap}")
399
+ if "context" in inputs.keys():
400
+ raise ValueError("RunScript inputs cannot include reserved name 'context'")
401
+
402
+ self._params = reserved_params | user_params
403
+ ensure_yamlizable(self._params_dict()[0], "RunScript parameters")
404
+
405
+ @staticmethod
406
+ def _default_stage_name(entrypoint: str) -> str:
407
+ words = entrypoint.replace("_", " ").split()
408
+ if not words:
409
+ return "RunScript"
410
+ return " ".join(word.capitalize() for word in words)
411
+
412
+ @staticmethod
413
+ def _normalize_output_types(
414
+ output_types: Mapping[str, type[PipelineOutput] | str | FlowableType],
415
+ ) -> dict[str, FlowableType]:
416
+ normalized: dict[str, FlowableType] = {}
417
+ if not output_types:
418
+ raise ValueError("RunScript stages must declare at least one output")
419
+ for name, value in output_types.items():
420
+ if isinstance(value, FlowableType):
421
+ normalized[name] = value
422
+ elif isinstance(value, str):
423
+ normalized[name] = FlowableType(value)
424
+ elif isinstance(value, type) and issubclass(value, PipelineOutput):
425
+ normalized[name] = flowable_class_to_name(value)
426
+ else:
427
+ raise TypeError(
428
+ f"Output '{name}' must be a PipelineOutput subclass or flowable type string, got {value}"
429
+ )
430
+ return normalized
431
+
432
+ @staticmethod
433
+ def _validate_script(
434
+ script: Callable[..., dict[str, Any]], inputs_and_params: set[str]
435
+ ) -> None:
436
+ closurevars = inspect.getclosurevars(script)
437
+ if closurevars.nonlocals:
438
+ raise ValueError(
439
+ f"RunScript functions must not close over non-local variables. Found these non-local variables: {', '.join(closurevars.nonlocals.keys())}"
440
+ )
441
+ globals_except_lc = {
442
+ k for k in closurevars.globals.keys() if k != "lc" and k != "luminarycloud"
443
+ }
444
+ if globals_except_lc:
445
+ raise ValueError(
446
+ f"RunScript functions must not rely on global variables, including imports. All modules your script needs (except `luminarycloud` or `lc`) must be imported in the function body. Found globals: {', '.join(globals_except_lc)}"
447
+ )
448
+ script_params = set(inspect.signature(script).parameters.keys())
449
+ if script_params != inputs_and_params and script_params != inputs_and_params | {"context"}:
450
+ raise ValueError(
451
+ f"RunScript function must take exactly the same parameters as the inputs and params (and optionally `context`): {script_params} != {inputs_and_params}"
452
+ )
453
+
454
+ @staticmethod
455
+ def _get_script_source(
456
+ script: Callable[..., dict[str, Any]] | str,
457
+ inputs_and_params: set[str],
458
+ ) -> tuple[str, str | None]:
459
+ if callable(script):
460
+ RunScript._validate_script(script, inputs_and_params)
461
+ try:
462
+ source_lines, _ = inspect.getsourcelines(script) # type: ignore[arg-type]
463
+ except (OSError, IOError, TypeError) as exc:
464
+ raise ValueError(f"Unable to retrieve source for {script.__name__}: {exc}") from exc
465
+ # Drop decorator lines (everything before the `def`)
466
+ for i, line in enumerate(source_lines):
467
+ if line.lstrip().startswith("def "):
468
+ source_lines = source_lines[i:]
469
+ break
470
+ source = "".join(source_lines)
471
+ entrypoint = script.__name__
472
+ else:
473
+ source = script
474
+ entrypoint = None
475
+ dedented = textwrap.dedent(source).strip()
476
+ if not dedented:
477
+ raise ValueError("RunScript code cannot be empty")
478
+ return dedented + "\n", entrypoint
479
+
480
+ @staticmethod
481
+ def _infer_entrypoint(script_source: str) -> str:
482
+ matches = re.findall(r"^def\s+([A-Za-z_][\w]*)\s*\(", script_source, re.MULTILINE)
483
+ if not matches:
484
+ raise ValueError(
485
+ "Could not determine the entrypoint for the RunScript code. Please set the `entrypoint` argument."
486
+ )
487
+ unique_matches = [match for match in matches if match]
488
+ if len(unique_matches) > 1:
489
+ raise ValueError(
490
+ "Multiple top-level functions were found in the RunScript code. Please specify the `entrypoint` argument."
491
+ )
492
+ return unique_matches[0]
493
+
494
+ def is_source(self) -> bool:
495
+ return len(self._inputs.inputs) == 0
496
+
497
+ def inputs_dict(self) -> dict[str, tuple["Stage", str]]:
498
+ inputs: dict[str, tuple["Stage", str]] = {}
499
+ for pipeline_input in self._inputs.inputs:
500
+ inputs[pipeline_input.name] = (
501
+ pipeline_input.upstream_output.owner,
502
+ pipeline_input.upstream_output.name,
503
+ )
504
+ return inputs
505
+
506
+ def downstream_stages(self) -> list["Stage"]:
507
+ return [inp.owner for inp in self.outputs.downstream_inputs()]
508
+
509
+ def _params_dict(self) -> tuple[dict, set[PipelineParameter]]:
510
+ d: dict[str, Any] = {}
511
+ pipeline_params = set()
512
+ for name, value in self._params.items():
513
+ if hasattr(value, "_to_pipeline_dict"):
514
+ d[name], downstream_params = value._to_pipeline_dict()
515
+ for param in downstream_params:
516
+ if not isinstance(param, PipelineParameter):
517
+ raise ValueError(
518
+ f"Expected `_to_pipeline_dict()` to only return PipelineParameters, but got {type(param)}"
519
+ )
520
+ pipeline_params.update(downstream_params)
521
+ else:
522
+ d[name] = value
523
+ d = {k: v for k, v in d.items() if v is not None}
524
+ return d, pipeline_params
525
+
526
+ def _to_dict(self, id_for_task: dict) -> tuple[dict, set[PipelineParameter]]:
527
+ params, pipeline_params = self._params_dict()
528
+ d = {
529
+ "name": self._name,
530
+ "operator": self._stage_type_name,
531
+ "params": params,
532
+ "inputs": self._inputs._to_dict(id_for_task),
533
+ }
534
+ return d, pipeline_params
535
+
536
+ @classmethod
537
+ def _parse_params(cls, params: dict) -> dict:
538
+ return params
539
+
540
+
541
+ def stage(
542
+ *,
543
+ inputs: dict[str, PipelineOutput] | None = None,
544
+ outputs: dict[str, type[PipelineOutput]] | None = None,
545
+ stage_name: str | None = None,
546
+ params: dict[str, PipelineParameter | PipelineArgValueType] | None = None,
547
+ ) -> Callable[[Callable[..., dict[str, Any]]], RunScript]:
548
+ """
549
+ Decorator for building a RunScript stage from a Python function.
550
+
551
+ Examples
552
+ --------
553
+ >>> @pipelines.stage(
554
+ ... inputs={"geometry": read_geo.outputs.geometry},
555
+ ... outputs={"geometry": pipelines.PipelineOutputGeometry},
556
+ ... )
557
+ ... def ensure_single_volume(geometry: lc.Geometry):
558
+ ... _, volumes = geometry.list_entities()
559
+ ... if len(volumes) != 1:
560
+ ... raise pipelines.StopRun("expected exactly one volume")
561
+ ... return {"geometry": geometry}
562
+ """
563
+
564
+ def decorator(fn: Callable[..., dict[str, Any]]) -> RunScript:
565
+ return RunScript(
566
+ script=fn,
567
+ stage_name=stage_name,
568
+ inputs=inputs,
569
+ outputs=outputs,
570
+ params=params,
571
+ )
572
+
573
+ return decorator
574
+
575
+
576
+ StandardStage._registry.register(RunScript)
577
+
578
+ Stage = StandardStage | RunScript
579
+
580
+
284
581
  class Pipeline:
285
582
  def __init__(self, stages: list[Stage]):
286
583
  self.stages = stages
@@ -350,7 +647,7 @@ class Pipeline:
350
647
 
351
648
  # first, parse the pipeline parameters...
352
649
  parsed_params = {}
353
- for param_name, param_metadata in d.get("params", {}).items():
650
+ for param_name, param_metadata in (d.get("params") or {}).items():
354
651
  parsed_params[param_name] = PipelineParameter._get_subclass(param_metadata["type"])(
355
652
  param_name
356
653
  )
@@ -405,7 +702,7 @@ def _parse_stage(pipeline_dict: dict, stage_id: str, all_stages: dict[str, Stage
405
702
  stage_type_name = stage_dict[
406
703
  "operator"
407
704
  ] # TODO: change key to "stage_type" when we're ready to bump the yaml schema version
408
- stage_class = Stage._get_subclass(stage_type_name)
705
+ stage_class = StandardStage._get_subclass(stage_type_name)
409
706
 
410
707
  parsed_inputs = {}
411
708
  for input_name, input_value in stage_dict["inputs"].items():
@@ -414,13 +711,29 @@ def _parse_stage(pipeline_dict: dict, stage_id: str, all_stages: dict[str, Stage
414
711
  source_output = getattr(source_stage.outputs, source_output_name)
415
712
  parsed_inputs[input_name] = source_output
416
713
 
417
- parsed_params = stage_class._parse_params(stage_dict["params"])
418
-
419
- stage_params = {
420
- "stage_name": stage_dict["name"],
421
- **parsed_params,
422
- **parsed_inputs,
423
- }
424
- stage = stage_class(**stage_params)
714
+ parsed_params = stage_class._parse_params(stage_dict.get("params"))
715
+
716
+ if stage_class == RunScript:
717
+ user_params = parsed_params.copy()
718
+ script = user_params.pop("$script", None)
719
+ output_types = user_params.pop("$output_types", None)
720
+ entrypoint = user_params.pop("$entrypoint", None)
721
+ if script is None or output_types is None:
722
+ raise ValueError("RunScript stages must define both `$script` and `$output_types`")
723
+ stage = RunScript(
724
+ stage_name=stage_dict["name"],
725
+ script=script,
726
+ inputs=parsed_inputs,
727
+ outputs=output_types,
728
+ entrypoint=entrypoint,
729
+ params=user_params,
730
+ )
731
+ else:
732
+ stage_params = {
733
+ "stage_name": stage_dict["name"],
734
+ **parsed_params,
735
+ **parsed_inputs,
736
+ }
737
+ stage = stage_class(**stage_params)
425
738
  all_stages[stage_id] = stage
426
739
  return stage