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.
- {lumilake_cli-0.1.2/src/lumilake_cli.egg-info → lumilake_cli-0.1.4}/PKG-INFO +3 -3
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/pyproject.toml +3 -3
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/commands/deploy.py +13 -10
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/commands/job.py +370 -7
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4/src/lumilake_cli.egg-info}/PKG-INFO +3 -3
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli.egg-info/requires.txt +2 -2
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/LICENSE +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/README.md +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/setup.cfg +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/__init__.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/cli.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/commands/__init__.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/commands/base.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/commands/trace.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/commands/worker.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/core/__init__.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/core/config.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/core/http.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/core/logging.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/core/query.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli/core/typer.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli.egg-info/SOURCES.txt +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli.egg-info/dependency_links.txt +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.4}/src/lumilake_cli.egg-info/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
344
|
-
artifacts)
|
|
345
|
-
``deploy up`` resumes against
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
497
|
+
"""Wipe every locally-managed volume, then start the stack fresh.
|
|
497
498
|
|
|
498
|
-
Destructive: removes the
|
|
499
|
-
|
|
500
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
22
|
+
Requires-Dist: lumilake-deploy==0.1.4; extra == "deploy"
|
|
23
23
|
Dynamic: license-file
|
|
24
24
|
|
|
25
25
|
# `lumilake-cli`
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|