lumilake-cli 0.1.2__tar.gz → 0.1.3__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.3}/PKG-INFO +3 -3
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/pyproject.toml +3 -3
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/commands/job.py +253 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3/src/lumilake_cli.egg-info}/PKG-INFO +3 -3
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli.egg-info/requires.txt +2 -2
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/LICENSE +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/README.md +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/setup.cfg +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/__init__.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/cli.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/commands/__init__.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/commands/base.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/commands/deploy.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/commands/trace.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/commands/worker.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/core/__init__.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/core/config.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/core/http.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/core/logging.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/core/query.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/core/typer.py +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli.egg-info/SOURCES.txt +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli.egg-info/dependency_links.txt +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli.egg-info/entry_points.txt +0 -0
- {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/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.3
|
|
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.3
|
|
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.3; 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.3"
|
|
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.3",
|
|
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.3"]
|
|
30
30
|
|
|
31
31
|
[project.urls]
|
|
32
32
|
Homepage = "https://github.com/mlsys-io/lumilake_OSS"
|
|
@@ -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
|
|
@@ -160,6 +164,11 @@ def submit(
|
|
|
160
164
|
'`{"query": ["NVDA", "TSLA"], "year": ["2024"]}`.'
|
|
161
165
|
),
|
|
162
166
|
),
|
|
167
|
+
optimizer: str | None = typer.Option(
|
|
168
|
+
None,
|
|
169
|
+
"--optimizer",
|
|
170
|
+
help="Override the server default optimizer (must be in /optimizer).",
|
|
171
|
+
),
|
|
163
172
|
) -> None:
|
|
164
173
|
"""Submit a workflow for optimization and execution."""
|
|
165
174
|
if not workflow.exists():
|
|
@@ -212,6 +221,8 @@ def submit(
|
|
|
212
221
|
"data": [item],
|
|
213
222
|
"priority": priority,
|
|
214
223
|
}
|
|
224
|
+
if optimizer is not None:
|
|
225
|
+
payload["optimizer"] = optimizer
|
|
215
226
|
|
|
216
227
|
client = client_from_config()
|
|
217
228
|
try:
|
|
@@ -624,6 +635,11 @@ def preview(
|
|
|
624
635
|
input_json: Path | None = typer.Option(
|
|
625
636
|
None, "--input-json", help="JSON file with full inputs object"
|
|
626
637
|
),
|
|
638
|
+
optimizer: str | None = typer.Option(
|
|
639
|
+
None,
|
|
640
|
+
"--optimizer",
|
|
641
|
+
help="Override the server default optimizer (must be in /optimizer).",
|
|
642
|
+
),
|
|
627
643
|
) -> None:
|
|
628
644
|
"""Preview the optimization schedule for a workflow without executing it.
|
|
629
645
|
|
|
@@ -646,6 +662,8 @@ def preview(
|
|
|
646
662
|
payload: dict[str, Any] = {
|
|
647
663
|
"data": [{"workflow": workflow_text, "inputs": inputs}],
|
|
648
664
|
}
|
|
665
|
+
if optimizer is not None:
|
|
666
|
+
payload["optimizer"] = optimizer
|
|
649
667
|
|
|
650
668
|
client = client_from_config()
|
|
651
669
|
try:
|
|
@@ -659,3 +677,238 @@ def preview(
|
|
|
659
677
|
logging.error(str(exc))
|
|
660
678
|
raise typer.Exit(code=1)
|
|
661
679
|
logging.log(json.dumps(response.json(), indent=2))
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _render_workflows_table(job_id: str, workflows: list[dict[str, Any]]) -> Any:
|
|
683
|
+
table = Table(
|
|
684
|
+
title=f"Job {job_id} - FlowMesh Workflows",
|
|
685
|
+
show_header=True,
|
|
686
|
+
header_style="bold",
|
|
687
|
+
)
|
|
688
|
+
table.add_column("Workflow ID", style="cyan", overflow="fold")
|
|
689
|
+
table.add_column("Status", style="magenta")
|
|
690
|
+
table.add_column("Submitted At", overflow="fold")
|
|
691
|
+
table.add_column("Tasks", justify="right")
|
|
692
|
+
table.add_column("Succeeded", justify="right")
|
|
693
|
+
table.add_column("Failed", justify="right")
|
|
694
|
+
for wf in workflows:
|
|
695
|
+
table.add_row(
|
|
696
|
+
str(wf.get("workflow_id", "")),
|
|
697
|
+
str(wf.get("status", "")),
|
|
698
|
+
str(wf.get("submitted_at") or ""),
|
|
699
|
+
str(wf.get("task_count") if wf.get("task_count") is not None else ""),
|
|
700
|
+
str(
|
|
701
|
+
wf.get("succeeded_count")
|
|
702
|
+
if wf.get("succeeded_count") is not None
|
|
703
|
+
else ""
|
|
704
|
+
),
|
|
705
|
+
str(wf.get("failed_count") if wf.get("failed_count") is not None else ""),
|
|
706
|
+
)
|
|
707
|
+
return table
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@app.command("workflows")
|
|
711
|
+
def workflows(
|
|
712
|
+
job_id: str = typer.Argument(..., help="Job identifier"),
|
|
713
|
+
as_json: bool = typer.Option(
|
|
714
|
+
False, "--json", help="Emit raw JSON instead of a table"
|
|
715
|
+
),
|
|
716
|
+
) -> None:
|
|
717
|
+
"""List FlowMesh workflows associated with a job."""
|
|
718
|
+
client = client_from_config()
|
|
719
|
+
try:
|
|
720
|
+
response = client.get(f"/jobs/{job_id}/workflows", version_prefix=True)
|
|
721
|
+
except HttpError as exc:
|
|
722
|
+
logging.error(str(exc))
|
|
723
|
+
raise typer.Exit(code=1)
|
|
724
|
+
payload = _unwrap(response.json())
|
|
725
|
+
workflow_list = payload.get("workflows", []) if isinstance(payload, dict) else []
|
|
726
|
+
if as_json:
|
|
727
|
+
logging.log(json.dumps(payload, indent=2))
|
|
728
|
+
return
|
|
729
|
+
Console().print(_render_workflows_table(job_id, workflow_list))
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _format_log_line(entry: dict[str, Any]) -> str:
|
|
733
|
+
event = entry.get("event", {}) if isinstance(entry, dict) else {}
|
|
734
|
+
if not isinstance(event, dict):
|
|
735
|
+
event = {}
|
|
736
|
+
ts = event.get("ts") or ""
|
|
737
|
+
level = event.get("level") or ""
|
|
738
|
+
stream = event.get("stream") or ""
|
|
739
|
+
message = event.get("message") or ""
|
|
740
|
+
head = f"{ts} {level:<5}".strip()
|
|
741
|
+
if stream:
|
|
742
|
+
head = f"{head} [{stream}]"
|
|
743
|
+
return f"{head} {message}".rstrip()
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _print_log_entries(entries: list[dict[str, Any]], as_json: bool) -> None:
|
|
747
|
+
for entry in entries:
|
|
748
|
+
if as_json:
|
|
749
|
+
logging.log(json.dumps(entry))
|
|
750
|
+
else:
|
|
751
|
+
logging.log(_format_log_line(entry))
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def _parse_sse_entries(raw: str) -> list[dict[str, Any]]:
|
|
755
|
+
entries: list[dict[str, Any]] = []
|
|
756
|
+
for block in raw.split("\n\n"):
|
|
757
|
+
current_event: str | None = None
|
|
758
|
+
for line in block.splitlines():
|
|
759
|
+
if line.startswith("event:"):
|
|
760
|
+
current_event = line[len("event:") :].strip()
|
|
761
|
+
elif line.startswith("data:"):
|
|
762
|
+
data = line[len("data:") :].strip()
|
|
763
|
+
if data:
|
|
764
|
+
try:
|
|
765
|
+
parsed = json.loads(data)
|
|
766
|
+
if current_event == "error" or (
|
|
767
|
+
isinstance(parsed, dict)
|
|
768
|
+
and parsed.get("kind") == "stream_error"
|
|
769
|
+
):
|
|
770
|
+
parsed["_sse_error"] = True
|
|
771
|
+
entries.append(parsed)
|
|
772
|
+
except json.JSONDecodeError:
|
|
773
|
+
pass
|
|
774
|
+
return entries
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
logs_app = get_typer(help="Fetch, stream, or download logs for a FlowMesh workflow.")
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
@logs_app.command("show")
|
|
781
|
+
def logs_show(
|
|
782
|
+
job_id: str = typer.Argument(..., help="Job identifier"),
|
|
783
|
+
workflow_id: str = typer.Argument(..., help="FlowMesh workflow identifier"),
|
|
784
|
+
limit: int = typer.Option(200, "--limit", help="Maximum entries per page (1-1000)"),
|
|
785
|
+
before: str | None = typer.Option(
|
|
786
|
+
None, "--before", help="Cursor to fetch entries older than this point"
|
|
787
|
+
),
|
|
788
|
+
after: str | None = typer.Option(
|
|
789
|
+
None, "--after", help="Cursor to fetch entries newer than this point"
|
|
790
|
+
),
|
|
791
|
+
as_json: bool = typer.Option(False, "--json", help="Emit raw JSON for each entry"),
|
|
792
|
+
) -> None:
|
|
793
|
+
"""Fetch one page of paginated logs for a FlowMesh workflow."""
|
|
794
|
+
if limit < 1 or limit > 1000:
|
|
795
|
+
logging.error("--limit must be between 1 and 1000")
|
|
796
|
+
raise typer.Exit(code=1)
|
|
797
|
+
client = client_from_config()
|
|
798
|
+
url = f"/jobs/{job_id}/workflows/{workflow_id}/logs"
|
|
799
|
+
params: list[tuple[str, str]] = [("limit", str(limit))]
|
|
800
|
+
if before is not None:
|
|
801
|
+
params.append(("before", before))
|
|
802
|
+
if after is not None:
|
|
803
|
+
params.append(("after", after))
|
|
804
|
+
try:
|
|
805
|
+
resp = client.get(url, version_prefix=True, params=params)
|
|
806
|
+
except HttpError as exc:
|
|
807
|
+
logging.error(str(exc))
|
|
808
|
+
raise typer.Exit(code=1)
|
|
809
|
+
payload = _unwrap(resp.json())
|
|
810
|
+
entries = payload.get("entries", []) if isinstance(payload, dict) else []
|
|
811
|
+
_print_log_entries(entries, as_json)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
@logs_app.command("stream")
|
|
815
|
+
def logs_stream(
|
|
816
|
+
job_id: str = typer.Argument(..., help="Job identifier"),
|
|
817
|
+
workflow_id: str = typer.Argument(..., help="FlowMesh workflow identifier"),
|
|
818
|
+
cursor: str | None = typer.Option(
|
|
819
|
+
None, "--cursor", help="Resume streaming from this cursor position"
|
|
820
|
+
),
|
|
821
|
+
) -> None:
|
|
822
|
+
"""Stream live log entries for a FlowMesh workflow via SSE."""
|
|
823
|
+
client = client_from_config()
|
|
824
|
+
stream_url = f"/jobs/{job_id}/workflows/{workflow_id}/logs/stream"
|
|
825
|
+
stream_params: list[tuple[str, str]] = []
|
|
826
|
+
if cursor is not None:
|
|
827
|
+
stream_params.append(("cursor", cursor))
|
|
828
|
+
try:
|
|
829
|
+
base = client.base_url.rstrip("/") + "/api/v1"
|
|
830
|
+
headers = {"Accept": "text/event-stream"}
|
|
831
|
+
if client.api_key:
|
|
832
|
+
headers["Authorization"] = f"Bearer {client.api_key}"
|
|
833
|
+
with requests.get(
|
|
834
|
+
base + stream_url,
|
|
835
|
+
headers=headers,
|
|
836
|
+
params=stream_params or None,
|
|
837
|
+
stream=True,
|
|
838
|
+
timeout=client.timeout,
|
|
839
|
+
) as resp:
|
|
840
|
+
if resp.status_code >= 400:
|
|
841
|
+
logging.error(f"Stream error: {resp.status_code} {resp.text}")
|
|
842
|
+
raise typer.Exit(code=1)
|
|
843
|
+
buffer = ""
|
|
844
|
+
stream_error: dict[str, Any] | None = None
|
|
845
|
+
for chunk in resp.iter_content(chunk_size=None, decode_unicode=True):
|
|
846
|
+
if chunk:
|
|
847
|
+
buffer += chunk
|
|
848
|
+
while "\n\n" in buffer:
|
|
849
|
+
block, buffer = buffer.split("\n\n", 1)
|
|
850
|
+
for entry in _parse_sse_entries(block + "\n\n"):
|
|
851
|
+
if entry.get("_sse_error"):
|
|
852
|
+
stream_error = entry
|
|
853
|
+
break
|
|
854
|
+
_print_log_entries([entry], False)
|
|
855
|
+
if stream_error is not None:
|
|
856
|
+
break
|
|
857
|
+
if stream_error is not None:
|
|
858
|
+
break
|
|
859
|
+
if stream_error is not None:
|
|
860
|
+
code = stream_error.get("code", "APIError")
|
|
861
|
+
message = stream_error.get("message", "Upstream stream error.")
|
|
862
|
+
logging.error(f"log stream error [{code}]: {message}")
|
|
863
|
+
raise typer.Exit(code=1)
|
|
864
|
+
except KeyboardInterrupt:
|
|
865
|
+
raise typer.Exit(code=0)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
@logs_app.command("download")
|
|
869
|
+
def logs_download(
|
|
870
|
+
job_id: str = typer.Argument(..., help="Job identifier"),
|
|
871
|
+
workflow_id: str = typer.Argument(..., help="FlowMesh workflow identifier"),
|
|
872
|
+
output: Path = typer.Option(
|
|
873
|
+
..., "--output", "-o", help="Directory to extract logs into"
|
|
874
|
+
),
|
|
875
|
+
) -> None:
|
|
876
|
+
"""Download per-task archived logs for a FlowMesh workflow."""
|
|
877
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
878
|
+
client = client_from_config()
|
|
879
|
+
try:
|
|
880
|
+
client.download(
|
|
881
|
+
f"/jobs/{job_id}/workflows/{workflow_id}/logs/download",
|
|
882
|
+
output / f"{workflow_id}-logs.tar",
|
|
883
|
+
version_prefix=True,
|
|
884
|
+
)
|
|
885
|
+
except HttpError as exc:
|
|
886
|
+
logging.error(str(exc))
|
|
887
|
+
raise typer.Exit(code=1)
|
|
888
|
+
except OSError as exc:
|
|
889
|
+
logging.error(f"Failed to write archive: {exc}")
|
|
890
|
+
raise typer.Exit(code=1)
|
|
891
|
+
|
|
892
|
+
archive_path = output / f"{workflow_id}-logs.tar"
|
|
893
|
+
extracted: list[Path] = []
|
|
894
|
+
try:
|
|
895
|
+
with tarfile.open(archive_path, "r") as tf:
|
|
896
|
+
members = tf.getmembers()
|
|
897
|
+
if not members:
|
|
898
|
+
logging.log(f"No logs downloaded for workflow {workflow_id}.")
|
|
899
|
+
archive_path.unlink(missing_ok=True)
|
|
900
|
+
return
|
|
901
|
+
tf.extractall(output, filter="data")
|
|
902
|
+
for member in members:
|
|
903
|
+
extracted.append(output / member.name)
|
|
904
|
+
except tarfile.TarError as exc:
|
|
905
|
+
logging.error(f"Failed to extract log archive: {exc}")
|
|
906
|
+
archive_path.unlink(missing_ok=True)
|
|
907
|
+
raise typer.Exit(code=1)
|
|
908
|
+
|
|
909
|
+
archive_path.unlink(missing_ok=True)
|
|
910
|
+
for path in extracted:
|
|
911
|
+
logging.log(str(path))
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
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.3
|
|
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.3
|
|
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.3; 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
|
|
File without changes
|