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.
Files changed (35) hide show
  1. {nostromo-0.dev0 → nostromo-0.dev1}/PKG-INFO +5 -2
  2. nostromo-0.dev1/nostromo/app.py +140 -0
  3. nostromo-0.dev1/nostromo/injector.py +33 -0
  4. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/protocols/path.py +4 -0
  5. nostromo-0.dev1/nostromo/protocols/pipeline_editor.py +10 -0
  6. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/protocols/pipelines.py +5 -0
  7. nostromo-0.dev1/nostromo/run.py +60 -0
  8. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/screens/logs.py +1 -1
  9. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/services/local_path.py +9 -1
  10. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/services/pypyr_pipelines.py +3 -3
  11. nostromo-0.dev1/nostromo/services/redis_pipeline_storage.py +22 -0
  12. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/services/rich_ui_log.py +1 -1
  13. nostromo-0.dev1/nostromo/tabs/pipeline_editor.py +244 -0
  14. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/tabs/pipelines.py +3 -20
  15. nostromo-0.dev1/nostromo/tcss.py +2 -0
  16. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/widgets/default_option_list.py +5 -3
  17. nostromo-0.dev1/nostromo/widgets/horizontal_form.py +73 -0
  18. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/widgets/pipeline_builder.py +5 -5
  19. {nostromo-0.dev0 → nostromo-0.dev1}/pyproject.toml +10 -2
  20. nostromo-0.dev0/nostromo/screens/pipeline_builder.py +0 -11
  21. nostromo-0.dev0/nostromo/tabs/scheduler.py +0 -12
  22. nostromo-0.dev0/nostromo/widgets/horizontal_form.py +0 -35
  23. {nostromo-0.dev0 → nostromo-0.dev1}/LICENSE +0 -0
  24. {nostromo-0.dev0 → nostromo-0.dev1}/README.md +0 -0
  25. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/__init__.py +0 -0
  26. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/models/__init__.py +0 -0
  27. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/models/pipeline.py +0 -0
  28. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/protocols/__init__.py +0 -0
  29. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/protocols/executor.py +0 -0
  30. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/protocols/ui_log.py +0 -0
  31. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/screens/__init__.py +0 -0
  32. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/services/__init__.py +0 -0
  33. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/services/redis_executor.py +0 -0
  34. {nostromo-0.dev0 → nostromo-0.dev1}/nostromo/tabs/__init__.py +0 -0
  35. {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.dev0
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[redis] (>=0.18.3,<0.19.0)
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)))
@@ -43,3 +43,7 @@ class PathProtocol(Protocol, metaclass=abc.ABCMeta):
43
43
  @abc.abstractmethod
44
44
  def scheduler_logs(self) -> Path:
45
45
  pass
46
+
47
+ @abc.abstractmethod
48
+ def ensure_log_dirs(self):
49
+ pass
@@ -0,0 +1,10 @@
1
+ import abc
2
+ from typing import Protocol
3
+
4
+ from ..models.pipeline import Pipeline
5
+
6
+
7
+ class PipelineEditorProtocol(Protocol, metaclass=abc.ABCMeta):
8
+ @abc.abstractmethod
9
+ def create_pipeline(self, pipeline: str) -> Pipeline:
10
+ pass
@@ -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()
@@ -3,7 +3,7 @@ from textual.app import ComposeResult
3
3
  from textual.screen import Screen
4
4
  from textual.widgets import Footer
5
5
 
6
- from nostromo.protocols.ui_log import UILogProtocol
6
+ from ..protocols.ui_log import UILogProtocol
7
7
 
8
8
 
9
9
  class LogsScreen(Screen):
@@ -2,7 +2,7 @@ import abc
2
2
  import os
3
3
  from pathlib import Path, PosixPath
4
4
 
5
- from nostromo.protocols.path import PathProtocol
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, timezone
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 = dotenv_values(str(self._paths.env_file))
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 nostromo.protocols.ui_log import UILogProtocol
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, events
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 nostromo.protocols.pipelines import PipelinesProtocol
11
- from nostromo.protocols.ui_log import UILogProtocol
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:
@@ -0,0 +1,2 @@
1
+ class Class:
2
+ BG = 'bg'
@@ -11,10 +11,12 @@ from textual.widgets import OptionList
11
11
 
12
12
  class DefaultOptionList(OptionList):
13
13
  DEFAULT_CSS = """
14
- DefaultOptionList {
15
- layer: above;
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 nostromo.protocols.path import PathProtocol
8
- from nostromo.protocols.pipelines import PipelinesProtocol
9
- from nostromo.protocols.ui_log import UILogProtocol
10
- from nostromo.widgets.default_option_list import DefaultOptionList
11
- from nostromo.widgets.horizontal_form import HorizontalForm
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.0"
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 = ["redis"], version = "^0.18.3"}
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