nextpipe 0.7.3.dev0__tar.gz → 0.7.4.dev0__tar.gz

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 (73) hide show
  1. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/PKG-INFO +1 -1
  2. nextpipe-0.7.4.dev0/nextpipe/__about__.py +1 -0
  3. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/nextpipe/decorators.py +113 -19
  4. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/nextpipe/flow.py +71 -4
  5. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/pyproject.toml +2 -2
  6. nextpipe-0.7.4.dev0/tests/pipelines/foreach-2-pred.json.golden +80 -0
  7. nextpipe-0.7.4.dev0/tests/pipelines/foreach.json.golden +65 -0
  8. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/test_integration.py +4 -3
  9. nextpipe-0.7.4.dev0/tests/test_optional.py +246 -0
  10. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/uv.lock +38 -38
  11. nextpipe-0.7.3.dev0/nextpipe/__about__.py +0 -1
  12. nextpipe-0.7.3.dev0/tests/pipelines/foreach-2-pred.json.golden +0 -65
  13. nextpipe-0.7.3.dev0/tests/pipelines/foreach.json.golden +0 -56
  14. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/.github/workflows/lint.yml +0 -0
  15. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/.github/workflows/release.yml +0 -0
  16. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/.github/workflows/test.yml +0 -0
  17. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/.gitignore +0 -0
  18. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/.nextmv/bump_requirements.sh +0 -0
  19. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/.prettierrc.yml +0 -0
  20. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/.python-version +0 -0
  21. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/LICENSE.md +0 -0
  22. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/README.md +0 -0
  23. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/RELEASE.md +0 -0
  24. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/nextpipe/__init__.py +0 -0
  25. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/nextpipe/config.py +0 -0
  26. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/nextpipe/constants.py +0 -0
  27. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/nextpipe/graph.py +0 -0
  28. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/nextpipe/schema.py +0 -0
  29. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/nextpipe/threads.py +0 -0
  30. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/nextpipe/uplink.py +0 -0
  31. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/nextpipe/utils.py +0 -0
  32. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/nextpipe.code-workspace +0 -0
  33. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/.gitignore +0 -0
  34. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/README.md +0 -0
  35. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/__init__.py +0 -0
  36. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/apps/echo/.gitignore +0 -0
  37. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/apps/echo/README.md +0 -0
  38. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/apps/echo/app.yaml +0 -0
  39. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/apps/echo/main.py +0 -0
  40. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/apps/echo/requirements.txt +0 -0
  41. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/apps/echo-multi/.gitignore +0 -0
  42. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/apps/echo-multi/README.md +0 -0
  43. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/apps/echo-multi/app.yaml +0 -0
  44. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/apps/echo-multi/inputs/data.csv +0 -0
  45. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/apps/echo-multi/inputs/input.xlsx +0 -0
  46. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/apps/echo-multi/main.py +0 -0
  47. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/apps/echo-multi/requirements.txt +0 -0
  48. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/deploy/app.yaml +0 -0
  49. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/deploy/main.py +0 -0
  50. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/deploy/requirements.txt +0 -0
  51. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/appapp.json +0 -0
  52. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/appapp.json.golden +0 -0
  53. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/appapp.py +0 -0
  54. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/chain.json +0 -0
  55. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/chain.json.golden +0 -0
  56. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/chain.py +0 -0
  57. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/complex.json +0 -0
  58. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/complex.json.golden +0 -0
  59. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/complex.py +0 -0
  60. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/fail.py +0 -0
  61. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/foreach-2-pred.json +0 -0
  62. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/foreach-2-pred.py +0 -0
  63. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/foreach.json +0 -0
  64. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/foreach.py +0 -0
  65. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/inputs/data.csv +0 -0
  66. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/inputs/input.xlsx +0 -0
  67. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/pipelines/multifile.py +0 -0
  68. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/test_flow.py +0 -0
  69. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/test_graph.py +0 -0
  70. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/test_schema.py +0 -0
  71. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/test_threads.py +0 -0
  72. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/test_uplink.py +0 -0
  73. {nextpipe-0.7.3.dev0 → nextpipe-0.7.4.dev0}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nextpipe
3
- Version: 0.7.3-dev.0
3
+ Version: 0.7.4.dev0
4
4
  Summary: Framework for Decision Pipeline modeling and execution
5
5
  Project-URL: Homepage, https://www.nextmv.io
6
6
  Project-URL: Documentation, https://docs.nextmv.io/nextpipe
