nextmv 0.23.0__py3-none-any.whl → 0.25.0__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.
@@ -16,10 +16,21 @@ from nextmv.cloud import package
16
16
  from nextmv.cloud.acceptance_test import AcceptanceTest, ExperimentStatus, Metric
17
17
  from nextmv.cloud.batch_experiment import BatchExperiment, BatchExperimentMetadata, BatchExperimentRun
18
18
  from nextmv.cloud.client import Client, get_size
19
- from nextmv.cloud.input_set import InputSet
19
+ from nextmv.cloud.input_set import InputSet, ManagedInput
20
20
  from nextmv.cloud.instance import Instance, InstanceConfiguration
21
21
  from nextmv.cloud.manifest import Manifest
22
- from nextmv.cloud.run import ExternalRunResult, RunConfiguration, RunInformation, RunLog, RunResult, TrackedRun
22
+ from nextmv.cloud.run import (
23
+ ExternalRunResult,
24
+ Format,
25
+ FormatInput,
26
+ RunConfiguration,
27
+ RunInformation,
28
+ RunLog,
29
+ RunResult,
30
+ TrackedRun,
31
+ )
32
+ from nextmv.cloud.safe import name_and_id
33
+ from nextmv.cloud.scenario import Scenario, ScenarioInputType, _option_sets, _scenarios_by_id
23
34
  from nextmv.cloud.secrets import Secret, SecretsCollection, SecretsCollectionSummary
24
35
  from nextmv.cloud.status import StatusV2
25
36
  from nextmv.cloud.version import Version
@@ -230,11 +241,15 @@ class Application:
230
241
  Deletes a batch experiment, along with all the associated information,
231
242
  such as its runs.
232
243
 
233
- Args:
234
- batch_id: ID of the batch experiment.
244
+ Parameters
245
+ ----------
246
+ batch_id: str
247
+ ID of the batch experiment.
235
248
 
236
- Raises:
237
- requests.HTTPError: If the response status code is not 2xx.
249
+ Raises
250
+ ------
251
+ requests.HTTPError
252
+ If the response status code is not 2xx.
238
253
  """
239
254
 
240
255
  _ = self.client.request(
@@ -242,6 +257,24 @@ class Application:
242
257
  endpoint=f"{self.experiments_endpoint}/batch/{batch_id}",
243
258
  )
244
259
 
260
+ def delete_scenario_test(self, scenario_test_id: str) -> None:
261
+ """
262
+ Deletes a scenario test. Scenario tests are based on the batch
263
+ experiments API, so this function summons `delete_batch_experiment`.
264
+
265
+ Parameters
266
+ ----------
267
+ scenario_test_id: str
268
+ ID of the scenario test.
269
+
270
+ Raises
271
+ ------
272
+ requests.HTTPError
273
+ If the response status code is not 2xx.
274
+ """
275
+
276
+ self.delete_batch_experiment(batch_id=scenario_test_id)
277
+
245
278
  def delete_secrets_collection(self, secrets_collection_id: str) -> None:
246
279
  """
247
280
  Deletes a secrets collection.
@@ -359,16 +392,21 @@ class Application:
359
392
  """
360
393
  List all batch experiments.
361
394
 
362
- Returns:
395
+ Returns
396
+ -------
397
+ list[BatchExperimentMetadata]
363
398
  List of batch experiments.
364
399
 
365
- Raises:
366
- requests.HTTPError: If the response status code is not 2xx.
400
+ Raises
401
+ ------
402
+ requests.HTTPError
403
+ If the response status code is not 2xx.
367
404
  """
368
405
 
369
406
  response = self.client.request(
370
407
  method="GET",
371
408
  endpoint=f"{self.experiments_endpoint}/batch",
409
+ query_params={"type": "batch"},
372
410
  )
373
411
 
374
412
  return [BatchExperimentMetadata.from_dict(batch_experiment) for batch_experiment in response.json()]
@@ -409,6 +447,53 @@ class Application:
409
447
 
410
448
  return [Instance.from_dict(instance) for instance in response.json()]
