diff --git a/README.md b/README.md index e69de29..d09fde8 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,21 @@ +# SCI Interface +The de-facto TUI interface for [sci](https://git.gtz.dk/agj/sci). + +Note that this application doesn't work unless an instance on `sci` is already running on the same machine you're using. + +## Screenshots +WIP + +## Install +`scii` is available from PyPi: +``` +python3 -m pip install scii +``` + +## Development +This project is built using the usual python virtual-environment setup: +```sh +python3 -m venv venv +source venv/bin/activate +python3 -m build +``` diff --git a/pyproject.toml b/pyproject.toml index 8fc564b..4dfe9e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "loguru", + "textual" ] [project.scripts] diff --git a/src/scii/main.py b/src/scii/main.py index 4d19748..baf46cb 100644 --- a/src/scii/main.py +++ b/src/scii/main.py @@ -2,24 +2,14 @@ import sys from loguru import logger -from scii.mq import MessageQueue +from scii.tui.tuiapp import TuiApp logger.catch -def main(): - setup_logging("TRACE", True) - logger.info("welcome to the SCI Interface") - with MessageQueue(rx="/sci_tx", tx="/sci_rx") as queue: - while True: - r = queue.receive() - if r is None: - break - logger.info("msg: {}", r) - queue.send("list") - r = queue.receive() - if r is None: - break - logger.info("list: {}", r) + +def main(): + logger.info("welcome to the SCI Interface") + TuiApp().setup_logging("TRACE", True).run() def setup_logging(level: str, use_colors: bool) -> None: diff --git a/src/scii/mq.py b/src/scii/mq.py index a408fa2..3d59808 100644 --- a/src/scii/mq.py +++ b/src/scii/mq.py @@ -1,9 +1,11 @@ from ctypes import cdll from ctypes import create_string_buffer +from typing import Any from loguru import logger libc = cdll.LoadLibrary("libc.so.6") +MAX_QUEUE_LEN = 8192 class MessageQueue: @@ -14,7 +16,7 @@ class MessageQueue: self._tx = tx self._rx_queue: int | None = None self._tx_queue: int | None = None - + def __enter__(self) -> "MessageQueue": self._rx_queue = libc.mq_open(bytes(self._rx, "utf-8"), 0, 666, None) if not self._is_rx_valid(): @@ -26,12 +28,12 @@ class MessageQueue: logger.error("bad tx open") return self - def __exit__(self, *_) -> None: + def __exit__(self, *_: list[Any]) -> None: if self._is_rx_valid(): libc.mq_close(self._rx_queue) if self._is_tx_valid(): libc.mq_close(self._tx_queue) - + def send(self, msg: str) -> None: if not self._is_tx_valid(): raise RuntimeError(f"tx queue is invalid {self._tx_queue}") @@ -43,16 +45,16 @@ class MessageQueue: def receive(self) -> str | None: if not self._is_rx_valid(): raise RuntimeError(f"rx queue is invalid {self._rx_queue}") - result = create_string_buffer(b'\0' * 64) - res = libc.mq_receive(self._rx_queue, result, 8192, None) + result = create_string_buffer(b"\0" * MAX_QUEUE_LEN) + res = libc.mq_receive(self._rx_queue, result, MAX_QUEUE_LEN, None) if res == -1: libc.perror(b"mq_receive") logger.error("bad receive") return None - return str(result.value) + return str(result.value, encoding="utf-8") def _is_rx_valid(self) -> bool: - return self._rx_queue is not None and self._rx_queue != -1 + return self._rx_queue is not None and self._rx_queue != -1 def _is_tx_valid(self) -> bool: - return self._tx_queue is not None and self._tx_queue != -1 + return self._tx_queue is not None and self._tx_queue != -1 diff --git a/src/scii/sci_pipelines.py b/src/scii/sci_pipelines.py new file mode 100644 index 0000000..f1bb640 --- /dev/null +++ b/src/scii/sci_pipelines.py @@ -0,0 +1,119 @@ +import asyncio +from collections.abc import Callable +from typing import NamedTuple + +from loguru import logger + +from scii.mq import MessageQueue + + +class PipelineDTO(NamedTuple): + id: str + start_time: str + + +class _PipelinesDiff(NamedTuple): + additions: dict[str, PipelineDTO] + deletions: dict[str, PipelineDTO] + + +class SciPipelines: + """SCI pipelines synchronizer class. Use this to automatically stay up to date.""" + + def __init__( + self, + pipeline_new_callback: Callable[[PipelineDTO], None], + pipeline_end_callback: Callable[[str, int | None], None], + ) -> None: + self._on_new_pipeline = pipeline_new_callback + self._on_end_pipeline = pipeline_end_callback + self._pipelines: dict[str, PipelineDTO] = {} + self._queue = MessageQueue(rx="/sci_tx", tx="/sci_rx") + self._is_running: bool = False + + async def async_run(self) -> None: + await asyncio.to_thread(self.run) + + async def async_request_list(self) -> None: + await asyncio.to_thread(self.request_list) + + def request_list(self) -> None: + if self._is_running: + self._queue.send("list") + + def run(self) -> None: + with self._queue: + try: + self._is_running = True + while True: + msg = self._queue.receive() + if msg is None: + logger.warning("did not receive anything") + continue + self._handle_message(msg) + finally: + self._is_running = False + + def _handle_message(self, msg: str) -> None: + msg_type = msg.split(" ")[0] + content = msg[len(msg_type) :] + logger.trace("handling sci message {}", msg_type) + match msg_type: + case "list": + pipelines_raw = content.split("\\n") + pipelines = self._from_raw(pipelines_raw) + diff = self._diff(self._pipelines, pipelines) + for key in diff.additions: + self._on_new_pipeline(diff.additions[key]) + for key in diff.deletions: + self._on_end_pipeline(key, None) + self._pipelines = pipelines + + case "pipeline_new": + pipeline = self._from_raw2(content) + if pipeline is not None: + self._on_new_pipeline(pipeline) + + case "pipeline_end": + raw = content.split(" ") + id = raw[0] + return_code = None + try: + return_code = int(raw[2]) + except ValueError: + logger.error("bad cast") + self._on_end_pipeline(id, return_code) + + case "sci_started": + for k in self._pipelines: + self._on_end_pipeline(k, None) + self._pipelines.clear() + + case _: + logger.warning("unknown sci message {}", msg) + + def _from_raw(self, raw: list[str]) -> dict[str, PipelineDTO]: + result: dict[str, PipelineDTO] = {} + for x in raw: + if x == "": + continue + dto = self._from_raw2(x) + if dto is not None: + result[dto.id] = dto + return result + + def _from_raw2(self, raw: str) -> PipelineDTO | None: + if raw == "": + return None + s = raw.split(" ") + return PipelineDTO(id=s[0], start_time=s[1]) + + def _diff(self, a: dict[str, PipelineDTO], b: dict[str, PipelineDTO]) -> _PipelinesDiff: + result = _PipelinesDiff(additions={}, deletions={}) + for akey in a: + if akey not in b: + result.deletions[akey] = a[akey] + for bkey in b: + if bkey not in a: + result.additions[bkey] = b[bkey] + return result diff --git a/src/scii/tui/tuiapp.py b/src/scii/tui/tuiapp.py new file mode 100644 index 0000000..6cbc8c5 --- /dev/null +++ b/src/scii/tui/tuiapp.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import asyncio +from typing import override + +import loguru +from loguru import logger +from textual.app import App +from textual.app import ComposeResult +from textual.containers import ScrollableContainer +from textual.driver import Driver +from textual.types import CSSPathType +from textual.widgets import Footer +from textual.widgets import Header +from textual.widgets import Log + +from scii.sci_pipelines import PipelineDTO +from scii.sci_pipelines import SciPipelines +from scii.tui.widgets.pipeline import PipelineWidget + + +class _WritableLog(loguru.Writable): + def __init__(self, log: Log) -> None: + super().__init__() + self._log = log + + @override + def write(self, message: loguru.Message) -> None: + _ = self._log.write(message) + + +class TuiApp(App[None]): + """A Textual app to manage sci pipelines.""" + + BINDINGS = [ + ("d", "toggle_dark", "Toggle dark mode"), + ("l", "toggle_log", "Toggle log"), + ("q", "quit", "Quit"), + ] + + def __init__( + self, + driver_class: type[Driver] | None = None, + css_path: CSSPathType | None = None, + watch_css: bool = False, + ansi_color: bool = False, + ): + super().__init__(driver_class, css_path, watch_css, ansi_color) + self._pipelines = SciPipelines(self._on_new_pipeline, self._on_end_pipeline) + self._my_log: Log = Log(id="log") + self._my_log_wrapper = _WritableLog(self._my_log) + + def setup_logging(self, level: str, use_colors: bool) -> "TuiApp": + """Setup logging. + + Args: + level: The logging level. + use_colors: Whether to use colors in the log output. + + Returns: + self + + """ + logger.remove() + _ = logger.add( + self._my_log_wrapper, + colorize=use_colors, + format="{time:HH:mm:ss} {level} {file}:{line}: {message}", + level=level + ) + return self + + def on_mount(self) -> None: + _ = asyncio.create_task(self._pipelines.async_run()) # run the pipelines task in the background + _ = self.set_interval(1, self._pipelines.async_request_list) + + @override + def compose(self) -> ComposeResult: + """Create child widgets for the app.""" + yield Header() + yield Footer() + yield ScrollableContainer(id="pipelines") + yield self._my_log + + @override + def action_toggle_dark(self) -> None: + """An action to toggle dark mode.""" + self.dark = not self.dark + + def action_toggle_log(self) -> None: + self._my_log.display = not self._my_log.display + + async def action_add_pipeline(self, id: str, start_time: str | None = None) -> None: + """An action to add a timer.""" + if start_time is None: + start_time = "N/A" + new_pipeline = PipelineWidget(id, start_time) + _ = self.query_one("#pipelines").mount(new_pipeline) + + async def action_remove_pipeline(self, id: str) -> None: + """Called to remove a timer.""" + containers = self.query_one("#pipelines") + for child in containers.children: + if not isinstance(child, PipelineWidget): + continue + if child.pipeline_id == id: + logger.trace("removing pipeline widget with id {}", id) + await child.remove() + + def _on_new_pipeline(self, pipeline: PipelineDTO) -> None: + logger.debug("TODO: add pipeline {} to tui", pipeline) + + def _on_end_pipeline(self, id: str, return_code: int | None) -> None: + logger.debug("TODO: remove pipeline {}({}) from tui", id, return_code) diff --git a/src/scii/tui/widgets/pipeline.py b/src/scii/tui/widgets/pipeline.py new file mode 100644 index 0000000..a4c1be9 --- /dev/null +++ b/src/scii/tui/widgets/pipeline.py @@ -0,0 +1,30 @@ +from typing import override + +from textual.app import ComposeResult +from textual.widgets import Button +from textual.widgets import Static + + +class PipelineWidget(Static): + def __init__(self, id: str, start_time: str): + """Construct a new PipelineWidget. + + Args: + id: The pipeline unique identifier. + start_time: The time that the pipeline started. + + """ + super().__init__() + self._pipeline_id = id + self._start_time = start_time + + @property + def pipeline_id(self) -> str: + """The id property.""" + return self._pipeline_id + + @override + def compose(self) -> ComposeResult: + """Create child widgets of a pipeline.""" + yield Button("Stop", id="stop", variant="error") + # yield TimeDisplay("00:00:00.00")