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.
Files changed (50) hide show
  1. cli/__main__.py +16 -3
  2. cli/config.py +45 -4
  3. cli/consts.py +1 -1
  4. cli/dataset_cmds.py +6 -14
  5. cli/dataset_recipe_cmds.py +78 -0
  6. cli/experiment_cmds.py +226 -43
  7. cli/keychain.py +88 -0
  8. cli/profile_cmds.py +10 -6
  9. cli/runc_cmds.py +5 -5
  10. cli/trainer_package_cmds.py +65 -0
  11. hafnia/__init__.py +2 -0
  12. hafnia/data/factory.py +1 -2
  13. hafnia/dataset/dataset_helpers.py +9 -14
  14. hafnia/dataset/dataset_names.py +10 -5
  15. hafnia/dataset/dataset_recipe/dataset_recipe.py +165 -67
  16. hafnia/dataset/dataset_recipe/recipe_transforms.py +48 -4
  17. hafnia/dataset/dataset_recipe/recipe_types.py +1 -1
  18. hafnia/dataset/dataset_upload_helper.py +265 -56
  19. hafnia/dataset/format_conversions/image_classification_from_directory.py +106 -0
  20. hafnia/dataset/format_conversions/torchvision_datasets.py +281 -0
  21. hafnia/dataset/hafnia_dataset.py +577 -213
  22. hafnia/dataset/license_types.py +63 -0
  23. hafnia/dataset/operations/dataset_stats.py +259 -3
  24. hafnia/dataset/operations/dataset_transformations.py +332 -7
  25. hafnia/dataset/operations/table_transformations.py +43 -5
  26. hafnia/dataset/primitives/__init__.py +8 -0
  27. hafnia/dataset/primitives/bbox.py +25 -12
  28. hafnia/dataset/primitives/bitmask.py +26 -14
  29. hafnia/dataset/primitives/classification.py +16 -8
  30. hafnia/dataset/primitives/point.py +7 -3
  31. hafnia/dataset/primitives/polygon.py +16 -9
  32. hafnia/dataset/primitives/segmentation.py +10 -7
  33. hafnia/experiment/hafnia_logger.py +111 -8
  34. hafnia/http.py +16 -2
  35. hafnia/platform/__init__.py +9 -3
  36. hafnia/platform/builder.py +12 -10
  37. hafnia/platform/dataset_recipe.py +104 -0
  38. hafnia/platform/datasets.py +47 -9
  39. hafnia/platform/download.py +25 -19
  40. hafnia/platform/experiment.py +51 -56
  41. hafnia/platform/trainer_package.py +57 -0
  42. hafnia/utils.py +81 -13
  43. hafnia/visualizations/image_visualizations.py +4 -4
  44. {hafnia-0.2.4.dist-info → hafnia-0.4.0.dist-info}/METADATA +40 -34
  45. hafnia-0.4.0.dist-info/RECORD +56 -0
  46. cli/recipe_cmds.py +0 -45
  47. hafnia-0.2.4.dist-info/RECORD +0 -49
  48. {hafnia-0.2.4.dist-info → hafnia-0.4.0.dist-info}/WHEEL +0 -0
  49. {hafnia-0.2.4.dist-info → hafnia-0.4.0.dist-info}/entry_points.txt +0 -0
  50. {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
- from cli import consts, dataset_cmds, experiment_cmds, profile_cmds, recipe_cmds, runc_cmds
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
- cfg_profile = ConfigSchema(api_key=api_key, platform_url=platform_url)
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(recipe_cmds.recipe)
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
- "recipes": "/api/v1/recipes",
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
- self.config.api_key = value
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(self.config_data.model_dump(), f, indent=4)
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
- ERROR_RECIPE_FILE_FORMAT: str = "Recipe filename must be a '.zip' file"
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
- import cli.consts as consts
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 dataset_list(cfg: Config) -> None:
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
- from hafnia.platform.datasets import dataset_list
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 data_download(cfg: Config, dataset_name: str, destination: Optional[click.Path], force: bool) -> Path:
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.argument("name")
18
- @click.argument("source_dir", type=Path)
19
- @click.argument("exec_cmd", type=str)
20
- @click.argument("dataset_name")
21
- @click.argument("env_name")
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 create(cfg: Config, name: str, source_dir: Path, exec_cmd: str, dataset_name: str, env_name: str) -> None:
24
- """Create a new experiment run"""
25
- from hafnia.platform import create_experiment, create_recipe, get_dataset_id, get_exp_environment_id
26
-
27
- if not source_dir.exists():
28
- raise click.ClickException(consts.ERROR_EXPERIMENT_DIR)
29
-
30
- try:
31
- dataset_id = get_dataset_id(dataset_name, cfg.get_platform_endpoint("datasets"), cfg.api_key)
32
- except Exception:
33
- raise click.ClickException(f"Error retrieving dataset '{dataset_name}'.")
34
-
35
- try:
36
- recipe_id = create_recipe(source_dir, cfg.get_platform_endpoint("recipes"), cfg.api_key)
37
- except Exception:
38
- raise click.ClickException(f"Failed to create recipe from '{source_dir}'")
39
-
40
- try:
41
- env_id = get_exp_environment_id(env_name, cfg.get_platform_endpoint("experiment_environments"), cfg.api_key)
42
- except Exception:
43
- raise click.ClickException(f"Environment '{env_name}' not found")
44
-
45
- try:
46
- experiment_id = create_experiment(
47
- name, dataset_id, recipe_id, exec_cmd, env_id, cfg.get_platform_endpoint("experiments"), cfg.api_key
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
- except Exception:
50
- raise click.ClickException(f"Failed to create experiment '{name}'")
51
-
52
- rprint(
53
- {
54
- "dataset_id": dataset_id,
55
- "recipe_id": recipe_id,
56
- "environment_id": env_id,
57
- "experiment_id": experiment_id,
58
- "status": "CREATED",
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