torchx-nightly 2025.7.9__py3-none-any.whl → 2025.11.12__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 (51) hide show
  1. torchx/{schedulers/ray/__init__.py → _version.py} +3 -1
  2. torchx/cli/cmd_list.py +1 -2
  3. torchx/cli/cmd_run.py +202 -28
  4. torchx/cli/cmd_tracker.py +1 -1
  5. torchx/components/__init__.py +1 -8
  6. torchx/components/dist.py +9 -3
  7. torchx/components/integration_tests/component_provider.py +2 -2
  8. torchx/components/utils.py +1 -1
  9. torchx/distributed/__init__.py +1 -1
  10. torchx/runner/api.py +92 -81
  11. torchx/runner/config.py +11 -9
  12. torchx/runner/events/__init__.py +20 -10
  13. torchx/runner/events/api.py +1 -1
  14. torchx/schedulers/__init__.py +7 -10
  15. torchx/schedulers/api.py +20 -15
  16. torchx/schedulers/aws_batch_scheduler.py +45 -2
  17. torchx/schedulers/docker_scheduler.py +3 -0
  18. torchx/schedulers/kubernetes_scheduler.py +200 -17
  19. torchx/schedulers/local_scheduler.py +1 -0
  20. torchx/schedulers/slurm_scheduler.py +160 -26
  21. torchx/specs/__init__.py +23 -6
  22. torchx/specs/api.py +279 -33
  23. torchx/specs/builders.py +109 -28
  24. torchx/specs/file_linter.py +117 -53
  25. torchx/specs/finder.py +25 -37
  26. torchx/specs/named_resources_aws.py +13 -2
  27. torchx/tracker/__init__.py +2 -2
  28. torchx/tracker/api.py +1 -1
  29. torchx/util/entrypoints.py +1 -6
  30. torchx/util/strings.py +1 -1
  31. torchx/util/types.py +12 -1
  32. torchx/version.py +2 -2
  33. torchx/workspace/api.py +102 -5
  34. {torchx_nightly-2025.7.9.dist-info → torchx_nightly-2025.11.12.dist-info}/METADATA +34 -48
  35. {torchx_nightly-2025.7.9.dist-info → torchx_nightly-2025.11.12.dist-info}/RECORD +39 -51
  36. {torchx_nightly-2025.7.9.dist-info → torchx_nightly-2025.11.12.dist-info}/WHEEL +1 -1
  37. torchx/examples/pipelines/__init__.py +0 -0
  38. torchx/examples/pipelines/kfp/__init__.py +0 -0
  39. torchx/examples/pipelines/kfp/advanced_pipeline.py +0 -289
  40. torchx/examples/pipelines/kfp/dist_pipeline.py +0 -71
  41. torchx/examples/pipelines/kfp/intro_pipeline.py +0 -83
  42. torchx/pipelines/kfp/__init__.py +0 -30
  43. torchx/pipelines/kfp/adapter.py +0 -274
  44. torchx/pipelines/kfp/version.py +0 -19
  45. torchx/schedulers/gcp_batch_scheduler.py +0 -497
  46. torchx/schedulers/ray/ray_common.py +0 -22
  47. torchx/schedulers/ray/ray_driver.py +0 -307
  48. torchx/schedulers/ray_scheduler.py +0 -454
  49. {torchx_nightly-2025.7.9.dist-info → torchx_nightly-2025.11.12.dist-info}/entry_points.txt +0 -0
  50. {torchx_nightly-2025.7.9.dist-info → torchx_nightly-2025.11.12.dist-info/licenses}/LICENSE +0 -0
  51. {torchx_nightly-2025.7.9.dist-info → torchx_nightly-2025.11.12.dist-info}/top_level.txt +0 -0
torchx/specs/__init__.py CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env python3
2
1
  # Copyright (c) Meta Platforms, Inc. and affiliates.
3
2
  # All rights reserved.
4
3
  #