@@ -0,0 +1 @@
1
+ __version__ = "v0.7.4.dev0"
@@ -45,6 +45,7 @@ app
45
45
  Decorator to mark a step as a Nextmv Application.
46
46
  """
47
47
 
48
+ import inspect
48
49
  import typing
49
50
  from collections.abc import Callable
50
51
  from enum import Enum
@@ -177,17 +178,37 @@ class Step:
177
178
 
178
179
  return hasattr(self, "needs")
179
180
 
181
+ def is_optional(self):
182
+ """
183
+ Check if the step is optional.
184
+
185
+ Returns
186
+ -------
187
+ bool
188
+ True if the step is optional, False otherwise.
189
+ """
190
+
191
+ return hasattr(self, "optional")
192
+
180
193
  def skip(self):
181
194
  """
182
195
  Check if the step should be skipped.
183
196
 
197
+ Deprecated: skipping is decided by the runner, which also passes the
198
+ step's inputs to the condition and propagates skips. Use
199
+ `is_optional()` to check whether a step has a condition.
200
+
184
201
  Returns
185
202
  -------
186
203
  bool
187
204
  True if the step should be skipped, False otherwise.
188
205
  """
189
206
 
190
- return hasattr(self, "optional") and not self.optional.condition(self)
207
+ deprecated(
208
+ "Step.skip",
209
+ "Step.skip() is deprecated and will be removed in a future release. Use Step.is_optional() instead.",
210
+ )
211
+ return self.is_optional() and not self.optional.condition(self)
191
212
 
192
213
  def is_repeat(self):
193
214
  """
@@ -435,9 +456,12 @@ class Optional:
435
456
  condition : callable
436
457
  A function that takes a step and returns a boolean indicating
437
458
  whether the step should be executed or not.
459
+ skip_downstream : bool
460
+ Whether skipping this step also skips the steps that depend only on
461
+ it.
438
462
  """
439
463
 
440
- def __init__(self, condition: callable):
464
+ def __init__(self, condition: callable, skip_downstream: bool = False):
441
465
  """
442
466
  Initialize an Optional object.
443
467
 
@@ -446,9 +470,45 @@ class Optional:
446
470
  condition : callable
447
471
  A function that takes a step and returns a boolean indicating
448
472
  whether the step should be executed or not.
473
+ skip_downstream : bool, optional
474
+ Whether skipping this step also skips the steps that depend only
475
+ on it, by default False.
449
476
  """
450
477
 
451
478
  self.condition = condition
479
+ self.skip_downstream = skip_downstream
480
+
481
+ def evaluate(self, step: Step, inputs: list[typing.Any]) -> bool:
482
+ """
483
+ Evaluate the condition.
484
+
485
+ Conditions with a single required parameter only receive the step.
486
+ Conditions with more required parameters (or `*args`) additionally
487
+ receive the inputs of the step (the results of its predecessors, or the
488
+ flow input if it has none).
489
+
490
+ Parameters
491
+ ----------
492
+ step : Step
493
+ The step the condition belongs to.
494
+ inputs : list[Any]
495
+ The inputs of the step.
496
+
497
+ Returns
498
+ -------
499
+ bool
500
+ True if the step should be executed, False otherwise.
501
+ """
502
+
503
+ # Parameters with defaults are not counted, and `self` of bound methods
504
+ # is not part of the signature.
505
+ params = inspect.signature(self.condition).parameters.values()
506
+ positional = (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
507
+ required = [p for p in params if p.kind in positional and p.default is inspect.Parameter.empty]
508
+ varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params)
509
+ if len(required) <= 1 and not varargs:
510
+ return self.condition(step)
511
+ return self.condition(step, *inputs)
452
512
 
453
513
  def __repr__(self):
454
514
  """
@@ -460,10 +520,10 @@ class Optional:
460
520
  A string representation of the optional condition.
461
521
  """
462
522
 
463
- return f"StepOnlyIf({self.condition})"
523
+ return f"StepOnlyIf({self.condition}, skip_downstream={self.skip_downstream})"
464
524
 
465
525
 
466
- def optional(condition: Callable[[Step], bool]):
526
+ def optional(condition: Callable[..., bool], skip_downstream: bool = False):
467
527
  """
468
528
  Decorator to mark a step as optional.
469
529
 
