mithril-client 0.1.0a1__cp314-cp314-macosx_11_0_arm64.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 (163) hide show
  1. mithril/__init__.py +7 -0
  2. mithril/_mcli.cpython-314-darwin.so +0 -0
  3. mithril/_mcli.pyi +7 -0
  4. mithril/_mcli_entry.py +75 -0
  5. mithril/api/__init__.py +7 -0
  6. mithril/api/bindings/.gitattributes +2 -0
  7. mithril/api/bindings/__init__.py +10 -0
  8. mithril/api/bindings/api/__init__.py +1 -0
  9. mithril/api/bindings/api/api_keys/__init__.py +1 -0
  10. mithril/api/bindings/api/api_keys/create_api_key_v2_api_keys_post.py +179 -0
  11. mithril/api/bindings/api/api_keys/get_api_keys_v2_api_keys_get.py +141 -0
  12. mithril/api/bindings/api/api_keys/revoke_api_key_v2_api_keys_key_fid_delete.py +173 -0
  13. mithril/api/bindings/api/image_versions/__init__.py +1 -0
  14. mithril/api/bindings/api/image_versions/get_image_versions_v2_image_versions_get.py +141 -0
  15. mithril/api/bindings/api/image_versions/get_mcc_image_versions_v2_mcc_image_versions_get.py +179 -0
  16. mithril/api/bindings/api/instance_types/__init__.py +1 -0
  17. mithril/api/bindings/api/instance_types/get_instance_types_v2_instance_types_get.py +137 -0
  18. mithril/api/bindings/api/instances/__init__.py +1 -0
  19. mithril/api/bindings/api/instances/get_instance_status_v2_instances_instance_id_status_get.py +165 -0
  20. mithril/api/bindings/api/instances/get_instances_v2_instances_get.py +409 -0
  21. mithril/api/bindings/api/kubernetes_clusters/__init__.py +1 -0
  22. mithril/api/bindings/api/kubernetes_clusters/create_kubernetes_cluster_v2_kubernetes_clusters_post.py +171 -0
  23. mithril/api/bindings/api/kubernetes_clusters/delete_kubernetes_cluster_v2_kubernetes_clusters_cluster_fid_delete.py +163 -0
  24. mithril/api/bindings/api/kubernetes_clusters/get_kubernetes_cluster_v2_kubernetes_clusters_cluster_fid_get.py +165 -0
  25. mithril/api/bindings/api/kubernetes_clusters/get_kubernetes_clusters_v2_kubernetes_clusters_get.py +175 -0
  26. mithril/api/bindings/api/lifecycle_scripts/__init__.py +1 -0
  27. mithril/api/bindings/api/lifecycle_scripts/create_lifecycle_script_v2_lifecycle_scripts_post.py +171 -0
  28. mithril/api/bindings/api/lifecycle_scripts/delete_lifecycle_script_v2_lifecycle_scripts_ls_fid_delete.py +155 -0
  29. mithril/api/bindings/api/lifecycle_scripts/get_lifecycle_script_content_v2_lifecycle_scripts_ls_fid_content_get.py +155 -0
  30. mithril/api/bindings/api/lifecycle_scripts/list_lifecycle_scripts_v2_lifecycle_scripts_get.py +247 -0
  31. mithril/api/bindings/api/lifecycle_scripts/update_lifecycle_script_v2_lifecycle_scripts_ls_fid_patch.py +179 -0
  32. mithril/api/bindings/api/pricing/__init__.py +1 -0
  33. mithril/api/bindings/api/pricing/get_current_prices_v2_v2_pricing_current_get.py +217 -0
  34. mithril/api/bindings/api/pricing/get_historical_prices_v2_v2_pricing_history_get.py +222 -0
  35. mithril/api/bindings/api/profile/__init__.py +1 -0
  36. mithril/api/bindings/api/profile/get_me_v2_me_get.py +132 -0
  37. mithril/api/bindings/api/profile/get_my_teammates_v2_me_teammates_get.py +153 -0
  38. mithril/api/bindings/api/projects/__init__.py +1 -0
  39. mithril/api/bindings/api/projects/get_projects_v2_projects_get.py +137 -0
  40. mithril/api/bindings/api/quotas/__init__.py +1 -0
  41. mithril/api/bindings/api/quotas/get_quotas_v2_quotas_get.py +175 -0
  42. mithril/api/bindings/api/reservations/__init__.py +1 -0
  43. mithril/api/bindings/api/reservations/create_reservation_v2_reservation_post.py +171 -0
  44. mithril/api/bindings/api/reservations/extend_reservation_v2_reservation_reservation_fid_extend_post.py +187 -0
  45. mithril/api/bindings/api/reservations/get_availability_v2_reservation_availability_get.py +664 -0
  46. mithril/api/bindings/api/reservations/get_extension_availability_v2_reservation_reservation_fid_extension_availability_get.py +165 -0
  47. mithril/api/bindings/api/reservations/get_reservations_v2_reservation_get.py +309 -0
  48. mithril/api/bindings/api/reservations/update_reservation_v2_reservation_reservation_fid_patch.py +187 -0
  49. mithril/api/bindings/api/spot/__init__.py +1 -0
  50. mithril/api/bindings/api/spot/cancel_bid_v2_spot_bids_bid_fid_delete.py +161 -0
  51. mithril/api/bindings/api/spot/create_bid_v2_spot_bids_post.py +171 -0
  52. mithril/api/bindings/api/spot/get_auctions_v2_spot_availability_get.py +137 -0
  53. mithril/api/bindings/api/spot/get_bid_history_v2_spot_bids_bid_fid_history_get.py +193 -0
  54. mithril/api/bindings/api/spot/get_bid_status_v2_spot_bids_bid_fid_status_get.py +189 -0
  55. mithril/api/bindings/api/spot/get_bid_v2_spot_bids_bid_fid_get.py +163 -0
  56. mithril/api/bindings/api/spot/get_bids_v2_spot_bids_get.py +330 -0
  57. mithril/api/bindings/api/spot/update_bid_v2_spot_bids_bid_fid_patch.py +185 -0
  58. mithril/api/bindings/api/ssh_keys/__init__.py +1 -0
  59. mithril/api/bindings/api/ssh_keys/create_ssh_key_v2_ssh_keys_post.py +175 -0
  60. mithril/api/bindings/api/ssh_keys/delete_ssh_key_v2_ssh_keys_ssh_key_fid_delete.py +167 -0
  61. mithril/api/bindings/api/ssh_keys/get_ssh_keys_v2_ssh_keys_get.py +175 -0
  62. mithril/api/bindings/api/ssh_keys/update_ssh_key_v2_ssh_keys_ssh_key_fid_patch.py +187 -0
  63. mithril/api/bindings/api/volumes/__init__.py +1 -0
  64. mithril/api/bindings/api/volumes/create_volume_v2_volumes_post.py +211 -0
  65. mithril/api/bindings/api/volumes/delete_volume_v2_volumes_volume_fid_delete.py +199 -0
  66. mithril/api/bindings/api/volumes/get_volumes_v2_volumes_get.py +239 -0
  67. mithril/api/bindings/api/volumes/update_volume_v2_volumes_volume_fid_patch.py +243 -0
  68. mithril/api/bindings/client.py +284 -0
  69. mithril/api/bindings/errors.py +18 -0
  70. mithril/api/bindings/models/__init__.py +169 -0
  71. mithril/api/bindings/models/api_key_model.py +114 -0
  72. mithril/api/bindings/models/auction_model.py +146 -0
  73. mithril/api/bindings/models/availability_slot_model.py +76 -0
  74. mithril/api/bindings/models/bid_history_event_model.py +157 -0
  75. mithril/api/bindings/models/bid_history_event_model_event_type.py +19 -0
  76. mithril/api/bindings/models/bid_history_response.py +84 -0
  77. mithril/api/bindings/models/bid_model.py +191 -0
  78. mithril/api/bindings/models/bid_model_status.py +14 -0
  79. mithril/api/bindings/models/bid_status_response.py +72 -0
  80. mithril/api/bindings/models/bid_status_response_status.py +15 -0
  81. mithril/api/bindings/models/check_availability_response.py +60 -0
  82. mithril/api/bindings/models/create_api_key_request.py +68 -0
  83. mithril/api/bindings/models/create_api_key_response.py +122 -0
  84. mithril/api/bindings/models/create_bid_request.py +116 -0
  85. mithril/api/bindings/models/create_kubernetes_cluster_request.py +136 -0
  86. mithril/api/bindings/models/create_kubernetes_cluster_request_k8s_version.py +11 -0
  87. mithril/api/bindings/models/create_lifecycle_script_request.py +115 -0
  88. mithril/api/bindings/models/create_reservation_request.py +124 -0
  89. mithril/api/bindings/models/create_ssh_key_request.py +99 -0
  90. mithril/api/bindings/models/create_volume_request.py +98 -0
  91. mithril/api/bindings/models/create_volume_request_disk_interface.py +11 -0
  92. mithril/api/bindings/models/created_ssh_key_model.py +122 -0
  93. mithril/api/bindings/models/current_prices_response.py +202 -0
  94. mithril/api/bindings/models/extend_reservation_request.py +60 -0
  95. mithril/api/bindings/models/extension_availability_response.py +68 -0
  96. mithril/api/bindings/models/get_availability_v2_reservation_availability_get_mode.py +12 -0
  97. mithril/api/bindings/models/get_bids_response.py +96 -0
  98. mithril/api/bindings/models/get_bids_v2_spot_bids_get_sort_by.py +11 -0
  99. mithril/api/bindings/models/get_bids_v2_spot_bids_get_status.py +14 -0
  100. mithril/api/bindings/models/get_instances_response.py +96 -0
  101. mithril/api/bindings/models/get_instances_v2_instances_get_order_type_in_type_0_item.py +11 -0
  102. mithril/api/bindings/models/get_instances_v2_instances_get_sort_by.py +12 -0
  103. mithril/api/bindings/models/get_instances_v2_instances_get_status_in_type_0_item.py +24 -0
  104. mithril/api/bindings/models/get_latest_end_time_response.py +68 -0
  105. mithril/api/bindings/models/get_reservations_response.py +96 -0
  106. mithril/api/bindings/models/get_reservations_v2_reservation_get_sort_by.py +11 -0
  107. mithril/api/bindings/models/get_reservations_v2_reservation_get_status.py +14 -0
  108. mithril/api/bindings/models/historical_price_point_model.py +94 -0
  109. mithril/api/bindings/models/historical_prices_response_model.py +76 -0
  110. mithril/api/bindings/models/http_validation_error.py +78 -0
  111. mithril/api/bindings/models/image_version_model.py +224 -0
  112. mithril/api/bindings/models/instance_model.py +211 -0
  113. mithril/api/bindings/models/instance_model_status.py +24 -0
  114. mithril/api/bindings/models/instance_status_response.py +141 -0
  115. mithril/api/bindings/models/instance_status_response_status.py +24 -0
  116. mithril/api/bindings/models/instance_type_model.py +170 -0
  117. mithril/api/bindings/models/kubernetes_cluster_model.py +207 -0
  118. mithril/api/bindings/models/kubernetes_cluster_model_status.py +12 -0
  119. mithril/api/bindings/models/launch_specification_model.py +152 -0
  120. mithril/api/bindings/models/lifecycle_script_model.py +134 -0
  121. mithril/api/bindings/models/lifecycle_script_scope.py +12 -0
  122. mithril/api/bindings/models/list_lifecycle_scripts_response.py +96 -0
  123. mithril/api/bindings/models/list_lifecycle_scripts_v2_lifecycle_scripts_get_sort_by.py +11 -0
  124. mithril/api/bindings/models/me_response.py +126 -0
  125. mithril/api/bindings/models/new_ssh_key_model.py +100 -0
  126. mithril/api/bindings/models/persistent_disk_change.py +92 -0
  127. mithril/api/bindings/models/project_model.py +76 -0
  128. mithril/api/bindings/models/public_lifecycle_script_scope.py +11 -0
  129. mithril/api/bindings/models/quota_model.py +132 -0
  130. mithril/api/bindings/models/reservation_model.py +215 -0
  131. mithril/api/bindings/models/reservation_model_status.py +14 -0
  132. mithril/api/bindings/models/size.py +70 -0
  133. mithril/api/bindings/models/size_unit.py +18 -0
  134. mithril/api/bindings/models/sort_direction.py +11 -0
  135. mithril/api/bindings/models/teammate_response.py +158 -0
  136. mithril/api/bindings/models/update_bid_request.py +143 -0
  137. mithril/api/bindings/models/update_lifecycle_script_request.py +109 -0
  138. mithril/api/bindings/models/update_reservation_request.py +103 -0
  139. mithril/api/bindings/models/update_ssh_key_request.py +60 -0
  140. mithril/api/bindings/models/update_volume_request.py +65 -0
  141. mithril/api/bindings/models/validation_error.py +89 -0
  142. mithril/api/bindings/models/volume_model.py +140 -0
  143. mithril/api/bindings/models/volume_model_attachments.py +46 -0
  144. mithril/api/bindings/models/volume_model_interface.py +11 -0
  145. mithril/api/bindings/types.py +56 -0
  146. mithril/api/client.py +138 -0
  147. mithril/cli/__init__.py +7 -0
  148. mithril/cli/commands/__init__.py +15 -0
  149. mithril/cli/commands/help.py +88 -0
  150. mithril/cli/commands/launch.py +353 -0
  151. mithril/cli/main.py +68 -0
  152. mithril/cli/utils/__init__.py +1 -0
  153. mithril/cli/utils/skypilot_passthrough.py +38 -0
  154. mithril/cli/utils/streaming.py +235 -0
  155. mithril/cli/utils/volumes.py +110 -0
  156. mithril/config.py +47 -0
  157. mithril/py.typed +0 -0
  158. mithril/sky/__init__.py +141 -0
  159. mithril/sky/client.py +176 -0
  160. mithril_client-0.1.0a1.dist-info/METADATA +56 -0
  161. mithril_client-0.1.0a1.dist-info/RECORD +163 -0
  162. mithril_client-0.1.0a1.dist-info/WHEEL +4 -0
  163. mithril_client-0.1.0a1.dist-info/entry_points.txt +3 -0