@@ -13,6 +12,8 @@ used by components to define the apps which can then be launched via a TorchX
13
12
  scheduler or pipeline adapter.
14
13
  """
15
14
  import difflib
15
+
16
+ import os
16
17
  from typing import Callable, Dict, Mapping, Optional
17
18
 
18
19
  from torchx.specs.api import (
@@ -42,9 +43,11 @@ from torchx.specs.api import (
42
43
  RoleStatus,
43
44
  runopt,
44
45
  runopts,
46
+ TORCHX_HOME,
45
47
  UnknownAppException,
46
48
  UnknownSchedulerException,
47
49
  VolumeMount,
50
+ Workspace,
48
51
  )
49
52
  from torchx.specs.builders import make_app_handle, materialize_appdef, parse_mounts
50
53
 
@@ -52,14 +55,22 @@ from torchx.util.entrypoints import load_group
52
55
 
53
56
  from torchx.util.modules import import_attr
54
57
 
55
- AWS_NAMED_RESOURCES: Mapping[str, Callable[[], Resource]] = import_attr(
58
+ GiB: int = 1024
59
+
60
+
61
+ ResourceFactory = Callable[[], Resource]
62
+
63
+ AWS_NAMED_RESOURCES: Mapping[str, ResourceFactory] = import_attr(
56
64
  "torchx.specs.named_resources_aws", "NAMED_RESOURCES", default={}
57
65
  )
58
- GENERIC_NAMED_RESOURCES: Mapping[str, Callable[[], Resource]] = import_attr(
66
+ GENERIC_NAMED_RESOURCES: Mapping[str, ResourceFactory] = import_attr(
59
67
  "torchx.specs.named_resources_generic", "NAMED_RESOURCES", default={}
60
68
  )
61
-
62
- GiB: int = 1024
69
+ CUSTOM_NAMED_RESOURCES: Mapping[str, ResourceFactory] = import_attr(
70
+ os.environ.get("TORCHX_CUSTOM_NAMED_RESOURCES", "torchx.specs.fb.named_resources"),
71
+ "NAMED_RESOURCES",
72
+ default={},
73
+ )
63
74
 
64
75
 
65
76
  def _load_named_resources() -> Dict[str, Callable[[], Resource]]:
@@ -69,6 +80,7 @@ def _load_named_resources() -> Dict[str, Callable[[], Resource]]:
69
80
  for name, resource in {
70
81
  **GENERIC_NAMED_RESOURCES,
71
82
  **AWS_NAMED_RESOURCES,
83
+ **CUSTOM_NAMED_RESOURCES,
72
84
  **resource_methods,
73
85
  }.items():
74
86
  materialized_resources[name] = resource
@@ -122,7 +134,7 @@ def resource(
122
134
 
123
135
  If ``h`` is specified then it is used to look up the
124
136
  resource specs from the list of registered named resources.
125
- See `registering named resource <https://pytorch.org/torchx/latest/advanced.html#registering-named-resources>`_.
137
+ See `registering named resource <https://meta-pytorch.org/torchx/latest/advanced.html#registering-named-resources>`_.
126
138
 
127
139
  Otherwise a ``Resource`` object is created from the raw resource specs.
128
140
 
@@ -225,5 +237,10 @@ __all__ = [
225
237
  "make_app_handle",
226
238
  "materialize_appdef",
227
239
  "parse_mounts",
240
+ "torchx_run_args_from_argparse",
241
+ "torchx_run_args_from_json",
242
+ "TorchXRunArgs",
228
243
  "ALL",
244
+ "TORCHX_HOME",
245
+ "Workspace",
229
246
  ]
torchx/specs/api.py CHANGED
@@ -1,4 +1,3 @@
1
- #!/usr/bin/env python3
2
1
  # Copyright (c) Meta Platforms, Inc. and affiliates.
3
2
  # All rights reserved.
4
3
  #
@@ -12,11 +11,15 @@ import copy
12
11
  import inspect
13
12
  import json
14
13
  import logging as logger
14
+ import os
15
+ import pathlib
15
16
  import re
17
+ import shutil
16
18
  import typing
19
+ import warnings
17
20
  from dataclasses import asdict, dataclass, field
18
21
  from datetime import datetime
19
- from enum import Enum
22
+ from enum import Enum, IntEnum
20
23
  from json import JSONDecodeError
21
24
  from string import Template
22
25
  from typing import (
@@ -67,6 +70,32 @@ YELLOW_BOLD = "\033[1;33m"
67
70
  RESET = "\033[0m"
68
71
 
69
72
 
73
+ def TORCHX_HOME(*subdir_paths: str) -> pathlib.Path:
74
+ """
75
+ Path to the "dot-directory" for torchx.
76
+ Defaults to `~/.torchx` and is overridable via the `TORCHX_HOME` environment variable.
77
+
78
+ Usage:
79
+
80
+ .. doc-test::
81
+
82
+ from pathlib import Path
83
+ from torchx.specs import TORCHX_HOME
84
+
85
+ assert TORCHX_HOME() == Path.home() / ".torchx"
86
+ assert TORCHX_HOME("conda-pack-out") == Path.home() / ".torchx" / "conda-pack-out"
87
+ ```
88
+ """
89
+
90
+ default_dir = str(pathlib.Path.home() / ".torchx")
91
+ torchx_home = pathlib.Path(os.getenv("TORCHX_HOME", default_dir))
92
+
93
+ torchx_home = torchx_home / os.path.sep.join(subdir_paths)
94
+ torchx_home.mkdir(parents=True, exist_ok=True)
95
+
96
+ return torchx_home
97
+
98
+
70
99
  # ========================================
71
100
  # ==== Distributed AppDef API =======
72
101
  # ========================================
@@ -83,6 +112,8 @@ class Resource:
83
112
  memMB: MB of ram
84
113
  capabilities: additional hardware specs (interpreted by scheduler)
85
114
  devices: a list of named devices with their quantities
115
+ tags: metadata tags for the resource (not interpreted by schedulers)
116
+ used to add non-functional information about resources (e.g. whether it is an alias of another resource)
86
117
 
87
118
  Note: you should prefer to use named_resources instead of specifying the raw
88
119
  resource requirement directly.
@@ -93,6 +124,7 @@ class Resource:
93
124
  memMB: int
94
125
  capabilities: Dict[str, Any] = field(default_factory=dict)
95
126
  devices: Dict[str, int] = field(default_factory=dict)
127
+ tags: Dict[str, object] = field(default_factory=dict)
96
128
 
97
129
  @staticmethod
98
130
  def copy(original: "Resource", **capabilities: Any) -> "Resource":
@@ -101,6 +133,7 @@ class Resource:
101
133
  are present in the original resource and as parameter, the one from parameter
102
134
  will be used.
103
135
  """
136
+
104
137
  res_capabilities = dict(original.capabilities)
105
138
  res_capabilities.update(capabilities)
106
139
  return Resource(
@@ -319,6 +352,121 @@ class DeviceMount:
319
352
  permissions: str = "rwm"
320
353
 
321
354
 
355
+ @dataclass
356
+ class Workspace:
357
+ """
358
+ Specifies a local "workspace" (a set of directories). Workspaces are ad-hoc built
359
+ into an (usually ephemeral) image. This effectively mirrors the local code changes
360
+ at job submission time.
361
+
362
+ For example:
363
+
364
+ 1. ``projects={"~/github/torch": "torch"}`` copies ``~/github/torch/**`` into ``$REMOTE_WORKSPACE_ROOT/torch/**``
365
+ 2. ``projects={"~/github/torch": ""}`` copies ``~/github/torch/**`` into ``$REMOTE_WORKSPACE_ROOT/**``
366
+
367
+ The exact location of ``$REMOTE_WORKSPACE_ROOT`` is implementation dependent and varies between
368
+ different implementations of :py:class:`~torchx.workspace.api.WorkspaceMixin`.
369
+ Check the scheduler documentation for details on which workspace it supports.
370
+
371
+ Note: ``projects`` maps the location of the local project to a sub-directory in the remote workspace root directory.
372
+ Typically the local project location is a directory path (e.g. ``/home/foo/github/torch``).
373
+
374
+
375
+ Attributes:
376
+ projects: mapping of local project to the sub-dir in the remote workspace dir.
377
+ """
378
+
379
+ projects: dict[str, str]
380
+
381
+ def __bool__(self) -> bool:
382
+ """False if no projects mapping. Lets us use workspace object in an if-statement"""
383
+ return bool(self.projects)
384
+
385
+ def __eq__(self, other: object) -> bool:
386
+ if not isinstance(other, Workspace):
387
+ return False
388
+ return self.projects == other.projects
389
+
390
+ def __hash__(self) -> int:
391
+ # makes it possible to use Workspace as the key in the workspace build cache
392
+ # see WorkspaceMixin.caching_build_workspace_and_update_role
393
+ return hash(frozenset(self.projects.items()))
394
+
395
+ def is_unmapped_single_project(self) -> bool:
396
+ """
397
+ Returns ``True`` if this workspace only has 1 project
398
+ and its target mapping is an empty string.
399
+ """
400
+ return len(self.projects) == 1 and not next(iter(self.projects.values()))
401
+
402
+ def merge_into(self, outdir: str | pathlib.Path) -> None:
403
+ """
404
+ Copies each project dir of this workspace into the specified ``outdir``.
405
+ Each project dir is copied into ``{outdir}/{target}`` where ``target`` is
406
+ the target mapping of the project dir.
407
+
408
+ For example:
409
+
410
+ .. code-block:: python
411
+ from os.path import expanduser
412
+
413
+ workspace = Workspace(
414
+ projects={
415
+ expanduser("~/workspace/torch"): "torch",
416
+ expanduser("~/workspace/my_project": "")
417
+ }
418
+ )
419
+ workspace.merge_into(expanduser("~/tmp"))
420
+
421
+ Copies:
422
+
423
+ * ``~/workspace/torch/**`` into ``~/tmp/torch/**``
424
+ * ``~/workspace/my_project/**`` into ``~/tmp/**``
425
+
426
+ """
427
+
428
+ for src, dst in self.projects.items():
429
+ dst_path = pathlib.Path(outdir) / dst
430
+ if pathlib.Path(src).is_file():
431
+ shutil.copy2(src, dst_path)
432
+ else: # src is dir
433
+ shutil.copytree(src, dst_path, dirs_exist_ok=True)
434
+
435
+ @staticmethod
436
+ def from_str(workspace: str | None) -> "Workspace":
437
+ import yaml
438
+
439
+ if not workspace:
440
+ return Workspace({})
441
+
442
+ projects = yaml.safe_load(workspace)
443
+ if isinstance(projects, str): # single project workspace
444
+ projects = {projects: ""}
445
+ else: # multi-project workspace
446
+ # Replace None mappings with "" (empty string)
447
+ projects = {k: ("" if v is None else v) for k, v in projects.items()}
448
+
449
+ return Workspace(projects)
450
+
451
+ def __str__(self) -> str:
452
+ """
453
+ Returns a string representation of the Workspace by concatenating
454
+ the project mappings using ';' as a delimiter and ':' between key and value.
455
+ If the single-project workspace with no target mapping, then simply
456
+ returns the src (local project dir)
457
+
458
+ NOTE: meant to be used for logging purposes not serde.
459
+ Therefore not symmetric with :py:func:`Workspace.from_str`.
460
+
461
+ """
462
+ if self.is_unmapped_single_project():
463
+ return next(iter(self.projects))
464
+ else:
465
+ return ";".join(
466
+ k if not v else f"{k}:{v}" for k, v in self.projects.items()
467
+ )
468
+
469
+
322
470
  @dataclass
323
471
  class Role:
324
472
  """
@@ -371,12 +519,15 @@ class Role:
371
519
  metadata: Free form information that is associated with the role, for example
372
520
  scheduler specific data. The key should follow the pattern: ``$scheduler.$key``
373
521
  mounts: a list of mounts on the machine
522
+ workspace: local project directories to be mirrored on the remote job.
523
+ NOTE: The workspace argument provided to the :py:class:`~torchx.runner.api.Runner` APIs
524
+ only takes effect on ``appdef.role[0]`` and overrides this attribute.
525
+
374
526
  """
375
527
 
376
528
  name: str
377
529
  image: str
378
530
  min_replicas: Optional[int] = None
379
- base_image: Optional[str] = None # DEPRECATED DO NOT SET, WILL BE REMOVED SOON
380
531
  entrypoint: str = MISSING
381
532
  args: List[str] = field(default_factory=list)
382
533
  env: Dict[str, str] = field(default_factory=dict)
@@ -386,9 +537,10 @@ class Role:
386
537
  resource: Resource = field(default_factory=_null_resource)
387
538
  port_map: Dict[str, int] = field(default_factory=dict)
388
539
  metadata: Dict[str, Any] = field(default_factory=dict)
389
- mounts: List[Union[BindMount, VolumeMount, DeviceMount]] = field(
390
- default_factory=list
391
- )
540
+ mounts: List[BindMount | VolumeMount | DeviceMount] = field(default_factory=list)
541
+ workspace: Workspace | None = None
542
+
543
+ # DEPRECATED DO NOT SET, WILL BE REMOVED SOON
392
544
  overrides: Dict[str, Any] = field(default_factory=dict)
393
545
 
394
546
  # pyre-ignore
@@ -788,6 +940,62 @@ class runopt:
788
940
  opt_type: Type[CfgVal]
789
941
  is_required: bool
790
942
  help: str
943
+ aliases: list[str] | None = None
944
+ deprecated_aliases: list[str] | None = None
945
+
946
+ @property
947
+ def is_type_list_of_str(self) -> bool:
948
+ """
949
+ Checks if the option type is a list of strings.
950
+
951
+ Returns:
952
+ bool: True if the option type is either List[str] or list[str], False otherwise.
953
+ """
954
+ return self.opt_type in (List[str], list[str])
955
+
956
+ @property
957
+ def is_type_dict_of_str(self) -> bool:
958
+ """
959
+ Checks if the option type is a dict of string keys to string values.
960
+
961
+ Returns:
962
+ bool: True if the option type is either Dict[str, str] or dict[str, str], False otherwise.
963
+ """
964
+ return self.opt_type in (Dict[str, str], dict[str, str])
965
+
966
+ def cast_to_type(self, value: str) -> CfgVal:
967
+ """Casts the given `value` (in its string representation) to the type of this run option.
968
+ Below are the cast rules for each option type and value literal:
969
+
970
+ 1. opt_type=str, value="foo" -> "foo"
971
+ 1. opt_type=bool, value="True"/"False" -> True/False
972
+ 1. opt_type=int, value="1" -> 1
973
+ 1. opt_type=float, value="1.1" -> 1.1
974
+ 1. opt_type=list[str]/List[str], value="a,b,c" or value="a;b;c" -> ["a", "b", "c"]
975
+ 1. opt_type=dict[str,str]/Dict[str,str],
976
+ value="key1:val1,key2:val2" or value="key1:val1;key2:val2" -> {"key1": "val1", "key2": "val2"}
977
+
978
+ NOTE: dict parsing uses ":" as the kv separator (rather than the standard "=") because "=" is used
979
+ at the top-level cfg to parse runopts (notice the plural) from the CLI. Originally torchx only supported
980
+ primitives and list[str] as CfgVal but dict[str,str] was added in https://github.com/meta-pytorch/torchx/pull/855
981
+ """
982
+
983
+ if self.opt_type is None:
984
+ raise ValueError("runopt's opt_type cannot be `None`")
985
+ elif self.opt_type == bool:
986
+ return value.lower() == "true"
987
+ elif self.opt_type in (List[str], list[str]):
988
+ # lists may be ; or , delimited
989
+ # also deal with trailing "," by removing empty strings
990
+ return [v for v in value.replace(";", ",").split(",") if v]
991
+ elif self.opt_type in (Dict[str, str], dict[str, str]):
992
+ return {
993
+ s.split(":", 1)[0]: s.split(":", 1)[1]
994
+ for s in value.replace(";", ",").split(",")
995
+ }
996
+ else:
997
+ assert self.opt_type in (str, int, float)
998
+ return self.opt_type(value)
791
999
 
792
1000
 
793
1001
  class runopts:
@@ -825,6 +1033,7 @@ class runopts:
825
1033
 
826
1034
  def __init__(self) -> None:
827
1035
  self._opts: Dict[str, runopt] = {}
1036
+ self._alias_to_key: dict[str, str] = {}
828
1037
 
829
1038
  def __iter__(self) -> Iterator[Tuple[str, runopt]]:
830
1039
  return self._opts.items().__iter__()
@@ -852,9 +1061,16 @@ class runopts:
852
1061
 
853
1062
  def get(self, name: str) -> Optional[runopt]:
854
1063
  """
855
- Returns option if any was registered, or None otherwise
1064
+ Returns option if any was registered, or None otherwise.
1065
+ First searches for the option by ``name``, then falls-back to matching ``name`` with any
1066
+ registered aliases.
1067
+
856
1068
  """
857
- return self._opts.get(name, None)
1069
+ if name in self._opts:
1070
+ return self._opts[name]
1071
+ if name in self._alias_to_key:
1072
+ return self._opts[self._alias_to_key[name]]
1073
+ return None
858
1074
 
859
1075
  def resolve(self, cfg: Mapping[str, CfgVal]) -> Dict[str, CfgVal]:
860
1076
  """
@@ -869,6 +1085,36 @@ class runopts:
869
1085
 
870
1086
  for cfg_key, runopt in self._opts.items():
871
1087
  val = resolved_cfg.get(cfg_key)
1088
+ resolved_name = None
1089
+ aliases = runopt.aliases or []
1090
+ deprecated_aliases = runopt.deprecated_aliases or []
1091
+ if val is None:
1092
+ for alias in aliases:
1093
+ val = resolved_cfg.get(alias)
1094
+ if alias in cfg or val is not None:
1095
+ resolved_name = alias
1096
+ break
1097
+ for alias in deprecated_aliases:
1098
+ val = resolved_cfg.get(alias)
1099
+ if val is not None:
1100
+ resolved_name = alias
1101
+ use_instead = self._alias_to_key.get(alias)
1102
+ warnings.warn(
1103
+ f"Run option `{alias}` is deprecated, use `{use_instead}` instead",
1104
+ UserWarning,
1105
+ stacklevel=2,
1106
+ )
1107
+ break
1108
+ else:
1109
+ resolved_name = cfg_key
1110
+ for alias in aliases:
1111
+ duplicate_val = resolved_cfg.get(alias)
1112
+ if alias in cfg or duplicate_val is not None:
1113
+ raise InvalidRunConfigException(
1114
+ f"Duplicate opt name. runopt: `{resolved_name}``, is an alias of runopt: `{alias}`",
1115
+ resolved_name,
1116
+ cfg,
1117
+ )
872
1118
 