411
449
 
450
+ def list_managed_inputs(self) -> list[ManagedInput]:
451
+ """
452
+ List all managed inputs.
453
+
454
+ Returns
455
+ -------
456
+ list[ManagedInput]
457
+ List of managed inputs.
458
+
459
+ Raises
460
+ ------
461
+ requests.HTTPError
462
+ If the response status code is not 2xx.
463
+ """
464
+
465
+ response = self.client.request(
466
+ method="GET",
467
+ endpoint=f"{self.endpoint}/inputs",
468
+ )
469
+
470
+ return [ManagedInput.from_dict(managed_input) for managed_input in response.json()]
471
+
472
+ def list_scenario_tests(self) -> list[BatchExperimentMetadata]:
473
+ """
474
+ List all batch scenario tests. Scenario tests are based on the batch
475
+ experiments API, so this function returns the same information as
476
+ `list_batch_experiments`, albeit using a different query parameter.
477
+
478
+ Returns
479
+ -------
480
+ list[BatchExperimentMetadata]
481
+ List of scenario tests.
482
+
483
+ Raises
484
+ ------
485
+ requests.HTTPError
486
+ If the response status code is not 2xx.
487
+ """
488
+
489
+ response = self.client.request(
490
+ method="GET",
491
+ endpoint=f"{self.experiments_endpoint}/batch",
492
+ query_params={"type": "scenario"},
493
+ )
494
+
495
+ return [BatchExperimentMetadata.from_dict(batch_experiment) for batch_experiment in response.json()]
496
+
412
497
  def list_secrets_collections(self) -> list[SecretsCollectionSummary]:
413
498
  """
414
499
  List all secrets collections.
@@ -445,6 +530,33 @@ class Application:
445
530
 
446
531
  return [Version.from_dict(version) for version in response.json()]
447
532
 
533
+ def managed_input(self, managed_input_id: str) -> ManagedInput:
534
+ """
535
+ Get a managed input.
536
+
537
+ Parameters
538
+ ----------
539
+ managed_input_id: str
540
+ ID of the managed input.
541
+
542
+ Returns
543
+ -------
544
+ ManagedInput
545
+ The managed input.
546
+
547
+ Raises
548
+ ------
549
+ requests.HTTPError
550
+ If the response status code is not 2xx.
551
+ """
552
+
553
+ response = self.client.request(
554
+ method="GET",
555
+ endpoint=f"{self.endpoint}/inputs/{managed_input_id}",
556
+ )
557
+
558
+ return ManagedInput.from_dict(response.json())
559
+
448
560
  @classmethod
449
561
  def new(
450
562
  cls,
@@ -640,37 +752,58 @@ class Application:
640
752
  def new_batch_experiment(
641
753
  self,
642
754
  name: str,
643
- input_set_id: str,
644
- instance_ids: list[str] = None,
755
+ input_set_id: Optional[str] = None,
756
+ instance_ids: Optional[list[str]] = None,
645
757
  description: Optional[str] = None,
646
758
  id: Optional[str] = None,
647
759
  option_sets: Optional[dict[str, dict[str, str]]] = None,
648
760
  runs: Optional[list[Union[BatchExperimentRun, dict[str, Any]]]] = None,
761
+ type: Optional[str] = "batch",
649
762
  ) -> str:
650
763
  """
651
764
  Create a new batch experiment.
652
765
 
653
- Args:
654
- name: Name of the batch experiment.
655
- input_set_id: ID of the input set to use for the experiment.
656
- instance_ids: List of instance IDs to use for the experiment.
657
- description: Description of the batch experiment.
658
- id: ID of the batch experiment.
659
- option_sets: Option sets to use for the experiment.
660
- runs: Runs to use for the experiment.
766
+ Parameters
767
+ ----------
768
+ name: str
769
+ Name of the batch experiment.
770
+ input_set_id: str
771
+ ID of the input set to use for the batch experiment.
772
+ instance_ids: list[str]
773
+ List of instance IDs to use for the batch experiment.
774
+ description: Optional[str]
775
+ Optional description of the batch experiment.
776
+ id: Optional[str]
777
+ ID of the batch experiment. Will be generated if not provided.
778
+ option_sets: Optional[dict[str, dict[str, str]]]
779
+ Option sets to use for the batch experiment. This is a dictionary
780
+ where the keys are option set IDs and the values are dictionaries
781
+ with the actual options.
782
+ runs: Optional[list[BatchExperimentRun]]
783
+ List of runs to use for the batch experiment.
784
+ type: Optional[str]
785
+ Type of the batch experiment. This is used to determine the
786
+ experiment type. The default value is "batch". If you want to
787
+ create a scenario test, set this to "scenario".
661
788
 
