lumilake-cli 0.1.0.dev1__tar.gz → 0.1.2__tar.gz

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 (27) hide show
  1. {lumilake_cli-0.1.0.dev1/src/lumilake_cli.egg-info → lumilake_cli-0.1.2}/PKG-INFO +3 -3
  2. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/pyproject.toml +3 -3
  3. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli/commands/__init__.py +1 -0
  4. lumilake_cli-0.1.2/src/lumilake_cli/commands/base.py +139 -0
  5. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli/commands/deploy.py +166 -16
  6. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli/commands/job.py +20 -3
  7. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli/commands/trace.py +7 -1
  8. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli/commands/worker.py +6 -2
  9. lumilake_cli-0.1.2/src/lumilake_cli/core/config.py +5 -0
  10. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli/core/http.py +32 -27
  11. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2/src/lumilake_cli.egg-info}/PKG-INFO +3 -3
  12. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli.egg-info/SOURCES.txt +0 -1
  13. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli.egg-info/requires.txt +2 -2
  14. lumilake_cli-0.1.0.dev1/src/lumilake_cli/commands/base.py +0 -85
  15. lumilake_cli-0.1.0.dev1/src/lumilake_cli/core/config.py +0 -38
  16. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/LICENSE +0 -0
  17. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/README.md +0 -0
  18. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/setup.cfg +0 -0
  19. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli/__init__.py +0 -0
  20. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli/cli.py +0 -0
  21. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli/core/__init__.py +0 -0
  22. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli/core/logging.py +0 -0
  23. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli/core/query.py +0 -0
  24. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli/core/typer.py +0 -0
  25. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli.egg-info/dependency_links.txt +0 -0
  26. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli.egg-info/entry_points.txt +0 -0
  27. {lumilake_cli-0.1.0.dev1 → lumilake_cli-0.1.2}/src/lumilake_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumilake-cli
3
- Version: 0.1.0.dev1
3
+ Version: 0.1.2
4
4
  Summary: Lumilake command-line interface
5
5
  Author: Lumilake contributors
6
6
  License-Expression: Apache-2.0
@@ -14,12 +14,12 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Requires-Python: <3.13,>=3.12
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
- Requires-Dist: lumilake-sdk==0.1.0.dev1
17
+ Requires-Dist: lumilake-sdk==0.1.2
18
18
  Requires-Dist: flowmesh-sdk==0.1.1
19
19
  Requires-Dist: requests>=2.32.3
20
20
  Requires-Dist: typer>=0.12.5
21
21
  Provides-Extra: deploy
22
- Requires-Dist: lumilake-deploy==0.1.0.dev1; extra == "deploy"
22
+ Requires-Dist: lumilake-deploy==0.1.2; extra == "deploy"
23
23
  Dynamic: license-file
24
24
 
25
25
  # `lumilake-cli`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lumilake-cli"
3
- version = "0.1.0.dev1"
3
+ version = "0.1.2"
4
4
  description = "Lumilake command-line interface"
5
5
  readme = "README.md"
6
6
  requires-python = "<3.13,>=3.12"
@@ -10,7 +10,7 @@ authors = [
10
10
  {name = "Lumilake contributors"},
11
11
  ]
