midiscripter 0.2__py3-none-any.whl

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 (49) hide show
  1. midiscripter/__init__.py +9 -0
  2. midiscripter/base/__init__.py +0 -0
  3. midiscripter/base/msg_base.py +95 -0
  4. midiscripter/base/port_base.py +251 -0
  5. midiscripter/base/shared.py +62 -0
  6. midiscripter/cli/__init__.py +1 -0
  7. midiscripter/cli/starters.py +41 -0
  8. midiscripter/file_event/__init__.py +2 -0
  9. midiscripter/file_event/file_event_msg.py +49 -0
  10. midiscripter/file_event/file_event_port.py +76 -0
  11. midiscripter/gui/__init__.py +11 -0
  12. midiscripter/gui/app.py +111 -0
  13. midiscripter/gui/gui_widgets/__init__.py +0 -0
  14. midiscripter/gui/gui_widgets/button.py +177 -0
  15. midiscripter/gui/gui_widgets/gui_msg.py +55 -0
  16. midiscripter/gui/gui_widgets/gui_widget_base.py +169 -0
  17. midiscripter/gui/gui_widgets/layout.py +63 -0
  18. midiscripter/gui/gui_widgets/list.py +67 -0
  19. midiscripter/gui/gui_widgets/mixins.py +114 -0
  20. midiscripter/gui/gui_widgets/text.py +52 -0
  21. midiscripter/gui/log_widget.py +156 -0
  22. midiscripter/gui/main_window.py +125 -0
  23. midiscripter/gui/menu_bar.py +206 -0
  24. midiscripter/gui/ports_widget.py +309 -0
  25. midiscripter/keyboard/__init__.py +4 -0
  26. midiscripter/keyboard/keyboard_msg.py +117 -0
  27. midiscripter/keyboard/keyboard_port.py +103 -0
  28. midiscripter/logger/__init__.py +3 -0
  29. midiscripter/logger/console_sink.py +55 -0
  30. midiscripter/logger/html_sink.py +61 -0
  31. midiscripter/logger/log.py +128 -0
  32. midiscripter/metronome/__init__.py +1 -0
  33. midiscripter/metronome/metronome_port.py +72 -0
  34. midiscripter/midi/__init__.py +4 -0
  35. midiscripter/midi/midi_msg.py +182 -0
  36. midiscripter/midi/midi_note_data.py +104 -0
  37. midiscripter/midi/midi_port.py +183 -0
  38. midiscripter/midi/midi_ports_update.py +56 -0
  39. midiscripter/osc/__init__.py +3 -0
  40. midiscripter/osc/osc_msg.py +42 -0
  41. midiscripter/osc/osc_port.py +95 -0
  42. midiscripter/osc/osc_query_maker.py +53 -0
  43. midiscripter/resources/icon.ico +0 -0
  44. midiscripter/resources/icon.svg +19 -0
  45. midiscripter-0.2.dist-info/METADATA +262 -0
  46. midiscripter-0.2.dist-info/RECORD +49 -0
  47. midiscripter-0.2.dist-info/WHEEL +4 -0
  48. midiscripter-0.2.dist-info/entry_points.txt +2 -0
  49. midiscripter-0.2.dist-info/licenses/LICENSE +165 -0
