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.
- mithril/__init__.py +7 -0
- mithril/_mcli.cpython-314-darwin.so +0 -0
- mithril/_mcli.pyi +7 -0
- mithril/_mcli_entry.py +75 -0
- mithril/api/__init__.py +7 -0
- mithril/api/bindings/.gitattributes +2 -0
- mithril/api/bindings/__init__.py +10 -0
- mithril/api/bindings/api/__init__.py +1 -0
- mithril/api/bindings/api/api_keys/__init__.py +1 -0
- mithril/api/bindings/api/api_keys/create_api_key_v2_api_keys_post.py +179 -0
- mithril/api/bindings/api/api_keys/get_api_keys_v2_api_keys_get.py +141 -0
- mithril/api/bindings/api/api_keys/revoke_api_key_v2_api_keys_key_fid_delete.py +173 -0
- mithril/api/bindings/api/image_versions/__init__.py +1 -0
- mithril/api/bindings/api/image_versions/get_image_versions_v2_image_versions_get.py +141 -0
- mithril/api/bindings/api/image_versions/get_mcc_image_versions_v2_mcc_image_versions_get.py +179 -0
- mithril/api/bindings/api/instance_types/__init__.py +1 -0
- mithril/api/bindings/api/instance_types/get_instance_types_v2_instance_types_get.py +137 -0
- mithril/api/bindings/api/instances/__init__.py +1 -0
- mithril/api/bindings/api/instances/get_instance_status_v2_instances_instance_id_status_get.py +165 -0
- mithril/api/bindings/api/instances/get_instances_v2_instances_get.py +409 -0
- mithril/api/bindings/api/kubernetes_clusters/__init__.py +1 -0
- mithril/api/bindings/api/kubernetes_clusters/create_kubernetes_cluster_v2_kubernetes_clusters_post.py +171 -0
- mithril/api/bindings/api/kubernetes_clusters/delete_kubernetes_cluster_v2_kubernetes_clusters_cluster_fid_delete.py +163 -0
- mithril/api/bindings/api/kubernetes_clusters/get_kubernetes_cluster_v2_kubernetes_clusters_cluster_fid_get.py +165 -0
- mithril/api/bindings/api/kubernetes_clusters/get_kubernetes_clusters_v2_kubernetes_clusters_get.py +175 -0
- mithril/api/bindings/api/lifecycle_scripts/__init__.py +1 -0
- mithril/api/bindings/api/lifecycle_scripts/create_lifecycle_script_v2_lifecycle_scripts_post.py +171 -0
- mithril/api/bindings/api/lifecycle_scripts/delete_lifecycle_script_v2_lifecycle_scripts_ls_fid_delete.py +155 -0
- mithril/api/bindings/api/lifecycle_scripts/get_lifecycle_script_content_v2_lifecycle_scripts_ls_fid_content_get.py +155 -0
- mithril/api/bindings/api/lifecycle_scripts/list_lifecycle_scripts_v2_lifecycle_scripts_get.py +247 -0
- mithril/api/bindings/api/lifecycle_scripts/update_lifecycle_script_v2_lifecycle_scripts_ls_fid_patch.py +179 -0
- mithril/api/bindings/api/pricing/__init__.py +1 -0
- mithril/api/bindings/api/pricing/get_current_prices_v2_v2_pricing_current_get.py +217 -0
- mithril/api/bindings/api/pricing/get_historical_prices_v2_v2_pricing_history_get.py +222 -0
- mithril/api/bindings/api/profile/__init__.py +1 -0
- mithril/api/bindings/api/profile/get_me_v2_me_get.py +132 -0
- mithril/api/bindings/api/profile/get_my_teammates_v2_me_teammates_get.py +153 -0
- mithril/api/bindings/api/projects/__init__.py +1 -0
- mithril/api/bindings/api/projects/get_projects_v2_projects_get.py +137 -0
- mithril/api/bindings/api/quotas/__init__.py +1 -0
- mithril/api/bindings/api/quotas/get_quotas_v2_quotas_get.py +175 -0
- mithril/api/bindings/api/reservations/__init__.py +1 -0
- mithril/api/bindings/api/reservations/create_reservation_v2_reservation_post.py +171 -0
- mithril/api/bindings/api/reservations/extend_reservation_v2_reservation_reservation_fid_extend_post.py +187 -0
- mithril/api/bindings/api/reservations/get_availability_v2_reservation_availability_get.py +664 -0
- mithril/api/bindings/api/reservations/get_extension_availability_v2_reservation_reservation_fid_extension_availability_get.py +165 -0
- mithril/api/bindings/api/reservations/get_reservations_v2_reservation_get.py +309 -0
- mithril/api/bindings/api/reservations/update_reservation_v2_reservation_reservation_fid_patch.py +187 -0
- mithril/api/bindings/api/spot/__init__.py +1 -0
- mithril/api/bindings/api/spot/cancel_bid_v2_spot_bids_bid_fid_delete.py +161 -0
- mithril/api/bindings/api/spot/create_bid_v2_spot_bids_post.py +171 -0
- mithril/api/bindings/api/spot/get_auctions_v2_spot_availability_get.py +137 -0
- mithril/api/bindings/api/spot/get_bid_history_v2_spot_bids_bid_fid_history_get.py +193 -0
- mithril/api/bindings/api/spot/get_bid_status_v2_spot_bids_bid_fid_status_get.py +189 -0
- mithril/api/bindings/api/spot/get_bid_v2_spot_bids_bid_fid_get.py +163 -0
- mithril/api/bindings/api/spot/get_bids_v2_spot_bids_get.py +330 -0
- mithril/api/bindings/api/spot/update_bid_v2_spot_bids_bid_fid_patch.py +185 -0
- mithril/api/bindings/api/ssh_keys/__init__.py +1 -0
- mithril/api/bindings/api/ssh_keys/create_ssh_key_v2_ssh_keys_post.py +175 -0
- mithril/api/bindings/api/ssh_keys/delete_ssh_key_v2_ssh_keys_ssh_key_fid_delete.py +167 -0
- mithril/api/bindings/api/ssh_keys/get_ssh_keys_v2_ssh_keys_get.py +175 -0
- mithril/api/bindings/api/ssh_keys/update_ssh_key_v2_ssh_keys_ssh_key_fid_patch.py +187 -0
- mithril/api/bindings/api/volumes/__init__.py +1 -0
- mithril/api/bindings/api/volumes/create_volume_v2_volumes_post.py +211 -0
- mithril/api/bindings/api/volumes/delete_volume_v2_volumes_volume_fid_delete.py +199 -0
- mithril/api/bindings/api/volumes/get_volumes_v2_volumes_get.py +239 -0
- mithril/api/bindings/api/volumes/update_volume_v2_volumes_volume_fid_patch.py +243 -0
- mithril/api/bindings/client.py +284 -0
- mithril/api/bindings/errors.py +18 -0
- mithril/api/bindings/models/__init__.py +169 -0
- mithril/api/bindings/models/api_key_model.py +114 -0
- mithril/api/bindings/models/auction_model.py +146 -0
- mithril/api/bindings/models/availability_slot_model.py +76 -0
- mithril/api/bindings/models/bid_history_event_model.py +157 -0
- mithril/api/bindings/models/bid_history_event_model_event_type.py +19 -0
- mithril/api/bindings/models/bid_history_response.py +84 -0
- mithril/api/bindings/models/bid_model.py +191 -0
- mithril/api/bindings/models/bid_model_status.py +14 -0
- mithril/api/bindings/models/bid_status_response.py +72 -0
- mithril/api/bindings/models/bid_status_response_status.py +15 -0
- mithril/api/bindings/models/check_availability_response.py +60 -0
- mithril/api/bindings/models/create_api_key_request.py +68 -0
- mithril/api/bindings/models/create_api_key_response.py +122 -0
- mithril/api/bindings/models/create_bid_request.py +116 -0
- mithril/api/bindings/models/create_kubernetes_cluster_request.py +136 -0
- mithril/api/bindings/models/create_kubernetes_cluster_request_k8s_version.py +11 -0
- mithril/api/bindings/models/create_lifecycle_script_request.py +115 -0
- mithril/api/bindings/models/create_reservation_request.py +124 -0
- mithril/api/bindings/models/create_ssh_key_request.py +99 -0
- mithril/api/bindings/models/create_volume_request.py +98 -0
- mithril/api/bindings/models/create_volume_request_disk_interface.py +11 -0
- mithril/api/bindings/models/created_ssh_key_model.py +122 -0
- mithril/api/bindings/models/current_prices_response.py +202 -0
- mithril/api/bindings/models/extend_reservation_request.py +60 -0
- mithril/api/bindings/models/extension_availability_response.py +68 -0
- mithril/api/bindings/models/get_availability_v2_reservation_availability_get_mode.py +12 -0
- mithril/api/bindings/models/get_bids_response.py +96 -0
- mithril/api/bindings/models/get_bids_v2_spot_bids_get_sort_by.py +11 -0
- mithril/api/bindings/models/get_bids_v2_spot_bids_get_status.py +14 -0
- mithril/api/bindings/models/get_instances_response.py +96 -0
- mithril/api/bindings/models/get_instances_v2_instances_get_order_type_in_type_0_item.py +11 -0
- mithril/api/bindings/models/get_instances_v2_instances_get_sort_by.py +12 -0
- mithril/api/bindings/models/get_instances_v2_instances_get_status_in_type_0_item.py +24 -0
- mithril/api/bindings/models/get_latest_end_time_response.py +68 -0
- mithril/api/bindings/models/get_reservations_response.py +96 -0
- mithril/api/bindings/models/get_reservations_v2_reservation_get_sort_by.py +11 -0
- mithril/api/bindings/models/get_reservations_v2_reservation_get_status.py +14 -0
- mithril/api/bindings/models/historical_price_point_model.py +94 -0
- mithril/api/bindings/models/historical_prices_response_model.py +76 -0
- mithril/api/bindings/models/http_validation_error.py +78 -0
- mithril/api/bindings/models/image_version_model.py +224 -0
- mithril/api/bindings/models/instance_model.py +211 -0
- mithril/api/bindings/models/instance_model_status.py +24 -0
- mithril/api/bindings/models/instance_status_response.py +141 -0
- mithril/api/bindings/models/instance_status_response_status.py +24 -0
- mithril/api/bindings/models/instance_type_model.py +170 -0
- mithril/api/bindings/models/kubernetes_cluster_model.py +207 -0
- mithril/api/bindings/models/kubernetes_cluster_model_status.py +12 -0
- mithril/api/bindings/models/launch_specification_model.py +152 -0
- mithril/api/bindings/models/lifecycle_script_model.py +134 -0
- mithril/api/bindings/models/lifecycle_script_scope.py +12 -0
- mithril/api/bindings/models/list_lifecycle_scripts_response.py +96 -0
- mithril/api/bindings/models/list_lifecycle_scripts_v2_lifecycle_scripts_get_sort_by.py +11 -0
- mithril/api/bindings/models/me_response.py +126 -0
- mithril/api/bindings/models/new_ssh_key_model.py +100 -0
- mithril/api/bindings/models/persistent_disk_change.py +92 -0
- mithril/api/bindings/models/project_model.py +76 -0
- mithril/api/bindings/models/public_lifecycle_script_scope.py +11 -0
- mithril/api/bindings/models/quota_model.py +132 -0
- mithril/api/bindings/models/reservation_model.py +215 -0
- mithril/api/bindings/models/reservation_model_status.py +14 -0
- mithril/api/bindings/models/size.py +70 -0
- mithril/api/bindings/models/size_unit.py +18 -0
- mithril/api/bindings/models/sort_direction.py +11 -0
- mithril/api/bindings/models/teammate_response.py +158 -0
- mithril/api/bindings/models/update_bid_request.py +143 -0
- mithril/api/bindings/models/update_lifecycle_script_request.py +109 -0
- mithril/api/bindings/models/update_reservation_request.py +103 -0
- mithril/api/bindings/models/update_ssh_key_request.py +60 -0
- mithril/api/bindings/models/update_volume_request.py +65 -0
- mithril/api/bindings/models/validation_error.py +89 -0
- mithril/api/bindings/models/volume_model.py +140 -0
- mithril/api/bindings/models/volume_model_attachments.py +46 -0
- mithril/api/bindings/models/volume_model_interface.py +11 -0
- mithril/api/bindings/types.py +56 -0
- mithril/api/client.py +138 -0
- mithril/cli/__init__.py +7 -0
- mithril/cli/commands/__init__.py +15 -0
- mithril/cli/commands/help.py +88 -0
- mithril/cli/commands/launch.py +353 -0
- mithril/cli/main.py +68 -0
- mithril/cli/utils/__init__.py +1 -0
- mithril/cli/utils/skypilot_passthrough.py +38 -0
- mithril/cli/utils/streaming.py +235 -0
- mithril/cli/utils/volumes.py +110 -0
- mithril/config.py +47 -0
- mithril/py.typed +0 -0
- mithril/sky/__init__.py +141 -0
- mithril/sky/client.py +176 -0
- mithril_client-0.1.0a1.dist-info/METADATA +56 -0
- mithril_client-0.1.0a1.dist-info/RECORD +163 -0
- mithril_client-0.1.0a1.dist-info/WHEEL +4 -0
- 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)
|
mithril/cli/__init__.py
ADDED
|
@@ -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)."""
|