hafnia 0.2.4__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli/__main__.py +16 -3
- cli/config.py +45 -4
- cli/consts.py +1 -1
- cli/dataset_cmds.py +6 -14
- cli/dataset_recipe_cmds.py +78 -0
- cli/experiment_cmds.py +226 -43
- cli/keychain.py +88 -0
- cli/profile_cmds.py +10 -6
- cli/runc_cmds.py +5 -5
- cli/trainer_package_cmds.py +65 -0
- hafnia/__init__.py +2 -0
- hafnia/data/factory.py +1 -2
- hafnia/dataset/dataset_helpers.py +9 -14
- hafnia/dataset/dataset_names.py +10 -5
- hafnia/dataset/dataset_recipe/dataset_recipe.py +165 -67
- hafnia/dataset/dataset_recipe/recipe_transforms.py +48 -4
- hafnia/dataset/dataset_recipe/recipe_types.py +1 -1
- hafnia/dataset/dataset_upload_helper.py +265 -56
- hafnia/dataset/format_conversions/image_classification_from_directory.py +106 -0
- hafnia/dataset/format_conversions/torchvision_datasets.py +281 -0
- hafnia/dataset/hafnia_dataset.py +577 -213
- hafnia/dataset/license_types.py +63 -0
- hafnia/dataset/operations/dataset_stats.py +259 -3
- hafnia/dataset/operations/dataset_transformations.py +332 -7
- hafnia/dataset/operations/table_transformations.py +43 -5
- hafnia/dataset/primitives/__init__.py +8 -0
- hafnia/dataset/primitives/bbox.py +25 -12
- hafnia/dataset/primitives/bitmask.py +26 -14
- hafnia/dataset/primitives/classification.py +16 -8
- hafnia/dataset/primitives/point.py +7 -3
- hafnia/dataset/primitives/polygon.py +16 -9
- hafnia/dataset/primitives/segmentation.py +10 -7
- hafnia/experiment/hafnia_logger.py +111 -8
- hafnia/http.py +16 -2
- hafnia/platform/__init__.py +9 -3
- hafnia/platform/builder.py +12 -10
- hafnia/platform/dataset_recipe.py +104 -0
- hafnia/platform/datasets.py +47 -9
- hafnia/platform/download.py +25 -19
- hafnia/platform/experiment.py +51 -56
- hafnia/platform/trainer_package.py +57 -0
- hafnia/utils.py +81 -13
- hafnia/visualizations/image_visualizations.py +4 -4
- {hafnia-0.2.4.dist-info → hafnia-0.4.0.dist-info}/METADATA +40 -34
- hafnia-0.4.0.dist-info/RECORD +56 -0
- cli/recipe_cmds.py +0 -45
- hafnia-0.2.4.dist-info/RECORD +0 -49
- {hafnia-0.2.4.dist-info → hafnia-0.4.0.dist-info}/WHEEL +0 -0
- {hafnia-0.2.4.dist-info → hafnia-0.4.0.dist-info}/entry_points.txt +0 -0
- {hafnia-0.2.4.dist-info → hafnia-0.4.0.dist-info}/licenses/LICENSE +0 -0
cli/__main__.py
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env python
|
|
2
2
|
import click
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
import hafnia
|
|
5
|
+
from cli import (
|
|
6
|
+
consts,
|
|
7
|
+
dataset_cmds,
|
|
8
|
+
dataset_recipe_cmds,
|
|
9
|
+
experiment_cmds,
|
|
10
|
+
profile_cmds,
|
|
11
|
+
runc_cmds,
|
|
12
|
+
trainer_package_cmds,
|
|
13
|
+
)
|
|
5
14
|
from cli.config import Config, ConfigSchema
|
|
6
15
|
|
|
7
16
|
|
|
8
17
|
@click.group()
|
|
18
|
+
@click.version_option(version=hafnia.__version__)
|
|
9
19
|
@click.pass_context
|
|
10
20
|
def main(ctx: click.Context) -> None:
|
|
11
21
|
"""Hafnia CLI."""
|
|
@@ -27,7 +37,9 @@ def configure(cfg: Config) -> None:
|
|
|
27
37
|
|
|
28
38
|
platform_url = click.prompt("Hafnia Platform URL", type=str, default=consts.DEFAULT_API_URL)
|
|
29
39
|
|
|
30
|
-
|
|
40
|
+
use_keychain = click.confirm("Store API key in system keychain?", default=False)
|
|
41
|
+
|
|
42
|
+
cfg_profile = ConfigSchema(platform_url=platform_url, api_key=api_key, use_keychain=use_keychain)
|
|
31
43
|
cfg.add_profile(profile_name, cfg_profile, set_active=True)
|
|
32
44
|
cfg.save_config()
|
|
33
45
|
profile_cmds.profile_show(cfg)
|
|
@@ -45,7 +57,8 @@ main.add_command(profile_cmds.profile)
|
|
|
45
57
|
main.add_command(dataset_cmds.dataset)
|
|
46
58
|
main.add_command(runc_cmds.runc)
|
|
47
59
|
main.add_command(experiment_cmds.experiment)
|
|
48
|
-
main.add_command(
|
|
60
|
+
main.add_command(trainer_package_cmds.trainer_package)
|
|
61
|
+
main.add_command(dataset_recipe_cmds.dataset_recipe)
|
|
49
62
|
|
|
50
63
|
if __name__ == "__main__":
|
|
51
64
|
main(max_content_width=120)
|
cli/config.py
CHANGED
|
@@ -6,10 +6,12 @@ from typing import Dict, List, Optional
|
|
|
6
6
|
from pydantic import BaseModel, field_validator
|
|
7
7
|
|
|
8
8
|
import cli.consts as consts
|
|
9
|
+
import cli.keychain as keychain
|
|
9
10
|
from hafnia.log import sys_logger, user_logger
|
|
10
11
|
|
|
11
12
|
PLATFORM_API_MAPPING = {
|
|
12
|
-
"
|
|
13
|
+
"trainers": "/api/v1/trainers",
|
|
14
|
+
"dataset_recipes": "/api/v1/dataset-recipes",
|
|
13
15
|
"experiments": "/api/v1/experiments",
|
|
14
16
|
"experiment_environments": "/api/v1/experiment-environments",
|
|
15
17
|
"experiment_runs": "/api/v1/experiment-runs",
|
|
@@ -18,9 +20,18 @@ PLATFORM_API_MAPPING = {
|
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
|
|
23
|
+
class SecretStr(str):
|
|
24
|
+
def __repr__(self):
|
|
25
|
+
return "********"
|
|
26
|
+
|
|
27
|
+
def __str__(self):
|
|
28
|
+
return "********"
|
|
29
|
+
|
|
30
|
+
|
|
21
31
|
class ConfigSchema(BaseModel):
|
|
22
32
|
platform_url: str = ""
|
|
23
33
|
api_key: Optional[str] = None
|
|
34
|
+
use_keychain: bool = False
|
|
24
35
|
|
|
25
36
|
@field_validator("api_key")
|
|
26
37
|
def validate_api_key(cls, value: Optional[str]) -> Optional[str]:
|
|
@@ -34,7 +45,7 @@ class ConfigSchema(BaseModel):
|
|
|
34
45
|
sys_logger.warning("API key is missing the 'ApiKey ' prefix. Prefix is being added automatically.")
|
|
35
46
|
value = f"ApiKey {value}"
|
|
36
47
|
|
|
37
|
-
return value
|
|
48
|
+
return SecretStr(value) # Keeps the API key masked in logs and repr
|
|
38
49
|
|
|
39
50
|
|
|
40
51
|
class ConfigFileSchema(BaseModel):
|
|
@@ -69,13 +80,32 @@ class Config:
|
|
|
69
80
|
|
|
70
81
|
@property
|
|
71
82
|
def api_key(self) -> str:
|
|
83
|
+
# Check keychain first if enabled
|
|
84
|
+
if self.config.use_keychain:
|
|
85
|
+
keychain_key = keychain.get_api_key(self.active_profile)
|
|
86
|
+
if keychain_key is not None:
|
|
87
|
+
return keychain_key
|
|
88
|
+
|
|
89
|
+
# Fall back to config file
|
|
72
90
|
if self.config.api_key is not None:
|
|
73
91
|
return self.config.api_key
|
|
92
|
+
|
|
74
93
|
raise ValueError(consts.ERROR_API_KEY_NOT_SET)
|
|
75
94
|
|
|
76
95
|
@api_key.setter
|
|
77
96
|
def api_key(self, value: str) -> None:
|
|
78
|
-
|
|
97
|
+
# Store in keychain if enabled
|
|
98
|
+
if self.config.use_keychain:
|
|
99
|
+
if keychain.store_api_key(self.active_profile, value):
|
|
100
|
+
# Successfully stored in keychain, don't store in config
|
|
101
|
+
self.config.api_key = None
|
|
102
|
+
else:
|
|
103
|
+
# Keychain storage failed, fall back to config file
|
|
104
|
+
sys_logger.warning("Failed to store in keychain, falling back to config file")
|
|
105
|
+
self.config.api_key = value
|
|
106
|
+
else:
|
|
107
|
+
# Not using keychain, store in config file
|
|
108
|
+
self.config.api_key = value
|
|
79
109
|
|
|
80
110
|
@property
|
|
81
111
|
def platform_url(self) -> str:
|
|
@@ -151,8 +181,19 @@ class Config:
|
|
|
151
181
|
raise ValueError("Failed to parse configuration file")
|
|
152
182
|
|
|
153
183
|
def save_config(self) -> None:
|
|
184
|
+
# Create a copy to avoid modifying the original data
|
|
185
|
+
config_to_save = self.config_data.model_dump()
|
|
186
|
+
|
|
187
|
+
# Store API key in keychain if enabled, and don't write to file
|
|
188
|
+
for profile_name, profile_data in config_to_save["profiles"].items():
|
|
189
|
+
if profile_data.get("use_keychain", False):
|
|
190
|
+
api_key = profile_data.get("api_key")
|
|
191
|
+
if api_key:
|
|
192
|
+
keychain.store_api_key(profile_name, api_key)
|
|
193
|
+
profile_data["api_key"] = None
|
|
194
|
+
|
|
154
195
|
with open(self.config_path, "w") as f:
|
|
155
|
-
json.dump(
|
|
196
|
+
json.dump(config_to_save, f, indent=4)
|
|
156
197
|
|
|
157
198
|
def remove_profile(self, profile_name: str) -> None:
|
|
158
199
|
if profile_name not in self.config_data.profiles:
|
cli/consts.py
CHANGED
|
@@ -10,7 +10,7 @@ ERROR_CREATE_PROFILE: str = "Failed to create profile. Profile name must be uniq
|
|
|
10
10
|
ERROR_GET_RESOURCE: str = "Failed to get the data from platform. Verify url or api key."
|
|
11
11
|
|
|
12
12
|
ERROR_EXPERIMENT_DIR: str = "Source directory does not exist"
|
|
13
|
-
|
|
13
|
+
ERROR_TRAINER_PACKAGE_FILE_FORMAT: str = "Trainer package must be a '.zip' file"
|
|
14
14
|
|
|
15
15
|
PROFILE_SWITCHED_SUCCESS: str = "Switched to profile:"
|
|
16
16
|
PROFILE_REMOVED_SUCCESS: str = "Removed profile:"
|
cli/dataset_cmds.py
CHANGED
|
@@ -2,12 +2,10 @@ from pathlib import Path
|
|
|
2
2
|
from typing import Optional
|
|
3
3
|
|
|
4
4
|
import click
|
|
5
|
-
from rich import print as rprint
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
from cli import consts
|
|
8
7
|
from cli.config import Config
|
|
9
8
|
from hafnia import utils
|
|
10
|
-
from hafnia.platform.datasets import create_rich_table_from_dataset
|
|
11
9
|
|
|
12
10
|
|
|
13
11
|
@click.group()
|
|
@@ -18,18 +16,12 @@ def dataset():
|
|
|
18
16
|
|
|
19
17
|
@dataset.command("ls")
|
|
20
18
|
@click.pass_obj
|
|
21
|
-
def
|
|
19
|
+
def cmd_list_datasets(cfg: Config) -> None:
|
|
22
20
|
"""List available datasets on Hafnia platform"""
|
|
21
|
+
from hafnia.platform.datasets import get_datasets, pretty_print_datasets
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
try:
|
|
27
|
-
datasets = dataset_list(cfg=cfg)
|
|
28
|
-
except Exception:
|
|
29
|
-
raise click.ClickException(consts.ERROR_GET_RESOURCE)
|
|
30
|
-
|
|
31
|
-
table = create_rich_table_from_dataset(datasets)
|
|
32
|
-
rprint(table)
|
|
23
|
+
datasets = get_datasets(cfg=cfg)
|
|
24
|
+
pretty_print_datasets(datasets)
|
|
33
25
|
|
|
34
26
|
|
|
35
27
|
@dataset.command("download")
|
|
@@ -43,7 +35,7 @@ def dataset_list(cfg: Config) -> None:
|
|
|
43
35
|
)
|
|
44
36
|
@click.option("--force", "-f", is_flag=True, default=False, help="Flag to enable force redownload")
|
|
45
37
|
@click.pass_obj
|
|
46
|
-
def
|
|
38
|
+
def cmd_dataset_download(cfg: Config, dataset_name: str, destination: Optional[click.Path], force: bool) -> Path:
|
|
47
39
|
"""Download dataset from Hafnia platform"""
|
|
48
40
|
|
|
49
41
|
from hafnia.platform import datasets
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Dict, Optional
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
from rich import print as rprint
|
|
6
|
+
|
|
7
|
+
from cli.config import Config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@click.group(name="dataset-recipe")
|
|
11
|
+
def dataset_recipe() -> None:
|
|
12
|
+
"""Dataset recipe commands"""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataset_recipe.command(name="create")
|
|
17
|
+
@click.argument("path_json_recipe", required=True)
|
|
18
|
+
@click.option(
|
|
19
|
+
"-n",
|
|
20
|
+
"--name",
|
|
21
|
+
type=str,
|
|
22
|
+
default=None,
|
|
23
|
+
show_default=True,
|
|
24
|
+
help="Name of the dataset recipe.",
|
|
25
|
+
)
|
|
26
|
+
@click.pass_obj
|
|
27
|
+
def cmd_get_or_create_dataset_recipe(cfg: Config, path_json_recipe: Path, name: Optional[str]) -> None:
|
|
28
|
+
"""Create Hafnia dataset recipe from dataset recipe JSON file"""
|
|
29
|
+
from hafnia.platform.dataset_recipe import get_or_create_dataset_recipe_from_path
|
|
30
|
+
|
|
31
|
+
endpoint = cfg.get_platform_endpoint("dataset_recipes")
|
|
32
|
+
recipe = get_or_create_dataset_recipe_from_path(path_json_recipe, endpoint=endpoint, api_key=cfg.api_key, name=name)
|
|
33
|
+
|
|
34
|
+
if recipe is None:
|
|
35
|
+
raise click.ClickException("Failed to create dataset recipe.")
|
|
36
|
+
|
|
37
|
+
rprint(recipe)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataset_recipe.command(name="ls")
|
|
41
|
+
@click.pass_obj
|
|
42
|
+
@click.option("-l", "--limit", type=int, default=None, help="Limit number of listed dataset recipes.")
|
|
43
|
+
def cmd_list_dataset_recipes(cfg: Config, limit: Optional[int]) -> None:
|
|
44
|
+
"""List available dataset recipes"""
|
|
45
|
+
from hafnia.platform.dataset_recipe import get_dataset_recipes, pretty_print_dataset_recipes
|
|
46
|
+
|
|
47
|
+
endpoint = cfg.get_platform_endpoint("dataset_recipes")
|
|
48
|
+
recipes = get_dataset_recipes(endpoint=endpoint, api_key=cfg.api_key)
|
|
49
|
+
# Sort recipes to have the most recent first
|
|
50
|
+
recipes = sorted(recipes, key=lambda x: x["created_at"], reverse=True)
|
|
51
|
+
if limit is not None:
|
|
52
|
+
recipes = recipes[:limit]
|
|
53
|
+
pretty_print_dataset_recipes(recipes)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataset_recipe.command(name="rm")
|
|
57
|
+
@click.option("-i", "--id", type=str, help="Dataset recipe ID to delete.")
|
|
58
|
+
@click.option("-n", "--name", type=str, help="Dataset recipe name to delete.")
|
|
59
|
+
@click.pass_obj
|
|
60
|
+
def cmd_delete_dataset_recipe(cfg: Config, id: Optional[str], name: Optional[str]) -> Dict:
|
|
61
|
+
"""Delete a dataset recipe by ID or name"""
|
|
62
|
+
from hafnia.platform.dataset_recipe import delete_dataset_recipe_by_id, delete_dataset_recipe_by_name
|
|
63
|
+
|
|
64
|
+
endpoint = cfg.get_platform_endpoint("dataset_recipes")
|
|
65
|
+
|
|
66
|
+
if id is not None:
|
|
67
|
+
return delete_dataset_recipe_by_id(id=id, endpoint=endpoint, api_key=cfg.api_key)
|
|
68
|
+
if name is not None:
|
|
69
|
+
dataset_recipe = delete_dataset_recipe_by_name(name=name, endpoint=endpoint, api_key=cfg.api_key)
|
|
70
|
+
if dataset_recipe is None:
|
|
71
|
+
raise click.ClickException(f"Dataset recipe with name '{name}' was not found.")
|
|
72
|
+
|
|
73
|
+
return dataset_recipe
|
|
74
|
+
|
|
75
|
+
raise click.MissingParameter(
|
|
76
|
+
"No dataset recipe identifier have been given. Provide either --id or --name. "
|
|
77
|
+
"Get available recipes with 'hafnia dataset-recipe ls'."
|
|
78
|
+
)
|
cli/experiment_cmds.py
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
|
+
from typing import Dict, Optional
|
|
2
3
|
|
|
3
4
|
import click
|
|
4
|
-
from rich import print as rprint
|
|
5
5
|
|
|
6
|
-
import cli.consts as consts
|
|
7
6
|
from cli.config import Config
|
|
7
|
+
from hafnia import utils
|
|
8
|
+
from hafnia.platform.dataset_recipe import (
|
|
9
|
+
get_dataset_recipe_by_id,
|
|
10
|
+
get_dataset_recipe_by_name,
|
|
11
|
+
get_or_create_dataset_recipe_by_dataset_name,
|
|
12
|
+
)
|
|
13
|
+
from hafnia.platform.trainer_package import create_trainer_package
|
|
8
14
|
|
|
9
15
|
|
|
10
16
|
@click.group(name="experiment")
|
|
@@ -13,48 +19,225 @@ def experiment() -> None:
|
|
|
13
19
|
pass
|
|
14
20
|
|
|
15
21
|
|
|
22
|
+
@experiment.command(name="environments")
|
|
23
|
+
@click.pass_obj
|
|
24
|
+
def cmd_view_environments(cfg: Config):
|
|
25
|
+
"""
|
|
26
|
+
View available experiment training environments.
|
|
27
|
+
"""
|
|
28
|
+
from hafnia.platform import get_environments, pretty_print_training_environments
|
|
29
|
+
|
|
30
|
+
envs = get_environments(cfg.get_platform_endpoint("experiment_environments"), cfg.api_key)
|
|
31
|
+
|
|
32
|
+
pretty_print_training_environments(envs)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def default_experiment_run_name():
|
|
36
|
+
return f"run-{utils.now_as_str()}"
|
|
37
|
+
|
|
38
|
+
|
|
16
39
|
@experiment.command(name="create")
|
|
17
|
-
@click.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
40
|
+
@click.option(
|
|
41
|
+
"-n",
|
|
42
|
+
"--name",
|
|
43
|
+
type=str,
|
|
44
|
+
default=default_experiment_run_name(),
|
|
45
|
+
required=False,
|
|
46
|
+
help=f"Name of the experiment. [default: run-[DATETIME] e.g. {default_experiment_run_name()}] ",
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"-c",
|
|
50
|
+
"--cmd",
|
|
51
|
+
type=str,
|
|
52
|
+
default="python scripts/train.py",
|
|
53
|
+
show_default=True,
|
|
54
|
+
help="Command to run the experiment.",
|
|
55
|
+
)
|
|
56
|
+
@click.option(
|
|
57
|
+
"-p",
|
|
58
|
+
"--trainer-path",
|
|
59
|
+
type=Path,
|
|
60
|
+
default=None,
|
|
61
|
+
help="Path to the trainer package directory. ",
|
|
62
|
+
)
|
|
63
|
+
@click.option(
|
|
64
|
+
"-i",
|
|
65
|
+
"--trainer-id",
|
|
66
|
+
type=str,
|
|
67
|
+
default=None,
|
|
68
|
+
help="ID of the trainer package. View available trainers with 'hafnia trainer ls'",
|
|
69
|
+
)
|
|
70
|
+
@click.option(
|
|
71
|
+
"-d",
|
|
72
|
+
"--dataset",
|
|
73
|
+
type=str,
|
|
74
|
+
default=None,
|
|
75
|
+
required=False,
|
|
76
|
+
help="DatasetIdentifier: Name of the dataset. View Available datasets with 'hafnia dataset ls'",
|
|
77
|
+
)
|
|
78
|
+
@click.option(
|
|
79
|
+
"-r",
|
|
80
|
+
"--dataset-recipe",
|
|
81
|
+
type=str,
|
|
82
|
+
default=None,
|
|
83
|
+
required=False,
|
|
84
|
+
help="DatasetIdentifier: Name of the dataset recipe. View available dataset recipes with 'hafnia dataset-recipe ls'",
|
|
85
|
+
)
|
|
86
|
+
@click.option(
|
|
87
|
+
"--dataset-recipe-id",
|
|
88
|
+
type=str,
|
|
89
|
+
default=None,
|
|
90
|
+
required=False,
|
|
91
|
+
help="DatasetIdentifier: ID of the dataset recipe. View dataset recipes with 'hafnia dataset-recipe ls'",
|
|
92
|
+
)
|
|
93
|
+
@click.option(
|
|
94
|
+
"-e",
|
|
95
|
+
"--environment",
|
|
96
|
+
type=str,
|
|
97
|
+
default="Free Tier",
|
|
98
|
+
show_default=True,
|
|
99
|
+
help="Experiment environment name. View available environments with 'hafnia experiment environments'",
|
|
100
|
+
)
|
|
22
101
|
@click.pass_obj
|
|
23
|
-
def
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
102
|
+
def cmd_create_experiment(
|
|
103
|
+
cfg: Config,
|
|
104
|
+
name: str,
|
|
105
|
+
cmd: str,
|
|
106
|
+
trainer_path: Path,
|
|
107
|
+
trainer_id: Optional[str],
|
|
108
|
+
dataset: Optional[str],
|
|
109
|
+
dataset_recipe: Optional[str],
|
|
110
|
+
dataset_recipe_id: Optional[str],
|
|
111
|
+
environment: str,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""
|
|
114
|
+
Create and launch a new experiment run
|
|
115
|
+
|
|
116
|
+
Requires one dataset recipe and one trainer package:.
|
|
117
|
+
- One dataset identifier is required either '--dataset', '--dataset-recipe' or '--dataset-recipe-id'.
|
|
118
|
+
- One trainer identifier is required either '--trainer-path' or '--trainer-id'.
|
|
119
|
+
|
|
120
|
+
\b
|
|
121
|
+
Examples:
|
|
122
|
+
# Launch an experiment with a dataset and a trainer package from local path
|
|
123
|
+
hafnia experiment create --dataset mnist --trainer-path ../trainer-classification
|
|
124
|
+
|
|
125
|
+
\b
|
|
126
|
+
# Launch experiment with dataset recipe by name and trainer package by id
|
|
127
|
+
hafnia experiment create --dataset-recipe mnist-recipe --trainer-id 5e454c0d-fdf1-4d1f-9732-771d7fecd28e
|
|
128
|
+
|
|
129
|
+
\b
|
|
130
|
+
# Show available options:
|
|
131
|
+
hafnia experiment create --name "My Experiment" -d mnist --cmd "python scripts/train.py" -e "Free Tier" -p ../trainer-classification
|
|
132
|
+
"""
|
|
133
|
+
from hafnia.platform import create_experiment, get_exp_environment_id
|
|
134
|
+
|
|
135
|
+
dataset_recipe_response = get_dataset_recipe_by_dataset_identifies(
|
|
136
|
+
cfg=cfg,
|
|
137
|
+
dataset_name=dataset,
|
|
138
|
+
dataset_recipe_name=dataset_recipe,
|
|
139
|
+
dataset_recipe_id=dataset_recipe_id,
|
|
140
|
+
)
|
|
141
|
+
dataset_recipe_id = dataset_recipe_response["id"]
|
|
142
|
+
|
|
143
|
+
trainer_id = get_trainer_package_by_identifies(
|
|
144
|
+
cfg=cfg,
|
|
145
|
+
trainer_path=trainer_path,
|
|
146
|
+
trainer_id=trainer_id,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
env_id = get_exp_environment_id(environment, cfg.get_platform_endpoint("experiment_environments"), cfg.api_key)
|
|
150
|
+
|
|
151
|
+
experiment = create_experiment(
|
|
152
|
+
experiment_name=name,
|
|
153
|
+
dataset_recipe_id=dataset_recipe_id,
|
|
154
|
+
trainer_id=trainer_id,
|
|
155
|
+
exec_cmd=cmd,
|
|
156
|
+
environment_id=env_id,
|
|
157
|
+
endpoint=cfg.get_platform_endpoint("experiments"),
|
|
158
|
+
api_key=cfg.api_key,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
experiment_properties = {
|
|
162
|
+
"ID": experiment.get("id", "N/A"),
|
|
163
|
+
"Name": experiment.get("name", "N/A"),
|
|
164
|
+
"State": experiment.get("state", "N/A"),
|
|
165
|
+
"Trainer Package ID": experiment.get("trainer", "N/A"),
|
|
166
|
+
"Dataset Recipe ID": experiment.get("dataset_recipe", "N/A"),
|
|
167
|
+
"Dataset ID": experiment.get("dataset", "N/A"),
|
|
168
|
+
"Created At": experiment.get("created_at", "N/A"),
|
|
169
|
+
}
|
|
170
|
+
print("Successfully created experiment: ")
|
|
171
|
+
for key, value in experiment_properties.items():
|
|
172
|
+
print(f" {key}: {value}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def get_dataset_recipe_by_dataset_identifies(
|
|
176
|
+
cfg: Config,
|
|
177
|
+
dataset_name: Optional[str],
|
|
178
|
+
dataset_recipe_name: Optional[str],
|
|
179
|
+
dataset_recipe_id: Optional[str],
|
|
180
|
+
) -> Dict:
|
|
181
|
+
dataset_identifiers = [dataset_name, dataset_recipe_name, dataset_recipe_id]
|
|
182
|
+
n_dataset_identifies_defined = sum([bool(identifier) for identifier in dataset_identifiers])
|
|
183
|
+
|
|
184
|
+
if n_dataset_identifies_defined > 1:
|
|
185
|
+
raise click.ClickException(
|
|
186
|
+
"Multiple dataset identifiers have been provided. Define only one dataset identifier."
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
dataset_recipe_endpoint = cfg.get_platform_endpoint("dataset_recipes")
|
|
190
|
+
if dataset_name:
|
|
191
|
+
return get_or_create_dataset_recipe_by_dataset_name(dataset_name, dataset_recipe_endpoint, cfg.api_key)
|
|
192
|
+
|
|
193
|
+
if dataset_recipe_name:
|
|
194
|
+
recipe = get_dataset_recipe_by_name(dataset_recipe_name, dataset_recipe_endpoint, cfg.api_key)
|
|
195
|
+
if recipe is None:
|
|
196
|
+
raise click.ClickException(f"Dataset recipe '{dataset_recipe_name}' was not found in the dataset library.")
|
|
197
|
+
return recipe
|
|
198
|
+
|
|
199
|
+
if dataset_recipe_id:
|
|
200
|
+
return get_dataset_recipe_by_id(dataset_recipe_id, dataset_recipe_endpoint, cfg.api_key)
|
|
201
|
+
|
|
202
|
+
raise click.MissingParameter(
|
|
203
|
+
"At least one dataset identifier must be provided. Set one of the following:\n"
|
|
204
|
+
" --dataset <name> -- E.g. '--dataset mnist'\n"
|
|
205
|
+
" --dataset-recipe <name> -- E.g. '--dataset-recipe my-recipe'\n"
|
|
206
|
+
" --dataset-recipe-id <id> -- E.g. '--dataset-recipe-id 5e454c0d-fdf1-4d1f-9732-771d7fecd28e'\n"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_trainer_package_by_identifies(
|
|
211
|
+
cfg: Config,
|
|
212
|
+
trainer_path: Optional[Path],
|
|
213
|
+
trainer_id: Optional[str],
|
|
214
|
+
) -> str:
|
|
215
|
+
from hafnia.platform import get_trainer_package_by_id
|
|
216
|
+
|
|
217
|
+
if trainer_path is not None and trainer_id is not None:
|
|
218
|
+
raise click.ClickException(
|
|
219
|
+
"Multiple trainer identifiers (--trainer-path, --trainer-id) have been provided. Define only one."
|
|
48
220
|
)
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
221
|
+
|
|
222
|
+
if trainer_path is not None:
|
|
223
|
+
trainer_path = Path(trainer_path)
|
|
224
|
+
if not trainer_path.exists():
|
|
225
|
+
raise click.ClickException(f"Trainer package path '{trainer_path}' does not exist.")
|
|
226
|
+
trainer_id = create_trainer_package(
|
|
227
|
+
trainer_path,
|
|
228
|
+
cfg.get_platform_endpoint("trainers"),
|
|
229
|
+
cfg.api_key,
|
|
230
|
+
)
|
|
231
|
+
return trainer_id
|
|
232
|
+
|
|
233
|
+
if trainer_id:
|
|
234
|
+
trainer_response = get_trainer_package_by_id(
|
|
235
|
+
id=trainer_id, endpoint=cfg.get_platform_endpoint("trainers"), api_key=cfg.api_key
|
|
236
|
+
)
|
|
237
|
+
return trainer_response["id"]
|
|
238
|
+
|
|
239
|
+
raise click.MissingParameter(
|
|
240
|
+
"At least one trainer identifier must be provided. Set one of the following:\n"
|
|
241
|
+
" --trainer-path <path> -- E.g. '--trainer-path .'\n"
|
|
242
|
+
" --trainer-id <id> -- E.g. '--trainer-id 5e454c0d-fdf1-4d1f-9732-771d7fecd28e'\n"
|
|
60
243
|
)
|
cli/keychain.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Keychain storage for API keys using the system keychain."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
from hafnia.log import sys_logger
|
|
6
|
+
|
|
7
|
+
# Keyring is optional - gracefully degrade if not available
|
|
8
|
+
try:
|
|
9
|
+
import keyring
|
|
10
|
+
|
|
11
|
+
KEYRING_AVAILABLE = True
|
|
12
|
+
except ImportError:
|
|
13
|
+
KEYRING_AVAILABLE = False
|
|
14
|
+
sys_logger.debug("keyring library not available, keychain storage disabled")
|
|
15
|
+
|
|
16
|
+
KEYRING_SERVICE_NAME = "hafnia-cli"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def store_api_key(profile_name: str, api_key: str) -> bool:
|
|
20
|
+
"""
|
|
21
|
+
Store an API key in the system keychain.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
profile_name: The profile name to associate with the key
|
|
25
|
+
api_key: The API key to store
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
True if successfully stored, False otherwise
|
|
29
|
+
"""
|
|
30
|
+
if not KEYRING_AVAILABLE:
|
|
31
|
+
sys_logger.warning("Keyring library not available, cannot store API key in keychain")
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
keyring.set_password(KEYRING_SERVICE_NAME, profile_name, api_key)
|
|
36
|
+
sys_logger.debug(f"Stored API key for profile '{profile_name}' in keychain")
|
|
37
|
+
return True
|
|
38
|
+
except Exception as e:
|
|
39
|
+
sys_logger.warning(f"Failed to store API key in keychain: {e}")
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_api_key(profile_name: str) -> Optional[str]:
|
|
44
|
+
"""
|
|
45
|
+
Retrieve an API key from the system keychain.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
profile_name: The profile name to retrieve the key for
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The API key if found, None otherwise
|
|
52
|
+
"""
|
|
53
|
+
if not KEYRING_AVAILABLE:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
api_key = keyring.get_password(KEYRING_SERVICE_NAME, profile_name)
|
|
58
|
+
if api_key:
|
|
59
|
+
sys_logger.debug(f"Retrieved API key for profile '{profile_name}' from keychain")
|
|
60
|
+
return api_key
|
|
61
|
+
except Exception as e:
|
|
62
|
+
sys_logger.warning(f"Failed to retrieve API key from keychain: {e}")
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def delete_api_key(profile_name: str) -> bool:
|
|
67
|
+
"""
|
|
68
|
+
Delete an API key from the system keychain.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
profile_name: The profile name to delete the key for
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
True if successfully deleted or didn't exist, False on error
|
|
75
|
+
"""
|
|
76
|
+
if not KEYRING_AVAILABLE:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
keyring.delete_password(KEYRING_SERVICE_NAME, profile_name)
|
|
81
|
+
sys_logger.debug(f"Deleted API key for profile '{profile_name}' from keychain")
|
|
82
|
+
return True
|
|
83
|
+
except keyring.errors.PasswordDeleteError:
|
|
84
|
+
# Key didn't exist, which is fine
|
|
85
|
+
return True
|
|
86
|
+
except Exception as e:
|
|
87
|
+
sys_logger.warning(f"Failed to delete API key from keychain: {e}")
|
|
88
|
+
return False
|