localargo 0.1.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 (42) hide show
  1. localargo/__about__.py +6 -0
  2. localargo/__init__.py +6 -0
  3. localargo/__main__.py +11 -0
  4. localargo/cli/__init__.py +49 -0
  5. localargo/cli/commands/__init__.py +5 -0
  6. localargo/cli/commands/app.py +150 -0
  7. localargo/cli/commands/cluster.py +312 -0
  8. localargo/cli/commands/debug.py +478 -0
  9. localargo/cli/commands/port_forward.py +311 -0
  10. localargo/cli/commands/secrets.py +300 -0
  11. localargo/cli/commands/sync.py +291 -0
  12. localargo/cli/commands/template.py +288 -0
  13. localargo/cli/commands/up.py +341 -0
  14. localargo/config/__init__.py +15 -0
  15. localargo/config/manifest.py +520 -0
  16. localargo/config/store.py +66 -0
  17. localargo/core/__init__.py +6 -0
  18. localargo/core/apps.py +330 -0
  19. localargo/core/argocd.py +509 -0
  20. localargo/core/catalog.py +284 -0
  21. localargo/core/cluster.py +149 -0
  22. localargo/core/k8s.py +140 -0
  23. localargo/eyecandy/__init__.py +15 -0
  24. localargo/eyecandy/progress_steps.py +283 -0
  25. localargo/eyecandy/table_renderer.py +154 -0
  26. localargo/eyecandy/tables.py +57 -0
  27. localargo/logging.py +99 -0
  28. localargo/manager.py +232 -0
  29. localargo/providers/__init__.py +6 -0
  30. localargo/providers/base.py +146 -0
  31. localargo/providers/k3s.py +206 -0
  32. localargo/providers/kind.py +326 -0
  33. localargo/providers/registry.py +52 -0
  34. localargo/utils/__init__.py +4 -0
  35. localargo/utils/cli.py +231 -0
  36. localargo/utils/proc.py +148 -0
  37. localargo/utils/retry.py +58 -0
  38. localargo-0.1.0.dist-info/METADATA +149 -0
  39. localargo-0.1.0.dist-info/RECORD +42 -0
  40. localargo-0.1.0.dist-info/WHEEL +4 -0
  41. localargo-0.1.0.dist-info/entry_points.txt +2 -0
  42. localargo-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,520 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Declarative cluster manifest loader and validator.
