nostromo 0.dev0__tar.gz → 0.dev1__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.
- {nostromo-0.dev0 → nostromo-0.dev1}/PKG-INFO +5 -2
- nostromo-0.dev1/nostromo/app.py +140 -0
- nostromo-0.dev1/nostromo/injector.py +33 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/protocols/path.py +4 -0
- nostromo-0.dev1/nostromo/protocols/pipeline_editor.py +10 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/protocols/pipelines.py +5 -0
- nostromo-0.dev1/nostromo/run.py +60 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/screens/logs.py +1 -1
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/services/local_path.py +9 -1
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/services/pypyr_pipelines.py +3 -3
- nostromo-0.dev1/nostromo/services/redis_pipeline_storage.py +22 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/services/rich_ui_log.py +1 -1
- nostromo-0.dev1/nostromo/tabs/pipeline_editor.py +244 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/tabs/pipelines.py +3 -20
- nostromo-0.dev1/nostromo/tcss.py +2 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/widgets/default_option_list.py +5 -3
- nostromo-0.dev1/nostromo/widgets/horizontal_form.py +73 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/widgets/pipeline_builder.py +5 -5
- {nostromo-0.dev0 → nostromo-0.dev1}/pyproject.toml +10 -2
- nostromo-0.dev0/nostromo/screens/pipeline_builder.py +0 -11
- nostromo-0.dev0/nostromo/tabs/scheduler.py +0 -12
- nostromo-0.dev0/nostromo/widgets/horizontal_form.py +0 -35
- {nostromo-0.dev0 → nostromo-0.dev1}/LICENSE +0 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/README.md +0 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/__init__.py +0 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/models/__init__.py +0 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/models/pipeline.py +0 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/protocols/__init__.py +0 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/protocols/executor.py +0 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/protocols/ui_log.py +0 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/screens/__init__.py +0 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/services/__init__.py +0 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/services/redis_executor.py +0 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/tabs/__init__.py +0 -0
- {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/widgets/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: nostromo
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.dev1
|
|
4
4
|
Summary: pipeline builder, runner, process manager, background jobs, job scheduling
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Danila Ganchar
|
|
@@ -13,14 +13,17 @@ Classifier: Programming Language :: Python :: 3.11
|
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
15
|
Requires-Dist: bigtree (>=0.22.3,<0.23.0)
|
|
16
|
+
Requires-Dist: func-timeout (>=4.3.5,<5.0.0)
|
|
16
17
|
Requires-Dist: inject (==4.1)
|
|
17
18
|
Requires-Dist: psutil (>=6.1.0,<7.0.0)
|
|
18
19
|
Requires-Dist: pypyr (==5.9.1)
|
|
19
20
|
Requires-Dist: pyyaml (>=6.0.1,<7.0.0)
|
|
20
21
|
Requires-Dist: redis (==5.0.1)
|
|
21
|
-
Requires-Dist: saq[
|
|
22
|
+
Requires-Dist: saq[hiredis,web] (>=0.18.3,<0.19.0)
|
|
22
23
|
Requires-Dist: setuptools (>=70.2.0,<71.0.0)
|
|
24
|
+
Requires-Dist: starlette (>=0.41.3,<0.42.0)
|
|
23
25
|
Requires-Dist: textual (==0.85.2)
|
|
26
|
+
Requires-Dist: typer (>=0.13.1,<0.14.0)
|
|
24
27
|
Description-Content-Type: text/markdown
|
|
25
28
|
|
|
26
29
|
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from asyncio import sleep
|
|
2
|
+
from typing import Callable
|
|
3
|
+
|
|
4
|
+
import inject
|
|
5
|
+
from rich.text import TextType
|
|
6
|
+
from textual import work
|
|
7
|
+
from textual.app import App, ComposeResult
|
|
8
|
+
from textual.widget import Widget
|
|
9
|
+
from textual.widgets import Footer, Tab, Tabs
|
|
10
|
+
|
|
11
|
+
from .protocols.ui_log import UILogProtocol
|
|
12
|
+
from .screens.logs import LogsScreen
|
|
13
|
+
from .tabs.pipeline_editor import PipelineEditorContent
|
|
14
|
+
from .tabs.pipelines import PipelienesContent
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _MainTabs(Tabs):
|
|
18
|
+
DEFAULT_CSS = """
|
|
19
|
+
_MainTabs {layer: below; opacity: 0.5 !important}
|
|
20
|
+
_MainTabs .underline--bar {background: orange 50% !important; color: orange !important}
|
|
21
|
+
_MainTabs Tab {opacity: 0.6; color: #4577D4}
|
|
22
|
+
_MainTabs Tab.-active, _MainTabs Tab.-active:hover {opacity: 1; color: orange !important}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, *tabs: Tab | TextType, active: str | None = None, name: str | None = None, id: str | None = None,
|
|
26
|
+
classes: str | None = None, disabled: bool = False, on_tab_callback: Callable):
|
|
27
|
+
super().__init__(*tabs, active=active, name=name, id=id, classes=classes, disabled=disabled)
|
|
28
|
+
self._on_tab_callback = on_tab_callback
|
|
29
|
+
|
|
30
|
+
def watch_active(self, previously_active: str, active: str) -> None:
|
|
31
|
+
super().watch_active(previously_active, active)
|
|
32
|
+
self._on_tab_callback(active)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def default_opacity(self) -> float:
|
|
36
|
+
return 0.5
|
|
37
|
+
|
|
38
|
+
def on_focus(self) -> None:
|
|
39
|
+
# visual loading
|
|
40
|
+
self.__on_focus()
|
|
41
|
+
|
|
42
|
+
@work(exclusive=True)
|
|
43
|
+
async def __on_focus(self):
|
|
44
|
+
for tab in self.query(Tab):
|
|
45
|
+
tab.disabled = False
|
|
46
|
+
|
|
47
|
+
content = self.loaded_content
|
|
48
|
+
self.styles.animate('opacity', value=100, duration=0.2)
|
|
49
|
+
content.styles.animate('opacity', value=0.6, duration=0.2)
|
|
50
|
+
await sleep(0.2)
|
|
51
|
+
self.styles.opacity = 100
|
|
52
|
+
content.styles.opacity = 0.6
|
|
53
|
+
|
|
54
|
+
def on_blur(self) -> None:
|
|
55
|
+
self.__on_blur()
|
|
56
|
+
|
|
57
|
+
@work(exclusive=True)
|
|
58
|
+
async def __on_blur(self):
|
|
59
|
+
# visual loading
|
|
60
|
+
loaded = self.loaded_content
|
|
61
|
+
while loaded.styles.opacity != 0.6:
|
|
62
|
+
await sleep(0.21)
|
|
63
|
+
|
|
64
|
+
self.styles.animate('opacity', value=self.default_opacity, duration=0.1)
|
|
65
|
+
await sleep(0.1)
|
|
66
|
+
self.styles.opacity = self.default_opacity
|
|
67
|
+
loaded.styles.opacity = 100
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def loaded_content(self) -> Widget:
|
|
71
|
+
return self.parent.query_one(TabContent).children[0]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TabContent(Widget):
|
|
75
|
+
DEFAULT_CSS = """
|
|
76
|
+
TabContent {padding: 1 1 1 1; opacity: 60 !important}
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Nostromo(App):
|
|
81
|
+
_log = inject.attr(UILogProtocol)
|
|
82
|
+
_tab_content = TabContent()
|
|
83
|
+
DEFAULT_CSS = """
|
|
84
|
+
Screen {layers: below above; scrollbar-size-vertical: 1 !important; scrollbar-color: #4577D4 20% !important}
|
|
85
|
+
Label {color: #c7c9ca; text-style: bold}
|
|
86
|
+
.-textual-loading-indicator {background: #1e1e1e!important; color: orange}
|
|
87
|
+
.vl {layout: vertical; height: auto}
|
|
88
|
+
.hl {layout: horizontal; height: auto}
|
|
89
|
+
.bg {background: #1e1e1e !important}
|
|
90
|
+
Switch:focus, Switch:hover { border: solid #4577D4 60% !important}
|
|
91
|
+
Switch.-on > .switch--slider { color: #00AF64 !important }
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
SCREENS = {'logs': LogsScreen}
|
|
95
|
+
BINDINGS = [
|
|
96
|
+
('ctrl+l', 'push_screen("logs")', 'Logs'),
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
def compose(self) -> ComposeResult:
|
|
100
|
+
yield _MainTabs(
|
|
101
|
+
Tab(PipelienesContent.get_id(), id=PipelienesContent.get_id(), disabled=False),
|
|
102
|
+
Tab('Pipeline Editor', id=PipelineEditorContent.get_id(), disabled=False),
|
|
103
|
+
on_tab_callback=self.open_tab,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
self._tab_content.styles.layer = 'below'
|
|
107
|
+
self._tab_content.display = 'none' # visual loading
|
|
108
|
+
yield self._tab_content
|
|
109
|
+
yield Footer()
|
|
110
|
+
self.open_tab(PipelienesContent.get_id())
|
|
111
|
+
|
|
112
|
+
@work(exclusive=True)
|
|
113
|
+
async def open_tab(self, tab_id: str):
|
|
114
|
+
# inject + mount waiting
|
|
115
|
+
while not self._tab_content or not self._tab_content.is_mounted:
|
|
116
|
+
await sleep(0.2)
|
|
117
|
+
break
|
|
118
|
+
# mounted
|
|
119
|
+
|
|
120
|
+
await self._tab_content.remove_children()
|
|
121
|
+
self._tab_content.display = 'block' # visual loading
|
|
122
|
+
self._tab_content.set_loading(True)
|
|
123
|
+
|
|
124
|
+
for tab_content in (
|
|
125
|
+
PipelienesContent,
|
|
126
|
+
PipelineEditorContent,
|
|
127
|
+
):
|
|
128
|
+
if tab_content.get_id() != tab_id:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
content = tab_content()
|
|
132
|
+
content.styles.opacity = 0
|
|
133
|
+
await self._tab_content.mount(content)
|
|
134
|
+
await sleep(0.3) # visual loading
|
|
135
|
+
self._tab_content.set_loading(False)
|
|
136
|
+
|
|
137
|
+
content.styles.animate('opacity', value=0.6, duration=0.2)
|
|
138
|
+
await sleep(0.2) # visual loading
|
|
139
|
+
content.styles.opacity = 0.6
|
|
140
|
+
break
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import inject
|
|
4
|
+
from textual.widgets import RichLog
|
|
5
|
+
import redis.asyncio as redis
|
|
6
|
+
|
|
7
|
+
from .protocols.executor import ExecutorProtocol
|
|
8
|
+
from .protocols.path import PathProtocol
|
|
9
|
+
from .protocols.pipelines import PipelinesProtocol, PipelineStorageProtocol
|
|
10
|
+
from .protocols.ui_log import UILogProtocol
|
|
11
|
+
from .services.local_path import LocalPath
|
|
12
|
+
from .services.pypyr_pipelines import PypyrPipelines
|
|
13
|
+
from .services.redis_executor import RedisExecutor
|
|
14
|
+
from .services.redis_pipeline_storage import RedisPipelineStorage
|
|
15
|
+
from .services.rich_ui_log import RichUILogService
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def default_config(binder: inject.Binder):
|
|
19
|
+
pool = redis.ConnectionPool.from_url(os.environ['NOSTROMO_REDIS_URL'])
|
|
20
|
+
client = redis.Redis.from_pool(pool)
|
|
21
|
+
binder.bind_to_constructor(PathProtocol, lambda: LocalPath())
|
|
22
|
+
binder.bind_to_constructor(PipelineStorageProtocol, lambda: RedisPipelineStorage(client))
|
|
23
|
+
binder.bind_to_constructor(ExecutorProtocol, lambda: RedisExecutor(os.environ['NOSTROMO_REDIS_URL']))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def ui_config(binder: inject.Binder):
|
|
27
|
+
binder.install(default_config)
|
|
28
|
+
|
|
29
|
+
pool = redis.ConnectionPool.from_url(os.environ['NOSTROMO_REDIS_URL'])
|
|
30
|
+
client = redis.Redis.from_pool(pool)
|
|
31
|
+
|
|
32
|
+
binder.bind_to_constructor(PipelinesProtocol, lambda: PypyrPipelines(client))
|
|
33
|
+
binder.bind_to_constructor(UILogProtocol, lambda: RichUILogService(RichLog(highlight=True, markup=True)))
|
|
@@ -44,3 +44,8 @@ class PipelinesProtocol(Protocol, metaclass=abc.ABCMeta):
|
|
|
44
44
|
|
|
45
45
|
async def kill_pipeline(self, run: PipelineRun) -> PipelineRun:
|
|
46
46
|
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PipelineStorageProtocol(Protocol, metaclass=abc.ABCMeta):
|
|
50
|
+
async def get_pipelines(self) -> List[Pipeline]:
|
|
51
|
+
pass
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from os import environ, makedirs
|
|
2
|
+
from pathlib import PosixPath
|
|
3
|
+
|
|
4
|
+
import func_timeout
|
|
5
|
+
import inject
|
|
6
|
+
import typer
|
|
7
|
+
from func_timeout import FunctionTimedOut
|
|
8
|
+
from redis import Redis
|
|
9
|
+
|
|
10
|
+
from .app import Nostromo
|
|
11
|
+
from .injector import ui_config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Runner:
|
|
15
|
+
def _check_redis(self):
|
|
16
|
+
if not environ.get('NOSTROMO_REDIS_URL'):
|
|
17
|
+
environ['NOSTROMO_REDIS_URL'] = 'redis://localhost'
|
|
18
|
+
|
|
19
|
+
Redis.from_url(environ['NOSTROMO_REDIS_URL']).ping()
|
|
20
|
+
|
|
21
|
+
def _validate(self):
|
|
22
|
+
if not environ.get('NOSTROMO_HOME'):
|
|
23
|
+
environ['NOSTROMO_HOME'] = '~/.nostromo'
|
|
24
|
+
|
|
25
|
+
full_path = PosixPath(environ['NOSTROMO_HOME']).expanduser()
|
|
26
|
+
makedirs(full_path, exist_ok=True)
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
func_timeout.func_timeout(2, self._check_redis, )
|
|
30
|
+
except FunctionTimedOut:
|
|
31
|
+
print(f'Redis connection error. Check connection or modify NOSTROMO_REDIS_URL variable')
|
|
32
|
+
exit(1)
|
|
33
|
+
|
|
34
|
+
def run_ui(self):
|
|
35
|
+
self._validate()
|
|
36
|
+
inject.configure(ui_config)
|
|
37
|
+
Nostromo().run(mouse=False)
|
|
38
|
+
|
|
39
|
+
def run_scheduler(self):
|
|
40
|
+
self._validate()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
app = typer.Typer(
|
|
44
|
+
help='Default env variables: NOSTROMO_HOME=~/.nostromo, NOSTROMO_REDIS_URL=redis://localhost',
|
|
45
|
+
add_completion=False,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.command(help='open terminal')
|
|
50
|
+
def ui():
|
|
51
|
+
Runner().run_ui()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@app.command(help='run scheduler')
|
|
55
|
+
def scheduler():
|
|
56
|
+
Runner().run_scheduler()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
app()
|
|
@@ -2,7 +2,7 @@ import abc
|
|
|
2
2
|
import os
|
|
3
3
|
from pathlib import Path, PosixPath
|
|
4
4
|
|
|
5
|
-
from
|
|
5
|
+
from ..protocols.path import PathProtocol
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
class LocalPath(PathProtocol, metaclass=abc.ABCMeta):
|
|
@@ -53,3 +53,11 @@ class LocalPath(PathProtocol, metaclass=abc.ABCMeta):
|
|
|
53
53
|
@property
|
|
54
54
|
def scheduler_logs(self) -> Path:
|
|
55
55
|
return self._scheduler_logs
|
|
56
|
+
|
|
57
|
+
def ensure_log_dirs(self):
|
|
58
|
+
for dir_path in (
|
|
59
|
+
self.logs,
|
|
60
|
+
self.pipelines_logs,
|
|
61
|
+
self.scheduler_logs,
|
|
62
|
+
):
|
|
63
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
@@ -2,13 +2,12 @@ import codecs
|
|
|
2
2
|
import json
|
|
3
3
|
import os
|
|
4
4
|
import subprocess
|
|
5
|
-
from datetime import datetime, UTC
|
|
5
|
+
from datetime import datetime, UTC
|
|
6
6
|
from typing import List, Dict
|
|
7
7
|
|
|
8
8
|
import inject
|
|
9
9
|
import psutil
|
|
10
10
|
import yaml
|
|
11
|
-
from dotenv import dotenv_values
|
|
12
11
|
from psutil import NoSuchProcess
|
|
13
12
|
from redis.asyncio import Redis
|
|
14
13
|
|
|
@@ -73,7 +72,7 @@ class PypyrPipelines(PipelinesProtocol):
|
|
|
73
72
|
return sorted(result)
|
|
74
73
|
|
|
75
74
|
def get_nostromo_env(self) -> Dict[str, str]:
|
|
76
|
-
config =
|
|
75
|
+
config = {}
|
|
77
76
|
config['NOSTOMO_HOME'] = os.environ.get('NOSTOMO_HOME')
|
|
78
77
|
return dict(sorted(config.items(), key=lambda x: x[0]))
|
|
79
78
|
|
|
@@ -86,6 +85,7 @@ class PypyrPipelines(PipelinesProtocol):
|
|
|
86
85
|
return Pipeline(**params)
|
|
87
86
|
|
|
88
87
|
def get_pipelines(self) -> List[Pipeline]:
|
|
88
|
+
return []
|
|
89
89
|
return [
|
|
90
90
|
self._load_pipeline_from_yml(p)
|
|
91
91
|
for p in os.listdir(self._paths.pipelines)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from asyncio import sleep
|
|
2
|
+
from typing import List
|
|
3
|
+
from redis.asyncio import Redis
|
|
4
|
+
from ..models.pipeline import Pipeline
|
|
5
|
+
from ..protocols.pipelines import PipelineStorageProtocol
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RedisPipelineStorage(PipelineStorageProtocol):
|
|
9
|
+
def __init__(self, client: Redis):
|
|
10
|
+
self._redis = client
|
|
11
|
+
self._lock = self._redis.lock('/test', timeout=21, blocking_timeout=4)
|
|
12
|
+
|
|
13
|
+
@property
|
|
14
|
+
def pipelines_redis_key(self) -> str:
|
|
15
|
+
return '/nostromo/pipelines/'
|
|
16
|
+
|
|
17
|
+
def _get_redis_key_by_name(self, name: str) -> str:
|
|
18
|
+
return f'{self.pipelines_redis_key}{name}'
|
|
19
|
+
|
|
20
|
+
async def get_pipelines(self) -> List[Pipeline]:
|
|
21
|
+
data = await self._redis.keys(self.pipelines_redis_key + '*')
|
|
22
|
+
return data
|
|
@@ -3,7 +3,7 @@ from datetime import datetime, UTC
|
|
|
3
3
|
from textual.notifications import SeverityLevel
|
|
4
4
|
from textual.widgets import RichLog
|
|
5
5
|
|
|
6
|
-
from
|
|
6
|
+
from ..protocols.ui_log import UILogProtocol
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class RichUILogService(UILogProtocol):
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
from asyncio import sleep
|
|
2
|
+
|
|
3
|
+
import inject
|
|
4
|
+
from textual import work
|
|
5
|
+
from textual.app import ComposeResult
|
|
6
|
+
from textual.containers import Container
|
|
7
|
+
from textual.validation import Regex, Length
|
|
8
|
+
from textual.widget import Widget
|
|
9
|
+
from textual.widgets import Input, Button, Switch
|
|
10
|
+
from textual.widgets import Tree
|
|
11
|
+
from textual.widgets._tree import TreeNode, TreeDataType
|
|
12
|
+
|
|
13
|
+
from ..protocols.pipelines import PipelinesProtocol
|
|
14
|
+
from ..tcss import Class
|
|
15
|
+
from ..widgets.default_option_list import DefaultOptionList
|
|
16
|
+
from ..widgets.horizontal_form import HorizontalForm
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _EntrypointOptions(DefaultOptionList):
|
|
20
|
+
DEFAULT_CSS = """
|
|
21
|
+
_EntrypointOptions {layer: above}
|
|
22
|
+
"""
|
|
23
|
+
_pipelines = inject.attr(PipelinesProtocol)
|
|
24
|
+
|
|
25
|
+
def _reset_scripts_options(self):
|
|
26
|
+
script_options: DefaultOptionList = self.parent.parent.query_one('#ScriptOptions')
|
|
27
|
+
script_options.clear_options()
|
|
28
|
+
options = self._pipelines.get_scripts_by_entrypoint(self.value)
|
|
29
|
+
script_options.reset_options(options)
|
|
30
|
+
|
|
31
|
+
def action_select(self) -> None:
|
|
32
|
+
super().action_select()
|
|
33
|
+
self._reset_scripts_options()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _CreateButton(Button):
|
|
37
|
+
DEFAULT_CSS = """
|
|
38
|
+
_CreateButton {border: round #00AF64 !important; opacity: 0.3 !important;}
|
|
39
|
+
_CreateButton:focus { background: #1e1e1e !important; color: #00AF64 !important; }
|
|
40
|
+
_CreateButton.-success {color: #00AF64 !important;}
|
|
41
|
+
_CreateButton.-active {
|
|
42
|
+
background: #00AF64 20% !important;
|
|
43
|
+
border: round #00AF64 !important;
|
|
44
|
+
}
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, pipeline_editor_form: '_PipelineEditorForm'):
|
|
48
|
+
self._pipeline_editor_form = pipeline_editor_form
|
|
49
|
+
super().__init__('Create', 'success', name=None, id=None, classes=Class.BG, disabled=True, tooltip=None,
|
|
50
|
+
action=None)
|
|
51
|
+
|
|
52
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
53
|
+
if str(event.button.label) == 'Create':
|
|
54
|
+
self._pipeline_editor_form.create_tree_item()
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
self._pipeline_editor_form.update_tree_item()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _PipelineEditorForm(HorizontalForm):
|
|
61
|
+
def __init__(self, content: 'PipelineEditorContent'):
|
|
62
|
+
self.content = content
|
|
63
|
+
self.group = Switch(value=True, animate=False, name='Is group', id='type', disabled=True)
|
|
64
|
+
self.name_input = Input(
|
|
65
|
+
name='Name',
|
|
66
|
+
placeholder='example: s3.files_upload',
|
|
67
|
+
validators=[
|
|
68
|
+
Regex('^[a-z0-9_.]*$',
|
|
69
|
+
failure_description='Group does not match regular expression ^\[a-z0-9_.]*$'),
|
|
70
|
+
Length(3, 25),
|
|
71
|
+
],
|
|
72
|
+
id='name',
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
self.group_type = DefaultOptionList(*['parallel', 'while', 'foreach'], id='group_type', name='Type')
|
|
76
|
+
self.create_btn = _CreateButton(self)
|
|
77
|
+
|
|
78
|
+
super().__init__(*[
|
|
79
|
+
self.group,
|
|
80
|
+
self.name_input,
|
|
81
|
+
self.group_type,
|
|
82
|
+
self.create_btn,
|
|
83
|
+
], name=None, id=None, classes=None, disabled=False)
|
|
84
|
+
|
|
85
|
+
def set_node_name(self, name: str):
|
|
86
|
+
self.name_input.value = name
|
|
87
|
+
|
|
88
|
+
def _on_valid_form(self):
|
|
89
|
+
self.create_btn.disabled = False
|
|
90
|
+
self.create_btn.styles.opacity = 100
|
|
91
|
+
|
|
92
|
+
def create_tree_item(self):
|
|
93
|
+
if self.group.value:
|
|
94
|
+
self.content.create_group(self._form_values['name'])
|
|
95
|
+
else:
|
|
96
|
+
self.content.create_task(self._form_values['name'])
|
|
97
|
+
|
|
98
|
+
self.clear_inputs()
|
|
99
|
+
self.name_input.focus()
|
|
100
|
+
self.group.value = False
|
|
101
|
+
|
|
102
|
+
def update_tree_item(self):
|
|
103
|
+
self.content.tree.update_tree_item(self.name_input.value)
|
|
104
|
+
self.content.tree.focus()
|
|
105
|
+
|
|
106
|
+
def set_create_mode(self):
|
|
107
|
+
self.name_input.value = ''
|
|
108
|
+
self.create_btn.label = 'Create'
|
|
109
|
+
self.group.disabled = False
|
|
110
|
+
self.group.value = False
|
|
111
|
+
self.group.styles.opacity = 100
|
|
112
|
+
self.styles.opacity = 100
|
|
113
|
+
self.name_input.focus()
|
|
114
|
+
|
|
115
|
+
def set_update_mode(self, node: TreeNode):
|
|
116
|
+
self.name_input.value = node.label.plain
|
|
117
|
+
self.create_btn.label = 'Update'
|
|
118
|
+
|
|
119
|
+
if node.allow_expand and node.expand:
|
|
120
|
+
self.group.value = True
|
|
121
|
+
else:
|
|
122
|
+
self.group.value = False
|
|
123
|
+
|
|
124
|
+
self.group.disabled = True
|
|
125
|
+
self.group.styles.opacity = 0.4
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class _PipelineTree(Tree):
|
|
129
|
+
BINDINGS = [
|
|
130
|
+
('ctrl+a', 'append_node()', 'Append'),
|
|
131
|
+
('ctrl+d', 'delete_node()', 'Delete'),
|
|
132
|
+
]
|
|
133
|
+
DEFAULT_CSS = """
|
|
134
|
+
_PipelineTree {
|
|
135
|
+
color: #c7c9ca !important;
|
|
136
|
+
padding: 1 0 0 2;
|
|
137
|
+
border-left: #1047A9 60% !important;
|
|
138
|
+
border-top: #1047A9 60% !important;
|
|
139
|
+
opacity: 0.8;
|
|
140
|
+
}
|
|
141
|
+
_PipelineTree .tree--highlight-line {background: #1e1e1e !important; color: orange !important;}
|
|
142
|
+
_PipelineTree .tree--cursor {background: #1e1e1e !important; color: orange !important;}
|
|
143
|
+
_PipelineTree .tree--guides {color: #1047A9}
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
content: 'PipelineEditorContent',
|
|
149
|
+
data: TreeDataType | None = None,
|
|
150
|
+
*,
|
|
151
|
+
label: str | None = None
|
|
152
|
+
) -> None:
|
|
153
|
+
super().__init__(label=label, data=data, name='', id=None, classes=Class.BG, disabled=False)
|
|
154
|
+
self.content = content
|
|
155
|
+
|
|
156
|
+
def update_tree_item(self, name: str):
|
|
157
|
+
self.cursor_node.label = name
|
|
158
|
+
|
|
159
|
+
def on_focus(self):
|
|
160
|
+
self.styles.opacity = 100
|
|
161
|
+
self.content.form.styles.opacity = 0.6
|
|
162
|
+
self.content.form.set_update_mode(self.cursor_node)
|
|
163
|
+
|
|
164
|
+
def on_blur(self) -> None:
|
|
165
|
+
self.styles.opacity = 0.7
|
|
166
|
+
self.content.form.styles.opacity = 100
|
|
167
|
+
|
|
168
|
+
def action_cursor_up(self) -> None:
|
|
169
|
+
super().action_cursor_up()
|
|
170
|
+
self.content.form.set_update_mode(self.cursor_node)
|
|
171
|
+
|
|
172
|
+
def action_cursor_down(self) -> None:
|
|
173
|
+
super().action_cursor_down()
|
|
174
|
+
self.content.form.set_update_mode(self.cursor_node)
|
|
175
|
+
|
|
176
|
+
def action_append_node(self):
|
|
177
|
+
if self.cursor_node.allow_expand and self.cursor_node.expand:
|
|
178
|
+
self.styles.opacity = 0.7
|
|
179
|
+
self.content.form.set_create_mode()
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
self.notify(f'Active element - task "{self.cursor_node.label}". You can only add elements to groups',
|
|
183
|
+
title='Task error',
|
|
184
|
+
severity='error')
|
|
185
|
+
|
|
186
|
+
def action_delete_node(self):
|
|
187
|
+
self.cursor_node.remove()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class PipelineEditorContent(Widget):
|
|
191
|
+
tree: _PipelineTree or None = None
|
|
192
|
+
DEFAULT_CSS = """
|
|
193
|
+
_PipelineEditorForm {width: 0.4fr !important; margin: 0 0 0 5}
|
|
194
|
+
Tree {display: none}
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def get_id(cls):
|
|
199
|
+
return 'PipelineEditor'
|
|
200
|
+
|
|
201
|
+
def compose(self) -> ComposeResult:
|
|
202
|
+
yield Container(
|
|
203
|
+
_PipelineEditorForm(self),
|
|
204
|
+
Container(id='tree'),
|
|
205
|
+
classes='hl',
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def create_btn(self) -> _CreateButton:
|
|
210
|
+
return self.query_one(_CreateButton)
|
|
211
|
+
|
|
212
|
+
def deactivate_create_btn(self):
|
|
213
|
+
btn = self.create_btn
|
|
214
|
+
btn.disabled = True
|
|
215
|
+
btn.styles.opacity = 0.3
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def form(self) -> _PipelineEditorForm:
|
|
219
|
+
return self.query_one(_PipelineEditorForm)
|
|
220
|
+
|
|
221
|
+
def create_task(self, name: str):
|
|
222
|
+
self.tree.cursor_node.add_leaf(name)
|
|
223
|
+
self.deactivate_create_btn()
|
|
224
|
+
|
|
225
|
+
def create_group(self, name: str):
|
|
226
|
+
if self.tree:
|
|
227
|
+
self.tree.cursor_node.add(name, expand=True)
|
|
228
|
+
else:
|
|
229
|
+
self.tree = _PipelineTree(content=self, label=name)
|
|
230
|
+
self.load_tree(self.tree)
|
|
231
|
+
|
|
232
|
+
self.deactivate_create_btn()
|
|
233
|
+
|
|
234
|
+
@work(exclusive=True)
|
|
235
|
+
async def load_tree(self, tree: Tree):
|
|
236
|
+
container = self.query_one('#tree')
|
|
237
|
+
container.set_loading(True)
|
|
238
|
+
await sleep(0.4)
|
|
239
|
+
await container.mount(tree)
|
|
240
|
+
|
|
241
|
+
tree.root.expand()
|
|
242
|
+
container.set_loading(False)
|
|
243
|
+
tree.styles.display = 'block'
|
|
244
|
+
self.query_one(Switch).disabled = False
|
|
@@ -2,13 +2,13 @@ from asyncio import sleep
|
|
|
2
2
|
|
|
3
3
|
import inject
|
|
4
4
|
from rich.text import Text
|
|
5
|
-
from textual import work
|
|
5
|
+
from textual import work
|
|
6
6
|
from textual.app import ComposeResult
|
|
7
7
|
from textual.widget import Widget
|
|
8
8
|
from textual.widgets import DataTable
|
|
9
9
|
|
|
10
|
-
from
|
|
11
|
-
from
|
|
10
|
+
from ..protocols.pipelines import PipelinesProtocol
|
|
11
|
+
from ..protocols.ui_log import UILogProtocol
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class _PipelinesTable(DataTable):
|
|
@@ -53,23 +53,6 @@ class _PipelinesTable(DataTable):
|
|
|
53
53
|
await self._pipelines.kill_pipeline(last_run)
|
|
54
54
|
self._log.warning(pipeline_name, 'Pipeline killed')
|
|
55
55
|
|
|
56
|
-
@property
|
|
57
|
-
def default_opacity(self) -> float:
|
|
58
|
-
return 0.7
|
|
59
|
-
|
|
60
|
-
def on_mount(self):
|
|
61
|
-
self.styles.opacity = self.default_opacity
|
|
62
|
-
if not self.rows:
|
|
63
|
-
return
|
|
64
|
-
|
|
65
|
-
self.track_pipelines()
|
|
66
|
-
|
|
67
|
-
def _on_focus(self, event: events.Focus) -> None:
|
|
68
|
-
self.styles.opacity = 1
|
|
69
|
-
|
|
70
|
-
def _on_blur(self, event: events.Blur) -> None:
|
|
71
|
-
self.styles.opacity = self.default_opacity
|
|
72
|
-
|
|
73
56
|
@work(exclusive=True)
|
|
74
57
|
async def track_pipelines(self):
|
|
75
58
|
while True:
|
|
@@ -11,10 +11,12 @@ from textual.widgets import OptionList
|
|
|
11
11
|
|
|
12
12
|
class DefaultOptionList(OptionList):
|
|
13
13
|
DEFAULT_CSS = """
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
14
|
+
.option-list--option {layer: above}
|
|
15
|
+
DefaultOptionList {width: 52 !important}
|
|
16
|
+
DefaultOptionList:focus {border: ascii #4577D4 60% !important}
|
|
17
|
+
DefaultOptionList:focus > .option-list--option-highlighted {background: #4577D4 60%}
|
|
17
18
|
"""
|
|
19
|
+
|
|
18
20
|
_options_copy = []
|
|
19
21
|
_input_buffer = reactive('')
|
|
20
22
|
_last_input_dt = datetime.now(UTC)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from textual import on
|
|
2
|
+
from textual.app import ComposeResult
|
|
3
|
+
from textual.containers import Container
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
from textual.widgets import Label, Input, Button
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class HorizontalForm(Widget):
|
|
9
|
+
DEFAULT_CSS = """
|
|
10
|
+
.error {color: #FF6440 !important}
|
|
11
|
+
.hl Label {padding: 1 0 0 0}
|
|
12
|
+
|
|
13
|
+
.-invalid {border: solid #FF6440 60% !important; color: #FF6440 !important}
|
|
14
|
+
.-invalid:focus {border: solid #FF6440 60% !important; color: #FF6440 !important}
|
|
15
|
+
|
|
16
|
+
Input {border: solid #4577D4 0% !important; margin-left: 1 !important; max-width: 50 !important}
|
|
17
|
+
Input:focus {border: solid #4577D4 60% !important; color: #4577D4}
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, *form_items: Widget, name: str | None = None, id: str | None = None, classes: str | None = None,
|
|
21
|
+
disabled: bool = False) -> None:
|
|
22
|
+
super().__init__(*[], name=name, id=id, classes=classes, disabled=disabled)
|
|
23
|
+
self._form_items = form_items
|
|
24
|
+
self._errors_by_name = {}
|
|
25
|
+
self._form_values = {}
|
|
26
|
+
|
|
27
|
+
def compose(self) -> ComposeResult:
|
|
28
|
+
max_label_width = max([len(w.name) for w in self._form_items if not isinstance(w, Button)]) + 1
|
|
29
|
+
|
|
30
|
+
fields = []
|
|
31
|
+
for field in self._form_items:
|
|
32
|
+
if isinstance(field, Button):
|
|
33
|
+
field.styles.margin = [0, 0, 0, 15]
|
|
34
|
+
fields.append(field)
|
|
35
|
+
continue
|
|
36
|
+
|
|
37
|
+
label = Label(field.name)
|
|
38
|
+
label.styles.width = max_label_width
|
|
39
|
+
fields.append(Container(
|
|
40
|
+
label,
|
|
41
|
+
field,
|
|
42
|
+
classes='hl',
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
error_label = Label(id=f'{field.id}-error')
|
|
46
|
+
error_label.styles.margin = [0, 0, 0, max_label_width + 1]
|
|
47
|
+
fields.append(error_label)
|
|
48
|
+
|
|
49
|
+
yield Container(*fields, classes='vl')
|
|
50
|
+
|
|
51
|
+
def clear_inputs(self):
|
|
52
|
+
for field in self._form_items:
|
|
53
|
+
if isinstance(field, Input):
|
|
54
|
+
field.value = ''
|
|
55
|
+
|
|
56
|
+
@on(Input.Changed)
|
|
57
|
+
def show_invalid_reasons(self, event: Input.Changed) -> None:
|
|
58
|
+
input_id = event.input.id
|
|
59
|
+
self._form_values[input_id] = event.input.value
|
|
60
|
+
self._errors_by_name.setdefault(input_id, [])
|
|
61
|
+
self._errors_by_name[input_id] = event.validation_result.failure_descriptions
|
|
62
|
+
|
|
63
|
+
for field, errors in self._errors_by_name.items():
|
|
64
|
+
error = self.query_one(f'#{field}-error')
|
|
65
|
+
error.remove_children()
|
|
66
|
+
for msg in errors:
|
|
67
|
+
error.mount(Label(msg, classes='error'))
|
|
68
|
+
|
|
69
|
+
if not all(self._errors_by_name.values()):
|
|
70
|
+
self._on_valid_form()
|
|
71
|
+
|
|
72
|
+
def _on_valid_form(self):
|
|
73
|
+
pass
|
|
@@ -4,11 +4,11 @@ from textual.validation import Regex
|
|
|
4
4
|
from textual.widget import Widget
|
|
5
5
|
from textual.widgets import Input, Button, Footer, Pretty
|
|
6
6
|
|
|
7
|
-
from
|
|
8
|
-
from
|
|
9
|
-
from
|
|
10
|
-
from
|
|
11
|
-
from
|
|
7
|
+
from .protocols.path import PathProtocol
|
|
8
|
+
from .protocols.pipelines import PipelinesProtocol
|
|
9
|
+
from .protocols.ui_log import UILogProtocol
|
|
10
|
+
from .widgets.default_option_list import DefaultOptionList
|
|
11
|
+
from .widgets.horizontal_form import HorizontalForm
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
class _EntrypointOptions(DefaultOptionList):
|
|
@@ -3,9 +3,14 @@ requires = ["poetry-core==1.9.1"]
|
|
|
3
3
|
build-backend = "poetry.core.masonry.api"
|
|
4
4
|
|
|
5
5
|
|
|
6
|
+
[tool.poetry.scripts]
|
|
7
|
+
nostromo = 'nostromo.run:app'
|
|
8
|
+
|
|
9
|
+
|
|
6
10
|
[tool.poetry]
|
|
11
|
+
package-mode = true
|
|
7
12
|
name = "nostromo"
|
|
8
|
-
version = "0.dev.
|
|
13
|
+
version = "0.dev.1"
|
|
9
14
|
description = "pipeline builder, runner, process manager, background jobs, job scheduling"
|
|
10
15
|
authors = ["Danila Ganchar"]
|
|
11
16
|
license = "MIT"
|
|
@@ -22,4 +27,7 @@ pypyr = "5.9.1"
|
|
|
22
27
|
bigtree = "^0.22.3"
|
|
23
28
|
psutil = "^6.1.0"
|
|
24
29
|
redis = "5.0.1"
|
|
25
|
-
saq = {extras = ["
|
|
30
|
+
saq = {extras = ["hiredis", "web"], version = "^0.18.3"}
|
|
31
|
+
func-timeout = "^4.3.5"
|
|
32
|
+
typer = "^0.13.1"
|
|
33
|
+
starlette = "^0.41.3"
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
from textual.app import ComposeResult
|
|
2
|
-
from textual.screen import Screen
|
|
3
|
-
|
|
4
|
-
from nostromo.widgets.pipeline_builder import PipelineBuilderWidget
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class PipelineBuilderScreen(Screen):
|
|
8
|
-
BINDINGS = [('ctrl+n', 'app.pop_screen', 'Close Pipeline Builder')]
|
|
9
|
-
|
|
10
|
-
def compose(self) -> ComposeResult:
|
|
11
|
-
yield PipelineBuilderWidget()
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
from textual.app import ComposeResult
|
|
2
|
-
from textual.widget import Widget
|
|
3
|
-
from textual.widgets import Label
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
class SchedulerContent(Widget):
|
|
7
|
-
@classmethod
|
|
8
|
-
def get_id(cls):
|
|
9
|
-
return 'Scheduler'
|
|
10
|
-
|
|
11
|
-
def compose(self) -> ComposeResult:
|
|
12
|
-
yield Label('Scheduler')
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
from textual.app import ComposeResult
|
|
2
|
-
from textual.containers import Horizontal, Vertical
|
|
3
|
-
from textual.widget import Widget
|
|
4
|
-
from textual.widgets import Label, OptionList
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class HorizontalForm(Horizontal):
|
|
8
|
-
DEFAULT_CSS = """
|
|
9
|
-
HorizontalForm Label {
|
|
10
|
-
padding: 1;
|
|
11
|
-
text-align: right;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
DefaultOptionList {
|
|
15
|
-
min-height: 3;
|
|
16
|
-
}
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
def __init__(self, *form_items: Widget, name: str | None = None, id: str | None = None, classes: str | None = None,
|
|
20
|
-
disabled: bool = False) -> None:
|
|
21
|
-
super().__init__(*[], name=name, id=id, classes=classes, disabled=disabled)
|
|
22
|
-
self._form_items = form_items
|
|
23
|
-
|
|
24
|
-
def compose(self) -> ComposeResult:
|
|
25
|
-
labels = Vertical(*[Label(w.name) for w in self._form_items])
|
|
26
|
-
labels.styles.max_width = max(*[len(w.name) for w in self._form_items])
|
|
27
|
-
yield labels
|
|
28
|
-
|
|
29
|
-
items = []
|
|
30
|
-
for item in self._form_items:
|
|
31
|
-
if isinstance(item, OptionList):
|
|
32
|
-
items.append(Label(item.name))
|
|
33
|
-
items.append(item)
|
|
34
|
-
|
|
35
|
-
yield Vertical(*items)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|