12
12
  dependencies = [
13
- "lumilake-sdk==0.1.0.dev1",
13
+ "lumilake-sdk==0.1.2",
14
14
  # Renders ``lumilake trace`` ProfileSummary payloads.
15
15
  "flowmesh-sdk==0.1.1",
16
16
  "requests>=2.32.3",
@@ -26,7 +26,7 @@ classifiers = [
26
26
 
27
27
  [project.optional-dependencies]
28
28
  # Thin ``pip install lumilake-cli`` (no extra) skips Docker/FlowMesh.
29
- deploy = ["lumilake-deploy==0.1.0.dev1"]
29
+ deploy = ["lumilake-deploy==0.1.2"]
30
30
 
31
31
  [project.urls]
32
32
  Homepage = "https://github.com/mlsys-io/lumilake_OSS"
@@ -51,6 +51,7 @@ else:
51
51
  "up",
52
52
  "down",
53
53
  "clean",
54
+ "purge",
54
55
  "reset",
55
56
  "status",
56
57
  "restart",
@@ -0,0 +1,139 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import typer
5
+ from lumilake._base_client import resolve_config
6
+ from lumilake.config import LumilakeConfig
7
+ from lumilake.errors import ConfigInvalidError, ConfigNotFoundError
8
+
9
+ from ..core import logging
10
+ from ..core.config import DEFAULT_CONFIG_PATH
11
+ from ..core.http import HttpError, client_from_config
12
+ from ..core.typer import get_typer
13
+
14
+ app = get_typer()
15
+
16
+
17
+ def _redact_api_key(
18
+ api_key: str | None, prefix: int | None = None, suffix: int | None = None
19
+ ) -> str | None:
20
+ if api_key is None:
21
+ return None
22
+ length = len(api_key)
23
+ if prefix is None and suffix is None:
24
+ prefix = suffix = 4
25
+ if length <= prefix + suffix:
26
+ return "*" * length
27
+ elif prefix is None or suffix is None:
28
+ raise ValueError("Both prefix and suffix must be provided together")
29
+ elif length <= prefix + suffix:
30
+ raise ValueError(
31
+ "API key is too short to redact with the given prefix/suffix lengths"
32
+ )
33
+ masked = "*" * (length - prefix - suffix)
34
+ return f"{api_key[:prefix]}{masked}{api_key[-suffix:]}"
35
+
36
+
37
+ @app.command()
38
+ def init(
39
+ url: str = typer.Argument(
40
+ "http://127.0.0.1:9000",
41
+ help="Lumilake server URL (e.g. http://127.0.0.1:9000).",
42
+ ),
43
+ api_key: str | None = typer.Option(
44
+ None, "--api-key", help="Bearer presented as Authorization: Bearer …"
45
+ ),
46
+ config_path: Path = typer.Option(
47
+ DEFAULT_CONFIG_PATH, "--config", help="Path to save configuration file."
48
+ ),
49
+ force: bool = typer.Option(
50
+ False,
51
+ "--force",
52
+ "-f",
53
+ help="Overwrite existing config file without confirmation.",
54
+ show_default=False,
55
+ ),
56
+ ) -> None:
57
+ """Initialize configuration for the Lumilake CLI."""
58
+ if config_path.exists():
59
+ if force:
60
+ logging.warning(f"Overwriting existing config at {config_path}")
61
+ elif not typer.confirm(f"Config file {config_path} already exists. Overwrite?"):
62
+ raise typer.Exit()
63
+ LumilakeConfig(base_url=url, api_key=api_key).save(config_path)
64
+ logging.success(f"Config saved to {config_path}")
65
+
66
+
67
+ @app.command()
68
+ def deinit(
69
+ config_path: Path = typer.Option(
70
+ DEFAULT_CONFIG_PATH, "--config", help="Path to configuration file."
71
+ ),
72
+ ) -> None:
73
+ """Delete the saved configuration file."""
74
+ if config_path.exists():
75
+ config_path.unlink()
76
+ logging.success(f"Deleted config file {config_path}")
77
+ else:
78
+ logging.warning(f"No config file found at {config_path}")
79
+
80
+
81
+ @app.command()
82
+ def config(
83
+ config_path: Path = typer.Option(
84
+ DEFAULT_CONFIG_PATH, "--config", help="Path to configuration file."
85
+ ),
86
+ source: str = typer.Option(
87
+ "auto",
88
+ "--source",
89
+ help="Configuration source: auto, file, or env.",
90
+ ),
91
+ show_api_key: bool = typer.Option(
92
+ False, "--show-api-key", help="Show api_key in plain text."
93
+ ),
94
+ ) -> None:
95
+ """Display the resolved Lumilake CLI configuration."""
96
+ normalized = source.strip().lower()
97
+ match normalized:
98
+ case "file":
99
+ try:
100
+ cfg = LumilakeConfig.from_file(config_path)
101
+ except (ConfigNotFoundError, ConfigInvalidError) as exc:
102
+ logging.error(f"Error loading config from file: {exc}")
103
+ raise typer.Exit(code=1)
104
+ case "env":
105
+ try:
106
+ cfg = LumilakeConfig.from_env()
107
+ except ConfigInvalidError as exc:
108
+ logging.error(f"Error loading config from environment: {exc}")
109
+ raise typer.Exit(code=1)
110
+ case "auto":
111
+ try:
112
+ cfg = resolve_config(config_path=config_path)
113
+ except Exception as exc:
114
+ logging.error(f"Error resolving config: {exc}")
115
+ raise typer.Exit(code=1)
116
+ case _:
117
+ logging.error("Invalid --source value. Expected one of: auto, file, env")
118
+ raise typer.Exit(code=2)
119
+
120
+ cfg.api_key = cfg.api_key if show_api_key else _redact_api_key(cfg.api_key)
121
+ logging.log(json.dumps(cfg.to_mapping(), indent=2))
122
+
123
+
124
+ @app.command()
125
+ def info() -> None:
126
+ """Query server health status."""
127
+ client = client_from_config()
128
+ try:
129
+ response = client.get("/healthz")
130
+ except HttpError as exc:
131
+ logging.error(str(exc))
132
+ raise typer.Exit(code=1)
133
+ logging.log(json.dumps(response.json(), indent=2))
134
+
135
+
136
+ @app.command()
137
+ def health() -> None:
138
+ """Check if the Lumilake server is reachable and healthy."""
139
+ info()
@@ -1,13 +1,17 @@
1
- """Deploy commands: init, doctor, up, down, clean, restart, reset, logs."""
1
+ """Deploy commands: init, doctor, up, down, clean, purge, restart, reset, logs."""
2
2
 
3
3
  import datetime as dt
4
+ import difflib
4
5
  import re
5
6
  import sys
6
7
  from pathlib import Path
7
8
 
8
9
  import typer
9
10
  from flowmesh_cli_stack.stack import stack_env_example
11
+ from lumilake import envs
12
+ from lumilake.errors import ConfigInvalidError, ConfigNotFoundError
10
13
  from lumilake_deploy import docker_client
14
+ from lumilake_deploy import purge as purge_mod
11
15
  from lumilake_deploy import setup as setup_mod
12
16
  from lumilake_deploy import stop as stop_mod
13
17
  from lumilake_deploy import update_flowmesh as update_fm_mod
@@ -27,6 +31,7 @@ from lumilake_deploy.setup import (
27
31
  )
28
32
 
29
33
  from ..core import logging
34
+ from ..core.config import DEFAULT_CONFIG_PATH, LumilakeConfig
30
35
  from ..core.typer import get_typer
31
36
 
32
37
  app = get_typer(
@@ -83,9 +88,44 @@ def _run_setup(
83
88
  raise typer.Exit(code=1) from exc
84
89
 
85
90
 
86
- def _confirm_overwrite(target: Path, *, force: bool) -> bool:
87
- """Return True when the target may be written, False to skip."""
88
- if not target.exists() or force:
91
+ def _preview_write(target: Path, new_content: str) -> None:
92
+ if target.exists():
93
+ old = target.read_text().splitlines(keepends=True)
94
+ new = new_content.splitlines(keepends=True)
95
+ diff = "".join(
96
+ difflib.unified_diff(
97
+ old,
98
+ new,
99
+ fromfile=f"a/{target.name}",
100
+ tofile=f"b/{target.name}",
101
+ n=3,
102
+ )
103
+ )
104
+ if diff:
105
+ logging.info(f"Diff vs existing {target}:")
106
+ for line in diff.splitlines():
107
+ logging.info(line)
108
+ else:
109
+ logging.info(f"{target} already matches the bundled template.")
110
+ return
111
+ logging.info(f"Preview of {target} (first 20 lines):")
112
+ for line in new_content.splitlines()[:20]:
113
+ logging.info(line)
114
+ remaining = max(0, len(new_content.splitlines()) - 20)
115
+ if remaining:
116
+ logging.info(f"... and {remaining} more line(s).")
117
+
118
+
119
+ def _confirm_overwrite(target: Path, *, force: bool, new_content: str) -> bool:
120
+ """Return True when the target may be written, False to skip.
121
+
122
+ Shows a preview (or diff vs an existing file) before prompting on
123
+ overwrite. Brand-new writes pass through without prompting.
124
+ """
125
+ if force:
126
+ return True
127
+ _preview_write(target, new_content)
128
+ if not target.exists():
89
129
  return True
90
130
  if typer.confirm(f"{target} already exists. Overwrite?", default=False):
91
131
  return True
@@ -156,21 +196,23 @@ def init(
156
196
  root = _project_dir(ctx)
157
197
  template = env_example_path()
158
198
  target = root / ENV_FILE_NAME
199
+ template_text = template.read_text()
159
200
  wrote_env = False
160
- if _confirm_overwrite(target, force=force):
161
- target.write_text(template.read_text())
201
+ if _confirm_overwrite(target, force=force, new_content=template_text):
202
+ target.write_text(template_text)
162
203
  logging.success(f"Wrote {target} from packaged {template.name}.")
163
204
  wrote_env = True
164
205
 
165
206
  if flowmesh:
166
207
  fm_target = root / FLOWMESH_ENV_FILE_NAME
208
+ example = stack_env_example()
209
+ if not example.exists():
210
+ logging.error(f"FlowMesh env example not found: {example}")
211
+ raise typer.Exit(code=1)
212
+ fm_template_text = example.read_text()
167
213
  wrote_flowmesh_env = False
168
- if _confirm_overwrite(fm_target, force=force):
169
- example = stack_env_example()
170
- if not example.exists():
171
- logging.error(f"FlowMesh env example not found: {example}")
172
- raise typer.Exit(code=1)
173
- fm_target.write_text(example.read_text())
214
+ if _confirm_overwrite(fm_target, force=force, new_content=fm_template_text):
215
+ fm_target.write_text(fm_template_text)
174
216
  logging.success(f"Wrote {fm_target} from {example.name}.")
175
217
  _patch_flowmesh_env(fm_target)
176
218
  wrote_flowmesh_env = True
@@ -240,6 +282,34 @@ def pull(ctx: typer.Context) -> None:
240
282
  raise typer.Exit(code=1) from exc
241
283
 
242
284
 
285
+ def _write_cli_config(root: Path, config_path: Path | None = None) -> None:
286
+ """Persist the local server URL so subsequent CLI / SDK calls find it.
287
+
288
+ Preserves any existing ``api_key`` (written by ``lumilake init``).
289
+ """
290
+ if config_path is None:
291
+ config_path = DEFAULT_CONFIG_PATH
292
+ setup_mod.load_project_env(root)
293
+ port = envs.LUMILAKE_SERVER_PORT or 9000
294
+ base_url = f"http://127.0.0.1:{port}"
295
+ try:
296
+ existing = LumilakeConfig.from_file(config_path)
297
+ except (ConfigNotFoundError, ConfigInvalidError):
298
+ existing = None
299
+ if existing is not None and existing.base_url == base_url:
300
+ logging.info(f"CLI config already points at {base_url}.")
301
+ return
302
+ if existing is not None and existing.base_url != base_url:
303
+ logging.info(f"Updating {config_path}: {existing.base_url} -> {base_url}")
304
+ api_key = existing.api_key if existing is not None else None
305
+ try:
306
+ LumilakeConfig(base_url=base_url, api_key=api_key).save(config_path)
307
+ except OSError as exc:
308
+ logging.warning(f"Could not save CLI config to {config_path}: {exc}")
309
+ return
310
+ logging.info(f"Saved CLI config to {config_path} (base_url={base_url}).")
311
+
312
+
243
313
  @app.command()
244
314
  def up(ctx: typer.Context) -> None:
245
315
  """Start the full Lumilake stack (Docker-backed).
@@ -247,7 +317,9 @@ def up(ctx: typer.Context) -> None:
247
317
  The server image must already be present locally — run
248
318
  ``lumilake deploy pull`` or ``lumilake deploy build`` first.
249
319
  """
250
- _run_setup(_project_dir(ctx), background=True)
320
+ root = _project_dir(ctx)
321
+ _run_setup(root, background=True)
322
+ _write_cli_config(root)
251
323
 
252
324
 
253
325
  @app.command()
@@ -266,7 +338,15 @@ def down(
266
338
  ),
267
339
  ),
268
340
  ) -> None:
269
- """Stop all services (keep data)."""
341
+ """Stop the stack but keep data volumes.
342
+
343
+ Safe to run between sessions: the archive bucket (job records, run
344
+ artifacts) and the compute postgres/minio volumes survive, so
345
+ ``deploy up`` resumes against the same state. ``--wipe-archive`` also
346
+ removes the compute Postgres and FlowMesh runtime-state volumes, while
347
+ preserving MinIO corpus data. Use ``deploy reset`` (destructive) to
348
+ wipe every volume instead.
349
+ """
270
350
  try:
271
351
  stop_mod.run_stop(
272
352
  _project_dir(ctx),
@@ -288,6 +368,56 @@ def clean(ctx: typer.Context) -> None:
288
368
  raise typer.Exit(code=1) from exc
289
369
 
290
370
 
371
+ @app.command()
372
+ def purge(
373
+ ctx: typer.Context,
374
+ image_tag: str = typer.Argument(
375
+ ...,
376
+ help="Lumilake server image tag to purge from local Docker.",
377
+ ),
378
+ dry_run: bool = typer.Option(
379
+ False,
380
+ "--dry-run",
381
+ help="List the image to be purged without deleting it.",
382
+ ),
383
+ force: bool = typer.Option(
384
+ False,
385
+ "--force",
386
+ help="Force image removal even if Docker reports dependent containers.",
387
+ ),
388
+ ) -> None:
389
+ """Purge one Lumilake server image tag from local Docker."""
390
+ try:
391
+ plan = purge_mod.build_server_image_purge_plan(
392
+ _project_dir(ctx),
393
+ image_tag=image_tag,
394
+ )
395
+ except DeployError as exc:
396
+ logging.error(str(exc))
397
+ raise typer.Exit(code=1) from exc
398
+
399
+ logging.info(f"Purging Lumilake server image with tag '{image_tag}'...")
400
+ if not plan.exists:
401
+ logging.warning(f"Image not found: {plan.image_ref}")
402
+ logging.info("No images to purge.")
403
+ return
404
+
405
+ if dry_run:
406
+ logging.info("Images to be purged:")
407
+ logging.info(f" {plan.image_ref}")
408
+ return
409
+
410
+ try:
411
+ result = purge_mod.run_server_image_purge(plan, force=force)
412
+ except DeployError as exc:
413
+ logging.error(str(exc))
414
+ raise typer.Exit(code=1) from exc
415
+ if result.removed:
416
+ logging.success(f"Removed image: {result.image_ref}")
417
+ return
418
+ logging.warning(f"Image already absent: {result.image_ref}")
419
+
420
+
291
421
  @app.command()
292
422
  def status(ctx: typer.Context) -> None:
293
423
  """Show the running state of every Lumilake stack container."""
@@ -354,8 +484,28 @@ def restart(
354
484
 
355
485
 
356
486
  @app.command()
357
- def reset(ctx: typer.Context) -> None:
358
- """Clean reset (stop + purge + up; deletes all data)."""
487
+ def reset(
488
+ ctx: typer.Context,
489
+ yes: bool = typer.Option(
490
+ False,
491
+ "--yes",
492
+ "-y",
493
+ help="Skip the destructive-action confirmation prompt.",
494
+ ),
495
+ ) -> None:
496
+ """Wipe the archive and every volume, then start the stack fresh.
497
+
498
+ Destructive: removes the compute postgres / minio volumes (job
499
+ records, run artifacts, demo data) and the FlowMesh runtime state.
500
+ Use ``deploy down`` if you want to stop the stack but keep its data.
501
+ """
502
+ if not yes and not typer.confirm(
503
+ "deploy reset deletes every Lumilake volume (archive + compute "
504
+ "data). Continue?",
505
+ default=False,
506
+ ):
507
+ logging.info("Aborted.")
508
+ raise typer.Exit(code=0)
359
509
  root = _project_dir(ctx)
360
510
  try:
361
511
  stop_mod.run_stop(root, purge=True)
@@ -135,13 +135,30 @@ def submit(
135
135
  None, "--batch-size", help="Input batch size"
136
136
  ),
137
137
  input_values: list[str] | None = typer.Option(
138
- None, "--input", help="Input as Name=v1,v2,v3 (repeatable for different names)"
138
+ None,
139
+ "--input",
140
+ help=(
141
+ "Input as Name=val1,val2,val3 (comma-separated; whitespace around "
142
+ "values is trimmed). Repeat for different names: "
143
+ "`--input query=NVDA,TSLA --input year=2024`. A single value "
144
+ "still uses the same syntax: `--input query=NVDA`."
145
+ ),
139
146
  ),
140
147
  input_files: list[str] | None = typer.Option(
141
- None, "--input-file", help="Input from file as Name=path.txt (repeatable)"
148
+ None,
149
+ "--input-file",
150
+ help=(
151
+ "Input from file as Name=path.txt; one value per non-empty line. "
152
+ "Repeatable: `--input-file tickers=tickers.txt`."
153
+ ),
142
154
  ),
143
155
  input_json: Path | None = typer.Option(
144
- None, "--input-json", help="JSON file with full inputs object"
156
+ None,
157
+ "--input-json",
158
+ help=(
159
+ "JSON file with the full inputs object, e.g. "
160
+ '`{"query": ["NVDA", "TSLA"], "year": ["2024"]}`.'
161
+ ),
145
162
  ),
146
163
  ) -> None:
147
164
  """Submit a workflow for optimization and execution."""
@@ -25,6 +25,8 @@ OutputFormat = typer.Option(
25
25
  help="Output format: summary | json | mermaid",
26
26
  )
27
27
 
28
+ _JSON_OPTION_HELP = "Shortcut for ``--format json``."
29
+
28
30
 
29
31
  @app.command("list")
30
32
  def list_traces(
@@ -46,14 +48,18 @@ def list_traces(
46
48
  def get_trace(
47
49
  exec_id: str = typer.Argument(..., help="Execution trace identifier"),
48
50
  output_format: str = OutputFormat,
51
+ json_output: bool = typer.Option(False, "--json", help=_JSON_OPTION_HELP),
49
52
  ) -> None:
50
53
  """Retrieve and render a specific execution trace.
51
54
 
52
55
  Default ``summary`` shows the FlowMesh ``ProfileSummary`` as
53
56
  headline metrics + hardware / network / critical-path tables.
54
57
  ``json`` returns the raw payload; ``mermaid`` prints the lineage
55
- graph in Mermaid syntax.
58
+ graph in Mermaid syntax. ``--json`` is a shortcut for
59
+ ``--format json``.
56
60
  """
61
+ if json_output:
62
+ output_format = "json"
57
63
  client = client_from_config()
58
64
  try:
59
65
  response = client.get(f"/trace/{exec_id}", version_prefix=True)
@@ -9,6 +9,10 @@ from ..core.typer import get_typer
9
9
  app = get_typer(help="Query runtime workers.")
10
10
 
11
11
 
12
+ def _emit_json(payload: object) -> None:
13
+ logging.log(json.dumps(payload, indent=2))
14
+
15
+
12
16
  @app.command("list")
13
17
  def list_workers() -> None:
14
18
  """List all available workers."""
@@ -18,7 +22,7 @@ def list_workers() -> None:
18
22
  except HttpError as exc:
19
23
  logging.error(str(exc))
20
24
  raise typer.Exit(code=1)
21
- logging.log(json.dumps(response.json(), indent=2))
25
+ _emit_json(response.json())
22
26
 
23
27
 
24
28
  @app.command("get")
@@ -32,4 +36,4 @@ def get_worker(
32
36
  except HttpError as exc:
33
37
  logging.error(str(exc))
34
38
  raise typer.Exit(code=1)
35
- logging.log(json.dumps(response.json(), indent=2))
39
+ _emit_json(response.json())
@@ -0,0 +1,5 @@
1
+ """Configuration handling for the Lumilake CLI."""
2
+
3
+ from lumilake.config import DEFAULT_CONFIG_DIR, DEFAULT_CONFIG_PATH, LumilakeConfig
4
+
5
+ __all__ = ["DEFAULT_CONFIG_DIR", "DEFAULT_CONFIG_PATH", "LumilakeConfig"]
@@ -3,20 +3,22 @@
3
3
  from collections.abc import Mapping
4
4
  from dataclasses import dataclass, field
5
5
  from pathlib import Path
6
- from typing import Any, NoReturn
6
+ from typing import Any
7
7
 
8
8
  import requests
9
- import typer
10
9
  from lumilake import envs
10
+ from lumilake._base_client import DEFAULT_BASE_URL, resolve_config
11
+ from lumilake.errors import ConfigInvalidError, ConfigNotFoundError
11
12
 
12
- from . import logging
13
- from .config import DEFAULT_CONFIG_PATH, LumilakeConfig, load_config
13
+ from .config import DEFAULT_CONFIG_PATH, LumilakeConfig
14
14
 
15
15
  API_VERSION_PREFIX = "/api/v1"
16
16
 
17
17
  DEFAULT_TIMEOUT: float = 300.0
18
18
  """Default request timeout in seconds."""
19
19
 
20
+ DEFAULT_LOCAL_BASE_URL = DEFAULT_BASE_URL
21
+
20
22
 
21
23
  def _resolve_timeout() -> float:
22
24
  """Read timeout from ``LUMILAKE_TIMEOUT`` env var, falling back to default."""
@@ -30,10 +32,14 @@ class HttpError(RuntimeError):
30
32
  @dataclass
31
33
  class HttpClient:
32
34
  base_url: str
35
+ api_key: str | None = None
33
36
  timeout: float = field(default_factory=_resolve_timeout)
34
37
 
35
38
  def _headers(self) -> Mapping[str, str]:
36
- return {"Accept": "application/json"}
39
+ headers: dict[str, str] = {"Accept": "application/json"}
40
+ if self.api_key:
41
+ headers["Authorization"] = f"Bearer {self.api_key}"
42
+ return headers
37
43
 
38
44
  def get(
39
45
  self, path: str, version_prefix: bool = False, **kwargs: Any
@@ -100,27 +106,26 @@ class HttpClient:
100
106
  return response
101
107
 
102
108
 
103
- def _require_config(path: Path = DEFAULT_CONFIG_PATH) -> LumilakeConfig:
104
- """Load config or exit with a clear message directing the user to login."""
105
-
106
- def _error(msg: str) -> NoReturn:
107
- logging.error(msg)
108
- raise typer.Exit(code=1)
109
+ def resolve_base_url(
110
+ config_path: Path = DEFAULT_CONFIG_PATH,
111
+ ) -> tuple[str, str]:
112
+ """Return ``(base_url, source)`` with source in {"env", "config", "default"}.
109
113
 
114
+ Env > config file > local default. Mirrors the SDK's precedence but
115
+ reports the source so ``lumilake config`` can show where the value
116
+ came from.
117
+ """
118
+ env_url = envs.get_lumilake_base_url()
119
+ if env_url:
120
+ return env_url, "env"
110
121
  try:
111
- config = load_config(path)
112
- except FileNotFoundError:
113
- _error("Not logged in. Run `lumilake login <url>` first.")
114
- except ValueError as exc:
115
- _error(f"Invalid config file: {exc}. Please re-login.")
116
-
117
- if not config.base_url:
118
- _error("Missing base_url in config. Please re-login.")
119
- return config
120
-
121
-
122
- def client_from_config(config: LumilakeConfig | None = None) -> HttpClient:
123
- """Build an HttpClient from saved config. Exits if not logged in."""
124
- if config is None:
125
- config = _require_config()
126
- return HttpClient(base_url=config.base_url)
122
+ cfg = LumilakeConfig.from_file(config_path)
123
+ except (ConfigNotFoundError, ConfigInvalidError):
124
+ return DEFAULT_LOCAL_BASE_URL, "default"
125
+ return cfg.base_url, "config"
126
+
127
+
128
+ def client_from_config(config_path: Path = DEFAULT_CONFIG_PATH) -> HttpClient:
129
+ """Build an HttpClient by resolving base_url + api_key (env > file > default)."""
130
+ cfg = resolve_config(config_path=config_path)
131
+ return HttpClient(base_url=cfg.base_url, api_key=cfg.api_key)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumilake-cli
3
- Version: 0.1.0.dev1
3
+ Version: 0.1.2
4
4
  Summary: Lumilake command-line interface
5
5
  Author: Lumilake contributors
6
6
  License-Expression: Apache-2.0
@@ -14,12 +14,12 @@ Classifier: Programming Language :: Python :: 3.12
14
14
  Requires-Python: <3.13,>=3.12
15
15
  Description-Content-Type: text/markdown
16
16
  License-File: LICENSE
17
- Requires-Dist: lumilake-sdk==0.1.0.dev1
17
+ Requires-Dist: lumilake-sdk==0.1.2
18
18
  Requires-Dist: flowmesh-sdk==0.1.1
19
19
  Requires-Dist: requests>=2.32.3
20
20
  Requires-Dist: typer>=0.12.5
21
21
  Provides-Extra: deploy
22
- Requires-Dist: lumilake-deploy==0.1.0.dev1; extra == "deploy"
22
+ Requires-Dist: lumilake-deploy==0.1.2; extra == "deploy"
23
23
  Dynamic: license-file
24
24
 
25
25
  # `lumilake-cli`
@@ -1,7 +1,6 @@
1
1
  LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
- ../../LICENSE
5
4
  src/lumilake_cli/__init__.py
6
5
  src/lumilake_cli/cli.py
7
6
  src/lumilake_cli.egg-info/PKG-INFO
@@ -1,7 +1,7 @@
1
- lumilake-sdk==0.1.0.dev1
1
+ lumilake-sdk==0.1.2
2
2
  flowmesh-sdk==0.1.1
3
3
  requests>=2.32.3
4
4
  typer>=0.12.5
5
5
 
6
6
  [deploy]
7
- lumilake-deploy==0.1.0.dev1
7
+ lumilake-deploy==0.1.2
@@ -1,85 +0,0 @@
1
- import json
2
- from pathlib import Path
3
-
4
- import typer
5
-
6
- from ..core import logging
7
- from ..core.config import DEFAULT_CONFIG_PATH, LumilakeConfig, save_config
8
- from ..core.http import HttpClient, HttpError, client_from_config
9
- from ..core.typer import get_typer
10
-
11
- app = get_typer()
12
-
13
-
14
- @app.command()
15
- def login(
16
- url: str = typer.Argument(
17
- ..., help="Lumilake server URL (e.g., http://localhost:9000)"
18
- ),
19
- config_path: Path = typer.Option(
20
- DEFAULT_CONFIG_PATH, "--config", help="Path to save configuration file"
21
- ),
22
- ) -> None:
23
- """Configure connection to a Lumilake server and save it locally."""
24
- client = HttpClient(base_url=url)
25
- try:
26
- client.get("/healthz")
27
- except HttpError as exc:
28
- logging.error(str(exc))
29
- raise typer.Exit(code=1)
30
- save_config(LumilakeConfig(base_url=url), path=config_path)
31
- logging.success(f"Login successful. Saved config to {config_path}")
32
-
33
-
34
- @app.command()
35
- def logout(
36
- config_path: Path = typer.Option(
37
- DEFAULT_CONFIG_PATH, "--config", help="Path to configuration file to remove"
38
- ),
39
- ) -> None:
40
- """Delete saved configuration."""
41
- config_path.unlink(missing_ok=True)
42
- logging.success(f"Logout successful. Removed config {config_path}")
43
-
44
-
45
- @app.command()
46
- def config(
47
- refresh: bool = typer.Option(
48
- False, "--refresh", "-r", help="Reload and show the latest configuration"
49
- ),
50
- config_path: Path = typer.Option(
51
- DEFAULT_CONFIG_PATH, "--config", help="Path to configuration file"
52
- ),
53
- ) -> None:
54
- """Show the current Lumilake CLI configuration."""
55
- from ..core.http import _require_config
56
-
57
- cfg = _require_config(config_path)
58
- if refresh:
59
- client = HttpClient(base_url=cfg.base_url)
60
- try:
61
- client.get("/healthz")
62
- except HttpError as exc:
63
- logging.error(str(exc))
64
- raise typer.Exit(code=1)
65
- save_config(cfg, path=config_path)
66
- logging.success(f"Configuration refreshed. Saved to {config_path}")
67
- logging.log(json.dumps(cfg.to_mapping(), indent=2))
68
-
69
-
70
- @app.command()
71
- def info() -> None:
72
- """Query server health status."""
73
- client = client_from_config()
74
- try:
75
- response = client.get("/healthz")
76
- except HttpError as exc:
77
- logging.error(str(exc))
78
- raise typer.Exit(code=1)
79
- logging.log(json.dumps(response.json(), indent=2))
80
-
81
-
82
- @app.command()
83
- def health() -> None:
84
- """Check if the Lumilake server is reachable and healthy."""
85
- info()
@@ -1,38 +0,0 @@
1
- """Configuration handling for the Lumilake CLI."""
2
-
3
- import tomllib
4
- from dataclasses import asdict, dataclass
5
- from pathlib import Path
6
- from typing import Any
7
-
8
- DEFAULT_CONFIG_DIR = Path.home() / ".lumilake"
9
- DEFAULT_CONFIG_PATH = DEFAULT_CONFIG_DIR / "config.toml"
10
-
11
-
12
- @dataclass
13
- class LumilakeConfig:
14
- base_url: str
15
-
16
- @classmethod
17
- def from_mapping(cls, data: dict[str, Any]) -> "LumilakeConfig":
18
- if "base_url" not in data:
19
- raise ValueError("Missing config key: base_url")
20
- return cls(base_url=data["base_url"])
21
-
22
- def to_mapping(self) -> dict[str, str]:
23
- return asdict(self)
24
-
25
-
26
- def load_config(path: Path = DEFAULT_CONFIG_PATH) -> LumilakeConfig:
27
- if not path.exists():
28
- raise FileNotFoundError(f"Config file not found at {path}")
29
- content = tomllib.loads(path.read_text())
30
- return LumilakeConfig.from_mapping(content)
31
-
32
-
33
- def save_config(config: LumilakeConfig, path: Path = DEFAULT_CONFIG_PATH) -> None:
34
- """Persist config to disk, creating the directory if needed."""
35
- path.parent.mkdir(parents=True, exist_ok=True)
36
- data = config.to_mapping()
37
- lines = [f'{key} = "{value}"' for key, value in data.items()]
38
- path.write_text("\n".join(lines) + ("\n" if lines else ""))
File without changes