@@ -0,0 +1,9 @@
1
+ from .base.shared import restart_script, run_after_ports_opened
2
+ from .cli import *
3
+ from .file_event import *
4
+ from .gui import *
5
+ from .keyboard import *
6
+ from .logger import *
7
+ from .metronome import *
8
+ from .midi import *
9
+ from .osc import *
File without changes
@@ -0,0 +1,95 @@
1
+ import enum
2
+ from typing import Optional
3
+ from collections.abc import Container
4
+
5
+ import midiscripter.base.shared
6
+ from midiscripter.base.port_base import Input
7
+
8
+
9
+ class AttrEnum(enum.StrEnum):
10
+ def __repr__(self):
11
+ return f'{self.__class__.__name__}({self.value.__repr__()})'
12
+
13
+
14
+ class Msg:
15
+ """The data object generated by input port that is sent as an argument to it's registered calls
16
+ and can be sent with an output port."""
17
+
18
+ type: str
19
+ """Message type description for filtering and representation"""
20
+
21
+ ctime: float
22
+ """Message creation time in epoch format"""
23
+
24
+ source: Input
25
+ """Input port instance that generated the message."""
26
+
27
+ __match_args__: tuple[str] = ('type',)
28
+
29
+ def __init__(self, type: str, source: Input | None = None):
30
+ """
31
+ Args:
32
+ source: Input port instance that generated the message
33
+ """
34
+ self.type = type
35
+ self.source = source
36
+ self.ctime = midiscripter.base.shared.precise_epoch_time()
37
+
38
+ self.ctime: float # workaround for mkdocstrings issue #607
39
+ """Message creation time in epoch format"""
40
+
41
+ def __repr__(self):
42
+ return f'{self.__class__.__name__}({", ".join(str(value) for value in self.__as_tuple())})'
43
+
44
+ def __str__(self):
45
+ return ' | '.join(str(value) for value in self.__as_tuple() if value is not None)
46
+
47
+ def __eq__(self, other_msg: 'Msg'):
48
+ return type(self) is type(other_msg) and self.__as_tuple() == other_msg.__as_tuple()
49
+
50
+ def matches(self, *args_conditions, **kwargs_conditions) -> bool:
51
+ """Checks if message's attributes match all provided attribute conditions:
52
+ 1. If condition is `None` or omitted it matches anything.
53
+ 2. If condition equals attribute it matches the attribute.
54
+ 3. If condition is a container and contains the message attribute it matches the attribute.
55
+
56
+ Returns:
57
+ True if all attribute match, False if any are not
58
+ """
59
+ attr_conditions = dict(zip(self.__match_args__, args_conditions, strict=False))
60
+ attr_conditions.update(kwargs_conditions)
61
+
62
+ for parameter, condition in attr_conditions.items():
63
+ if condition is None:
64
+ continue
65
+
66
+ try:
67
+ attr = getattr(self, parameter)
68
+ except AttributeError:
69
+ continue
70
+
71
+ if attr == condition:
72
+ continue
73
+
74
+ if (
75
+ isinstance(condition, Container)
76
+ and not isinstance(condition, str)
77
+ and attr in condition
78
+ ):
79
+ continue
80
+
81
+ return False
82
+ return True
83
+
84
+ @property
85
+ def _age_ms(self) -> float:
86
+ """Time passed since message creation in milliseconds."""
87
+ return round((midiscripter.base.shared.precise_epoch_time() - self.ctime) * 1000, 3)
88
+
89
+ def __as_tuple(self) -> tuple:
90
+ """Converts message to a tuple based on message's __match_args__ class attribute
91
+
92
+ Returns:
93
+ Tuple with values of attributes specified in __match_args__ class attribute.
94
+ """
95
+ return tuple(getattr(self, attr_name) for attr_name in self.__match_args__)
@@ -0,0 +1,251 @@
1
+ import collections
2
+ import contextlib
3
+ import copy
4
+ import traceback
5
+ from typing import TYPE_CHECKING, TypeVar
6
+ from collections.abc import Callable, Hashable
7
+
8
+ import midiscripter.base.shared
9
+ from midiscripter.logger import log
10
+
11
+ if TYPE_CHECKING:
12
+ from midiscripter.base.msg_base import Msg
13
+
14
+
15
+ @contextlib.contextmanager
16
+ def _all_opened():
17
+ for port in _PortRegistryMeta.instance_registry.values():
18
+ port._open()
19
+
20
+ for call in midiscripter.base.shared.run_after_ports_open_subscribed_calls:
21
+
22
+ def __call_runner():
23
+ try:
24
+ log('Running {call}', call=call) # noqa: B023
25
+ call() # noqa: B023
26
+ except Exception as exc:
27
+ log.red(''.join(traceback.format_exception(exc)))
28
+
29
+ midiscripter.base.shared.thread_executor.submit(__call_runner)
30
+
31
+ yield
32
+
33
+ for port in _PortRegistryMeta.instance_registry.values():
34
+ port._close()
35
+
36
+ log._flush()
37
+ log._sink = None
38
+ midiscripter.base.shared.thread_executor.shutdown(wait=True)
39
+
40
+
41
+ class _PortRegistryMeta(type):
42
+ """Metaclass that enforces one uid - one port instance (singleton) rule"""
43
+
44
+ __singleton_instance_type = TypeVar('__singleton_instance_type', bound='Port')
45
+ """Type for correct IDE recognition and code completion for returned port subclasses"""
46
+
47
+ instance_registry: dict[tuple[str, Hashable], __singleton_instance_type] = {}
48
+ """Declared ports register as port class name and port uid to port instance map"""
49
+
50
+ def __call__(
51
+ cls: type[__singleton_instance_type], *args, **kwargs
52
+ ) -> __singleton_instance_type:
53
+ """
54
+ Args:
55
+ *args: if the class' `_force_uid` attribute is `None` (default)
56
+ the metaclass uses the first argument as uid
57
+ **kwargs: -
58
+
59
+ Returns:
60
+ The singleton class instance that has requested uid
61
+ """
62
+ uid = cls._force_uid or args[0]
63
+
64
+ try:
65
+ return cls.instance_registry[(cls.__name__, uid)]
66
+ except KeyError:
67
+ instance = super().__call__(*args, **kwargs)
68
+ cls.instance_registry[(cls.__name__, uid)] = instance
69
+ return instance
70
+
71
+
72
+ class Port(metaclass=_PortRegistryMeta):
73
+ """Port base class
74
+
75
+ Notes:
76
+ Port declaration with `uid` of an already existing port
77
+ will return the existing port (singleton)
78
+ """
79
+
80
+ is_enabled: bool
81
+ """`True` if port is listening messages / ready to send messages"""
82
+
83
+ _force_uid = None
84
+ """UID override for classes that have can have only one instance per whole class,
85
+ like keyboard port class which can have only one instance for the system.
86
+ Object for these classe are declared without arguments.
87
+ """
88
+
89
+ def __init__(self, uid: Hashable):
90
+ """
91
+ Args:
92
+ uid: Port's unique ID that will always lead to the same port instance
93
+ """
94
+ self._uid = uid
95
+ self.is_enabled = False
96
+
97
+ self.is_enabled: bool # workaround for mkdocstrings issue #607
98
+ """`True` if port is listening messages / ready to send messages"""
99
+
100
+ def __repr__(self):
101
+ if self._force_uid:
102
+ return f'{self.__class__.__name__}()'
103
+ else:
104
+ return f'{self.__class__.__name__}({self._uid.__repr__()})'
105
+
106
+ def __str__(self):
107
+ return str(self._uid)
108
+
109
+ @property
110
+ def _is_available(self) -> bool:
111
+ """Port is available and can be opened."""
112
+ return True
113
+
114
+ def _open(self) -> None:
115
+ """Prepares and activates the port
116
+
117
+ Notes:
118
+ Supposed to be overridden in subclasses
119
+ Should have a check against second opening
120
+ Must set `is_enabled` parameter to `True`
121
+ """
122
+ self.is_enabled = True
123
+
124
+ def _close(self) -> None:
125
+ """Deactivates the port.
126
+
127
+ Notes:
128
+ Supposed to be overridden in subclasses
129
+ Must set `is_enabled` parameter to `False`
130
+ """
131
+ self.is_enabled = False
132
+
133
+
134
+ class Input(Port):
135
+ """Input port base class"""
136
+
137
+ is_enabled: bool
138
+ """`True` if port is listening and generating messages."""
139
+
140
+ subscribed_calls: list[Callable]
141
+ """Callables that will be called with incoming messages. Can be modified."""
142
+
143
+ _call_statistics: dict[Callable, collections.deque[float]] = {}
144
+ """Statistics for each call's execution time in milliseconds for the last 20 runs."""
145
+
146
+ def __init__(self, uid: Hashable):
147
+ super().__init__(uid)
148
+ self.subscribed_calls: list[Callable] = []
149
+
150
+ self.subscribed_calls: list[Callable] # workaround for mkdocstrings issue #607
151
+ """Callables that will be called with incoming messages. Can be modified."""
152
+
153
+ # A Decorator
154
+ def subscribe(self, function: Callable[['Msg'], None]) -> Callable:
155
+ """Decorator to subscribe a callable to the input's messages
156
+
157
+ ??? Example
158
+ ``` python
159
+ @input_instance.subscribe
160
+ def function(msg: Msg) -> None:
161
+ pass
162
+ ```
163
+ ``` python
164
+ input_instance.subscribe(object.method)
165
+ ```
166
+
167
+ Args:
168
+ function: A callable that takes the input port's message as the only argument.
169
+
170
+ Returns:
171
+ Subscribed callable.
172
+ """
173
+ if function not in self.subscribed_calls:
174
+ self._call_statistics[function] = collections.deque(maxlen=20)
175
+ self.subscribed_calls.append(function)
176
+ log('{input} subscribed {call}', input=self, call=function)
177
+ return function
178
+
179
+ def _send_input_msg_to_calls(self, msg: 'Msg') -> None:
180
+ """Sends the message received by the input port to subscribed calls.
181
+
182
+ Notes:
183
+ Not supposed to be overridden in subclasses.
184
+ Supposed to be called from the listener thread started
185
+ by the subclass implementation of `open` method.
186
+
187
+ Args:
188
+ msg: A message received by the input port to send to it's registered calls.
189
+ """
190
+ log('{input} got message {msg}', input=self, msg=msg)
191
+
192
+ if self.is_enabled:
193
+ # pre-copying messages to reduce jitter a little
194
+ msg_copies = [copy.copy(msg) for _ in range(len(self.subscribed_calls))]
195
+ midiscripter.base.shared.thread_executor.map(
196
+ self.__call_worker, self.subscribed_calls, msg_copies
197
+ )
198
+
199
+ def __call_worker(self, function: Callable, msg: 'Msg') -> None:
200
+ """Function called in thread for each subscribed call and each received message.
201
+
202
+ Notes:
203
+ Not supposed to be overridden in subclasses.
204
+ Supposed to be called from `_send_input_msg_to_calls` method.
205
+
206
+ Args:
207
+ function: Subscribed callable.
208
+ msg: Received message to use as callable only argument.
209
+ """
210
+ try:
211
+ function(msg)
212
+ self._call_statistics[function].append(msg._age_ms)
213
+ except TypeError:
214
+ function()
215
+ self._call_statistics[function].append(msg._age_ms)
216
+ except Exception as exc:
217
+ log.red(''.join(traceback.format_exception(exc)))
218
+
219
+
220
+ class Output(Port):
221
+ """Output port base class"""
222
+
223
+ is_enabled: bool
224
+ """`True` if port is ready to send messages"""
225
+
226
+ def send(self, msg: 'Msg') -> None:
227
+ """Send message using the output port.
228
+
229
+ Args:
230
+ msg: Message to send.
231
+ """
232
+ with self._check_and_log_sent_message(msg):
233
+ raise NotImplementedError
234
+
235
+ @contextlib.contextmanager
236
+ def _check_and_log_sent_message(self, msg: 'Msg'):
237
+ if not self.is_enabled:
238
+ log.red("Can't send message {msg}. {output} is disabled!", msg=msg, output=self)
239
+ return
240
+
241
+ yield
242
+
243
+ if msg.source:
244
+ log(
245
+ '{output} sent message {msg} received {age_ms} ms ago',
246
+ output=self,
247
+ msg=msg,
248
+ age_ms=msg._age_ms,
249
+ )
250
+ else:
251
+ log('{output} sent message {msg}', output=self, msg=msg)
@@ -0,0 +1,62 @@
1
+ import concurrent.futures
2
+ import os
3
+ import platform
4
+ import sys
5
+ import time
6
+ from collections.abc import Callable
7
+
8
+ import __main__
9
+
10
+ if platform.system() == 'Windows':
11
+ import win32api
12
+ import win32con
13
+ import win32process
14
+
15
+
16
+ try:
17
+ script_path = __main__.__file__
18
+ except AttributeError: # in subprocess
19
+ pass
20
+
21
+
22
+ thread_executor = concurrent.futures.ThreadPoolExecutor(100)
23
+
24
+
25
+ _precise_time_delta = time.time() - time.perf_counter()
26
+
27
+
28
+ def precise_epoch_time():
29
+ """current time in epoch format with nanosecond precision"""
30
+ return _precise_time_delta + time.perf_counter()
31
+
32
+
33
+ def restart_script():
34
+ """Exit and restart current script"""
35
+ os.execv(sys.executable, ['python', script_path])
36
+ exit(0)
37
+
38
+
39
+ def _raise_current_process_cpu_priority() -> None:
40
+ """Sets HIGH process priority in Windows for current python process"""
41
+ if platform.system() == 'Windows':
42
+ pid = win32api.GetCurrentProcessId()
43
+ handle = win32api.OpenProcess(win32con.PROCESS_ALL_ACCESS, True, pid)
44
+ win32process.SetPriorityClass(handle, win32process.HIGH_PRIORITY_CLASS)
45
+
46
+
47
+ run_after_ports_open_subscribed_calls = []
48
+
49
+
50
+ # A Decorator
51
+ def run_after_ports_opened(function: Callable[[], None]) -> Callable:
52
+ """Decorator to subscribe a callable to run after all ports are opened at the script start
53
+
54
+ Args:
55
+ function: A callable with no arguments
56
+
57
+ Returns:
58
+ Subscribed callable.
59
+ """
60
+ if function not in run_after_ports_open_subscribed_calls:
61
+ run_after_ports_open_subscribed_calls.append(function)
62
+ return function
@@ -0,0 +1 @@
1
+ from midiscripter.cli.starters import start_cli_debug, start_silent
@@ -0,0 +1,41 @@
1
+ import time
2
+ from typing import NoReturn
3
+
4
+ import midiscripter.base.port_base
5
+ import midiscripter.base.shared
6
+ import midiscripter.logger.console_sink
7
+ import midiscripter.logger.log
8
+ import midiscripter.midi
9
+ from midiscripter.logger import log
10
+
11
+
12
+ def start_cli_debug() -> NoReturn:
13
+ """Starts the script with log output to console.
14
+ Console prints increase latency and jitter. Use for debugging only.
15
+ """
16
+ log._sink = midiscripter.logger.console_sink.ConsoleSink()
17
+ log('')
18
+ log('Available MIDI inputs:')
19
+ [log('{input}', input=port_name) for port_name in midiscripter.midi.MidiIn._available_names]
20
+ log('')
21
+ log('Available MIDI outputs:')
22
+ [log('{output}', output=port_name) for port_name in midiscripter.midi.MidiOut._available_names]
23
+ log('')
24
+ _run_cli_loop()
25
+
26
+
27
+ def start_silent() -> NoReturn:
28
+ """Starts the script without logging. The fastest way to run the script."""
29
+ log.is_enabled = False
30
+ _run_cli_loop()
31
+
32
+
33
+ def _run_cli_loop() -> NoReturn:
34
+ """Opens the ports and loops until broken by user."""
35
+ midiscripter.base.shared._raise_current_process_cpu_priority()
36
+ with midiscripter.base.port_base._all_opened():
37
+ while True:
38
+ try:
39
+ time.sleep(1)
40
+ except (KeyboardInterrupt, SystemExit):
41
+ break
@@ -0,0 +1,2 @@
1
+ from midiscripter.file_event.file_event_msg import FileEventMsg, FileEventType
2
+ from midiscripter.file_event.file_event_port import FileEventIn
@@ -0,0 +1,49 @@
1
+ import pathlib
2
+ from typing import TYPE_CHECKING, Optional, Union
3
+
4
+ import midiscripter.base.msg_base
5
+
6
+ if TYPE_CHECKING:
7
+ from midiscripter.file_event.file_event_port import FileEventIn
8
+
9
+
10
+ class FileEventType(midiscripter.base.msg_base.AttrEnum):
11
+ # Names are hardcoded, equal watchdog's event types
12
+ MOVED = 'MOVED'
13
+ DELETED = 'DELETED'
14
+ CREATED = 'CREATED'
15
+ MODIFIED = 'MODIFIED'
16
+ CLOSED = 'CLOSED'
17
+ OPENED = 'OPENED'
18
+
19
+
20
+ class FileEventMsg(midiscripter.base.msg_base.Msg):
21
+ ___match_args__ = ('type', 'path')
22
+
23
+ type: FileEventType
24
+ """File event type"""
25
+
26
+ path: pathlib.Path
27
+ """File path of event"""
28
+
29
+ source: Optional['FileEventIn']
30
+
31
+ def __init__(
32
+ self,
33
+ type: FileEventType | str,
34
+ path: pathlib.Path,
35
+ *,
36
+ source: Optional['FileEventIn'] = None,
37
+ ):
38
+ """
39
+ Args:
40
+ type: File event type
41
+ path: File path
42
+ source (FileEventIn): The [`FileEventIn`][midiscripter.FileEventIn] instance that generated the message
43
+ """
44
+ super().__init__(type, source)
45
+ self.type = type
46
+ self.path = path
47
+
48
+ def matches(self, type=None, path=None):
49
+ return super().matches(type, path)
@@ -0,0 +1,76 @@
1
+ import pathlib
2
+ from typing import Union
3
+
4
+ import watchdog.events
5
+ import watchdog.observers
6
+
7
+ import midiscripter.base.port_base
8
+ import midiscripter.file_event
9
+ import midiscripter.logger
10
+
11
+ shared_observer = watchdog.observers.Observer()
12
+ shared_observer.daemon = True
13
+ shared_observer.start()
14
+
15
+
16
+ class FileEventIn(midiscripter.base.port_base.Input, watchdog.events.FileSystemEventHandler):
17
+ """File system events input port. Watches file/directory modifications.
18
+ Produces [`FileEventMsg`][midiscripter.FileEventMsg] objects.
19
+ """
20
+
21
+ def __init__(self, path: str | pathlib.Path, recursive: bool = False):
22
+ """
23
+ Args:
24
+ path: File/directory path to watch
25
+ recursive: `True` to watch directory path recursively
26
+ """
27
+ if isinstance(path, str):
28
+ path = pathlib.Path(path)
29
+
30
+ midiscripter.base.port_base.Input.__init__(self, path)
31
+ watchdog.events.FileSystemEventHandler.__init__(self)
32
+
33
+ self.__path = path
34
+
35
+ if self.__path.is_dir():
36
+ self.__path_to_watch = self.__path
37
+ self.__watch_dir_changes = True
38
+ else:
39
+ self.__path_to_watch = self.__path.parent # some editors recreate a file on save
40
+ self.__watch_dir_changes = False
41
+
42
+ self.__recursive = recursive
43
+ self.__watch = None
44
+
45
+ def __repr__(self):
46
+ return f"{self.__class__.__name__}('{str(self._uid)}')"
47
+
48
+ def __str__(self):
49
+ return f"'{self.__path.relative_to(self.__path.parent.parent)}' watcher"
50
+
51
+ def _open(self) -> None:
52
+ self.__watch = shared_observer.schedule(self, str(self.__path_to_watch), self.__recursive)
53
+ if not shared_observer.is_alive():
54
+ shared_observer.start()
55
+ self.is_enabled = True
56
+ midiscripter.logger.log('Opened {input}', input=self)
57
+
58
+ def _close(self) -> None:
59
+ if self.__watch:
60
+ shared_observer.unschedule(self.__watch)
61
+ self.is_enabled = False
62
+ midiscripter.logger.log('Stopped {input}', input=self)
63
+
64
+ def on_any_event(self, event: watchdog.events.FileSystemEvent) -> None:
65
+ # Override of `watchdog.events.FileSystemEventHandler` method.
66
+ # Runs on each file system change in `_path`.
67
+ if not self.is_enabled:
68
+ return
69
+
70
+ event_path = pathlib.Path(event.src_path)
71
+
72
+ if self.__watch_dir_changes or event_path == self.__path:
73
+ msg = midiscripter.file_event.file_event_msg.FileEventMsg(
74
+ event.event_type.upper(), self.__path, source=self
75
+ )
76
+ self._send_input_msg_to_calls(msg)
@@ -0,0 +1,11 @@
1
+ from midiscripter.gui.app import add_qwidget, remove_qwidget, start_gui
2
+ from midiscripter.gui.gui_widgets.button import (
3
+ GuiButton,
4
+ GuiButtonSelectorH,
5
+ GuiButtonSelectorV,
6
+ GuiToggleButton,
7
+ )
8
+ from midiscripter.gui.gui_widgets.gui_msg import GuiEventMsg, GuiEventType
9
+ from midiscripter.gui.gui_widgets.layout import GuiWidgetLayout
10
+ from midiscripter.gui.gui_widgets.list import GuiListSelector
11
+ from midiscripter.gui.gui_widgets.text import GuiText