873
1119
  # check required opt
874
1120
  if runopt.is_required and val is None:
@@ -888,7 +1134,7 @@ class runopts:
888
1134
  )
889
1135
 
890
1136
  # not required and not set, set to default
891
- if val is None:
1137
+ if val is None and resolved_name is None:
892
1138
  resolved_cfg[cfg_key] = runopt.default
893
1139
  return resolved_cfg
894
1140
 
@@ -948,27 +1194,11 @@ class runopts:
948
1194
 
949
1195
  """
950
1196
 
951
- def _cast_to_type(value: str, opt_type: Type[CfgVal]) -> CfgVal:
952
- if opt_type == bool:
953
- return value.lower() == "true"
954
- elif opt_type == List[str]:
955
- # lists may be ; or , delimited
956
- # also deal with trailing "," by removing empty strings
957
- return [v for v in value.replace(";", ",").split(",") if v]
958
- elif opt_type == Dict[str, str]:
959
- return {
960
- s.split(":", 1)[0]: s.split(":", 1)[1]
961
- for s in value.replace(";", ",").split(",")
962
- }
963
- else:
964
- # pyre-ignore[19, 6] type won't be dict here as we handled it above
965
- return opt_type(value)
966
-
967
1197
  cfg: Dict[str, CfgVal] = {}
968
1198
  for key, val in to_dict(cfg_str).items():
969
- runopt_ = self.get(key)
970
- if runopt_:
971
- cfg[key] = _cast_to_type(val, runopt_.opt_type)
1199
+ opt = self.get(key)
1200
+ if opt:
1201
+ cfg[key] = opt.cast_to_type(val)
972
1202
  else:
973
1203
  logger.warning(
974
1204
  f"{YELLOW_BOLD}Unknown run option passed to scheduler: {key}={val}{RESET}"
@@ -982,16 +1212,16 @@ class runopts:
982
1212
  cfg: Dict[str, CfgVal] = {}
983
1213
  cfg_dict = json.loads(json_repr)
984
1214
  for key, val in cfg_dict.items():
985
- runopt_ = self.get(key)
986
- if runopt_:
1215
+ opt = self.get(key)
1216
+ if opt:
987
1217
  # Optional runopt cfg values default their value to None,
988
1218
  # but use `_type` to specify their type when provided.
989
1219
  # Make sure not to treat None's as lists/dictionaries
990
1220
  if val is None:
991
1221
  cfg[key] = val
992
- elif runopt_.opt_type == List[str]:
1222
+ elif opt.is_type_list_of_str:
993
1223
  cfg[key] = [str(v) for v in val]
994
- elif runopt_.opt_type == Dict[str, str]:
1224
+ elif opt.is_type_dict_of_str:
995
1225
  cfg[key] = {str(k): str(v) for k, v in val.items()}
996
1226
  else:
997
1227
  cfg[key] = val
@@ -1004,12 +1234,16 @@ class runopts:
1004
1234
  help: str,
1005
1235
  default: CfgVal = None,
1006
1236
  required: bool = False,
1237
+ aliases: Optional[list[str]] = None,
1238
+ deprecated_aliases: Optional[list[str]] = None,
1007
1239
  ) -> None:
1008
1240
  """
