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.
Files changed (25) hide show
  1. {lumilake_cli-0.1.2/src/lumilake_cli.egg-info → lumilake_cli-0.1.3}/PKG-INFO +3 -3
  2. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/pyproject.toml +3 -3
  3. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/commands/job.py +253 -0
  4. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3/src/lumilake_cli.egg-info}/PKG-INFO +3 -3
  5. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli.egg-info/requires.txt +2 -2
  6. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/LICENSE +0 -0
  7. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/README.md +0 -0
  8. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/setup.cfg +0 -0
  9. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/__init__.py +0 -0
  10. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/cli.py +0 -0
  11. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/commands/__init__.py +0 -0
  12. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/commands/base.py +0 -0
  13. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/commands/deploy.py +0 -0
  14. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/commands/trace.py +0 -0
  15. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/commands/worker.py +0 -0
  16. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/core/__init__.py +0 -0
  17. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/core/config.py +0 -0
  18. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/core/http.py +0 -0
  19. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/core/logging.py +0 -0
  20. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/core/query.py +0 -0
  21. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli/core/typer.py +0 -0
  22. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli.egg-info/SOURCES.txt +0 -0
  23. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli.egg-info/dependency_links.txt +0 -0
  24. {lumilake_cli-0.1.2 → lumilake_cli-0.1.3}/src/lumilake_cli.egg-info/entry_points.txt +0 -0
  25. {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.2
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.2
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.2; extra == "deploy"
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.2"
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.2",
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.2"]
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.2
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.2
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.2; extra == "deploy"
22
+ Requires-Dist: lumilake-deploy==0.1.3; 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.3
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.3
File without changes
File without changes
File without changes