diff --git a/foucault/__init__.py b/foucault/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/foucault/__pycache__/__init__.cpython-313.pyc b/foucault/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..8513b21 Binary files /dev/null and b/foucault/__pycache__/__init__.cpython-313.pyc differ diff --git a/foucault/__pycache__/gui.cpython-313.pyc b/foucault/__pycache__/gui.cpython-313.pyc new file mode 100644 index 0000000..51e83fa Binary files /dev/null and b/foucault/__pycache__/gui.cpython-313.pyc differ diff --git a/foucault/gui.py b/foucault/gui.py new file mode 100644 index 0000000..c890b3b --- /dev/null +++ b/foucault/gui.py @@ -0,0 +1,331 @@ +""" +Foucault allows you to govern a set of commands, which can be useful for +i.e. an art installation setting, in which different scripts need to work +together. While one can be (re)started independently of the rest. +""" + +import asyncio +from collections import defaultdict +import os +import pathlib +import random +from subprocess import Popen +import subprocess +from tempfile import mkstemp +import threading +import time +import tomllib +from typing import Optional +from nicegui import ui +import logging + +import tomli_w + +logger = logging.getLogger('procescontroller') + + +numbers = [] +processes = [] + +class Argument(): + def __init__(self, name: str, enabled: bool = True, inline: bool = False, boolean: bool = False, options: list[str] = [], selected: list[str] = []): + self.name = name + self.enabled = enabled + self.inline = inline + self.boolean = boolean + self.options = options + self.selected = selected + + + def as_list(self) -> list[str]: + if not self.enabled: + return [] + l = [] + if not self.inline: + l.append(self.name) + if not self.boolean: + l += self.selected + return l + + def select(self, option: str): + self.selected = [option] + + def toggle(self): + self.enabled = not self.enabled + + @classmethod + def from_dict(cls, data: dict): + return cls( + data['name'], + data.get('enabled', False), + data.get('inline', False), + data.get('boolean', False), + data.get('options', []), + data.get('selected', []), + ) + + def to_dict(self) -> dict: + return self.__dict__ + + + +class SubprocessController(): + def __init__(self, name: str, cmd: list[str], arguments: list[Argument], environment: dict[str,str] = {}, fn: Optional[pathlib.Path] = None): + self.cmd = cmd + self.arguments = arguments + self.name = name + self.filename = fn + # self.tmp_config_fp, self.tmp_config_filename = mkstemp(suffix=".yml", prefix=f"config_{self.name}_") + self.env_overrides = environment + # dict[str, str] = { + # "DISPLAY": ":1" + # } + self.proc=None + self.running_config_state: Optional[str] = None + self.saved_state = self.config_state() + + @classmethod + def from_toml(cls, filename: os.PathLike): + path = pathlib.Path(filename) + name = path.stem + args = [] + with path.open('rb') as fp: + data = tomllib.load(fp) + print(data) + + for arg in data['arguments']: + args.append(Argument.from_dict(arg)) + + sc = cls( + name, + [data['cmd']] if data['cmd'] is str else data['cmd'], + args, + data.get('environment', {}), + path + ) + return sc + + def to_dict(self): + return { + "cmd": self.cmd, + "arguments": [a.to_dict() for a in self.arguments], + "environment": self.env_overrides, + } + + + def update_config(self): + pass + + def get_environment(self): + default_env = os.environ.copy() + return default_env | self.env_overrides + + def get_environment_strs(self): + return [f"{k}=\"{v}\"" for k, v in self.env_overrides.items()] + + def rm_env(self, key): + if key in self.env_overrides: + del self.env_overrides[key] + + def add_env(self, key, value): + self.env_overrides[key] = value + + + + def get_arguments(self)-> list[str]: + r = [] + for a in self.arguments: + r.extend(a.as_list()) + return r + + + def as_bash_string(self): + return " " . join(self.get_environment_strs() + self.cmd + self.get_arguments()) + + + def run(self): + logger.info(f"Run: {self.as_bash_string()}") + self.running_config_state = self.as_bash_string() + self.proc = Popen( + self.cmd + self.get_arguments(), + env=self.get_environment(), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL) + + def is_stale(self): + """Config has changed since starting the process""" + return self.is_running() and self.running_config_state != self.as_bash_string() + + + def quit(self): + if self.is_running(): + self.proc.terminate() + + def restart(self): + self.quit() + self.run() + + def return_code(self) -> Optional[int]: + if self.proc is None: + return None + return self.proc.returncode + + def is_running(self): + return self.proc is not None and self.proc.poll() is None + + def state(self): + return "::".join( + [self.name, str(self.is_running()), str(self.return_code())] + ) + "::" + self.config_state() + (self.running_config_state if self.running_config_state else "") + + def config_state(self): + return "::".join( + self.cmd + self.get_arguments() + ["+".join(a.options) for a in self.arguments] + [str(self.filename.stat().st_mtime) if self.filename else ""] + + list(self.env_overrides.values()) + ) + + def save(self) -> str: + # print(self.to_dict()) + with self.filename.open('wb') as fp: + tomli_w.dump(self.to_dict(), fp) + self.saved_state = self.config_state() + + def unsaved_changes(self): + return self.config_state() != self.saved_state + + +class Foucault(): + def __init__(self): + self.processes: list[SubprocessController] = [] + self.uis: list[SubprocessUI] = [] + + def add_process(self, sc: SubprocessController): + self.processes.append(sc) + self.uis.append(SubprocessUI(sc)) + + def run_all(self): + for p in self.processes: + p.run() + + def stop_all(self): + for p in self.processes: + p.quit() + + def restart_all(self): + # when restarting all, make sure all are stopped + # before starting, so we start with a clean slate + self.stop_all() + self.run_all() + + def ui(self): + with ui.row(): + ui.button('▶', on_click=self.run_all) + ui.button('■', on_click=self.stop_all) + ui.button('↺', on_click=self.restart_all) + + with ui.grid(columns="40em 40em 40em"): + for proc_ui in self.uis: + proc_ui.ui() + + def run(self): + + # t = threading.Thread(target=reloader, daemon=True) + t = threading.Thread(target=self.monitor, daemon=True) + t.start() + + #build ui: + self.ui() + ui.page_title("Conduct of conduct") + ui.run(show=False, favicon="🫣") + + + def monitor(self): + + states = defaultdict(lambda: "") + + i=0 + while True: + i+=1 + # if we're only interested in running, we can use os.waitpid(), but we also check config changes + time.sleep(.3) + for proc_ui in self.uis: + state = proc_ui.sc.state() + if state != states[proc_ui.sc]: + proc_ui.ui.refresh() + states[proc_ui.sc] = state + + + +class SubprocessUI: + def __init__(self, sc: SubprocessController): + self.sc = sc + + @ui.refreshable_method + def ui(self): + card_class = "bg-teal" if self.sc.is_running() else ("bg-warning" if self.sc.return_code() else "bg-gray") + with ui.card().classes(card_class): + with ui.row(align_items="stretch").classes('w-full'): + if self.sc.return_code(): + ui.label(f'⚠').classes('text-h5') + + ui.label(f'{self.sc.name}').tooltip(str(self.sc.filename)).classes('text-h5') + ui.space() + with ui.button_group(): + ui.button('▶', on_click=self.sc.run) + ui.button('■', on_click=self.sc.quit) + ui.button('↺', on_click=self.sc.restart, color='red' if self.sc.is_stale() else "primary") + + + ui.label(f'Running: {self.sc.is_running()}') + ui.code(f'{" ".join(self.sc.cmd)}') + ui.code(self.sc.as_bash_string()) + ui.separator() + with ui.row().classes('w-full'): + edit = ui.switch(value=self.sc.unsaved_changes()) + ui.space() + ui.button('🖫', on_click=self.sc.save, color="red" if self.sc.unsaved_changes() else 'lightgray').classes('text-lg') + with ui.grid().bind_visibility_from(edit, 'value'): + ui.label('Arguments').classes('text-h6') + for i, argument in enumerate(self.sc.arguments): + with ui.card().classes('w-full'): + with ui.row(align_items='center').classes('w-full'): + ui.button("⊗", on_click=lambda i=i: self.sc.arguments.pop(i)) + ui.label(f'{argument.name}').classes('text-stone-400' if argument.inline else '') + ui.space() + ui.switch(value=argument.enabled, on_change=lambda i=argument: i.toggle()) + if not argument.boolean: + with ui.dropdown_button(f"{len(argument.options)} | " + ' '.join(argument.selected), auto_close=False).classes('normal-case'): + # ui.badge('0', color='red').props('floating') + for o in argument.options: + # TODO)) Remove + ui.item(o +'i', on_click=(lambda a=argument, o=o: a.select(o) )) + with ui.item(): + ui.input(placeholder="add new").on('keydown.enter', (lambda e, a=argument: a.options.append(e.sender.value) or print(a.options))) + # ui.label(f'{argument.name}') + # with ui.row(): + with ui.card(): + with ui.row().classes('w-full'): + name = ui.input(placeholder="argument").classes('w-full')#.on("keydown.enter", ) + with ui.row(): + enabled = ui.switch('enabled', value=True) + positional = ui.checkbox('positional') #inline + boolean = ui.checkbox('boolean') + ui.button("+", on_click=lambda e, name=name, enabled=enabled, positional=positional, boolean=boolean: self.sc.arguments.append(Argument( + name.value, + enabled.value, + positional.value, + boolean.value + ))) + ui.label('Environment variables').classes('text-h6') + with ui.card(): + for k, v in self.sc.env_overrides.items(): + with ui.row(align_items="center"): + ui.button("⊗", on_click=lambda k=k: self.sc.rm_env(k)) + ui.label(f"{k}=\"{v}\"") + + with ui.row(): + name = ui.input(placeholder="name")#.on("keydown.enter", ) + value = ui.input(placeholder="value")#.on("keydown.enter", ) + ui.button("+", on_click=lambda e, name=name, enabled=value: self.sc.add_env(name.value, value.value)) + + diff --git a/gui.py b/gui.py deleted file mode 100644 index 0bd3c99..0000000 --- a/gui.py +++ /dev/null @@ -1,133 +0,0 @@ -import asyncio -import os -import pathlib -import random -from subprocess import Popen -from tempfile import mkstemp -import threading -import time -from nicegui import ui - -numbers = [] -processes = [] - -class SubprocessController(): - def __init__(self, name: str, cmd): - self.cmd = cmd - self.name = name - # self.tmp_config_fp, self.tmp_config_filename = mkstemp(suffix=".yml", prefix=f"config_{self.name}_") - self.env_overrides: dict[str, str] = { - "DISPLAY": ":1" - } - self.proc=None - pass - - def update_config(self): - pass - - def get_environment(self): - default_env = os.environ.copy() - return default_env | self.env_overrides - - def run(self): - self.proc = Popen( - self.cmd, - env=self.get_environment(), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE) - - - def quit(self): - if self.is_running(): - self.proc.terminate() - - - def is_running(self): - return self.proc is not None and self.proc.poll() is None - - -# class SubprocessUi(): -# def __init__(self, controller: SubprocessController): -# self.controller = controller - -# @ui.refreshable_method -# def card(self): -# with ui.card(): -# ui.label(self.controller.name) -# ui.label(str(self.controller.is_running())) - - - -class Foucault(): - def __init__(self): - self.processes: list[SubprocessController] = [] - self.uis: list[SubprocessUI] = [] - - def add_process(self, sc: SubprocessController): - self.processes.append(sc) - self.uis.append(SubprocessUI(sc)) - - def ui(self): - with ui.row(): - for proc_ui in self.uis: - proc_ui.ui() - - def run(self): - - i=0 - while True: - i+=1 - time.sleep(1) - for proc_ui in self.uis: - proc_ui.ui.refresh() - # if i >10: - # print('cancelling') - # sc.quit() - - -conductofconduct = Foucault() - -@ui.refreshable -def number_ui() -> None: - print(conductofconduct.processes) - ui.label("test: " + ','.join([str(process.is_running()) for process in conductofconduct.processes])) - # ui.label(', '.join(str(n) for n in sorted(conductofconduct.processes))) - -def add_number() -> None: - numbers.append(random.randint(0, 100)) - number_ui.refresh() - -def reloader(): - """Test reloading""" - for i in range(200): - print("add") - numbers.append(i) - time.sleep(1) - number_ui.refresh() - -class SubprocessUI: - def __init__(self, sc: SubprocessController): - self.sc = sc - - @ui.refreshable_method - def ui(self): - with ui.card(): - ui.label(f'{self.sc.name}') - ui.label(f'Running: {self.sc.is_running()}') - ui.button('Run', on_click=self.sc.run) - ui.button('Stop', on_click=self.sc.quit) - - -conductofconduct.add_process(SubprocessController("test1" , ["tail", '-f', "gui.py"])) -conductofconduct.add_process(SubprocessController("test2" , ["tail", '-f', "gui.py"])) - -conductofconduct.ui() - -# t = threading.Thread(target=reloader, daemon=True) -t = threading.Thread(target=conductofconduct.run, daemon=True) -t.start() - -# number_ui() -# ui.button('Add random number', on_click=add_number) - -ui.run(show=False) diff --git a/main.py b/main.py new file mode 100644 index 0000000..2e24fb4 --- /dev/null +++ b/main.py @@ -0,0 +1,22 @@ +from pathlib import Path +from foucault.gui import * + + +# print(sc) + +logging.basicConfig(level=logging.INFO) + +conductofconduct = Foucault() +sc = SubprocessController.from_toml(Path('processes/tail.toml')) +sc2 = SubprocessController.from_toml(Path('processes/tail2.toml')) +sc3 = SubprocessController.from_toml(Path('processes/tail.toml')) +conductofconduct.add_process(sc) +conductofconduct.add_process(sc2) +conductofconduct.add_process(sc3) +# conductofconduct.add_process(SubprocessController("test1" , ["tail"], ['-f', "gui.py"])) +# conductofconduct.add_process(SubprocessController("test2" , ["tail"], ['-f', "gui.py"])) +# conductofconduct.add_process(SubprocessController("test3 broken" , ["tail"], ['-f', "nonexistent.py"])) +# conductofconduct.add_process(SubprocessController("system status" , ["uv run status.py"], ['-f', "nonexistent.py"])) + +conductofconduct.run() + diff --git a/processes/tail.toml b/processes/tail.toml new file mode 100644 index 0000000..3bd5609 --- /dev/null +++ b/processes/tail.toml @@ -0,0 +1,29 @@ +cmd = [ + "tail", +] + +[[arguments]] +name = "-f" +enabled = true +inline = false +boolean = true +options = [] +selected = [] + +[[arguments]] +name = "name" +enabled = true +inline = true +boolean = false +options = [ + "gui.py", + "nonexistent.py", + "test", + "meer", +] +selected = [ + "gui.py", +] + +[environment] +":DISPLAY" = "1" diff --git a/pyproject.toml b/pyproject.toml index 2c6f2f7..9d8c178 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,4 +6,5 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "nicegui>=2.12.1", + "tomli-w>=1.2.0", ] diff --git a/uv.lock b/uv.lock index c5cbadd..57d3c96 100644 --- a/uv.lock +++ b/uv.lock @@ -210,10 +210,14 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "nicegui" }, + { name = "tomli-w" }, ] [package.metadata] -requires-dist = [{ name = "nicegui", specifier = ">=2.12.1" }] +requires-dist = [ + { name = "nicegui", specifier = ">=2.12.1" }, + { name = "tomli-w", specifier = ">=1.2.0" }, +] [[package]] name = "h11" @@ -646,6 +650,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/4b/528ccf7a982216885a1ff4908e886b8fb5f19862d1962f56a3fce2435a70/starlette-0.46.1-py3-none-any.whl", hash = "sha256:77c74ed9d2720138b25875133f3a2dae6d854af2ec37dceb56aef370c1d8a227", size = 71995 }, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675 }, +] + [[package]] name = "typing-extensions" version = "4.12.2"