Set configuration options for processes and save to toml
This commit is contained in:
parent
9ed078505a
commit
78d3b40c03
9 changed files with 397 additions and 134 deletions
0
foucault/__init__.py
Normal file
0
foucault/__init__.py
Normal file
BIN
foucault/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
foucault/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
foucault/__pycache__/gui.cpython-313.pyc
Normal file
BIN
foucault/__pycache__/gui.cpython-313.pyc
Normal file
Binary file not shown.
331
foucault/gui.py
Normal file
331
foucault/gui.py
Normal file
|
@ -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))
|
||||
|
||||
|
133
gui.py
133
gui.py
|
@ -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)
|
22
main.py
Normal file
22
main.py
Normal file
|
@ -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()
|
||||
|
29
processes/tail.toml
Normal file
29
processes/tail.toml
Normal file
|
@ -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"
|
|
@ -6,4 +6,5 @@ readme = "README.md"
|
|||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"nicegui>=2.12.1",
|
||||
"tomli-w>=1.2.0",
|
||||
]
|
||||
|
|
15
uv.lock
15
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"
|
||||
|
|
Loading…
Reference in a new issue