1009
1241
  Adds the ``config`` option with the given help string and ``default``
1010
1242
  value (if any). If the ``default`` is not specified then this option
1011
1243
  is a required option.
1012
1244
  """
1245
+ aliases = aliases or []
1246
+ deprecated_aliases = deprecated_aliases or []
1013
1247
  if required and default is not None:
1014
1248
  raise ValueError(
1015
1249
  f"Required option: {cfg_key} must not specify default value. Given: {default}"
@@ -1021,7 +1255,19 @@ class runopts:
1021
1255
  f" Given: {default} ({type(default).__name__})"
1022
1256
  )
1023
1257
 
1024
- self._opts[cfg_key] = runopt(default, type_, required, help)
1258
+ opt = runopt(
1259
+ default,
1260
+ type_,
1261
+ required,
1262
+ help,
1263
+ list(set(aliases)),
1264
+ list(set(deprecated_aliases)),
1265
+ )
1266
+ for alias in aliases:
1267
+ self._alias_to_key[alias] = cfg_key
1268
+ for deprecated_alias in deprecated_aliases:
1269
+ self._alias_to_key[deprecated_alias] = cfg_key
1270
+ self._opts[cfg_key] = opt
1025
1271
 
1026
1272
  def update(self, other: "runopts") -> None:
1027
1273
  self._opts.update(other._opts)