662
- Returns:
789
+ Returns
790
+ -------
791
+ str
663
792
  ID of the batch experiment.
664
793
 
665
- Raises:
666
- requests.HTTPError: If the response status code is not 2xx.
794
+ Raises
795
+ ------
796
+ requests.HTTPError
797
+ If the response status code is not 2xx.
667
798
  """
668
799
 
669
800
  payload = {
670
801
  "name": name,
671
- "input_set_id": input_set_id,
672
- "instance_ids": instance_ids,
673
802
  }
803
+ if input_set_id is not None:
804
+ payload["input_set_id"] = input_set_id
805
+ if instance_ids is not None:
806
+ payload["instance_ids"] = instance_ids
674
807
  if description is not None:
675
808
  payload["description"] = description
676
809
  if id is not None:
@@ -682,6 +815,8 @@ class Application:
682
815
  for i, run in enumerate(runs):
683
816
  payload_runs[i] = run.to_dict() if isinstance(run, BatchExperimentRun) else run
684
817
  payload["runs"] = payload_runs
818
+ if type is not None:
819
+ payload["type"] = type
685
820
 
686
821
  response = self.client.request(
687
822
  method="POST",
@@ -701,26 +836,61 @@ class Application:
701
836
  maximum_runs: Optional[int] = None,
702
837
  run_ids: Optional[list[str]] = None,
703
838
  start_time: Optional[datetime] = None,
839
+ inputs: Optional[list[ManagedInput]] = None,
704
840
  ) -> InputSet:
705
841
  """
706
- Create a new input set.
842
+ Create a new input set. You can create an input set from three
843
+ different methodologies:
707
844
 
708
- Args:
709
- id: ID of the input set.
710
- name: Name of the input set.
711
- description: Description of the input set.
712
- end_time: End time of the runs to construct the input set.
713
- instance_id: ID of the instance to use for the input set. If not
714
- provided, the default_instance_id will be used.
715
- maximum_runs: Maximum number of runs to use for the input set.
716
- run_ids: IDs of the runs to use for the input set.
717
- start_time: Start time of the runs to construct the input set.
845
+ 1. Using `instance_id`, `start_time`, `end_time` and `maximum_runs`.
846
+ Instance runs will be obtained from the application matching the
847
+ criteria of dates and maximum number of runs.
848
+ 2. Using `run_ids`. The input set will be created using the list of
849
+ runs specified by the user.
850
+ 3. Using `inputs`. The input set will be created using the list of
851
+ inputs specified by the user. This is useful for creating an input
852
+ set from a list of inputs that are already available in the
853
+ application.
718
854
 
719
- Returns:
720
- Input set.
855
+ Parameters
856
+ ----------
857
+ id: str
858
+ ID of the input set
859
+ name: str
860
+ Name of the input set.
861
+ description: Optional[str]
862
+ Optional description of the input set.
863
+ end_time: Optional[datetime]
864
+ End time of the input set. This is used to filter the runs
865
+ associated with the input set.
866
+ instance_id: Optional[str]
867
+ ID of the instance to use for the input set. This is used to
868
+ filter the runs associated with the input set. If not provided,
869
+ the application’s `default_instance_id` is used.
870
+ maximum_runs: Optional[int]
871
+ Maximum number of runs to use for the input set. This is used to
872
+ filter the runs associated with the input set. If not provided,
873
+ all runs are used.
874
+ run_ids: Optional[list[str]]
875
+ List of run IDs to use for the input set.
876
+ start_time: Optional[datetime]
877
+ Start time of the input set. This is used to filter the runs
878
+ associated with the input set.
879
+ inputs: Optional[list[ExperimentInput]]
880
+ List of inputs to use for the input set. This is used to create
881
+ the input set from a list of inputs that are already available in
882
+ the application.
721
883
 