5
+
6
+ This module supports two related YAML schemas:
7
+
8
+ - Legacy cluster-only manifest with top-level key 'clusters' (list of name/provider).
9
+ - Extended up-manifest used by `localargo up` and `localargo validate` with
10
+ top-level keys: 'cluster', 'apps', 'repo_creds', 'secrets'.
11
+
12
+ Both schemas are parsed into dataclasses with validation helpers.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from localargo.providers.registry import get_provider
22
+
23
+ try: # type: ignore[unused-ignore]
24
+ import yaml
25
+ except ImportError: # pragma: no cover - handled at runtime
26
+ yaml = None # type: ignore[assignment]
27
+
28
+
29
+ class ManifestError(Exception):
30
+ """Base exception for manifest-related errors."""
31
+
32
+
33
+ class ManifestValidationError(ManifestError):
34
+ """Raised when manifest validation fails."""
35
+
36
+
37
+ # ------------------------
38
+ # Legacy cluster-only schema
39
+ # ------------------------
40
+
41
+
42
+ @dataclass
43
+ class ClusterConfig:
44
+ """Configuration for a single cluster."""
45
+
46
+ name: str
47
+ provider: str
48
+ kwargs: dict[str, Any] = field(default_factory=dict)
49
+
50
+ def __init__(self, name: str, provider: str, **kwargs: Any) -> None:
51
+ self.name = name
52
+ self.provider = provider
53
+ self.kwargs = kwargs
54
+
55
+ def __repr__(self) -> str:
56
+ kwargs_str = f", kwargs={self.kwargs!r}" if self.kwargs else ""
57
+ return f"ClusterConfig(name={self.name!r}, provider={self.provider!r}{kwargs_str})"
58
+
59
+
60
+ @dataclass
61
+ class ClusterManifest:
62
+ """Cluster manifest containing multiple cluster configurations."""
63
+
64
+ clusters: list[ClusterConfig]
65
+
66
+
67
+ def load_manifest(manifest_path: str | Path) -> ClusterManifest:
68
+ """
69
+ Load cluster manifest from YAML file.
70
+
71
+ Args:
72
+ manifest_path (str | Path): Path to YAML manifest file
73
+
74
+ Returns:
75
+ ClusterManifest: Loaded cluster manifest object
76
+
77
+ Raises:
78
+ ManifestError: If manifest cannot be loaded or is invalid
79
+ """
80
+ manifest_path = Path(manifest_path)
81
+
82
+ if not manifest_path.exists():
83
+ msg = f"Manifest file not found: {manifest_path}"
84
+ raise ManifestError(msg)
85
+
86
+ if yaml is None:
87
+ msg = "PyYAML is required to load manifests. Install with: pip install PyYAML"
88
+ raise ManifestError(msg)
89
+
90
+ try:
91
+ with open(manifest_path, encoding="utf-8") as f:
92
+ data = yaml.safe_load(f)
93
+ except Exception as e:
94
+ msg = f"Failed to parse manifest file {manifest_path}: {e}"
95
+ raise ManifestError(msg) from e
96
+
97
+ return _parse_manifest_data(data)
98
+
99
+
100
+ def _parse_manifest_data(data: Any) -> ClusterManifest:
101
+ """
102
+ Parse manifest data and validate structure.
103
+
104
+ Args:
105
+ data (Any): Parsed YAML data
106
+
107
+ Returns:
108
+ ClusterManifest: Validated cluster manifest object
109
+
110
+ Raises:
111
+ ManifestValidationError: If data structure is invalid
112
+ """
113
+ if not isinstance(data, dict):
114
+ msg = f"Manifest must be a dictionary, got {type(data)}"
115
+ raise ManifestValidationError(msg)
116
+
117
+ # Legacy schema: {'clusters': [...]}
118
+ if "clusters" in data:
119
+ return _parse_legacy_manifest(data)
120
+
121
+ # If not legacy, it might be an up-manifest; validate minimal presence
122
+ if "cluster" in data and isinstance(data["cluster"], list):
123
+ clusters = [_parse_cluster_data(c, i) for i, c in enumerate(data["cluster"])]
124
+ return ClusterManifest(clusters)
125
+
126
+ # Preserve legacy error message expected by existing unit tests
127
+ msg = "Manifest must contain 'clusters' key"
128
+ raise ManifestValidationError(msg)
129
+
130
+
131
+ def _parse_legacy_manifest(data: dict[str, Any]) -> ClusterManifest:
132
+ clusters_data = data["clusters"]
133
+ if not isinstance(clusters_data, list):
134
+ msg = "Manifest 'clusters' must be a list"
135
+ raise ManifestValidationError(msg)
136
+
137
+ clusters: list[ClusterConfig] = []
138
+ for i, cluster_data in enumerate(clusters_data):
139
+ try:
140
+ cluster = _parse_cluster_data(cluster_data, i)
141
+ clusters.append(cluster)
142
+ except ManifestValidationError as e:
143
+ msg = f"Error in cluster {i}: {e}"
144
+ raise ManifestValidationError(msg) from e
145
+ return ClusterManifest(clusters)
146
+
147
+
148
+ def _parse_cluster_data(cluster_data: Any, index: int) -> ClusterConfig:
149
+ """
150
+ Parse individual cluster configuration.
151
+
152
+ Args:
153
+ cluster_data (Any): Cluster configuration data
154
+ index (int): Cluster index for error reporting
155
+
156
+ Returns:
157
+ ClusterConfig: Parsed cluster configuration object
158
+ """
159
+ _validate_cluster_data_type(cluster_data, index)
160
+ _validate_required_fields(cluster_data, index)
161
+
162
+ name = cluster_data["name"]
163
+ provider_name = cluster_data["provider"]
164
+
165
+ _validate_field_types(name, provider_name, index)
166
+ _validate_provider_exists(provider_name, index)
167
+
168
+ kwargs = _extract_additional_kwargs(cluster_data)
169
+
170
+ return ClusterConfig(name=name, provider=provider_name, **kwargs)
171
+
172
+
173
+ def _validate_cluster_data_type(cluster_data: Any, index: int) -> None:
174
+ """Validate that cluster data is a dictionary."""
175
+ if not isinstance(cluster_data, dict):
176
+ msg = f"Cluster {index} must be a dictionary"
177
+ raise ManifestValidationError(msg)
178
+
179
+
180
+ def _validate_required_fields(cluster_data: dict[str, Any], index: int) -> None:
181
+ """Validate that required fields are present."""
182
+ if "name" not in cluster_data:
183
+ msg = f"Cluster {index} missing required 'name' field"
184
+ raise ManifestValidationError(msg)
185
+
186
+ if "provider" not in cluster_data:
187
+ msg = f"Cluster {index} missing required 'provider' field"
188
+ raise ManifestValidationError(msg)
189
+
190
+
191
+ def _validate_field_types(name: Any, provider_name: Any, index: int) -> None:
192
+ """Validate that name and provider fields are strings."""
193
+ if not isinstance(name, str):
194
+ msg = f"Cluster {index} 'name' must be a string"
195
+ raise ManifestValidationError(msg)
196
+
197
+ if not isinstance(provider_name, str):
198
+ msg = f"Cluster {index} 'provider' must be a string"
199
+ raise ManifestValidationError(msg)
200
+
201
+
202
+ def _validate_provider_exists(provider_name: str, index: int) -> None:
203
+ """Validate that the provider exists."""
204
+ try:
205
+ get_provider(provider_name)
206
+ except ValueError as e:
207
+ msg = f"Cluster {index}: {e}"
208
+ raise ManifestValidationError(msg) from e
209
+
210
+
211
+ def _extract_additional_kwargs(cluster_data: dict[str, Any]) -> dict[str, Any]:
212
+ """Extract additional kwargs excluding name and provider."""
213
+ return {k: v for k, v in cluster_data.items() if k not in ("name", "provider")}
214
+
215
+
216
+ def validate_manifest(manifest_path: str | Path) -> bool:
217
+ """
218
+ Validate manifest file without loading it.
219
+
220
+ Args:
221
+ manifest_path (str | Path): Path to manifest file
222
+
223
+ Returns:
224
+ bool: True if manifest is valid
225
+ """
226
+ load_manifest(manifest_path)
227
+ return True
228
+
229
+
230
+ # ------------------------
231
+ # Extended up-manifest schema (cluster/apps/repo_creds/secrets)
232
+ # ------------------------
233
+
234
+
235
+ @dataclass
236
+ class AppHelmConfig:
237
+ """Helm-specific options for an application entry."""
238
+
239
+ release_name: str | None = None
240
+ value_files: list[str] = field(default_factory=list)
241
+
242
+
243
+ @dataclass
244
+ class SourceSpec:
245
+ """A single application source entry (git path or helm chart)."""
246
+
247
+ repo_url: str
248
+ target_revision: str = "HEAD"
249
+ path: str | None = None
250
+ chart: str | None = None
251
+ ref: str | None = None
252
+ helm: AppHelmConfig | None = None
253
+
254
+
255
+ @dataclass
256
+ class AppEntry: # pylint: disable=too-many-instance-attributes
257
+ """Application entry as defined in up-manifest 'apps' list."""
258
+
259
+ name: str
260
+ namespace: str
261
+ app_file: str | None = None
262
+ sources: list[SourceSpec] = field(default_factory=list)
263
+ # Back-compat normalized single-source view for current CLI code paths
264
+ repo_url: str = ""
265
+ target_revision: str = "HEAD"
266
+ path: str = "."
267
+ helm: AppHelmConfig | None = None
268
+ chart_name: str | None = None
269
+ # reduce pylint instance-attribute warning by grouping extra computed fields
270
+ _extras: dict[str, Any] = field(default_factory=dict, repr=False)
271
+
272
+
273
+ @dataclass
274
+ class RepoCredEntry:
275
+ """Repository credential entry for ArgoCD access."""
276
+
277
+ name: str
278
+ repo_url: str
279
+ username: str
280
+ password: str
281
+ type: str = "git"
282
+ enable_oci: bool = False
283
+ description: str | None = None
284
+
285
+
286
+ @dataclass
287
+ class SecretValueFromEnv:
288
+ """Secret value sourced from an environment variable."""
289
+
290
+ from_env: str
291
+
292
+
293
+ @dataclass
294
+ class SecretEntry:
295
+ """Kubernetes secret specification to be created or updated."""
296
+
297
+ name: str
298
+ namespace: str
299
+ secret_name: str
300
+ secret_key: str
301
+ secret_value: list[SecretValueFromEnv]
302
+
303
+
304
+ @dataclass
305
+ class UpManifest:
306
+ """Top-level up-manifest schema used by validate/up/down commands."""
307
+
308
+ clusters: list[ClusterConfig]
309
+ apps: list[AppEntry]
310
+ repo_creds: list[RepoCredEntry]
311
+ secrets: list[SecretEntry]
312
+
313
+
314
+ def load_up_manifest(path: str | Path) -> UpManifest:
315
+ """Load extended up-manifest matching provided YAML example."""
316
+ p = Path(path)
317
+ _ensure_manifest_file(p)
318
+ raw = _load_yaml_mapping(p)
319
+ clusters = _parse_clusters(raw.get("cluster") or [])
320
+ apps = _parse_apps(raw.get("apps") or [], base_dir=p.parent)
321
+ repocreds = _parse_repo_creds(raw.get("repo_creds") or [])
322
+ secrets = _parse_secrets(raw.get("secrets") or [])
323
+ return UpManifest(clusters=clusters, apps=apps, repo_creds=repocreds, secrets=secrets)
324
+
325
+
326
+ def _ensure_manifest_file(path: Path) -> None:
327
+ if not path.exists():
328
+ msg = f"Manifest file not found: {path}"
329
+ raise ManifestError(msg)
330
+ if yaml is None:
331
+ msg = "PyYAML is required to load manifests. Install with: pip install PyYAML"
332
+ raise ManifestError(msg)
333
+
334
+
335
+ def _load_yaml_mapping(path: Path) -> dict[str, Any]:
336
+ with open(path, encoding="utf-8") as f:
337
+ data = yaml.safe_load(f) or {}
338
+ if not isinstance(data, dict):
339
+ msg = "Up-manifest must be a mapping"
340
+ raise ManifestValidationError(msg)
341
+ return data
342
+
343
+
344
+ def _parse_clusters(clusters_raw: Any) -> list[ClusterConfig]:
345
+ if not isinstance(clusters_raw, list) or not clusters_raw:
346
+ msg = "'cluster' must be a non-empty list"
347
+ raise ManifestValidationError(msg)
348
+ return [_parse_cluster_data(c, i) for i, c in enumerate(clusters_raw)]
349
+
350
+
351
+ def _parse_apps(apps_raw: Any, *, base_dir: Path) -> list[AppEntry]:
352
+ if not isinstance(apps_raw, list):
353
+ msg = "'apps' must be a list"
354
+ raise ManifestValidationError(msg)
355
+ result: list[AppEntry] = []
356
+ for idx, item in enumerate(apps_raw):
357
+ result.append(_parse_single_app(idx, item, base_dir=base_dir))
358
+ return result
359
+
360
+
361
+ def _parse_single_app(idx: int, item: Any, *, base_dir: Path) -> AppEntry:
362
+ name, spec_any = _coerce_single_key_mapping(item, f"apps[{idx}]")
363
+ namespace = str(spec_any.get("namespace", "")).strip()
364
+ if not namespace:
365
+ msg = f"apps[{idx}].{name} missing required 'namespace' field"
366
+ raise ManifestValidationError(msg)
367
+ app_file_raw = spec_any.get("app_file")
368
+ app_file: str | None = None
369
+ if isinstance(app_file_raw, str) and app_file_raw.strip():
370
+ # Resolve relative to manifest directory
371
+ app_path = (base_dir / app_file_raw).resolve()
372
+ app_file = str(app_path)
373
+ sources = _parse_sources(idx, name, spec_any.get("sources"))
374
+ if sources:
375
+ return _normalize_first_source(name, namespace, sources, app_file)
376
+ return _parse_single_source_fallback(name, namespace, spec_any, app_file)
377
+
378
+
379
+ def _parse_sources(idx: int, app_name: str, raw: Any) -> list[SourceSpec]:
380
+ if not isinstance(raw, list) or not raw:
381
+ return []
382
+ return [_build_source_spec(idx, app_name, sidx, s) for sidx, s in enumerate(raw)]
383
+
384
+
385
+ def _build_source_spec(idx: int, app_name: str, sidx: int, s: Any) -> SourceSpec:
386
+ if not isinstance(s, dict):
387
+ msg = f"apps[{idx}].{app_name} sources[{sidx}] must be a mapping"
388
+ raise ManifestValidationError(msg)
389
+ repo_url = str(s.get("repoURL", ""))
390
+ target_revision = str(s.get("targetRevision", "HEAD"))
391
+ path_raw = s.get("path")
392
+ chart_raw = s.get("chart")
393
+ ref_raw = s.get("ref")
394
+ path_val = None if path_raw is None else str(path_raw)
395
+ chart_val = None if chart_raw is None else str(chart_raw)
396
+ ref_val = None if ref_raw is None else str(ref_raw)
397
+ helm_cfg = _parse_helm_config(s.get("helm"))
398
+ return SourceSpec(
399
+ repo_url=repo_url,
400
+ target_revision=target_revision,
401
+ path=path_val,
402
+ chart=chart_val,
403
+ ref=ref_val,
404
+ helm=helm_cfg,
405
+ )
406
+
407
+
408
+ def _normalize_first_source(
409
+ name: str, namespace: str, sources: list[SourceSpec], app_file: str | None
410
+ ) -> AppEntry:
411
+ first = sources[0]
412
+ return AppEntry(
413
+ name=name,
414
+ namespace=namespace,
415
+ app_file=app_file,
416
+ sources=sources,
417
+ repo_url=first.repo_url,
418
+ target_revision=first.target_revision,
419
+ path=first.path or ".",
420
+ helm=first.helm,
421
+ chart_name=first.chart,
422
+ )
423
+
424
+
425
+ def _parse_single_source_fallback(
426
+ name: str, namespace: str, spec_any: dict[str, Any], app_file: str | None
427
+ ) -> AppEntry:
428
+ repo_url = str(spec_any.get("repoURL", ""))
429
+ path_val = str(spec_any.get("path", "."))
430
+ target_revision = str(spec_any.get("targetRevision", "HEAD"))
431
+ helm_cfg = _parse_helm_config(spec_any.get("helm"))
432
+ return AppEntry(
433
+ name=name,
434
+ namespace=namespace,
435
+ app_file=app_file,
436
+ sources=[],
437
+ repo_url=repo_url,
438
+ target_revision=target_revision,
439
+ path=path_val,
440
+ helm=helm_cfg,
441
+ chart_name=None,
442
+ )
443
+
444
+
445
+ def _parse_repo_creds(repocreds_raw: Any) -> list[RepoCredEntry]:
446
+ if not isinstance(repocreds_raw, list):
447
+ msg = "'repo_creds' must be a list"
448
+ raise ManifestValidationError(msg)
449
+ result: list[RepoCredEntry] = []
450
+ for idx, item in enumerate(repocreds_raw):
451
+ result.append(_parse_single_repo_cred(idx, item))
452
+ return result
453
+
454
+
455
+ def _parse_single_repo_cred(idx: int, item: Any) -> RepoCredEntry:
456
+ name, spec_any = _coerce_single_key_mapping(item, f"repo_creds[{idx}]")
457
+ return RepoCredEntry(
458
+ name=name,
459
+ repo_url=str(spec_any.get("repoURL", "")),
460
+ username=str(spec_any.get("username", "")),
461
+ password=str(spec_any.get("password", "")),
462
+ type=str(spec_any.get("type", "git")),
463
+ enable_oci=bool(spec_any.get("enableOCI", False)),
464
+ description=(
465
+ str(spec_any.get("description"))
466
+ if spec_any.get("description") is not None
467
+ else None
468
+ ),
469
+ )
470
+
471
+
472
+ def _parse_secrets(secrets_raw: Any) -> list[SecretEntry]:
473
+ if not isinstance(secrets_raw, list):
474
+ msg = "'secrets' must be a list"
475
+ raise ManifestValidationError(msg)
476
+ result: list[SecretEntry] = []
477
+ for idx, item in enumerate(secrets_raw):
478
+ result.append(_parse_single_secret(idx, item))
479
+ return result
480
+
481
+
482
+ def _parse_single_secret(idx: int, item: Any) -> SecretEntry:
483
+ name, spec_any = _coerce_single_key_mapping(item, f"secrets[{idx}]")
484
+ vals = _parse_secret_values(spec_any.get("secretValue") or [])
485
+ return SecretEntry(
486
+ name=name,
487
+ namespace=str(spec_any.get("namespace", "default")),
488
+ secret_name=str(spec_any.get("secretName", "")),
489
+ secret_key=str(spec_any.get("secretKey", "")),
490
+ secret_value=vals,
491
+ )
492
+
493
+
494
+ def _parse_helm_config(helm_raw: Any) -> AppHelmConfig | None:
495
+ if not isinstance(helm_raw, dict):
496
+ return None
497
+ release = str(helm_raw.get("releaseName")) if helm_raw.get("releaseName") else None
498
+ values = [str(v) for v in (helm_raw.get("valueFiles") or [])]
499
+ return AppHelmConfig(release_name=release, value_files=values)
500
+
501
+
502
+ def _parse_secret_values(seq: Any) -> list[SecretValueFromEnv]:
503
+ if not isinstance(seq, list):
504
+ return []
505
+ return [
506
+ SecretValueFromEnv(from_env=str(v.get("fromEnv", "")))
507
+ for v in seq
508
+ if isinstance(v, dict)
509
+ ]
510
+
511
+
512
+ def _coerce_single_key_mapping(item: Any, ctx: str) -> tuple[str, dict[str, Any]]:
513
+ if not isinstance(item, dict) or len(item) != 1:
514
+ msg = f"{ctx} must be a single-key mapping of name to spec"
515
+ raise ManifestValidationError(msg)
516
+ name, spec_any = next(iter(item.items()))
517
+ if not isinstance(spec_any, dict):
518
+ msg = f"{ctx}.{name} spec must be a mapping"
519
+ raise ManifestValidationError(msg)
520
+ return str(name), spec_any
@@ -0,0 +1,66 @@
1
+ """YAML-backed persistent config store for LocalArgo.
2
+
3
+ Supports default path at ~/.localargo/config.yaml and override via
4
+ LOCALARGO_CONFIG environment variable. Provides simple load/save helpers
5
+ and a small class wrapper to interact with nested dict-like configs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+ DEFAULT_CONFIG_RELATIVE = Path(".localargo/config.yaml")
18
+ ENV_OVERRIDE = "LOCALARGO_CONFIG"
19
+
20
+
21
+ def _resolve_config_path() -> Path:
22
+ """Resolve the config file path honoring the environment override."""
23
+ env_path = os.getenv(ENV_OVERRIDE)
24
+ if env_path:
25
+ return Path(env_path).expanduser()
26
+ return Path.home() / DEFAULT_CONFIG_RELATIVE
27
+
28
+
29
+ def load_config() -> dict[str, Any]:
30
+ """Load configuration from disk; return empty dict if missing or empty."""
31
+ path = _resolve_config_path()
32
+ if not path.exists():
33
+ return {}
34
+ with open(path, encoding="utf-8") as f:
35
+ data = yaml.safe_load(f) or {}
36
+ if not isinstance(data, dict):
37
+ return {}
38
+ return data
39
+
40
+
41
+ def save_config(config: dict[str, Any]) -> Path:
42
+ """Persist configuration to disk, creating parent directory as needed."""
43
+ path = _resolve_config_path()
44
+ path.parent.mkdir(parents=True, exist_ok=True)
45
+ with open(path, "w", encoding="utf-8") as f:
46
+ yaml.safe_dump(config, f, sort_keys=True)
47
+ return path
48
+
49
+
50
+ @dataclass
51
+ class ConfigStore:
52
+ """Small dict-like wrapper for LocalArgo config with persistence helpers."""
53
+
54
+ data: dict[str, Any] = field(default_factory=load_config)
55
+
56
+ def get(self, key: str, default: Any | None = None) -> Any | None:
57
+ """Return the value for key if present, else default."""
58
+ return self.data.get(key, default)
59
+
60
+ def set(self, key: str, value: Any) -> None:
61
+ """Set a configuration key to the provided value."""
62
+ self.data[key] = value
63
+
64
+ def save(self) -> Path:
65
+ """Persist current configuration to disk and return the file path."""
66
+ return save_config(self.data)
@@ -0,0 +1,6 @@
1
+ # SPDX-FileCopyrightText: 2025-present William Born <william.born.git@gmail.com>
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+ """Core functionality for localargo cluster management."""
5
+
6
+ from __future__ import annotations