lumilake-cli 0.1.2__tar.gz → 0.1.4__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 (25) hide show
  1. {lumilake_cli-0.1.2/src/lumilake_cli.egg-info → lumilake_cli-0.1.4}/PKG-INFO +3 -3
  2. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/pyproject.toml +3 -3
  3. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/commands/deploy.py +13 -10
  4. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/commands/job.py +370 -7
  5. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4/src/lumilake_cli.egg-info}/PKG-INFO +3 -3
  6. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli.egg-info/requires.txt +2 -2
  7. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/LICENSE +0 -0
  8. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/README.md +0 -0
  9. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/setup.cfg +0 -0
  10. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/__init__.py +0 -0
  11. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/cli.py +0 -0
  12. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/commands/__init__.py +0 -0
  13. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/commands/base.py +0 -0
  14. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/commands/trace.py +0 -0
  15. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/commands/worker.py +0 -0
  16. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/core/__init__.py +0 -0
  17. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/core/config.py +0 -0
  18. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/core/http.py +0 -0
  19. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/core/logging.py +0 -0
  20. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/core/query.py +0 -0
  21. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/core/typer.py +0 -0
  22. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli.egg-info/SOURCES.txt +0 -0
  23. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli.egg-info/dependency_links.txt +0 -0
  24. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli.egg-info/entry_points.txt +0 -0
  25. {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/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.2
3
+ Version: 0.1.4
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.2
17
+ Requires-Dist: lumilake-sdk==0.1.4
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.2; extra == "deploy"
22
+ Requires-Dist: lumilake-deploy==0.1.4; 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.2"
3
+ version = "0.1.4"
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.2",
13
+ "lumilake-sdk==0.1.4",
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.2"]
29
+ deploy = ["lumilake-deploy==0.1.4"]
30
30
 
31
31
  [project.urls]
32
32
  Homepage = "https://github.com/mlsys-io/lumilake_OSS"
@@ -340,12 +340,13 @@ def down(
340
340
  ) -> None:
341
341
  """Stop the stack but keep data volumes.
342
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.
343
+ Safe to run between sessions: the archive layer (job records, runtime
344
+ artifacts) lives in lumid-data-app under ``S3_ARCHIVE_PREFIX`` and is
345
+ not managed by ``lumilake deploy``, so ``deploy up`` resumes against
346
+ the same lumid-data-app state. ``--wipe-archive`` wipes the FlowMesh
347
+ runtime-state volumes that ``lumilake deploy`` does own; remote
348
+ lumid-data-app state is untouched. Use ``deploy reset`` (destructive)
349
+ to wipe every locally-managed volume instead.
349
350
  """
350
351
  try:
351
352
  stop_mod.run_stop(
@@ -493,11 +494,13 @@ def reset(
493
494
  help="Skip the destructive-action confirmation prompt.",
494
495
  ),
495
496
  ) -> None:
496
- """Wipe the archive and every volume, then start the stack fresh.
497
+ """Wipe every locally-managed volume, then start the stack fresh.
497
498
 
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.
499
+ Destructive: removes the FlowMesh runtime-state volumes managed by
500
+ ``lumilake deploy``. The archive layer (job records, runtime
501
+ artifacts) lives in lumid-data-app under ``S3_ARCHIVE_PREFIX`` and
502
+ is **not** wiped by this command. Use ``deploy down`` if you want
503
+ to stop the stack but keep its local state.
501
504
  """
502
505
  if not yes and not typer.confirm(
503
506
  "deploy reset deletes every Lumilake volume (archive + compute "
@@ -1,11 +1,15 @@
1
1
  """Job submission, monitoring, and result retrieval commands."""
2
2
 
3
3
  import json
4
+ import tarfile
4
5
  import time
5
6
  from pathlib import Path
6
7
  from typing import Any
7
8
 
9
+ import requests
8
10
  import typer
11
+ from rich.console import Console
12
+ from rich.table import Table
9
13
 
10
14
  from ..core import logging
11
15
  from ..core.http import HttpError, client_from_config
@@ -23,6 +27,55 @@ def _unwrap(response_json: dict[str, Any]) -> dict[str, Any]:
23
27
  return response_json.get("data", response_json)
24
28
 
25
29
 
30
+ _HARDWARE_FIELDS = ("cpu", "memory", "gpu", "gpu_memory")
31
+
32
+
33
+ def _build_hardware_payload(
34
+ cpu: int | None,
35
+ memory: str | None,
36
+ gpu: int | None,
37
+ gpu_memory: str | None,
38
+ hardware_json: Path | None,
39
+ ) -> dict[str, Any] | None:
40
+ """Merge ``--hardware-json`` with individual ``--cpu`` etc. flags.
41
+
42
+ Individual flags win on conflict so users can layer a one-off override on a
43
+ saved profile. Returns ``None`` when nothing is set so the server keeps the
44
+ env defaults.
45
+ """
46
+ base: dict[str, Any] = {}
47
+ if hardware_json is not None:
48
+ if not hardware_json.is_file():
49
+ logging.error(f"--hardware-json file not found: {hardware_json}")
50
+ raise typer.Exit(code=1)
51
+ try:
52
+ loaded = json.loads(hardware_json.read_text())
53
+ except json.JSONDecodeError as exc:
54
+ logging.error(f"--hardware-json is not valid JSON: {exc}")
55
+ raise typer.Exit(code=1) from exc
56
+ if not isinstance(loaded, dict):
57
+ logging.error("--hardware-json must contain a JSON object")
58
+ raise typer.Exit(code=1)
59
+ unknown = set(loaded) - set(_HARDWARE_FIELDS)
60
+ if unknown:
61
+ logging.error(
62
+ "--hardware-json has unknown fields "
63
+ f"{sorted(unknown)}; allowed: {list(_HARDWARE_FIELDS)}"
64
+ )
65
+ raise typer.Exit(code=1)
66
+ base.update(loaded)
67
+ overrides: dict[str, Any] = {
68
+ "cpu": cpu,
69
+ "memory": memory,
70
+ "gpu": gpu,
71
+ "gpu_memory": gpu_memory,
72
+ }
73
+ for key, value in overrides.items():
74
+ if value is not None:
75
+ base[key] = value
76
+ return base or None
77
+
78
+
26
79
  def _build_inputs(
27
80
  input_values: list[str] | None,
28
81
  input_files: list[str] | None,
@@ -118,11 +171,6 @@ def submit(
118
171
  "--output-prefix",
119
172
  help="S3 output prefix (required when --output-type s3)",
120
173
  ),
121
- output_connection_string: str | None = typer.Option(
122
- None,
123
- "--output-connection-string",
124
- help="S3 connection string (s3://user:pass@endpoint/bucket)",
125
- ),
126
174
  output_table: str | None = typer.Option(
127
175
  None, "--output-table", help="DB output table (required when --output-type db)"
128
176
  ),
@@ -160,6 +208,53 @@ def submit(
160
208
  '`{"query": ["NVDA", "TSLA"], "year": ["2024"]}`.'
161
209
  ),
162
210
  ),
211
+ optimizer: str | None = typer.Option(
212
+ None,
213
+ "--optimizer",
214
+ help="Override the server default optimizer (must be in /optimizer).",
215
+ ),
216
+ cpu: int | None = typer.Option(
217
+ None,
218
+ "--cpu",
219
+ help=(
220
+ "Per-job CPU cores override. Unset falls back to "
221
+ "HARDWARE_CPU_REQUIREMENT."
222
+ ),
223
+ ),
224
+ memory: str | None = typer.Option(
225
+ None,
226
+ "--memory",
227
+ help=(
228
+ "Per-job RAM override (e.g. `32Gi`). Unset falls back to "
229
+ "HARDWARE_MEMORY_REQUIREMENT."
230
+ ),
231
+ ),
232
+ gpu: int | None = typer.Option(
233
+ None,
234
+ "--gpu",
235
+ help=(
236
+ "Per-job GPU count override. `0` disables GPU; the server rejects "
237
+ "workflows with GPU ops when `--gpu 0`. Unset falls back to "
238
+ "HARDWARE_GPU_REQUIREMENT."
239
+ ),
240
+ ),
241
+ gpu_memory: str | None = typer.Option(
242
+ None,
243
+ "--gpu-memory",
244
+ help=(
245
+ "Per-job GPU memory override (e.g. `24Gi`). Unset falls back to "
246
+ "HARDWARE_GPU_MEMORY_REQUIREMENT."
247
+ ),
248
+ ),
249
+ hardware_json: Path | None = typer.Option(
250
+ None,
251
+ "--hardware-json",
252
+ help=(
253
+ "JSON file with the full hardware override (keys: cpu, memory, gpu, "
254
+ "gpu_memory). Merged with `--cpu`/`--memory`/`--gpu`/`--gpu-memory`; "
255
+ "flag values win on conflict."
256
+ ),
257
+ ),
163
258
  ) -> None:
164
259
  """Submit a workflow for optimization and execution."""
165
260
  if not workflow.exists():
@@ -184,8 +279,6 @@ def submit(
184
279
  logging.error("--output-prefix is required when --output-type is s3")
185
280
  raise typer.Exit(code=1)
186
281
  output_location: dict[str, Any] = {"type": "s3", "prefix": output_prefix}
187
- if output_connection_string:
188
- output_location["connection_string"] = output_connection_string
189
282
  elif output_type == "db":
190
283
  if not output_table or not output_column:
191
284
  logging.error("--output-table and --output-column required for db output")
@@ -212,6 +305,13 @@ def submit(
212
305
  "data": [item],
213
306
  "priority": priority,
214
307
  }
308
+ if optimizer is not None:
309
+ payload["optimizer"] = optimizer
310
+ hardware_payload = _build_hardware_payload(
311
+ cpu, memory, gpu, gpu_memory, hardware_json
312
+ )
313
+ if hardware_payload is not None:
314
+ payload["hardware"] = hardware_payload
215
315
 
216
316
  client = client_from_config()
217
317
  try:
@@ -624,6 +724,27 @@ def preview(
624
724
  input_json: Path | None = typer.Option(
625
725
  None, "--input-json", help="JSON file with full inputs object"
626
726
  ),
727
+ optimizer: str | None = typer.Option(
728
+ None,
729
+ "--optimizer",
730
+ help="Override the server default optimizer (must be in /optimizer).",
731
+ ),
732
+ cpu: int | None = typer.Option(None, "--cpu", help="Per-job CPU cores override."),
733
+ memory: str | None = typer.Option(
734
+ None, "--memory", help="Per-job RAM override (e.g. `32Gi`)."
735
+ ),
736
+ gpu: int | None = typer.Option(None, "--gpu", help="Per-job GPU count override."),
737
+ gpu_memory: str | None = typer.Option(
738
+ None, "--gpu-memory", help="Per-job GPU memory override (e.g. `24Gi`)."
739
+ ),
740
+ hardware_json: Path | None = typer.Option(
741
+ None,
742
+ "--hardware-json",
743
+ help=(
744
+ "JSON file with the full hardware override; merged with `--cpu` / "
745
+ "`--memory` / `--gpu` / `--gpu-memory` (flags win on conflict)."
746
+ ),
747
+ ),
627
748
  ) -> None:
628
749
  """Preview the optimization schedule for a workflow without executing it.
629
750
 
@@ -646,6 +767,13 @@ def preview(
646
767
  payload: dict[str, Any] = {
647
768
  "data": [{"workflow": workflow_text, "inputs": inputs}],
648
769
  }
770
+ if optimizer is not None:
771
+ payload["optimizer"] = optimizer
772
+ hardware_payload = _build_hardware_payload(
773
+ cpu, memory, gpu, gpu_memory, hardware_json
774
+ )
775
+ if hardware_payload is not None:
776
+ payload["hardware"] = hardware_payload
649
777
 
650
778
  client = client_from_config()
651
779
  try:
@@ -659,3 +787,238 @@ def preview(
659
787
  logging.error(str(exc))
660
788
  raise typer.Exit(code=1)
661
789
  logging.log(json.dumps(response.json(), indent=2))
790
+
791
+
792
+ def _render_workflows_table(job_id: str, workflows: list[dict[str, Any]]) -> Any:
793
+ table = Table(
794
+ title=f"Job {job_id} - FlowMesh Workflows",
795
+ show_header=True,
796
+ header_style="bold",
797
+ )
798
+ table.add_column("Workflow ID", style="cyan", overflow="fold")
799
+ table.add_column("Status", style="magenta")
800
+ table.add_column("Submitted At", overflow="fold")
801
+ table.add_column("Tasks", justify="right")
802
+ table.add_column("Succeeded", justify="right")
803
+ table.add_column("Failed", justify="right")
804
+ for wf in workflows:
805
+ table.add_row(
806
+ str(wf.get("workflow_id", "")),
807
+ str(wf.get("status", "")),
808
+ str(wf.get("submitted_at") or ""),
809
+ str(wf.get("task_count") if wf.get("task_count") is not None else ""),
810
+ str(
811
+ wf.get("succeeded_count")
812
+ if wf.get("succeeded_count") is not None
813
+ else ""
814
+ ),
815
+ str(wf.get("failed_count") if wf.get("failed_count") is not None else ""),
816
+ )
817
+ return table
818
+
819
+
820
+ @app.command("workflows")
821
+ def workflows(
822
+ job_id: str = typer.Argument(..., help="Job identifier"),
823
+ as_json: bool = typer.Option(
824
+ False, "--json", help="Emit raw JSON instead of a table"
825
+ ),
826
+ ) -> None:
827
+ """List FlowMesh workflows associated with a job."""
828
+ client = client_from_config()
829
+ try:
830
+ response = client.get(f"/jobs/{job_id}/workflows", version_prefix=True)
831
+ except HttpError as exc:
832
+ logging.error(str(exc))
833
+ raise typer.Exit(code=1)
834
+ payload = _unwrap(response.json())
835
+ workflow_list = payload.get("workflows", []) if isinstance(payload, dict) else []
836
+ if as_json:
837
+ logging.log(json.dumps(payload, indent=2))
838
+ return
839
+ Console().print(_render_workflows_table(job_id, workflow_list))
840
+
841
+
842
+ def _format_log_line(entry: dict[str, Any]) -> str:
843
+ event = entry.get("event", {}) if isinstance(entry, dict) else {}
844
+ if not isinstance(event, dict):
845
+ event = {}
846
+ ts = event.get("ts") or ""
847
+ level = event.get("level") or ""
848
+ stream = event.get("stream") or ""
849
+ message = event.get("message") or ""
850
+ head = f"{ts} {level:<5}".strip()
851
+ if stream:
852
+ head = f"{head} [{stream}]"
853
+ return f"{head} {message}".rstrip()
854
+
855
+
856
+ def _print_log_entries(entries: list[dict[str, Any]], as_json: bool) -> None:
857
+ for entry in entries:
858
+ if as_json:
859
+ logging.log(json.dumps(entry))
860
+ else:
861
+ logging.log(_format_log_line(entry))
862
+
863
+
864
+ def _parse_sse_entries(raw: str) -> list[dict[str, Any]]:
865
+ entries: list[dict[str, Any]] = []
866
+ for block in raw.split("\n\n"):
867
+ current_event: str | None = None
868
+ for line in block.splitlines():
869
+ if line.startswith("event:"):
870
+ current_event = line[len("event:") :].strip()
871
+ elif line.startswith("data:"):
872
+ data = line[len("data:") :].strip()
873
+ if data:
874
+ try:
875
+ parsed = json.loads(data)
876
+ if current_event == "error" or (
877
+ isinstance(parsed, dict)
878
+ and parsed.get("kind") == "stream_error"
879
+ ):
880
+ parsed["_sse_error"] = True
881
+ entries.append(parsed)
882
+ except json.JSONDecodeError:
883
+ pass
884
+ return entries
885
+
886
+
887
+ logs_app = get_typer(help="Fetch, stream, or download logs for a FlowMesh workflow.")
888
+
889
+
890
+ @logs_app.command("show")
891
+ def logs_show(
892
+ job_id: str = typer.Argument(..., help="Job identifier"),
893
+ workflow_id: str = typer.Argument(..., help="FlowMesh workflow identifier"),
894
+ limit: int = typer.Option(200, "--limit", help="Maximum entries per page (1-1000)"),
895
+ before: str | None = typer.Option(
896
+ None, "--before", help="Cursor to fetch entries older than this point"
897
+ ),
898
+ after: str | None = typer.Option(
899
+ None, "--after", help="Cursor to fetch entries newer than this point"
900
+ ),
901
+ as_json: bool = typer.Option(False, "--json", help="Emit raw JSON for each entry"),
902
+ ) -> None:
903
+ """Fetch one page of paginated logs for a FlowMesh workflow."""
904
+ if limit < 1 or limit > 1000:
905
+ logging.error("--limit must be between 1 and 1000")
906
+ raise typer.Exit(code=1)
907
+ client = client_from_config()
908
+ url = f"/jobs/{job_id}/workflows/{workflow_id}/logs"
909
+ params: list[tuple[str, str]] = [("limit", str(limit))]
910
+ if before is not None:
911
+ params.append(("before", before))
912
+ if after is not None:
913
+ params.append(("after", after))
914
+ try:
915
+ resp = client.get(url, version_prefix=True, params=params)
916
+ except HttpError as exc:
917
+ logging.error(str(exc))
918
+ raise typer.Exit(code=1)
919
+ payload = _unwrap(resp.json())
920
+ entries = payload.get("entries", []) if isinstance(payload, dict) else []
921
+ _print_log_entries(entries, as_json)
922
+
923
+
924
+ @logs_app.command("stream")
925
+ def logs_stream(
926
+ job_id: str = typer.Argument(..., help="Job identifier"),
927
+ workflow_id: str = typer.Argument(..., help="FlowMesh workflow identifier"),
928
+ cursor: str | None = typer.Option(
929
+ None, "--cursor", help="Resume streaming from this cursor position"
930
+ ),
931
+ ) -> None:
932
+ """Stream live log entries for a FlowMesh workflow via SSE."""
933
+ client = client_from_config()
934
+ stream_url = f"/jobs/{job_id}/workflows/{workflow_id}/logs/stream"
935
+ stream_params: list[tuple[str, str]] = []
936
+ if cursor is not None:
937
+ stream_params.append(("cursor", cursor))
938
+ try:
939
+ base = client.base_url.rstrip("/") + "/api/v1"
940
+ headers = {"Accept": "text/event-stream"}
941
+ if client.api_key:
942
+ headers["Authorization"] = f"Bearer {client.api_key}"
943
+ with requests.get(
944
+ base + stream_url,
945
+ headers=headers,
946
+ params=stream_params or None,
947
+ stream=True,
948
+ timeout=client.timeout,
949
+ ) as resp:
950
+ if resp.status_code >= 400:
951
+ logging.error(f"Stream error: {resp.status_code} {resp.text}")
952
+ raise typer.Exit(code=1)
953
+ buffer = ""
954
+ stream_error: dict[str, Any] | None = None
955
+ for chunk in resp.iter_content(chunk_size=None, decode_unicode=True):
956
+ if chunk:
957
+ buffer += chunk
958
+ while "\n\n" in buffer:
959
+ block, buffer = buffer.split("\n\n", 1)
960
+ for entry in _parse_sse_entries(block + "\n\n"):
961
+ if entry.get("_sse_error"):
962
+ stream_error = entry
963
+ break
964
+ _print_log_entries([entry], False)
965
+ if stream_error is not None:
966
+ break
967
+ if stream_error is not None:
968
+ break
969
+ if stream_error is not None:
970
+ code = stream_error.get("code", "APIError")
971
+ message = stream_error.get("message", "Upstream stream error.")
972
+ logging.error(f"log stream error [{code}]: {message}")
973
+ raise typer.Exit(code=1)
974
+ except KeyboardInterrupt:
975
+ raise typer.Exit(code=0)
976
+
977
+
978
+ @logs_app.command("download")
979
+ def logs_download(
980
+ job_id: str = typer.Argument(..., help="Job identifier"),
981
+ workflow_id: str = typer.Argument(..., help="FlowMesh workflow identifier"),
982
+ output: Path = typer.Option(
983
+ ..., "--output", "-o", help="Directory to extract logs into"
984
+ ),
985
+ ) -> None:
986
+ """Download per-task archived logs for a FlowMesh workflow."""
987
+ output.mkdir(parents=True, exist_ok=True)
988
+ client = client_from_config()
989
+ try:
990
+ client.download(
991
+ f"/jobs/{job_id}/workflows/{workflow_id}/logs/download",
992
+ output / f"{workflow_id}-logs.tar",
993
+ version_prefix=True,
994
+ )
995
+ except HttpError as exc:
996
+ logging.error(str(exc))
997
+ raise typer.Exit(code=1)
998
+ except OSError as exc:
999
+ logging.error(f"Failed to write archive: {exc}")
1000
+ raise typer.Exit(code=1)
1001
+
1002
+ archive_path = output / f"{workflow_id}-logs.tar"
1003
+ extracted: list[Path] = []
1004
+ try:
1005
+ with tarfile.open(archive_path, "r") as tf:
1006
+ members = tf.getmembers()
1007
+ if not members:
1008
+ logging.log(f"No logs downloaded for workflow {workflow_id}.")
1009
+ archive_path.unlink(missing_ok=True)
1010
+ return
1011
+ tf.extractall(output, filter="data")
1012
+ for member in members:
1013
+ extracted.append(output / member.name)
1014
+ except tarfile.TarError as exc:
1015
+ logging.error(f"Failed to extract log archive: {exc}")
1016
+ archive_path.unlink(missing_ok=True)
1017
+ raise typer.Exit(code=1)
1018
+
1019
+ archive_path.unlink(missing_ok=True)
1020
+ for path in extracted:
1021
+ logging.log(str(path))
1022
+
1023
+
1024
+ app.add_typer(logs_app, name="logs")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lumilake-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
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.2
17
+ Requires-Dist: lumilake-sdk==0.1.4
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.2; extra == "deploy"
22
+ Requires-Dist: lumilake-deploy==0.1.4; extra == "deploy"
23
23
  Dynamic: license-file
24
24
 
25
25
  # `lumilake-cli`
@@ -1,7 +1,7 @@
1
- lumilake-sdk==0.1.2
1
+ lumilake-sdk==0.1.4
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.2
7
+ lumilake-deploy==0.1.4
File without changes
File without changes
File without changes