@@ -474,41 +534,75 @@ def optional(condition: Callable[[Step], bool]):
474
534
  ```
475
535
 
476
536
  This is used to determine whether the step should be executed or not. The
477
- condition is a callable that takes the step as an argument and returns a
478
- boolean indicating whether the step should be executed or not. The
479
- condition is evaluated at runtime, so it can depend on the runtime state of
480
- the pipeline.
537
+ condition is evaluated at runtime, right before the step would run. It
538
+ receives the step and, if it accepts more arguments, the inputs of the step
539
+ (the results of its predecessors, or the flow input if it has none).
540
+
541
+ A skipped step has the status `skipped` and its result is `None`. By
542
+ default, only the step itself is skipped: its successors still run and
543
+ receive `None` as its result.
544
+
545
+ With `skip_downstream=True`, the skip propagates: a step whose
546
+ predecessors were all skipped this way is skipped as well (and so on). A
547
+ step with at least one other predecessor still runs and receives `None`
548
+ for each skipped one. This allows modeling if/else branches that are
549
+ merged again by a common successor.
481
550
 
482
551
  Parameters
483
552
  ----------
484
- condition : Callable[[Step], bool]
553
+ condition : Callable[..., bool]
485
554
  The condition to evaluate. This is a callable that takes the step
486
- as an argument and returns a boolean indicating whether the step
487
- should be executed or not.
555
+ (and optionally the inputs of the step) as arguments and returns a
556
+ boolean indicating whether the step should be executed or not.
557
+ skip_downstream : bool, optional
558
+ Whether skipping this step also skips the steps that depend only on
559
+ it, by default False.
488
560
 
489
561
  Example
490
562
  -------
491
- In this example the step `step1` is executed given that the condition is
492
- true.
563
+ In this example either `solve_small` or `solve_large` (and its successor
564
+ `polish`) is executed, depending on the input. `report` merges both branches.
493
565
 
494
566
  ```
495
- from nextpipe import FlowSpec, log, optional, step
567
+ from nextpipe import FlowSpec, log, needs, optional, step
496
568
 
497
569
 
498
570
  class Flow(FlowSpec):
499
- @optional(condition=lambda step: step.get_id() == "step1")
500
571
  @step
501
- def step1() -> None:
502
- log("Execute optional step 1")
572
+ def prepare(data: dict) -> dict:
573
+ return data
503
574
 
575
+ @optional(condition=lambda step, data: len(data["orders"]) <= 10, skip_downstream=True)
576
+ @needs(predecessors=[prepare])
577
+ @step
578
+ def solve_small(data: dict) -> dict:
579
+ return {"solver": "small"}
504
580
 
505
- flow = Flow("DecisionFlow", None)
581
+ @optional(condition=lambda step, data: len(data["orders"]) > 10, skip_downstream=True)
582
+ @needs(predecessors=[prepare])
583
+ @step
584
+ def solve_large(data: dict) -> dict:
585
+ return {"solver": "large"}
586
+
587
+ @needs(predecessors=[solve_large])
588
+ @step
589
+ def polish(result: dict) -> dict: # skipped if solve_large is skipped
590
+ return result | {"polished": True}
591
+
592
+ @needs(predecessors=[solve_small, polish])
593
+ @step
594
+ def report(small: dict | None, large: dict | None) -> dict:
595
+ return small or large
596
+
597
+
598
+ flow = Flow("DecisionFlow", {"orders": [1, 2, 3]})
506
599
  flow.run()
