flexsds 0.2.0__py3-none-any.whl
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.
- flexsds/__init__.py +4 -0
- flexsds/assets/ptit_logo.png +0 -0
- flexsds/cli/__init__.py +1 -0
- flexsds/cli/banner.py +84 -0
- flexsds/cli/main.py +330 -0
- flexsds/cli/output.py +69 -0
- flexsds/cli/task_runner.py +171 -0
- flexsds/cli/wizard.py +360 -0
- flexsds/client/__init__.py +4 -0
- flexsds/client/chat.py +137 -0
- flexsds/client/client.py +107 -0
- flexsds/client/monitor.py +95 -0
- flexsds/core/__init__.py +44 -0
- flexsds/core/compatibility.py +442 -0
- flexsds/core/controller.py +502 -0
- flexsds/core/cost_model.py +530 -0
- flexsds/core/credentials.py +111 -0
- flexsds/core/hardware.py +77 -0
- flexsds/core/tree_topology.py +296 -0
- flexsds/profiles/profile_sm75_t4.json +62 -0
- flexsds/profiles/profile_sm89_l4.json +194 -0
- flexsds/server/__init__.py +1 -0
- flexsds/server/colab_runner.py +62 -0
- flexsds/server/local_runner.py +208 -0
- flexsds/server/modal_app.py +50 -0
- flexsds/server/modal_runner.py +98 -0
- flexsds/server/vscode_cli.py +82 -0
- flexsds-0.2.0.dist-info/METADATA +204 -0
- flexsds-0.2.0.dist-info/RECORD +62 -0
- flexsds-0.2.0.dist-info/WHEEL +5 -0
- flexsds-0.2.0.dist-info/entry_points.txt +2 -0
- flexsds-0.2.0.dist-info/top_level.txt +4 -0
- pipeline/entity_eval.py +171 -0
- pipeline/examples/node1_extractor_input_m0_04_001.txt +9 -0
- pipeline/examples/node1_extractor_output_m0_04_001.json +90 -0
- pipeline/extractor.py +502 -0
- pipeline/invoice_models.py +163 -0
- pipeline/node2_questions.py +30 -0
- pipeline/openai_compatible.py +87 -0
- pipeline/prompts/README.md +18 -0
- pipeline/prompts/node1_extractor_system.md +92 -0
- pipeline/prompts/node3_report_user.md +5 -0
- pipeline/report_writer.py +143 -0
- pipeline/schemas/invoice_extraction.schema.json +469 -0
- pipeline/sql_guard.py +140 -0
- pipeline/sqlite/schema.sql +219 -0
- pipeline/text_to_sql.py +170 -0
- pipeline/validation.py +166 -0
- pipeline/w1_workload.py +139 -0
- proposer/__init__.py +31 -0
- proposer/adapter.py +153 -0
- proposer/controller.py +460 -0
- proposer/cost_model.py +216 -0
- proposer/tree_adaptive.py +466 -0
- server/__init__.py +3 -0
- server/adaptive_scheduler.py +215 -0
- server/budget_planner.py +287 -0
- server/lineage.py +290 -0
- server/lineage_diagnostics.py +38 -0
- server/lineage_executor.py +129 -0
- server/lineage_startup.py +47 -0
- server/t4_rowwise_attention.py +262 -0
flexsds/__init__.py
ADDED
|
Binary file
|
flexsds/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""FlexSDS CLI module."""
|
flexsds/cli/banner.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""FlexSDS branding banner module with PTIT logo in ANSI TrueColor."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
from typing import Optional
|
|
6
|
+
try:
|
|
7
|
+
from flexsds.cli.output import ascii_text, print_output, supports_truecolor, supports_unicode
|
|
8
|
+
from flexsds.core.hardware import HardwareInfo, detect_hardware
|
|
9
|
+
except ModuleNotFoundError:
|
|
10
|
+
from src.flexsds.cli.output import ascii_text, print_output, supports_truecolor, supports_unicode
|
|
11
|
+
from src.flexsds.core.hardware import HardwareInfo, detect_hardware
|
|
12
|
+
|
|
13
|
+
# Pre-compiled ANSI grayscale half-blocks for the official PTIT logo (26 cols x 12 rows)
|
|
14
|
+
# Zero-dependency, sub-millisecond monochrome rendering without PIL at runtime.
|
|
15
|
+
PTIT_LOGO_ANSI = [
|
|
16
|
+
" \033[38;2;181;181;181m▄\033[0m\033[38;2;112;112;112m▄\033[0m\033[38;2;185;185;185;48;2;119;119;119m▀\033[0m\033[38;2;128;128;128;48;2;154;154;154m▀\033[0m\033[38;2;95;95;95;48;2;176;176;176m▀\033[0m\033[38;2;81;81;81;48;2;175;175;175m▀\033[0m\033[38;2;83;83;83;48;2;145;145;145m▀\033[0m\033[38;2;103;103;103;48;2;95;95;95m▀\033[0m\033[38;2;140;140;140;48;2;72;72;72m▀\033[0m\033[38;2;214;214;214;48;2;87;87;87m▀\033[0m\033[38;2;196;196;196m▄\033[0m ",
|
|
17
|
+
" \033[38;2;170;170;170m▄\033[0m\033[38;2;202;202;202;48;2;165;165;165m▀\033[0m\033[38;2;139;139;139;48;2;222;222;222m▀\033[0m\033[38;2;150;150;150m▀\033[0m\033[38;2;221;221;221m▀\033[0m \033[38;2;149;149;149m▀\033[0m\033[38;2;74;74;74;48;2;138;138;138m▀\033[0m\033[38;2;101;101;101;48;2;74;74;74m▀\033[0m\033[38;2;216;216;216m▄\033[0m ",
|
|
18
|
+
" \033[38;2;211;211;211m▄\033[0m\033[38;2;215;215;215;48;2;152;152;152m▀\033[0m\033[38;2;169;169;169m▀\033[0m\033[38;2;215;215;215m▀\033[0m \033[38;2;222;222;222m▀\033[0m\033[38;2;76;76;76;48;2;99;99;99m▀\033[0m\033[38;2;206;206;206;48;2;214;214;214m▀\033[0m ",
|
|
19
|
+
" \033[38;2;221;221;221;48;2;108;108;108m▀\033[0m\033[38;2;125;125;125;48;2;217;217;217m▀\033[0m \033[38;2;120;120;120;48;2;145;145;145m▀\033[0m ",
|
|
20
|
+
" \033[38;2;177;177;177m▄\033[0m\033[38;2;124;124;124;48;2;87;87;87m▀\033[0m\033[38;2;155;155;155m▀\033[0m \033[38;2;231;231;231m▄\033[0m \033[38;2;208;208;208;48;2;187;187;187m▀\033[0m\033[38;2;187;187;187m▀\033[0m ",
|
|
21
|
+
" \033[38;2;160;160;160m▄\033[0m\033[38;2;80;80;80;48;2;71;71;71m▀\033[0m\033[38;2;160;160;160;48;2;227;227;227m▀\033[0m \033[38;2;185;185;185m▄\033[0m\033[38;2;181;181;181m▄\033[0m\033[38;2;178;178;178m▄\033[0m\033[38;2;212;212;212m▄\033[0m \033[38;2;183;183;183m▄\033[0m\033[38;2;185;185;185m▄\033[0m\033[38;2;193;193;193m▄\033[0m\033[38;2;176;176;176m▄\033[0m\033[38;2;210;210;210m▄\033[0m\033[38;2;232;232;232m▄\033[0m\033[38;2;230;230;230m▄\033[0m\033[38;2;218;218;218;48;2;181;181;181m▀\033[0m\033[38;2;219;219;219;48;2;207;207;207m▀\033[0m\033[38;2;229;229;229m▄\033[0m\033[38;2;227;227;227;48;2;170;170;170m▀\033[0m\033[38;2;173;173;173m▄\033[0m\033[38;2;181;181;181m▄\033[0m\033[38;2;166;166;166m▄\033[0m\033[38;2;198;198;198m▄\033[0m",
|
|
22
|
+
" \033[38;2;209;209;209m▄\033[0m\033[38;2;98;98;98;48;2;71;71;71m▀\033[0m\033[38;2;100;100;100;48;2;132;132;132m▀\033[0m \033[38;2;180;180;180;48;2;181;181;181m▀\033[0m\033[38;2;77;77;77;48;2;95;95;95m▀\033[0m\033[38;2;179;179;179;48;2;225;225;225m▀\033[0m\033[38;2;170;170;170;48;2;218;218;218m▀\033[0m\033[38;2;94;94;94;48;2;92;92;92m▀\033[0m\033[38;2;155;155;155;48;2;145;145;145m▀\033[0m\033[38;2;173;173;173m▀\033[0m\033[38;2;129;129;129;48;2;196;196;196m▀\033[0m\033[38;2;90;90;90;48;2;111;111;111m▀\033[0m\033[38;2;161;161;161m▀\033[0m\033[38;2;206;206;206;48;2;246;246;246m▀\033[0m\033[38;2;218;218;218;48;2;210;210;210m▀\033[0m\033[38;2;173;173;173;48;2;185;185;185m▀\033[0m\033[38;2;194;194;194;48;2;205;205;205m▀\033[0m\033[38;2;232;232;232m▀\033[0m \033[38;2;177;177;177m▀\033[0m\033[38;2;137;137;137;48;2;200;200;200m▀\033[0m\033[38;2;86;86;86;48;2;107;107;107m▀\033[0m\033[38;2;172;172;172m▀\033[0m\033[38;2;202;202;202m▀\033[0m",
|
|
23
|
+
" \033[38;2;165;165;165;48;2;138;138;138m▀\033[0m\033[38;2;66;66;66;48;2;68;68;68m▀\033[0m\033[38;2;149;149;149;48;2;149;149;149m▀\033[0m \033[38;2;183;183;183;48;2;173;173;173m▀\033[0m\033[38;2;86;86;86;48;2;86;86;86m▀\033[0m\033[38;2;143;143;143m▀\033[0m\033[38;2;138;138;138m▀\033[0m\033[38;2;168;168;168m▀\033[0m \033[38;2;188;188;188;48;2;184;184;184m▀\033[0m\033[38;2;110;110;110;48;2;95;95;95m▀\033[0m \033[38;2;221;221;221m▄\033[0m\033[38;2;222;222;222;48;2;107;107;107m▀\033[0m\033[38;2;144;144;144;48;2;129;129;129m▀\033[0m\033[38;2;133;133;133;48;2;92;92;92m▀\033[0m \033[38;2;190;190;190;48;2;186;186;186m▀\033[0m\033[38;2;106;106;106;48;2;92;92;92m▀\033[0m ",
|
|
24
|
+
" \033[38;2;126;126;126;48;2;135;135;135m▀\033[0m\033[38;2;72;72;72;48;2;78;78;78m▀\033[0m\033[38;2;131;131;131;48;2;97;97;97m▀\033[0m \033[38;2;217;217;217m▀\033[0m\033[38;2;178;178;178m▀\033[0m \033[38;2;221;221;221m▀\033[0m\033[38;2;182;182;182;48;2;157;157;157m▀\033[0m\033[38;2;198;198;198;48;2;68;68;68m▀\033[0m\033[38;2;81;81;81;48;2;101;101;101m▀\033[0m\033[38;2;106;106;106;48;2;224;224;224m▀\033[0m\033[38;2;197;197;197m▀\033[0m\033[38;2;179;179;179m▀\033[0m \033[38;2;223;223;223m▀\033[0m\033[38;2;181;181;181m▀\033[0m ",
|
|
25
|
+
" \033[38;2;193;193;193m▀\033[0m\033[38;2;74;74;74;48;2;165;165;165m▀\033[0m\033[38;2;77;77;77;48;2;96;96;96m▀\033[0m\033[38;2;145;145;145;48;2;86;86;86m▀\033[0m\033[38;2;152;152;152m▄\033[0m \033[38;2;227;227;227m▄\033[0m\033[38;2;166;166;166m▄\033[0m\033[38;2;93;93;93m▄\033[0m\033[38;2;175;175;175;48;2;69;69;69m▀\033[0m\033[38;2;87;87;87;48;2;121;121;121m▀\033[0m\033[38;2;68;68;68;48;2;205;205;205m▀\033[0m\033[38;2;142;142;142m▀\033[0m ",
|
|
26
|
+
" \033[38;2;195;195;195m▀\033[0m\033[38;2;148;148;148;48;2;209;209;209m▀\033[0m\033[38;2;124;124;124;48;2;179;179;179m▀\033[0m\033[38;2;130;130;130;48;2;160;160;160m▀\033[0m\033[38;2;117;117;117;48;2;164;164;164m▀\033[0m\033[38;2;88;88;88;48;2;193;193;193m▀\033[0m\033[38;2;94;94;94;48;2;226;226;226m▀\033[0m\033[38;2;147;147;147m▀\033[0m\033[38;2;218;218;218m▀\033[0m ",
|
|
27
|
+
"\033[38;2;125;125;125;48;2;141;141;141m▀\033[0m\033[38;2;118;118;118;48;2;136;136;136m▀\033[0m\033[38;2;119;119;119;48;2;137;137;137m▀\033[0m\033[38;2;120;120;120;48;2;138;138;138m▀\033[0m\033[38;2;125;125;125;48;2;137;137;137m▀\033[0m\033[38;2;130;130;130;48;2;136;136;136m▀\033[0m\033[38;2;133;133;133;48;2;136;136;136m▀\033[0m\033[38;2;132;132;132;48;2;137;137;137m▀\033[0m\033[38;2;128;128;128;48;2;138;138;138m▀\033[0m\033[38;2;122;122;122;48;2;140;140;140m▀\033[0m\033[38;2;118;118;118;48;2;141;141;141m▀\033[0m\033[38;2;113;113;113;48;2;136;136;136m▀\033[0m\033[38;2;148;148;148;48;2;141;141;141m▀\033[0m\033[38;2;149;149;149;48;2;144;144;144m▀\033[0m\033[38;2;114;114;114;48;2;134;134;134m▀\033[0m\033[38;2;118;118;118;48;2;143;143;143m▀\033[0m\033[38;2;118;118;118;48;2;140;140;140m▀\033[0m\033[38;2;118;118;118;48;2;140;140;140m▀\033[0m\033[38;2;118;118;118;48;2;140;140;140m▀\033[0m\033[38;2;118;118;118;48;2;139;139;139m▀\033[0m\033[38;2;119;119;119;48;2;139;139;139m▀\033[0m\033[38;2;119;119;119;48;2;138;138;138m▀\033[0m\033[38;2;119;119;119;48;2;138;138;138m▀\033[0m\033[38;2;119;119;119;48;2;137;137;137m▀\033[0m\033[38;2;119;119;119;48;2;137;137;137m▀\033[0m\033[38;2;119;119;119;48;2;137;137;137m▀\033[0m",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
def render_banner(hw_info: Optional[HardwareInfo] = None) -> str:
|
|
31
|
+
"""Builds and returns the complete side-by-side FlexSDS banner."""
|
|
32
|
+
if hw_info is None:
|
|
33
|
+
hw_info = detect_hardware()
|
|
34
|
+
|
|
35
|
+
gpu_str = hw_info.summary()
|
|
36
|
+
|
|
37
|
+
info_lines = [
|
|
38
|
+
"\033[1;37m ███████╗██╗ ███████╗██╗ ██╗███████╗██████╗ ███████╗\033[0m",
|
|
39
|
+
"\033[1;37m ██╔════╝██║ ██╔════╝╚██╗██╔╝██╔════╝██╔══██╗██╔════╝\033[0m",
|
|
40
|
+
"\033[1;37m █████╗ ██║ █████╗ ╚███╔╝ ███████╗██║ ██║███████╗\033[0m",
|
|
41
|
+
"\033[1;37m ██╔══╝ ██║ ██╔══╝ ██╔██╗ ╚════██║██║ ██║╚════██║\033[0m",
|
|
42
|
+
"\033[1;37m ██║ ███████╗███████╗██╔╝ ██╗███████║██████╔╝███████║\033[0m",
|
|
43
|
+
"",
|
|
44
|
+
" \033[1;37mFlexSDS\033[0m \033[90m· Speculative Decoding Serving Engine · v0.2.0-alpha\033[0m",
|
|
45
|
+
" \033[90mNhóm đồ án D22\033[0m",
|
|
46
|
+
" \033[90mHọc viện Công nghệ Bưu chính Viễn thông (PTIT)\033[0m",
|
|
47
|
+
"",
|
|
48
|
+
f" \033[90m•\033[0m \033[37mHardware\033[0m : \033[90m{gpu_str}\033[0m",
|
|
49
|
+
" \033[90m•\033[0m \033[37mEngine\033[0m : \033[90mHardwareAdaptive v0.2\033[0m",
|
|
50
|
+
" \033[90m•\033[0m \033[37mBackends\033[0m : \033[90mModal / Colab / Local vLLM\033[0m",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
max_rows = max(len(PTIT_LOGO_ANSI), len(info_lines))
|
|
54
|
+
col_logo = list(PTIT_LOGO_ANSI) + [" " * 26] * (max_rows - len(PTIT_LOGO_ANSI))
|
|
55
|
+
col_info = list(info_lines) + [""] * (max_rows - len(info_lines))
|
|
56
|
+
|
|
57
|
+
output = []
|
|
58
|
+
for l, r in zip(col_logo, col_info):
|
|
59
|
+
output.append(f" {l} {r}")
|
|
60
|
+
return "\n".join(output)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def render_ascii_banner(hw_info: HardwareInfo) -> str:
|
|
64
|
+
"""Compact branding/status fallback; hardware logic remains in HardwareInfo."""
|
|
65
|
+
return ascii_text(
|
|
66
|
+
"FlexSDS | Speculative Decoding Serving Engine | v0.2.0-alpha\n"
|
|
67
|
+
"PTIT | Hoc vien Cong nghe Buu chinh Vien thong | Nhom do an D22\n"
|
|
68
|
+
f"Hardware: {hw_info.summary()}\n"
|
|
69
|
+
"Engine: TreeAdaptive v0.2\n"
|
|
70
|
+
"Backends: Modal / Colab / Local vLLM"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def print_banner(hw_info: Optional[HardwareInfo] = None) -> None:
|
|
75
|
+
"""Preserve rich branding on capable terminals; otherwise use plain ASCII."""
|
|
76
|
+
if hw_info is None:
|
|
77
|
+
hw_info = detect_hardware()
|
|
78
|
+
fallback = render_ascii_banner(hw_info)
|
|
79
|
+
rich = supports_unicode(sys.stdout) and supports_truecolor(sys.stdout)
|
|
80
|
+
print_output(render_banner(hw_info) if rich else fallback, fallback=fallback)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
print_banner()
|
flexsds/cli/main.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""FlexSDS CLI main dispatcher module."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from flexsds.cli.output import print_output as print
|
|
10
|
+
from flexsds import __version__
|
|
11
|
+
from flexsds.cli.banner import print_banner
|
|
12
|
+
from flexsds.cli.wizard import run_interactive_wizard
|
|
13
|
+
from flexsds.core.hardware import detect_hardware, HardwareInfo
|
|
14
|
+
from flexsds.core.compatibility import (
|
|
15
|
+
check_compatibility,
|
|
16
|
+
evaluate_serving_preflight,
|
|
17
|
+
ServingBlockedError,
|
|
18
|
+
)
|
|
19
|
+
from flexsds.core.cost_model import (
|
|
20
|
+
FlexCostModel,
|
|
21
|
+
load_profile_for_hardware,
|
|
22
|
+
run_hardware_micro_benchmark,
|
|
23
|
+
)
|
|
24
|
+
from flexsds.core.credentials import CredentialsManager
|
|
25
|
+
from flexsds.client.chat import run_interactive_chat
|
|
26
|
+
from flexsds.client.monitor import run_live_monitor
|
|
27
|
+
from flexsds.cli.task_runner import TaskRunner
|
|
28
|
+
from flexsds.server.local_runner import LocalServingRunner, finish_launch
|
|
29
|
+
from flexsds.server.modal_runner import ModalServingRunner
|
|
30
|
+
except ModuleNotFoundError:
|
|
31
|
+
from src.flexsds.cli.output import print_output as print
|
|
32
|
+
from src.flexsds import __version__
|
|
33
|
+
from src.flexsds.cli.banner import print_banner
|
|
34
|
+
from src.flexsds.cli.wizard import run_interactive_wizard
|
|
35
|
+
from src.flexsds.core.hardware import detect_hardware, HardwareInfo
|
|
36
|
+
from src.flexsds.core.compatibility import (
|
|
37
|
+
check_compatibility,
|
|
38
|
+
evaluate_serving_preflight,
|
|
39
|
+
ServingBlockedError,
|
|
40
|
+
)
|
|
41
|
+
from src.flexsds.core.cost_model import (
|
|
42
|
+
FlexCostModel,
|
|
43
|
+
load_profile_for_hardware,
|
|
44
|
+
run_hardware_micro_benchmark,
|
|
45
|
+
)
|
|
46
|
+
from src.flexsds.core.credentials import CredentialsManager
|
|
47
|
+
from src.flexsds.client.chat import run_interactive_chat
|
|
48
|
+
from src.flexsds.client.monitor import run_live_monitor
|
|
49
|
+
from src.flexsds.cli.task_runner import TaskRunner
|
|
50
|
+
from src.flexsds.server.local_runner import LocalServingRunner, finish_launch
|
|
51
|
+
from src.flexsds.server.modal_runner import ModalServingRunner
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
|
|
55
|
+
parser = argparse.ArgumentParser(
|
|
56
|
+
prog="flexsds",
|
|
57
|
+
description="FlexSDS: Flexible Speculative Decoding Serving Engine for LLMs",
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"-v", "--version", action="version", version=f"flexsds {__version__}"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
|
|
64
|
+
|
|
65
|
+
# Command: detect
|
|
66
|
+
subparsers.add_parser("detect", help="Detect and display hardware / GPU specs")
|
|
67
|
+
|
|
68
|
+
# Command: profile
|
|
69
|
+
profile_parser = subparsers.add_parser("profile", help="Load hardware cost profile for current GPU")
|
|
70
|
+
profile_parser.add_argument(
|
|
71
|
+
"--benchmark",
|
|
72
|
+
action="store_true",
|
|
73
|
+
help="Run micro-benchmark to measure and save profile",
|
|
74
|
+
)
|
|
75
|
+
profile_parser.add_argument(
|
|
76
|
+
"--target",
|
|
77
|
+
type=str,
|
|
78
|
+
default="Qwen/Qwen2.5-7B-Instruct-AWQ",
|
|
79
|
+
help="Target model identifier",
|
|
80
|
+
)
|
|
81
|
+
profile_parser.add_argument(
|
|
82
|
+
"--draft",
|
|
83
|
+
type=str,
|
|
84
|
+
default="duckhoi/qwen2.5-7b-eagle3.1-speculator",
|
|
85
|
+
help="Draft model or speculator head identifier",
|
|
86
|
+
)
|
|
87
|
+
profile_parser.add_argument(
|
|
88
|
+
"--model",
|
|
89
|
+
type=str,
|
|
90
|
+
default=None,
|
|
91
|
+
help="Model identifier override for benchmarking",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Command: stop
|
|
95
|
+
subparsers.add_parser("stop", help="Stop running local FlexSDS server")
|
|
96
|
+
|
|
97
|
+
# Command: auth
|
|
98
|
+
auth_parser = subparsers.add_parser("auth", help="Manage secure credentials outside repository")
|
|
99
|
+
auth_subparsers = auth_parser.add_subparsers(dest="auth_action", help="Auth action to perform")
|
|
100
|
+
|
|
101
|
+
# auth login
|
|
102
|
+
auth_login_parser = auth_subparsers.add_parser("login", help="Save API key / token securely")
|
|
103
|
+
auth_login_parser.add_argument("--provider", choices=["modal", "huggingface", "cloudflare"], default="modal", help="Target service provider")
|
|
104
|
+
auth_login_parser.add_argument("--key", type=str, default=None, help="Specific credential key name")
|
|
105
|
+
auth_login_parser.add_argument("--token", type=str, default=None, help="Token value (prompted securely if omitted)")
|
|
106
|
+
|
|
107
|
+
# auth status
|
|
108
|
+
auth_subparsers.add_parser("status", help="Show masked status of saved credentials")
|
|
109
|
+
|
|
110
|
+
# auth logout
|
|
111
|
+
auth_logout_parser = auth_subparsers.add_parser("logout", help="Delete saved credentials")
|
|
112
|
+
auth_logout_parser.add_argument("--provider", type=str, default=None, help="Target service provider to remove")
|
|
113
|
+
|
|
114
|
+
# Command: serve
|
|
115
|
+
serve_parser = subparsers.add_parser("serve", help="Launch speculative serving server")
|
|
116
|
+
serve_parser.add_argument("--target", type=str, help="Target model identifier")
|
|
117
|
+
serve_parser.add_argument("--draft", type=str, help="Draft model or speculator head identifier")
|
|
118
|
+
serve_parser.add_argument(
|
|
119
|
+
"--backend", choices=["modal", "local", "colab"], default="modal", help="Serving backend platform"
|
|
120
|
+
)
|
|
121
|
+
serve_parser.add_argument("--gpu", choices=["L4", "T4", "A10G"], default="L4", help="Remote GPU memory budget and Modal allocation")
|
|
122
|
+
serve_parser.add_argument(
|
|
123
|
+
"--port", type=int, default=8000, help="API server port"
|
|
124
|
+
)
|
|
125
|
+
serve_parser.add_argument(
|
|
126
|
+
"--background", action="store_true", help="Run local server as background daemon"
|
|
127
|
+
)
|
|
128
|
+
serve_parser.add_argument(
|
|
129
|
+
"--deploy", action="store_true", help="Deploy persistently on Modal Cloud"
|
|
130
|
+
)
|
|
131
|
+
serve_parser.add_argument(
|
|
132
|
+
"--no-adaptive", action="store_true", help="Disable HardwareAdaptive optimization engine"
|
|
133
|
+
)
|
|
134
|
+
serve_parser.add_argument(
|
|
135
|
+
"--allow-fallback", action="store_true", help="Allow running with conservative analytical fallback if exact GPU profile is missing"
|
|
136
|
+
)
|
|
137
|
+
serve_parser.add_argument(
|
|
138
|
+
"--dry-run", action="store_true", help="Print execution command without launching"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Command: chat
|
|
142
|
+
chat_parser = subparsers.add_parser("chat", help="Start interactive streaming chat with served model")
|
|
143
|
+
chat_parser.add_argument("--endpoint", type=str, default="http://localhost:8000/v1", help="API base URL")
|
|
144
|
+
chat_parser.add_argument("--model", type=str, default=None, help="Served model name override")
|
|
145
|
+
chat_parser.add_argument("--temperature", type=float, default=0.0, help="Sampling temperature")
|
|
146
|
+
|
|
147
|
+
# Command: monitor
|
|
148
|
+
monitor_parser = subparsers.add_parser("monitor", help="Live telemetry monitor for speculative decoding traces")
|
|
149
|
+
monitor_parser.add_argument("--trace-file", type=str, default="telemetry/serving_trace.jsonl", help="Path to JSONL telemetry trace")
|
|
150
|
+
monitor_parser.add_argument("--refresh-rate", type=float, default=1.0, help="Telemetry poll interval in seconds")
|
|
151
|
+
|
|
152
|
+
return parser.parse_args(argv)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_serving_cost_profile(
|
|
156
|
+
hw_info: HardwareInfo,
|
|
157
|
+
allow_fallback: bool = False,
|
|
158
|
+
custom_profile_path: Optional[str] = None,
|
|
159
|
+
) -> tuple[Optional[FlexCostModel], str]:
|
|
160
|
+
"""Tải hồ sơ chi phí phần cứng phục vụ cho lệnh serve với cơ chế chặn sớm (fail-fast).
|
|
161
|
+
|
|
162
|
+
Nếu allow_fallback=False và không tìm thấy hồ sơ chính xác cho GPU hiện tại,
|
|
163
|
+
trả về (None, "") để kích hoạt xử lý chặn sớm an toàn.
|
|
164
|
+
Nếu allow_fallback=True, cho phép nạp hồ sơ suy diễn dè dặt phân tích an toàn.
|
|
165
|
+
"""
|
|
166
|
+
return load_profile_for_hardware(
|
|
167
|
+
hw_info,
|
|
168
|
+
custom_profile_path=custom_profile_path,
|
|
169
|
+
strict=not allow_fallback,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
174
|
+
"""Main entry point for FlexSDS command-line interface."""
|
|
175
|
+
args = parse_args(argv)
|
|
176
|
+
if args.command == "serve" and not 1 <= args.port <= 65535:
|
|
177
|
+
print("Invalid port: expected 1..65535")
|
|
178
|
+
return 2
|
|
179
|
+
if args.command == "monitor" and args.refresh_rate <= 0:
|
|
180
|
+
print("Refresh rate must be positive")
|
|
181
|
+
return 2
|
|
182
|
+
hw_info = detect_hardware()
|
|
183
|
+
|
|
184
|
+
if args.command == "stop":
|
|
185
|
+
local_runner = LocalServingRunner()
|
|
186
|
+
local_stopped = local_runner.stop()
|
|
187
|
+
if local_stopped:
|
|
188
|
+
print("\033[32m✔ Local vLLM server process stopped successfully.\033[0m")
|
|
189
|
+
else:
|
|
190
|
+
print("\033[90mNo active local server process found.\033[0m")
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
if args.command == "auth":
|
|
194
|
+
cred_mgr = CredentialsManager()
|
|
195
|
+
if args.auth_action == "status":
|
|
196
|
+
masked = cred_mgr.get_all_masked()
|
|
197
|
+
print("\n\033[1;37mFlexSDS Secure Credentials (~/.flexsds/credentials):\033[0m")
|
|
198
|
+
if not masked:
|
|
199
|
+
print(" \033[90m(No credentials saved. Run 'flexsds auth login' to add.)\033[0m")
|
|
200
|
+
for prov, keys in masked.items():
|
|
201
|
+
print(f" \033[32m✔ {prov}:\033[0m")
|
|
202
|
+
for k, v in keys.items():
|
|
203
|
+
print(f" {k}: {v}")
|
|
204
|
+
return 0
|
|
205
|
+
elif args.auth_action == "logout":
|
|
206
|
+
target_prov = getattr(args, "provider", None)
|
|
207
|
+
if target_prov:
|
|
208
|
+
deleted = cred_mgr.delete_credential(target_prov)
|
|
209
|
+
if deleted:
|
|
210
|
+
print(f"\033[32m✔ Removed credentials for provider: {target_prov}\033[0m")
|
|
211
|
+
else:
|
|
212
|
+
print(f"\033[90mNo credentials found for provider: {target_prov}\033[0m")
|
|
213
|
+
else:
|
|
214
|
+
for prov in list(cred_mgr.get_all_masked().keys()):
|
|
215
|
+
cred_mgr.delete_credential(prov)
|
|
216
|
+
print("\033[32m✔ Removed all stored credentials.\033[0m")
|
|
217
|
+
return 0
|
|
218
|
+
elif args.auth_action == "login":
|
|
219
|
+
import getpass
|
|
220
|
+
prov = args.provider
|
|
221
|
+
key_name = args.key or ("HF_TOKEN" if prov == "huggingface" else ("MODAL_TOKEN_ID" if prov == "modal" else "TOKEN"))
|
|
222
|
+
val = args.token or getpass.getpass(f"Enter {key_name} for {prov}: ")
|
|
223
|
+
if val:
|
|
224
|
+
cred_mgr.set_credential(prov, key_name, val.strip())
|
|
225
|
+
print(f"\033[32m✔ Securely saved {key_name} for {prov} (POSIX mode 0600; Windows uses filesystem ACLs).\033[0m")
|
|
226
|
+
return 0
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
# Display banner on interactive startup or regular commands
|
|
230
|
+
print_banner(hw_info)
|
|
231
|
+
|
|
232
|
+
if args.command == "detect":
|
|
233
|
+
print(f"\n\033[1;32m✔ Hardware: {hw_info.summary()}\033[0m")
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
if args.command == "profile":
|
|
237
|
+
if getattr(args, "benchmark", False):
|
|
238
|
+
target_model = args.model or args.target
|
|
239
|
+
draft_model = args.draft
|
|
240
|
+
cost_model, saved_path = run_hardware_micro_benchmark(
|
|
241
|
+
hw_info=hw_info,
|
|
242
|
+
target_model=target_model,
|
|
243
|
+
draft_model=draft_model,
|
|
244
|
+
)
|
|
245
|
+
print(f"\n\033[1;32m✔ Calibrated & saved new hardware profile: {saved_path}\033[0m")
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
_, profile_name = load_profile_for_hardware(hw_info)
|
|
249
|
+
print(f"\n\033[1;32m✔ Linked hardware profile: {profile_name}\033[0m")
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
if args.command == "chat":
|
|
253
|
+
return run_interactive_chat(
|
|
254
|
+
endpoint=args.endpoint,
|
|
255
|
+
model=args.model,
|
|
256
|
+
temperature=args.temperature,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if args.command == "monitor":
|
|
260
|
+
return run_live_monitor(
|
|
261
|
+
trace_file=args.trace_file,
|
|
262
|
+
refresh_rate=args.refresh_rate,
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if args.command == "serve":
|
|
266
|
+
cost_model, profile_name = get_serving_cost_profile(
|
|
267
|
+
hw_info, allow_fallback=getattr(args, "allow_fallback", False)
|
|
268
|
+
)
|
|
269
|
+
if cost_model is None:
|
|
270
|
+
print(
|
|
271
|
+
f"\n\033[1;31m✖ [Lỗi phần cứng] Chưa có hồ sơ độ trễ cho GPU '{hw_info.device_name} ({hw_info.sm_architecture})'. "
|
|
272
|
+
f"Vui lòng chạy lệnh đo đạc: flexsds profile --benchmark (hoặc thêm cờ '--allow-fallback').\033[0m"
|
|
273
|
+
)
|
|
274
|
+
return 1
|
|
275
|
+
|
|
276
|
+
if profile_name == "conservative_analytical_fallback":
|
|
277
|
+
print(
|
|
278
|
+
f" \033[1;33m⚠ [Cảnh báo phần cứng] Sử dụng hồ sơ suy diễn dè dặt ({profile_name} / Conservative Fallback).\033[0m"
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
if args.target and args.draft:
|
|
282
|
+
target_model = args.target
|
|
283
|
+
draft_model = args.draft
|
|
284
|
+
|
|
285
|
+
preflight = evaluate_serving_preflight(target_model, draft_model, hw_info,
|
|
286
|
+
args.backend, getattr(args, "gpu", "L4"))
|
|
287
|
+
print(preflight.report.summary())
|
|
288
|
+
if not preflight.is_safe:
|
|
289
|
+
print("Launch blocked: " + "; ".join(preflight.reasons))
|
|
290
|
+
if args.backend != "local":
|
|
291
|
+
print(f"Remote {getattr(args, 'gpu', 'L4')} memory budget is estimated, not measured free VRAM.")
|
|
292
|
+
cfg = {"serving": {
|
|
293
|
+
"target_model": target_model, "draft_model": draft_model,
|
|
294
|
+
"draft_method": preflight.report.draft_method,
|
|
295
|
+
"use_heterogeneous_vocab": preflight.report.use_heterogeneous_vocab,
|
|
296
|
+
"port": args.port, "target_gpu": getattr(args, "gpu", "L4"),
|
|
297
|
+
"enable_adaptive": not args.no_adaptive,
|
|
298
|
+
}}
|
|
299
|
+
if args.backend == "colab":
|
|
300
|
+
print("Colab guidance only: no server or endpoint was created; supplied models are not forwarded to the notebook.")
|
|
301
|
+
print("Open notebooks/flexsds_colab_server.ipynb and configure the exact models on that runtime.")
|
|
302
|
+
return 0
|
|
303
|
+
if not args.dry_run and not preflight.is_safe:
|
|
304
|
+
return 2
|
|
305
|
+
if args.dry_run:
|
|
306
|
+
print("Preview only; not a readiness check.")
|
|
307
|
+
if args.backend == "local":
|
|
308
|
+
LocalServingRunner().launch(cfg, port=args.port, background=args.background, dry_run=True)
|
|
309
|
+
else:
|
|
310
|
+
ModalServingRunner().launch(cfg, deploy=args.deploy, dry_run=True)
|
|
311
|
+
return 0
|
|
312
|
+
tasks = TaskRunner()
|
|
313
|
+
task = tasks.add_task(f"Start {args.backend} serving process")
|
|
314
|
+
def launch(t):
|
|
315
|
+
if args.backend == "local":
|
|
316
|
+
return LocalServingRunner().launch(cfg, port=args.port, background=args.background)
|
|
317
|
+
return ModalServingRunner().launch(cfg, deploy=args.deploy)
|
|
318
|
+
proc = tasks.run_task(task, launch)
|
|
319
|
+
return finish_launch(proc, background=args.backend == "local" and args.background)
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
result = run_interactive_wizard(hw_info)
|
|
323
|
+
return result.get("exit_code", 0) if isinstance(result, dict) else 0
|
|
324
|
+
except ServingBlockedError as exc:
|
|
325
|
+
print(str(exc))
|
|
326
|
+
return 2
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
if __name__ == "__main__":
|
|
330
|
+
sys.exit(main())
|
flexsds/cli/output.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Encoding-safe presentation without reconfiguring caller-owned streams."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def supports_unicode(stream) -> bool:
|
|
13
|
+
"""Unknown codecs/streams conservatively use ASCII."""
|
|
14
|
+
encoding = getattr(stream, "encoding", None)
|
|
15
|
+
if not encoding:
|
|
16
|
+
return False
|
|
17
|
+
try:
|
|
18
|
+
"\u2584\u2714\u26a0\u00b7".encode(encoding)
|
|
19
|
+
except (UnicodeEncodeError, LookupError):
|
|
20
|
+
return False
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def supports_truecolor(stream) -> bool:
|
|
25
|
+
"""Require positive terminal capability evidence, never send color to pipes."""
|
|
26
|
+
if "NO_COLOR" in os.environ or os.environ.get("TERM") == "dumb":
|
|
27
|
+
return False
|
|
28
|
+
try:
|
|
29
|
+
if not getattr(stream, "isatty", lambda: False)():
|
|
30
|
+
return False
|
|
31
|
+
except OSError:
|
|
32
|
+
return False
|
|
33
|
+
return (os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit")
|
|
34
|
+
or os.environ.get("TERM", "").endswith(("-direct", "-truecolor"))
|
|
35
|
+
or bool(os.environ.get("WT_SESSION")))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def ascii_text(text: str) -> str:
|
|
39
|
+
"""Keep status meanings; escape other unencodable content rather than omit it."""
|
|
40
|
+
text = _ANSI.sub("", text)
|
|
41
|
+
for symbol, replacement in (("\u2714", "[OK]"), ("\u26a0", "[WARN]"),
|
|
42
|
+
("\u00b7", "|"), ("\u2022", "*")):
|
|
43
|
+
text = text.replace(symbol, replacement)
|
|
44
|
+
return text.encode("ascii", errors="backslashreplace").decode("ascii")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_output(text: str = "", *, file=None, end: str = "\n", fallback=None) -> None:
|
|
48
|
+
"""Write one message; only encoding errors may cause an ASCII retry.
|
|
49
|
+
|
|
50
|
+
I/O errors such as a closed pipe propagate. The stream's encoding is never
|
|
51
|
+
changed. A supplied fallback preserves a compact banner if metadata lies.
|
|
52
|
+
"""
|
|
53
|
+
stream = sys.stdout if file is None else file
|
|
54
|
+
if stream is None:
|
|
55
|
+
return
|
|
56
|
+
message = text + end
|
|
57
|
+
if not supports_truecolor(stream):
|
|
58
|
+
message = _ANSI.sub("", message)
|
|
59
|
+
if not supports_unicode(stream):
|
|
60
|
+
message = ascii_text((fallback if fallback is not None else text) + end)
|
|
61
|
+
try:
|
|
62
|
+
stream.write(message)
|
|
63
|
+
except UnicodeEncodeError:
|
|
64
|
+
stream.write(ascii_text((fallback if fallback is not None else text) + end))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def write_output(text: str) -> None:
|
|
68
|
+
"""Encoding-safe incremental output for streamed chat content."""
|
|
69
|
+
print_output(text, end="")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Interactive task manager with compact view and Ctrl+O toggled details."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import enum
|
|
5
|
+
import os
|
|
6
|
+
import select
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from flexsds.cli.output import ascii_text, supports_unicode
|
|
15
|
+
except ModuleNotFoundError:
|
|
16
|
+
from src.flexsds.cli.output import ascii_text, supports_unicode
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TaskStatus(enum.Enum):
|
|
20
|
+
PENDING = "pending"
|
|
21
|
+
RUNNING = "running"
|
|
22
|
+
COMPLETED = "completed"
|
|
23
|
+
FAILED = "failed"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Task:
|
|
28
|
+
"""Represents a discrete setup or serving execution task."""
|
|
29
|
+
name: str
|
|
30
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
31
|
+
logs: List[str] = field(default_factory=list)
|
|
32
|
+
start_time: float = 0.0
|
|
33
|
+
duration: float = 0.0
|
|
34
|
+
error: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
def log(self, message: str) -> None:
|
|
37
|
+
"""Appends a log line to task buffer without polluting terminal."""
|
|
38
|
+
clean_msg = message.rstrip("\r\n")
|
|
39
|
+
if clean_msg:
|
|
40
|
+
self.logs.append(clean_msg)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TaskRunner:
|
|
44
|
+
"""Orchestrates tasks, renders interactive status, and handles Ctrl+O log toggle."""
|
|
45
|
+
|
|
46
|
+
SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
47
|
+
|
|
48
|
+
def __init__(self, interactive: Optional[bool] = None) -> None:
|
|
49
|
+
self.tasks: List[Task] = []
|
|
50
|
+
self.show_details: bool = False
|
|
51
|
+
self.is_interactive = interactive if interactive is not None else sys.stdout.isatty()
|
|
52
|
+
self._current_frame_idx = 0
|
|
53
|
+
self._listener_thread: Optional[threading.Thread] = None
|
|
54
|
+
self._stop_listener = threading.Event()
|
|
55
|
+
|
|
56
|
+
def add_task(self, name: str) -> Task:
|
|
57
|
+
"""Enqueues a new task."""
|
|
58
|
+
task = Task(name=name)
|
|
59
|
+
self.tasks.append(task)
|
|
60
|
+
return task
|
|
61
|
+
|
|
62
|
+
def toggle_details(self) -> None:
|
|
63
|
+
"""Toggles between compact task list and expanded log details."""
|
|
64
|
+
self.show_details = not self.show_details
|
|
65
|
+
|
|
66
|
+
def handle_key(self, key: str) -> bool:
|
|
67
|
+
"""Handles keypress events (Ctrl+O is ASCII 0x0F)."""
|
|
68
|
+
if key == "\x0f" or key == "\x0e": # Ctrl+O (or Ctrl+N alternative)
|
|
69
|
+
self.toggle_details()
|
|
70
|
+
return True
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
def render_view(self) -> str:
|
|
74
|
+
"""Renders current status of all tasks with optional log detail box."""
|
|
75
|
+
lines: List[str] = []
|
|
76
|
+
active_task: Optional[Task] = None
|
|
77
|
+
|
|
78
|
+
for idx, t in enumerate(self.tasks, 1):
|
|
79
|
+
if t.status == TaskStatus.COMPLETED:
|
|
80
|
+
icon = "\033[32m✔\033[0m"
|
|
81
|
+
dur_str = f" \033[90m({t.duration:.1f}s)\033[0m"
|
|
82
|
+
lines.append(f" {icon} [{idx}/{len(self.tasks)}] {t.name}{dur_str}")
|
|
83
|
+
elif t.status == TaskStatus.RUNNING:
|
|
84
|
+
active_task = t
|
|
85
|
+
spinner = self.SPINNER_FRAMES[self._current_frame_idx % len(self.SPINNER_FRAMES)]
|
|
86
|
+
icon = f"\033[36m{spinner}\033[0m"
|
|
87
|
+
elapsed = max(time.perf_counter() - t.start_time, 0.0) if t.start_time > 0 else 0.0
|
|
88
|
+
dur_str = f" \033[33m({elapsed:.1f}s)\033[0m"
|
|
89
|
+
lines.append(f" {icon} [{idx}/{len(self.tasks)}] \033[1m{t.name}\033[0m{dur_str}")
|
|
90
|
+
elif t.status == TaskStatus.FAILED:
|
|
91
|
+
active_task = t
|
|
92
|
+
icon = "\033[31m✖\033[0m"
|
|
93
|
+
lines.append(f" {icon} [{idx}/{len(self.tasks)}] \033[31m{t.name}\033[0m")
|
|
94
|
+
else:
|
|
95
|
+
icon = "\033[90m○\033[0m"
|
|
96
|
+
lines.append(f" {icon} [{idx}/{len(self.tasks)}] \033[90m{t.name}\033[0m")
|
|
97
|
+
|
|
98
|
+
# Footer hướng dẫn phím tắt
|
|
99
|
+
if self.show_details:
|
|
100
|
+
footer = " \033[90m[\033[36mCtrl+O\033[90m: Thu gọn chi tiết log | \033[36mCtrl+C\033[90m: Dừng]\033[0m"
|
|
101
|
+
else:
|
|
102
|
+
footer = " \033[90m[\033[36mCtrl+O\033[90m: Xem chi tiết tác vụ | \033[36mCtrl+C\033[90m: Dừng]\033[0m"
|
|
103
|
+
|
|
104
|
+
lines.append("")
|
|
105
|
+
lines.append(footer)
|
|
106
|
+
|
|
107
|
+
# Nếu chế độ mở rộng đang bật, hiển thị khung log của tác vụ đang chạy hoặc vừa chạy
|
|
108
|
+
if self.show_details and active_task and active_task.logs:
|
|
109
|
+
lines.append("")
|
|
110
|
+
lines.append(f" \033[90m┌─── \033[1;37mNhật ký tác vụ: {active_task.name}\033[0m \033[90m" + "─" * 30 + "┐\033[0m")
|
|
111
|
+
recent_logs = active_task.logs[-8:] # Hiển thị 8 dòng gần nhất
|
|
112
|
+
for log_line in recent_logs:
|
|
113
|
+
lines.append(f" \033[90m│\033[0m \033[90m{log_line[:76]}\033[0m")
|
|
114
|
+
lines.append(" \033[90m└" + "─" * 60 + "┘\033[0m")
|
|
115
|
+
|
|
116
|
+
view = "\n".join(lines)
|
|
117
|
+
return view if supports_unicode(sys.stdout) else ascii_text(view)
|
|
118
|
+
|
|
119
|
+
def _start_key_listener(self) -> None:
|
|
120
|
+
"""Starts background non-blocking key listener for Ctrl+O."""
|
|
121
|
+
if not self.is_interactive or not sys.stdin.isatty():
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
def _listen() -> None:
|
|
125
|
+
try:
|
|
126
|
+
import termios
|
|
127
|
+
import tty
|
|
128
|
+
|
|
129
|
+
old_settings = termios.tcgetattr(sys.stdin)
|
|
130
|
+
tty.setcbreak(sys.stdin.fileno())
|
|
131
|
+
try:
|
|
132
|
+
while not self._stop_listener.is_set():
|
|
133
|
+
# Kiểm tra xem có dữ liệu stdin trong 0.1 giây không
|
|
134
|
+
rlist, _, _ = select.select([sys.stdin], [], [], 0.1)
|
|
135
|
+
if rlist:
|
|
136
|
+
ch = sys.stdin.read(1)
|
|
137
|
+
if ch:
|
|
138
|
+
self.handle_key(ch)
|
|
139
|
+
finally:
|
|
140
|
+
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
self._stop_listener.clear()
|
|
145
|
+
self._listener_thread = threading.Thread(target=_listen, daemon=True)
|
|
146
|
+
self._listener_thread.start()
|
|
147
|
+
|
|
148
|
+
def _stop_key_listener(self) -> None:
|
|
149
|
+
"""Stops background key listener."""
|
|
150
|
+
self._stop_listener.set()
|
|
151
|
+
if self._listener_thread and self._listener_thread.is_alive():
|
|
152
|
+
self._listener_thread.join(timeout=0.3)
|
|
153
|
+
|
|
154
|
+
def run_task(self, task: Task, action: Callable[[Task], Any]) -> Any:
|
|
155
|
+
"""Executes a single task with live status updates."""
|
|
156
|
+
task.status = TaskStatus.RUNNING
|
|
157
|
+
task.start_time = time.perf_counter()
|
|
158
|
+
self._start_key_listener()
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
result = action(task)
|
|
162
|
+
task.duration = max(time.perf_counter() - task.start_time, 0.0)
|
|
163
|
+
task.status = TaskStatus.COMPLETED
|
|
164
|
+
return result
|
|
165
|
+
except Exception as e:
|
|
166
|
+
task.duration = max(time.perf_counter() - task.start_time, 0.0)
|
|
167
|
+
task.status = TaskStatus.FAILED
|
|
168
|
+
task.error = str(e)
|
|
169
|
+
raise
|
|
170
|
+
finally:
|
|
171
|
+
self._stop_key_listener()
|