722
- Raises:
723
- requests.HTTPError: If the response status code is not 2xx.
884
+
885
+ Returns
886
+ -------
887
+ InputSet
888
+ The new input set.
889
+
890
+ Raises
891
+ ------
892
+ requests.HTTPError
893
+ If the response status code is not 2xx.
724
894
  """
725
895
 
726
896
  payload = {
@@ -739,6 +909,8 @@ class Application:
739
909
  payload["run_ids"] = run_ids
740
910
  if start_time is not None:
741
911
  payload["start_time"] = start_time.isoformat()
912
+ if inputs is not None:
913
+ payload["inputs"] = [input.to_dict() for input in inputs]
742
914
 
743
915
  response = self.client.request(
744
916
  method="POST",
@@ -794,6 +966,84 @@ class Application:
794
966
 
795
967
  return Instance.from_dict(response.json())
796
968
 
969
+ def new_managed_input(
970
+ self,
971
+ id: str,
972
+ name: str,
973
+ description: Optional[str] = None,
974
+ upload_id: Optional[str] = None,
975
+ run_id: Optional[str] = None,
976
+ format: Optional[Union[Format, dict[str, any]]] = None,
977
+ ) -> ManagedInput:
978
+ """
979
+ Create a new managed input. There are two methods for creating a
980
+ managed input:
981
+
982
+ 1. Specifying the `upload_id` parameter. You may use the `upload_url`
983
+ method to obtain the upload ID and the `upload_large_input` method
984
+ to upload the data to it.
985
+ 2. Specifying the `run_id` parameter. The managed input will be
986
+ created from the run specified by the `run_id` parameter.
987
+
988
+ Either the `upload_id` or the `run_id` parameter must be specified.
989
+
990
+ Parameters
991
+ ----------
992
+ id: str
993
+ ID of the managed input.
994
+ name: str
995
+ Name of the managed input.
996
+ description: Optional[str]
997
+ Optional description of the managed input.
998
+ upload_id: Optional[str]
999
+ ID of the upload to use for the managed input.
1000
+ run_id: Optional[str]
1001
+ ID of the run to use for the managed input.
1002
+ format: Optional[Format]
1003
+ Format of the managed input. Default will be formatted as `JSON`.
1004
+
1005
+ Returns
1006
+ -------
1007
+ ManagedInput
1008
+ The new managed input.
1009
+
1010
+ Raises
1011
+ ------
1012
+ requests.HTTPError
1013
+ If the response status code is not 2xx.
1014
+ ValueError
1015
+ If neither the `upload_id` nor the `run_id` parameter is
1016
+ specified.
1017
+ """
1018
+
1019
+ if upload_id is None and run_id is None:
1020
+ raise ValueError("Either upload_id or run_id must be specified")
1021
+
1022
+ payload = {
1023
+ "id": id,
1024
+ "name": name,
1025
+ }
1026
+
1027
+ if description is not None:
1028
+ payload["description"] = description
1029
+ if upload_id is not None:
1030
+ payload["upload_id"] = upload_id
1031
+ if run_id is not None:
1032
+ payload["run_id"] = run_id
1033
+
1034
+ if format is not None:
1035
+ payload["format"] = format.to_dict() if isinstance(format, Format) else format
1036
+ else:
1037
+ payload["format"] = Format(format_input=FormatInput(input_type=InputFormat.JSON)).to_dict()
1038
+
1039
+ response = self.client.request(
1040
+ method="POST",
1041
+ endpoint=f"{self.endpoint}/inputs",
1042
+ payload=payload,
1043
+ )
1044
+
1045
+ return ManagedInput.from_dict(response.json())
1046
+
797
1047
  def new_run( # noqa: C901 # Refactor this function at some point.
798
1048
  self,
799
1049
  input: Union[Input, dict[str, Any], BaseModel, str] = None,
@@ -893,11 +1143,11 @@ class Application:
893
1143
 
894
1144
  options_dict = {}
895
1145
  if isinstance(input, Input) and input.options is not None:
896
- options_dict = input.options.to_cloud_dict()
1146
+ options_dict = input.options.to_dict_cloud()
897
1147
 
898
1148
  if options is not None:
899
1149
  if isinstance(options, Options):
900
- options_dict = options.to_cloud_dict()
1150
+ options_dict = options.to_dict_cloud()
901
1151
  elif isinstance(options, dict):
902
1152
  for k, v in options.items():
903
1153
  if isinstance(v, str):
@@ -1048,6 +1298,127 @@ class Application:
1048
1298
  polling_options=polling_options,
1049
1299
  )
1050
1300
 
1301
+ def new_scenario_test(
1302
+ self,
1303
+ id: str,
1304
+ name: str,
1305
+ scenarios: list[Scenario],
1306
+ description: Optional[str] = None,
1307
+ repetitions: Optional[int] = 0,
1308
+ ) -> str:
1309
+ """
1310
+ Create a new scenario test. The test is based on `scenarios` and you
1311
+ may specify `repetitions` to run the test multiple times. 0 repetitions
1312
+ means that the tests will be executed once. 1 repetition means that the
1313
+ test will be repeated once, i.e.: it will be executed twice. 2
1314
+ repetitions equals 3 executions, so on, and so forth.
1315
+
1316
+ For each scenario, consider the `scenario_input` and `configuration`.
1317
+ The `scenario_input.scenario_input_type` allows you to specify the data
1318
+ that will be used for that scenario.
1319
+
1320
+ - `ScenarioInputType.INPUT_SET`: the data should be taken from an
1321
+ existing input set.
1322
+ - `ScenarioInputType.INPUT`: the data should be taken from a list of
1323
+ existing inputs. When using this type, an input set will be created
1324
+ from this set of managed inputs.
1325
+ - `ScenarioInputType.New`: a new set of data will be uploaded as a set
1326
+ of managed inputs. A new input set will be created from this set of
1327
+ managed inputs.
1328
+
1329
+ On the other hand, the `configuration` allows you to specify multiple
1330
+ option variations for the scenario. Please see the
1331
+ `ScenarioConfiguration` class for more information.
1332
+
1333
+ The scenario tests uses the batch experiments API under the hood.
1334
+
1335
+ Parameters
1336
+ ----------
1337
+ id: str
1338
+ ID of the scenario test.
1339
+ name: str
1340
+ Name of the scenario test.
1341
+ scenarios: list[Scenario]
1342
+ List of scenarios to use for the scenario test. At least one
1343
+ scenario should be provided.
1344
+ description: Optional[str]
1345
+ Optional description of the scenario test.
1346
+ repetitions: Optional[int]
1347
+ Number of repetitions to use for the scenario test. 0
1348
+ repetitions means that the tests will be executed once. 1
1349
+ repetition means that the test will be repeated once, i.e.: it
1350
+ will be executed twice. 2 repetitions equals 3 executions, so on,
1351
+ and so forth.
1352
+
1353
+ Returns
1354
+ -------
1355
+ str
1356
+ ID of the scenario test.
1357
+
1358
+ Raises
1359
+ ------
1360
+ requests.HTTPError
1361
+ If the response status code is not 2xx.
1362
+ ValueError
1363
+ If no scenarios are provided.
1364
+ """
1365
+
1366
+ if len(scenarios) < 1:
1367
+ raise ValueError("At least one scenario must be provided")
1368
+
1369
+ scenarios_by_id = _scenarios_by_id(scenarios)
1370
+
1371
+ # Save all the information needed by scenario.
1372
+ input_sets = {}
1373
+ instances = {}
1374
+ for scenario_id, scenario in scenarios_by_id.items():
1375
+ instance = self.instance(instance_id=scenario.instance_id)
1376
+
1377
+ # Each scenario is associated to an input set, so we must either
1378
+ # get it or create it.
1379
+ input_set = self.__input_set_for_scenario(scenario, scenario_id)
1380
+
1381
+ instances[scenario_id] = instance
1382
+ input_sets[scenario_id] = input_set
1383
+
1384
+ # Calculate the combinations of all the option sets across scenarios.
1385
+ opt_sets_by_scenario = _option_sets(scenarios)
1386
+
1387
+ # The scenario tests results in multiple individual runs.
1388
+ runs = []
1389
+ run_counter = 0
1390
+ opt_sets = {}
1391
+ for scenario_id, scenario_opt_sets in opt_sets_by_scenario.items():
1392
+ opt_sets = {**opt_sets, **scenario_opt_sets}
1393
+ input_set = input_sets[scenario_id]
1394
+ scenario = scenarios_by_id[scenario_id]
1395
+
1396
+ for set_key in scenario_opt_sets.keys():
1397
+ inputs = input_set.input_ids if len(input_set.input_ids) > 0 else input_set.inputs
1398
+ for input in inputs:
1399
+ input_id = input.id if isinstance(input, ManagedInput) else input
1400
+ for repetition in range(repetitions + 1):
1401
+ run_counter += 1
1402
+ run = BatchExperimentRun(
1403
+ input_id=input_id,
1404
+ input_set_id=input_set.id,
1405
+ instance_id=scenario.instance_id,
1406
+ option_set=set_key,
1407
+ scenario_id=scenario_id,
1408
+ repetition=repetition,
1409
+ run_number=f"{run_counter}",
1410
+ )
1411
+ runs.append(run)
1412
+
1413
+ return self.new_batch_experiment(
1414
+ id=id,
1415
+ name=name,
1416
+ description=description,
1417
+ type="scenario",
1418
+ option_sets=opt_sets,
1419
+ runs=runs,
1420
+ )
1421
+
1051
1422
  def new_secrets_collection(
1052
1423
  self,
1053
1424
  secrets: list[Secret],
@@ -1201,12 +1572,12 @@ class Application:
1201
1572
 
1202
1573
 
1203
1574
  # Define the options that the model needs.
1204
- parameters = []
1575
+ opt = []
1205
1576
  default_options = nextroute.Options()
1206
1577
  for name, default_value in default_options.to_dict().items():
1207
- parameters.append(nextmv.Parameter(name.lower(), type(default_value), default_value, name, False))
1578
+ opt.append(nextmv.Option(name.lower(), type(default_value), default_value, name, False))
1208
1579
 
1209
- options = nextmv.Options(*parameters)
1580
+ options = nextmv.Options(*opt)
1210
1581
 
1211
1582
  # Instantiate the model and model configuration.
1212
1583
  model = DecisionModel()
@@ -1326,7 +1697,10 @@ class Application:
1326
1697
  endpoint=f"{self.endpoint}/runs/{run_id}/metadata",
1327
1698
  )
1328
1699
 
1329
- return RunInformation.from_dict(response.json())
1700
+ info = RunInformation.from_dict(response.json())
1701
+ info.console_url = self.__console_url(info.id)
1702
+
1703
+ return info
1330
1704
 
1331
1705
  def run_logs(self, run_id: str) -> RunLog:
1332
1706
  """