600
+ log(flow.get_result(flow.report)) # {'solver': 'small'}
507
601
  ```
508
602
  """
509
603
 
510
604
  def decorator(function):
511
- function.step.optional = Optional(condition)
605
+ function.step.optional = Optional(condition, skip_downstream)
512
606
  return function
513
607
 
514
608
  return decorator
@@ -59,6 +59,9 @@ STATUS_SUCCEEDED = "succeeded"
59
59
  STATUS_FAILED = "failed"
60
60
  """Status constant indicating a failed step or node."""
61
61
 
62
+ STATUS_SKIPPED = "skipped"
63
+ """Status constant indicating a skipped step or node."""
64
+
62
65
 
63
66
  class FlowStep:
64
67
  """
@@ -89,6 +92,10 @@ class FlowStep:
89
92
  Thread lock for this step.
90
93
  done : bool
91
94
  Whether this step has completed execution.
95
+ skipped : bool
96
+ Whether this step was skipped.
97
+ skip_downstream : bool
98
+ Whether the skip of this step propagates to its successors.
92
99
  successors : list[FlowStep]
93
100
  Steps that depend on this step.
94
101
  predecessors : list[FlowStep]
@@ -121,6 +128,8 @@ class FlowStep:
121
128
  self.docstring = docstring
122
129
  self.lock = threading.Lock()
123
130
  self.done = False
131
+ self.skipped = False
132
+ self.skip_downstream = False
124
133
  self.successors: list[FlowStep] = []
125
134
  self.predecessors: list[FlowStep] = []
126
135
  self.nodes: list[FlowNode] = []
@@ -162,7 +171,7 @@ class FlowNode:
162
171
  id : str
163
172
  Unique identifier for this node.
164
173
  status : str
165
- Current execution status (pending, running, succeeded, failed).
174
+ Current execution status (pending, running, succeeded, failed, skipped).
166
175
  error : str or None
167
176
  Error message if the node execution failed.
168
177
  predecessors : list[FlowNode]
@@ -804,6 +813,10 @@ class Runner:
804
813
  predecessor_inputs = {}
805
814
  for predecessor in step.predecessors:
806
815
  predecessor_results = [res.result for res in predecessor.nodes]
816
+ if predecessor.skipped:
817
+ # Skipped predecessors contribute a single None input.
818
+ predecessor_inputs[predecessor] = [None]
819
+ continue
807
820
  if predecessor.definition.is_foreach():
808
821
  # Make sure the result is in fact a list.
809
822
  if len(predecessor_results) != 1 or not isinstance(predecessor_results[0], list):
@@ -1138,6 +1151,53 @@ class Runner:
1138
1151
  output = node.parent.definition.function(*inputs)
1139
1152
  return output
1140
1153
 
1154
+ @staticmethod
1155
+ def __skipped_by_predecessors(step: FlowStep) -> bool:
1156
+ """
1157
+ Check whether a step is skipped because of its predecessors.
1158
+
1159
+ This is the case if all of its predecessors were skipped with
1160
+ `skip_downstream`. A step with at least one other predecessor still runs
1161
+ and receives `None` for each skipped one (e.g., merging branches).
1162
+
1163
+ Parameters
1164
+ ----------
1165
+ step : FlowStep
1166
+ The step to check. All its predecessors must be done.
1167
+
1168
+ Returns
1169
+ -------
1170
+ bool
1171
+ True if the step is skipped because of its predecessors.
1172
+ """
1173
+
1174
+ return bool(step.predecessors) and all(p.skipped and p.skip_downstream for p in step.predecessors)
1175
+
1176
+ def __should_skip(self, step: FlowStep) -> bool:
1177
+ """
1178
+ Determine whether a step should be skipped.
1179
+
1180
+ A step is skipped if it is skipped because of its predecessors (see
1181
+ `__skipped_by_predecessors`) or if it is decorated with `@optional` and
1182
+ its condition is not met.
1183
+
1184
+ Parameters
1185
+ ----------
1186
+ step : FlowStep
1187
+ The step to check. All its predecessors must be done.
1188
+
1189
+ Returns
1190
+ -------
1191
+ bool
1192
+ True if the step should be skipped, False otherwise.
1193
+ """
1194
+
1195
+ if self.__skipped_by_predecessors(step):
1196
+ return True
1197
+ if not step.definition.is_optional():
1198
+ return False
1199
+ return not step.definition.optional.evaluate(step.definition, self.spec._get_inputs(step))
1200
+
1141
1201
  def __create_job(self, node: FlowNode, inputs: list[Any] | Any) -> threads.Job:
1142
1202
  """
1143
1203
  Create a job for executing a step.
@@ -1207,14 +1267,21 @@ class Runner:
1207
1267
  # No more steps to run at this point. Wait for the remaining tasks to finish.
1208
1268
  break
1209
1269
  open_steps.remove(step)
1210
- # Skip the step if it is optional and the condition is not met
1211
- if step.definition.skip():
1270
+ # Skip the step if its predecessors were skipped (with skip_downstream) or its condition is not met
1271
+ if self.__should_skip(step):
1212
1272
  utils.log_internal(f"Skipping step {step.definition.get_id()}")
1213
1273
  # Create dummy node
1214
1274
  node = FlowNode(step, 0)
1215
- node.status = STATUS_SUCCEEDED
1275
+ node.status = STATUS_SKIPPED
1216
1276
  node.result = None
1277
+ node.done = True
1217
1278
  step.nodes.append(node)
1279
+ step.skipped = True
1280
+ # Propagated skips keep propagating, own skips only if requested
1281
+ step.skip_downstream = (
1282
+ self.__skipped_by_predecessors(step) or step.definition.optional.skip_downstream
1283
+ )
1284
+ step.done = True
1218
1285
  closed_steps.add(step)
1219
1286
  open_steps.update(step.successors)
1220
1287
  self.uplink.submit_update(self.graph._to_uplink_dto())
@@ -70,7 +70,7 @@ path = "nextpipe/__about__.py"
70
70
 
71
71
  [dependency-groups]
72
72
  dev = [
73
- "goldie==0.1.8",
74
- "pytest==9.0.2",
73
+ "goldie==0.1.10",
74
+ "pytest==9.1.1",
75
75
  "ruff==0.16.3",
76
76
  ]
@@ -0,0 +1,80 @@
1
+ [
2
+ [
3
+ {
4
+ "options": {},
5
+ "solution": {
6
+ "echo": {
7
+ "data": {
8
+ "hello": "world!"
9
+ },
10
+ "args": [
11
+ "-param=0"
12
+ ]
13
+ }
14
+ },
15
+ "assets": [],
16
+ "metrics": {
17
+ "run": {
18
+ "duration": 0.123
19
+ }
20
+ }
21
+ },
22
+ {
23
+ "stats": {
24
+ "count": 1
25
+ }
26
+ }
27
+ ],
28
+ [
29
+ {
30
+ "options": {},
31
+ "solution": {
32
+ "echo": {
33
+ "data": {
34
+ "hello": "world!"
35
+ },
36
+ "args": [
37
+ "-param=1"
38
+ ]
39
+ }
40
+ },
41
+ "assets": [],
42
+ "metrics": {
43
+ "run": {
44
+ "duration": 0.123
45
+ }
46
+ }
47
+ },
48
+ {
49
+ "stats": {
50
+ "count": 1
51
+ }
52
+ }
53
+ ],
54
+ [
55
+ {
56
+ "options": {},
57
+ "solution": {
58
+ "echo": {
59
+ "data": {
60
+ "hello": "world!"
61
+ },
62
+ "args": [
63
+ "-param=2"
64
+ ]
65
+ }
66
+ },
67
+ "assets": [],
68
+ "metrics": {
69
+ "run": {
70
+ "duration": 0.123
71
+ }
72
+ }
73
+ },
74
+ {
75
+ "stats": {
76
+ "count": 1
77
+ }
78
+ }
79
+ ]
80
+ ]
@@ -0,0 +1,65 @@
1
+ [
2
+ [
3
+ {
4
+ "options": {},
5
+ "solution": {
6
+ "echo": {
7
+ "data": {
8
+ "hello": "world!"
9
+ },
10
+ "args": [
11
+ "-param=0"
12
+ ]
13
+ }
14
+ },
15
+ "assets": [],
16
+ "metrics": {
17
+ "run": {
18
+ "duration": 0.123
19
+ }
20
+ }
21
+ }
22
+ ],
23
+ [
24
+ {
25
+ "options": {},
26
+ "solution": {
27
+ "echo": {
28
+ "data": {
29
+ "hello": "world!"
30
+ },
31
+ "args": [
32
+ "-param=1"
33
+ ]
34
+ }
35
+ },
36
+ "assets": [],
37
+ "metrics": {
38
+ "run": {
39
+ "duration": 0.123
40
+ }
41
+ }
42
+ }
43
+ ],
44
+ [
45
+ {
46
+ "options": {},
47
+ "solution": {
48
+ "echo": {
49
+ "data": {
50
+ "hello": "world!"
51
+ },
52
+ "args": [
53
+ "-param=2"
54
+ ]
55
+ }
56
+ },
57
+ "assets": [],
58
+ "metrics": {
59
+ "run": {
60
+ "duration": 0.123
61
+ }
62
+ }
63
+ }
64
+ ]
65
+ ]
@@ -147,9 +147,10 @@ class TestExample(unittest.TestCase):
147
147
  # FOREACH
148
148
  config.comparison_configuration.json_processing_config = goldie.ConfigProcessJson(
149
149
  replacements=[
150
- goldie.JsonReplacement(path="$[0].metrics.run.duration", value=0.123),
151
- goldie.JsonReplacement(path="$[1].metrics.run.duration", value=0.123),
152
- goldie.JsonReplacement(path="$[2].metrics.run.duration", value=0.123),
150
+ # A join step receives one entry per parallel run, each holding one result per predecessor
151
+ goldie.JsonReplacement(path="$[0][0].metrics.run.duration", value=0.123),
152
+ goldie.JsonReplacement(path="$[1][0].metrics.run.duration", value=0.123),
153
+ goldie.JsonReplacement(path="$[2][0].metrics.run.duration", value=0.123),
153
154
  ],
154
155
  )
155
156
  goldie.run_file_unittest(