mithril/api/client.py ADDED
@@ -0,0 +1,138 @@
1
+ """Mithril API client.
2
+
3
+ Provides a high-level interface for interacting with the Mithril API.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import TYPE_CHECKING
9
+
10
+ import httpx
11
+
12
+ from mithril.api.bindings import AuthenticatedClient
13
+ from mithril.api.bindings.api.volumes import get_volumes_v2_volumes_get
14
+ from mithril.api.bindings.models import HTTPValidationError
15
+ from mithril.config import load_config
16
+
17
+ if TYPE_CHECKING:
18
+ from mithril.api.bindings.models import VolumeModel
19
+ from mithril.config import Config
20
+
21
+ DEFAULT_TIMEOUT = httpx.Timeout(30.0)
22
+
23
+
24
+ class MithrilAPIError(Exception):
25
+ """Raised when the API returns an unexpected or invalid response."""
26
+
27
+
28
+ class MithrilClient:
29
+ """Client for interacting with the Mithril API.
30
+
31
+ Can be constructed with explicit credentials or loaded from config:
32
+
33
+ # Load from environment/config file
34
+ client = MithrilClient()
35
+
36
+ # Explicit credentials
37
+ client = MithrilClient(api_url="https://api.mithril.ai", api_key="...")
38
+
39
+ # From existing config
40
+ client = MithrilClient.from_config(config)
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ api_url: str | None = None,
46
+ api_key: str | None = None,
47
+ *,
48
+ project_id: str | None = None,
49
+ timeout: httpx.Timeout | None = None,
50
+ ) -> None:
51
+ """Initialize the client.
52
+
53
+ Args:
54
+ api_url: Base URL of the API. If not provided, loaded from config.
55
+ api_key: API key for authentication. If not provided, loaded from config.
56
+ project_id: Default project ID. If not provided, loaded from config.
57
+ timeout: Request timeout configuration.
58
+
59
+ Raises:
60
+ ConfigError: If credentials not provided and config is missing/invalid.
61
+ """
62
+ if api_url is not None and api_key is not None:
63
+ self._api_url = api_url
64
+ self._api_key = api_key
65
+ self._project_id = project_id or ""
66
+ else:
67
+ config = load_config()
68
+ self._api_url = api_url or config.api_url
69
+ self._api_key = api_key or config.api_key
70
+ self._project_id = project_id or config.project_id
71
+
72
+ self._timeout = timeout or DEFAULT_TIMEOUT
73
+ self._http_client: AuthenticatedClient | None = None
74
+
75
+ @classmethod
76
+ def from_config(cls, config: Config) -> MithrilClient:
77
+ """Create a client from an existing Config object.
78
+
79
+ Args:
80
+ config: Configuration object with API credentials.
81
+
82
+ Returns:
83
+ Configured MithrilClient instance.
84
+ """
85
+ return cls(
86
+ api_url=config.api_url,
87
+ api_key=config.api_key,
88
+ project_id=config.project_id,
89
+ )
90
+
91
+ @property
92
+ def project_id(self) -> str:
93
+ """The default project ID for this client."""
94
+ return self._project_id
95
+
96
+ @property
97
+ def _client(self) -> AuthenticatedClient:
98
+ """Lazily create the underlying HTTP client."""
99
+ if self._http_client is None:
100
+ self._http_client = AuthenticatedClient(
101
+ base_url=self._api_url,
102
+ token=self._api_key,
103
+ timeout=self._timeout,
104
+ )
105
+ return self._http_client
106
+
107
+ def list_volumes(
108
+ self,
109
+ project_id: str | None = None,
110
+ *,
111
+ region: str | None = None,
112
+ ) -> list[VolumeModel]:
113
+ """List volumes for a project.
114
+
115
+ Args:
116
+ project_id: Project to list volumes for. Defaults to client's project_id.
117
+ region: Optional region filter.
118
+
119
+ Returns:
120
+ List of VolumeModel objects.
121
+
122
+ Raises:
123
+ MithrilAPIError: If the API returns a validation error or
124
+ unexpected response.
125
+ """
126
+ project = project_id or self._project_id
127
+ result = get_volumes_v2_volumes_get.sync(
128
+ client=self._client,
129
+ project=project,
130
+ region=region,
131
+ )
132
+ if isinstance(result, list):
133
+ return result
134
+ error_info = (
135
+ result.detail if isinstance(result, HTTPValidationError) else result
136
+ )
137
+ msg = f"Unexpected API response when listing volumes: {error_info}"
138
+ raise MithrilAPIError(msg)
@@ -0,0 +1,7 @@
1
+ """Mithril CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mithril.cli.main import cli, main
6
+
7
+ __all__ = ["cli", "main"]
@@ -0,0 +1,15 @@
1
+ """CLI commands package.
2
+
3
+ Do not re-export command objects from this package (e.g. `from .launch import launch`).
4
+
5
+ Why: doing so binds `mithril.cli.commands.launch` to the exported object, which can
6
+ shadow the `mithril.cli.commands.launch` *module* and break code/tests that import the
7
+ module to access helpers. This is common in CLIs because command objects are often
8
+ defined at import time, and it's common to name the command the same as the module
9
+ (e.g. `launch.py` exporting `launch`), making re-exports especially tempting.
10
+
11
+ Prefer importing from the submodule:
12
+ from mithril.cli.commands.launch import launch # ✓
13
+ Avoid:
14
+ from mithril.cli.commands import launch # ✗
15
+ """
@@ -0,0 +1,88 @@
1
+ """Help command for the Mithril CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import version
6
+
7
+ from cyclopts import App
8
+ from rich.console import Console
9
+
10
+ from mithril._mcli import run as run_rust
11
+ from mithril.cli.commands.launch import launch
12
+ from mithril.cli.utils.skypilot_passthrough import SKY_ALIAS_COMMANDS, run_sky
13
+
14
+ RUST_COMMANDS = {"ssh", "instance", "k8s"}
15
+
16
+
17
+ def print_help() -> None:
18
+ """Print top-level help.
19
+
20
+ We route some commands directly to SkyPilot for passthrough, so we keep the
21
+ top-level help text explicit and stable.
22
+ """
23
+ ver = version("mithril-client")
24
+ help_text = f"""[dim]Mithril CLI {ver}[/dim]
25
+
26
+ [dim]❊ Quickstart:[/dim]
27
+ [bold]1. ml setup[/bold] First-time setup
28
+ [bold]2. ml launch --gpus H100:8[/bold] Spin up a GPU instance
29
+ [bold]3. ml status[/bold] Check running clusters
30
+ [bold]4. ml down[/bold] Tear down when you're done
31
+
32
+ [dim]❊ Workload:[/dim]
33
+ launch Launch a cluster and run a task.
34
+ exec Execute a command or open an interactive shell on a cluster.
35
+ status Show cluster and job status.
36
+ start Start a stopped cluster.
37
+ stop Stop/pause a cluster.
38
+ down Delete a cluster.
39
+
40
+ [dim]❊ Infrastructure:[/dim]
41
+ instance Manage compute instances.
42
+ k8s Manage Kubernetes clusters.
43
+ ssh SSH into an instance.
44
+
45
+ [dim]❊ Utilities:[/dim]
46
+ sky Direct SkyPilot access.
47
+ setup Configure credentials.
48
+ help Show help manual.
49
+ """
50
+ Console(highlight=False).print(help_text)
51
+
52
+
53
+ def help_cmd(command: tuple[str, ...] = ()) -> None:
54
+ """Show help for `ml` or a specific command.
55
+
56
+ For SkyPilot passthrough commands, this forwards to SkyPilot's help output.
57
+ For Rust commands (ssh, instance, k8s), this delegates to the Rust CLI.
58
+ """
59
+ if not command:
60
+ print_help()
61
+ return
62
+
63
+ cmd_name, *rest = command
64
+
65
+ # Rust commands: delegate to Rust CLI for help
66
+ if cmd_name in RUST_COMMANDS:
67
+ args = ["ml", cmd_name, *rest, "--help"]
68
+ raise SystemExit(run_rust(args))
69
+
70
+ # SkyPilot passthrough commands
71
+ if cmd_name == "sky":
72
+ args = [*rest, "--help"] if rest else ["--help"]
73
+ raise SystemExit(run_sky(*args))
74
+
75
+ if cmd_name in SKY_ALIAS_COMMANDS:
76
+ args = [cmd_name, *rest, "--help"] if rest else [cmd_name, "--help"]
77
+ raise SystemExit(run_sky(*args))
78
+
79
+ # First-class Python commands: render help via Cyclopts.
80
+ app = App(
81
+ name="ml",
82
+ version=version("mithril-client"),
83
+ version_flags=["--version"],
84
+ help_formatter="plain",
85
+ )
86
+ app.command(launch)
87
+
88
+ app([cmd_name, *rest, "--help"])
@@ -0,0 +1,353 @@
1
+ """Launch command for the Mithril CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from importlib.metadata import version as package_version
7
+ from typing import TYPE_CHECKING, Annotated, Any, cast
8
+
9
+ import click
10
+ from cyclopts import Parameter
11
+ from rich.console import Console
12
+
13
+ from mithril.api.client import MithrilClient
14
+ from mithril.cli.utils.skypilot_passthrough import SKY_ALIAS_COMMANDS
15
+ from mithril.cli.utils.streaming import poll_launch, stream_job_logs
16
+ from mithril.cli.utils.volumes import ensure_volumes_registered
17
+ from mithril.sky import SkyClient
18
+
19
+ if TYPE_CHECKING:
20
+ from sky import Resources as SkyResources
21
+ from sky import Task as SkyTask
22
+
23
+ console = Console()
24
+ INDENT_SYMBOL = "├── "
25
+ INDENT_LAST_SYMBOL = "└── "
26
+
27
+
28
+ @dataclass(frozen=True, slots=True)
29
+ class ResourceParams:
30
+ """Resource-related CLI options."""
31
+
32
+ gpus: str | None = None
33
+ cpus: str | None = None
34
+ memory: str | None = None
35
+ cloud: str | None = None
36
+ region: str | None = None
37
+ use_spot: bool | None = None
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class TaskParams:
42
+ """Task-related CLI options."""
43
+
44
+ entrypoint: str | None
45
+ name: str | None = None
46
+ workdir: str | None = None
47
+ num_nodes: int | None = None
48
+ env: tuple[str, ...] = ()
49
+ resources: ResourceParams = field(default_factory=ResourceParams)
50
+
51
+
52
+ def _exit(code: int) -> None:
53
+ raise SystemExit(code)
54
+
55
+
56
+ # `ml launch` is its own command (rather than only exposing `ml sky launch ...`).
57
+ # Today it's a thin proxy over SkyPilot: we translate flags into a `sky.Task` /
58
+ # `sky.Resources` and delegate the launch.
59
+ #
60
+ # Keeping `ml launch` first-class gives us control over the launch sequence (e.g. better
61
+ # logging/UX), and a stable place to add Mithril features that SkyPilot may choose not
62
+ # to support (or that we eventually implement without going through SkyPilot).
63
+ def launch(
64
+ entrypoint: str | None = None,
65
+ *,
66
+ cluster_name: Annotated[
67
+ str | None,
68
+ Parameter(name=["-c", "--cluster"], help="Cluster name."),
69
+ ] = None,
70
+ gpus: Annotated[
71
+ str | None,
72
+ Parameter(name="--gpus", help="GPU type and count (e.g., 'A100:4')."),
73
+ ] = None,
74
+ cpus: Annotated[
75
+ str | None,
76
+ Parameter(name="--cpus", help="Number of vCPUs."),
77
+ ] = None,
78
+ memory: Annotated[
79
+ str | None,
80
+ Parameter(name="--memory", help="Memory in GB."),
81
+ ] = None,
82
+ cloud: Annotated[
83
+ str | None,
84
+ Parameter(name="--cloud", help="Cloud provider."),
85
+ ] = None,
86
+ region: Annotated[str | None, Parameter(name="--region", help="Region.")] = None,
87
+ use_spot: Annotated[
88
+ bool | None,
89
+ Parameter(name="use-spot", negative="no-spot", help="Use spot instances."),
90
+ ] = None,
91
+ num_nodes: Annotated[
92
+ int | None,
93
+ Parameter(name="--num-nodes", help="Number of nodes."),
94
+ ] = None,
95
+ idle_minutes_to_autostop: Annotated[
96
+ int | None,
97
+ Parameter(
98
+ name=["-i", "--idle-minutes-to-autostop"],
99
+ help="Auto-stop after idle.",
100
+ ),
101
+ ] = None,
102
+ down: Annotated[
103
+ bool,
104
+ Parameter(name="--down", help="Tear down after jobs finish."),
105
+ ] = False,
106
+ detach_run: Annotated[
107
+ bool,
108
+ Parameter(name=["-d", "--detach-run"], help="Don't stream logs."),
109
+ ] = False,
110
+ retry_until_up: Annotated[
111
+ bool,
112
+ Parameter(name=["-r", "--retry-until-up"], help="Retry until up."),
113
+ ] = False,
114
+ yes: Annotated[
115
+ bool,
116
+ Parameter(name=["-y", "--yes"], help="Skip confirmation prompts."),
117
+ ] = False,
118
+ dryrun: Annotated[
119
+ bool,
120
+ Parameter(name="--dryrun", help="Show what would be launched."),
121
+ ] = False,
122
+ name: Annotated[
123
+ str | None,
124
+ Parameter(name=["-n", "--name"], help="Task name."),
125
+ ] = None,
126
+ workdir: Annotated[
127
+ str | None,
128
+ Parameter(name="--workdir", help="Working directory to sync."),
129
+ ] = None,
130
+ env: Annotated[
131
+ tuple[str, ...],
132
+ Parameter(name=["-e", "--env"], help="Environment variable (KEY=VALUE)."),
133
+ ] = (),
134
+ sky: SkyClient | None = None,
135
+ ) -> None:
136
+ r"""Launch a cluster and run a task.
137
+
138
+ Examples
139
+
140
+ ml launch task.yaml -c mycluster
141
+ ml launch 'echo hello' --gpus A100:1
142
+ ml launch --gpus A100:4 -c dev
143
+ """
144
+ if sky is None:
145
+ sky = SkyClient()
146
+ resources = ResourceParams(
147
+ gpus=gpus,
148
+ cpus=cpus,
149
+ memory=memory,
150
+ cloud=cloud,
151
+ region=region,
152
+ use_spot=use_spot,
153
+ )
154
+ task = build_task(
155
+ TaskParams(
156
+ entrypoint=entrypoint,
157
+ name=name,
158
+ workdir=workdir,
159
+ num_nodes=num_nodes,
160
+ env=env,
161
+ resources=resources,
162
+ ),
163
+ sky,
164
+ )
165
+
166
+ if dryrun:
167
+ console.print("[cyan]Dry run mode:[/cyan]")
168
+ console.print(f" Cluster: {cluster_name or '(auto-generated)'}")
169
+ console.print(f" Task: {task}")
170
+ console.print(f" Resources: {task.resources}")
171
+ return
172
+
173
+ _print_version_banner(console, sky)
174
+
175
+ # Sync Mithril volumes to SkyPilot before launch. Volume values that are
176
+ # strings are persistent volume names; dicts are ephemeral configs (no sync
177
+ # needed).
178
+ if task.volumes:
179
+ volume_names = [v for v in task.volumes.values() if isinstance(v, str)]
180
+ if volume_names:
181
+ ensure_volumes_registered(
182
+ volume_names,
183
+ mithril=MithrilClient(),
184
+ sky=sky,
185
+ )
186
+ try:
187
+ request_id = sky.launch(
188
+ task=task,
189
+ cluster_name=cluster_name,
190
+ retry_until_up=retry_until_up,
191
+ idle_minutes_to_autostop=idle_minutes_to_autostop,
192
+ dryrun=dryrun,
193
+ down=down,
194
+ _need_confirmation=not yes,
195
+ )
196
+ except click.exceptions.Abort:
197
+ # User pressed Ctrl+C during confirmation prompt
198
+ return
199
+
200
+ result, cluster_name = poll_launch(
201
+ request_id,
202
+ console,
203
+ sky=sky,
204
+ initial_cluster_name=cluster_name,
205
+ )
206
+ job_id, handle = cast(tuple[int | None, object | None], result)
207
+ cluster_name = _get_cluster_name(handle, cluster_name)
208
+
209
+ exit_code = 0
210
+ if not detach_run and job_id is not None:
211
+ assert cluster_name is not None, "cluster_name should be set for jobs"
212
+ exit_code = stream_job_logs(cluster_name, job_id, console, sky=sky)
213
+ hints = _format_command_hints(job_id=job_id, cluster_name=cluster_name)
214
+ if hints:
215
+ console.print(hints, markup=False)
216
+ if exit_code != 0:
217
+ _exit(exit_code)
218
+
219
+
220
+ def build_task(params: TaskParams, sky: SkyClient) -> SkyTask:
221
+ """Build a sky.Task from CLI parameters."""
222
+ envs = parse_env_vars(params.env)
223
+
224
+ if params.entrypoint and params.entrypoint.endswith((".yaml", ".yml")):
225
+ task = sky.Task.from_yaml(params.entrypoint)
226
+ if params.name:
227
+ task.name = params.name
228
+ if params.workdir:
229
+ task.workdir = params.workdir
230
+ if envs:
231
+ task.update_envs(envs)
232
+ if _has_resource_overrides(params.resources):
233
+ task.set_resources(build_resources(params.resources, sky))
234
+ if params.num_nodes:
235
+ task.num_nodes = params.num_nodes
236
+ return task
237
+
238
+ resources = build_resources(params.resources, sky)
239
+ task = sky.Task(
240
+ name=params.name,
241
+ run=params.entrypoint,
242
+ workdir=params.workdir,
243
+ envs=envs if envs else None,
244
+ num_nodes=params.num_nodes or 1,
245
+ )
246
+ task.set_resources(resources)
247
+ return task
248
+
249
+
250
+ def build_resources(params: ResourceParams, sky: SkyClient) -> SkyResources:
251
+ """Build sky.Resources from CLI parameters."""
252
+ kwargs: dict[str, Any] = {}
253
+ clouds = sky.clouds
254
+ if params.gpus:
255
+ kwargs["accelerators"] = params.gpus
256
+ if params.cpus:
257
+ kwargs["cpus"] = params.cpus
258
+ if params.memory:
259
+ kwargs["memory"] = params.memory
260
+ if params.region:
261
+ kwargs["region"] = params.region
262
+ if params.use_spot is not None:
263
+ kwargs["use_spot"] = params.use_spot
264
+ if params.cloud:
265
+ cloud_cls = getattr(clouds, params.cloud.upper(), None)
266
+ if cloud_cls is not None:
267
+ kwargs["cloud"] = cloud_cls()
268
+ return sky.Resources(**kwargs)
269
+
270
+
271
+ def _has_resource_overrides(params: ResourceParams) -> bool:
272
+ """Return True if any resource overrides were provided."""
273
+ return any(
274
+ [
275
+ params.gpus,
276
+ params.cpus,
277
+ params.memory,
278
+ params.cloud,
279
+ params.region,
280
+ params.use_spot is not None,
281
+ ]
282
+ )
283
+
284
+
285
+ def parse_env_vars(env: tuple[str, ...]) -> dict[str, str]:
286
+ """Parse environment variables from CLI args."""
287
+ envs: dict[str, str] = {}
288
+ for item in env:
289
+ if "=" in item:
290
+ key, _, value = item.partition("=")
291
+ envs[key] = value
292
+ return envs
293
+
294
+
295
+ def _print_version_banner(console: Console, sky: SkyClient) -> None:
296
+ client_version = package_version("mithril-client")
297
+ server_version = sky.api_info().version
298
+ console.print(f"[dim]mithril-client {client_version}[/dim]", highlight=False)
299
+ if server_version:
300
+ console.print(
301
+ f"[dim]SkyPilot API server {server_version}[/dim]", highlight=False
302
+ )
303
+ else:
304
+ console.print("[dim]SkyPilot API server (version unknown)[/dim]")
305
+
306
+
307
+ def _get_cluster_name(handle: object | None, fallback: str | None) -> str | None:
308
+ if handle is None:
309
+ return fallback
310
+ getter = getattr(handle, "get_cluster_name", None)
311
+ if callable(getter):
312
+ return cast("str | None", getter())
313
+ return cast("str | None", getattr(handle, "cluster_name", fallback))
314
+
315
+
316
+ def _format_command_hints(job_id: int | None, cluster_name: str | None) -> str:
317
+ if cluster_name is None:
318
+ return ""
319
+ lines = ["📋 Useful Commands"]
320
+ if job_id is not None:
321
+ lines.append(f"Job ID: {job_id}")
322
+ lines.append(
323
+ f"{INDENT_SYMBOL}To cancel the job:\t\t "
324
+ f"{_cli_command('cancel')} {cluster_name} {job_id}"
325
+ )
326
+ lines.append(
327
+ f"{INDENT_SYMBOL}To stream job logs:\t\t "
328
+ f"{_cli_command('logs')} {cluster_name} {job_id}"
329
+ )
330
+ lines.append(
331
+ f"{INDENT_LAST_SYMBOL}To view job queue:\t\t "
332
+ f"{_cli_command('queue')} {cluster_name}"
333
+ )
334
+ lines.append(f"Cluster name: {cluster_name}")
335
+ lines.append(f"{INDENT_SYMBOL}To log into the head VM:\t ssh {cluster_name}")
336
+ lines.append(
337
+ f"{INDENT_SYMBOL}To submit a job:\t\t "
338
+ f"{_cli_command('exec')} {cluster_name} yaml_file"
339
+ )
340
+ lines.append(
341
+ f"{INDENT_SYMBOL}To stop the cluster:\t {_cli_command('stop')} {cluster_name}"
342
+ )
343
+ lines.append(
344
+ f"{INDENT_LAST_SYMBOL}To teardown the cluster:\t {_cli_command('down')} "
345
+ f"{cluster_name}"
346
+ )
347
+ return "\n" + "\n".join(lines)
348
+
349
+
350
+ def _cli_command(command: str) -> str:
351
+ if command in SKY_ALIAS_COMMANDS:
352
+ return f"ml {command}"
353
+ return f"ml sky {command}"
mithril/cli/main.py ADDED
@@ -0,0 +1,68 @@
1
+ """Mithril CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from importlib.metadata import version
7
+
8
+ from cyclopts import App, Group
9
+
10
+ from mithril.cli.commands.help import help_cmd, print_help
11
+ from mithril.cli.commands.launch import launch
12
+ from mithril.cli.utils.skypilot_passthrough import SKY_ALIAS_COMMANDS, run_sky
13
+
14
+ _PACKAGE_NAME = "mithril-client"
15
+
16
+
17
+ def cli(argv: list[str] | None = None) -> int:
18
+ """CLI router; returns an exit code."""
19
+ argv = list(sys.argv[1:] if argv is None else argv)
20
+
21
+ if not argv or argv[0] in {"-h", "--help"}:
22
+ print_help()
23
+ return 0
24
+
25
+ if argv[0] == "--version":
26
+ print(version(_PACKAGE_NAME))
27
+ return 0
28
+
29
+ cmd, rest = argv[0], argv[1:]
30
+
31
+ if cmd == "sky":
32
+ # Preserve Click-like behavior: `ml sky` shows SkyPilot help.
33
+ args = rest or ["--help"]
34
+ return run_sky(*args)
35
+
36
+ if cmd in SKY_ALIAS_COMMANDS:
37
+ return run_sky(cmd, *rest)
38
+
39
+ # Our first-class commands.
40
+ app = App(
41
+ name="ml",
42
+ version=version(_PACKAGE_NAME),
43
+ version_flags=["--version"],
44
+ # Match SkyPilot's Click-style help output more closely by disabling the
45
+ # rich "boxed table" help renderer.
46
+ help_formatter="plain",
47
+ help_format="plaintext",
48
+ # Use "Options" heading to match clap's style
49
+ group_parameters=Group("Options", sort_key=0),
50
+ )
51
+ app.command(help_cmd, name="help")
52
+ app.command(launch)
53
+
54
+ try:
55
+ app(argv)
56
+ except SystemExit as e:
57
+ return e.code if isinstance(e.code, int) else 1
58
+ else:
59
+ return 0
60
+
61
+
62
+ def main() -> None:
63
+ """Entry point."""
64
+ raise SystemExit(cli())
65
+
66
+
67
+ if __name__ == "__main__":
68
+ main()
@@ -0,0 +1 @@
1
+ """Internal CLI utilities (not user-facing commands)."""