@@ -1401,7 +1775,31 @@ class Application:
1401
1775
 
1402
1776
  return self.__run_result(run_id=run_id, run_information=run_information)
1403
1777
 
1404
- def track_run(self, tracked_run: TrackedRun) -> str:
1778
+ def scenario_test(self, scenario_test_id: str) -> BatchExperiment:
1779
+ """
1780
+ Get the scenario test. Scenario tests are based on batch experiments,
1781
+ so this function will return the corresponding batch experiment
1782
+ associated to the scenario test.
1783
+
1784
+ Parameters
1785
+ ----------
1786
+ scenario_test_id : str
1787
+ ID of the scenario test.
1788
+
1789
+ Returns
1790
+ -------
1791
+ BatchExperiment
1792
+ The scenario test.
1793
+
1794
+ Raises
1795
+ ------
1796
+ requests.HTTPError
1797
+ If the response status code is not 2xx.
1798
+ """
1799
+
1800
+ return self.batch_experiment(batch_id=scenario_test_id)
1801
+
1802
+ def track_run(self, tracked_run: TrackedRun, instance_id: Optional[str] = None) -> str:
1405
1803
  """
1406
1804
  Track an external run.
1407
1805
 
@@ -1414,6 +1812,9 @@ class Application:
1414
1812
  ----------
1415
1813
  tracked_run : TrackedRun
1416
1814
  The run to track.
1815
+ instance_id: Optional[str]
1816
+ Optional instance ID if you want to associate your tracked run with
1817
+ an instance.
1417
1818
 
1418
1819
  Returns
1419
1820
  -------
@@ -1431,7 +1832,7 @@ class Application:
1431
1832
 
1432
1833
  upload_input = tracked_run.input
1433
1834
  if isinstance(tracked_run.input, Input):
1434
- upload_input = tracked_run.input.to_dict()
1835
+ upload_input = tracked_run.input.data
1435
1836
 
1436
1837
  self.upload_large_input(input=upload_input, upload_url=url_input)
1437
1838
 
@@ -1457,12 +1858,17 @@ class Application:
1457
1858
  if tracked_run.error is not None and tracked_run.error != "":
1458
1859
  external_result.error_message = tracked_run.error
1459
1860
 
1460
- return self.new_run(upload_id=url_input.upload_id, external_result=external_result)
1861
+ return self.new_run(
1862
+ upload_id=url_input.upload_id,
1863
+ external_result=external_result,
1864
+ instance_id=instance_id,
1865
+ )
1461
1866
 
1462
1867
  def track_run_with_result(
1463
1868
  self,
1464
1869
  tracked_run: TrackedRun,
1465
1870
  polling_options: PollingOptions = _DEFAULT_POLLING_OPTIONS,
1871
+ instance_id: Optional[str] = None,
1466
1872
  ) -> RunResult:
1467
1873
  """
1468
1874
  Track an external run and poll for the result. This is a convenience
@@ -1476,6 +1882,9 @@ class Application:
1476
1882
  The run to track.
1477
1883
  polling_options : PollingOptions
1478
1884
  Options to use when polling for the run result.
1885
+ instance_id: Optional[str]
1886
+ Optional instance ID if you want to associate your tracked run with
1887
+ an instance.
1479
1888
 
1480
1889
  Returns
1481
1890
  -------
@@ -1495,7 +1904,7 @@ class Application:
1495
1904
  If the run does not succeed after the polling strategy is
1496
1905
  exhausted based on number of tries.
1497
1906
  """
1498
- run_id = self.track_run(tracked_run=tracked_run)
1907
+ run_id = self.track_run(tracked_run=tracked_run, instance_id=instance_id)
1499
1908
 
1500
1909
  return self.run_result_with_polling(
1501
1910
  run_id=run_id,
@@ -1546,6 +1955,45 @@ class Application:
1546
1955
 
1547
1956
  return Instance.from_dict(response.json())
1548
1957
 
1958
+ def update_managed_input(
1959
+ self,
1960
+ managed_input_id: str,
1961
+ name: str,
1962
+ description: str,
1963
+ ) -> None:
1964
+ """
1965
+ Update a managed input.
1966
+
1967
+ Parameters
1968
+ ----------
1969
+ managed_input_id : str
1970
+ ID of the managed input to update.
1971
+ name : str
1972
+ Name of the managed input.
1973
+ description : str
1974
+ Description of the managed input.
1975
+
1976
+ Returns
1977
+ -------
1978
+ None
1979
+ No return value.
1980
+
1981
+ Raises
1982
+ ------
1983
+ requests.HTTPError
1984
+ If the response status code is not 2xx.
1985
+ """
1986
+
1987
+ payload = {
1988
+ "name": name,
1989
+ "description": description,
1990
+ }
1991
+ _ = self.client.request(
1992
+ method="PUT",
1993
+ endpoint=f"{self.endpoint}/inputs/{managed_input_id}",
1994
+ payload=payload,
1995
+ )
1996
+
1549
1997
  def update_secrets_collection(
1550
1998
  self,
1551
1999
  secrets_collection_id: str,
@@ -1605,7 +2053,7 @@ class Application:
1605
2053
  if isinstance(input, dict):
1606
2054
  input = json.dumps(input)
1607
2055
 
1608
- _ = self.client.upload_to_presigned_url(
2056
+ self.client.upload_to_presigned_url(
1609
2057
  url=upload_url.upload_url,
1610
2058
  data=input,
1611
2059
  )
@@ -1702,6 +2150,8 @@ class Application:
1702
2150
  query_params=query_params,
1703
2151
  )
1704
2152
  result = RunResult.from_dict(response.json())
2153
+ result.console_url = self.__console_url(result.id)
2154
+
1705
2155
  if not large_output:
1706
2156
  return result
1707
2157
 
@@ -1766,6 +2216,61 @@ class Application:
1766
2216
  )
1767
2217
  )
1768
2218
 
2219
+ def __console_url(self, run_id: str) -> str:
2220
+ """Auxiliary method to get the console URL for a run."""
2221
+
2222
+ return f"{self.client.console_url}/app/{self.id}/run/{run_id}?view=details"
2223
+
2224
+ def __input_set_for_scenario(self, scenario: Scenario, scenario_id: str) -> InputSet:
2225
+ # If working with an input set, there is no need to create one.
2226
+ if scenario.scenario_input.scenario_input_type == ScenarioInputType.INPUT_SET:
2227
+ input_set = self.input_set(input_set_id=scenario.scenario_input.scenario_input_data)
2228
+ return input_set
2229
+
2230
+ # If working with a list of managed inputs, we need to create an
2231
+ # input set.
2232
+ if scenario.scenario_input.scenario_input_type == ScenarioInputType.INPUT:
2233
+ name, id = name_and_id(prefix="inpset", entity_id=scenario_id)
2234
+ input_set = self.new_input_set(
2235
+ id=id,
2236
+ name=name,
2237
+ description=f"Automatically created from scenario test: {id}",
2238
+ maximum_runs=20,
2239
+ inputs=[
2240
+ ManagedInput.from_dict(data={"id": input_id})
2241
+ for input_id in scenario.scenario_input.scenario_input_data
2242
+ ],
2243
+ )
2244
+ return input_set
2245
+
2246
+ # If working with new data, we need to create managed inputs, and then,
2247
+ # an input set.
2248
+ if scenario.scenario_input.scenario_input_type == ScenarioInputType.NEW:
2249
+ managed_inputs = []
2250
+ for data in scenario.scenario_input.scenario_input_data:
2251
+ upload_url = self.upload_url()
2252
+ self.upload_large_input(input=data, upload_url=upload_url)
2253
+ name, id = name_and_id(prefix="man-input", entity_id=scenario_id)
2254
+ managed_input = self.new_managed_input(
2255
+ id=id,
2256
+ name=name,
2257
+ description=f"Automatically created from scenario test: {id}",
2258
+ upload_id=upload_url.upload_id,
2259
+ )
2260
+ managed_inputs.append(managed_input)
2261
+
2262
+ name, id = name_and_id(prefix="inpset", entity_id=scenario_id)
2263
+ input_set = self.new_input_set(
2264
+ id=id,
2265
+ name=name,
2266
+ description=f"Automatically created from scenario test: {id}",
2267
+ maximum_runs=20,
2268
+ inputs=managed_inputs,
2269
+ )
2270
+ return input_set
2271
+
2272
+ raise ValueError(f"Unknown scenario input type: {scenario.scenario_input.scenario_input_type}")
2273
+
1769
2274
 
1770
2275
  def poll(polling_options: PollingOptions, polling_func: Callable[[], tuple[any, bool]]) -> any:
1771
2276
  """