markten 0.1.0__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.
- markten/__consts.py +5 -0
- markten/__init__.py +15 -0
- markten/__main__.py +34 -0
- markten/__permutations.py +38 -0
- markten/__recipe.py +261 -0
- markten/__spinners.py +236 -0
- markten/__term_tools.py +97 -0
- markten/__utils.py +27 -0
- markten/actions/__action.py +47 -0
- markten/actions/__async_process.py +52 -0
- markten/actions/__init__.py +21 -0
- markten/actions/editor.py +27 -0
- markten/actions/git.py +67 -0
- markten/actions/process.py +89 -0
- markten/actions/python.py +44 -0
- markten/actions/time.py +44 -0
- markten/more_itertools.py +45 -0
- markten/parameters/__fs.py +63 -0
- markten/parameters/__init__.py +15 -0
- markten/parameters/__io.py +18 -0
- markten/parameters/__object.py +23 -0
- markten-0.1.0.dist-info/LICENSE +21 -0
- markten-0.1.0.dist-info/METADATA +84 -0
- markten-0.1.0.dist-info/RECORD +26 -0
- markten-0.1.0.dist-info/WHEEL +4 -0
- markten-0.1.0.dist-info/entry_points.txt +3 -0
markten/__consts.py
ADDED
markten/__init__.py
ADDED
markten/__main__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
# MarkTen / Main
|
|
3
|
+
|
|
4
|
+
Programmatic entrypoint to MarkTen, allowing it to be run as a script.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
from . import __utils as utils
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def show_info():
|
|
12
|
+
utils.show_banner()
|
|
13
|
+
print("Usage:")
|
|
14
|
+
print(" markten <recipe-script> [arguments]")
|
|
15
|
+
print(" This will execute the given script in Markten's Python")
|
|
16
|
+
print(" environment.")
|
|
17
|
+
print("License: MIT")
|
|
18
|
+
print("Author: Maddy Guthridge")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main():
|
|
22
|
+
if len(sys.argv) == 1 or sys.argv[1] in ["-h", "--help"]:
|
|
23
|
+
show_info()
|
|
24
|
+
exit(1)
|
|
25
|
+
else:
|
|
26
|
+
# Attempt to execute the given file with any remaining arguments
|
|
27
|
+
recipe = sys.argv[1]
|
|
28
|
+
args = sys.argv[2:]
|
|
29
|
+
|
|
30
|
+
os.execv(sys.executable, ("python", recipe, *args))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
if __name__ == '__main__':
|
|
34
|
+
main()
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable, Mapping, Generator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def recursive_generator(
|
|
8
|
+
keys: list[str],
|
|
9
|
+
params_dict: Mapping[str, Iterable[Any]],
|
|
10
|
+
) -> Generator[dict[str, Any], None, None]:
|
|
11
|
+
"""
|
|
12
|
+
Recursively iterate over the given keys, producing a dict of values.
|
|
13
|
+
"""
|
|
14
|
+
keys_head = keys[0]
|
|
15
|
+
# Base case: this is the last remaining key
|
|
16
|
+
if len(keys) == 1:
|
|
17
|
+
for value in params_dict[keys_head]:
|
|
18
|
+
yield {keys_head: value}
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
# Recursive case, other keys remain, and we need to iterate over those too
|
|
22
|
+
keys_tail = keys[1:]
|
|
23
|
+
|
|
24
|
+
for value in params_dict[keys_head]:
|
|
25
|
+
# Iterate over remaining keys
|
|
26
|
+
for current_params in recursive_generator(keys_tail, params_dict):
|
|
27
|
+
# Overall keys is the union of the current key-value pair with
|
|
28
|
+
# the params yielded by the recursion
|
|
29
|
+
yield {keys_head: value} | current_params
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def dict_permutations_iterator(
|
|
33
|
+
params: Mapping[str, Iterable[Any]],
|
|
34
|
+
) -> Generator[dict[str, Any], None, None]:
|
|
35
|
+
"""
|
|
36
|
+
Iterate over all possible parameter values provided by the generators.
|
|
37
|
+
"""
|
|
38
|
+
return recursive_generator(list(params.keys()), params)
|
markten/__recipe.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
# MarkTen / Recipe
|
|
3
|
+
|
|
4
|
+
Contains the definition for the main MarkTen class.
|
|
5
|
+
"""
|
|
6
|
+
import asyncio
|
|
7
|
+
import inspect
|
|
8
|
+
from traceback import print_exception
|
|
9
|
+
from .actions import MarkTenAction
|
|
10
|
+
from typing import Union, Callable, Any
|
|
11
|
+
from collections.abc import Mapping, Iterable
|
|
12
|
+
from .__permutations import dict_permutations_iterator
|
|
13
|
+
from . import __utils as utils
|
|
14
|
+
from .__spinners import SpinnerManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ParameterPermutations = Mapping[str, Iterable[Any]]
|
|
18
|
+
"""
|
|
19
|
+
Mapping containing iterables for all permutations of the available params.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
GeneratedActions = Union[
|
|
23
|
+
MarkTenAction,
|
|
24
|
+
tuple[MarkTenAction, ...],
|
|
25
|
+
Mapping[str, MarkTenAction],
|
|
26
|
+
]
|
|
27
|
+
"""
|
|
28
|
+
`GeneratedActions` is a collection of actions run in parallel as a part of a
|
|
29
|
+
step in the marking recipe.
|
|
30
|
+
|
|
31
|
+
This can be one of:
|
|
32
|
+
|
|
33
|
+
* `MarkTenAction`: a single anonymous action, whose result is discarded.
|
|
34
|
+
* `tuple[MarkTenAction, ...]`: a collection of anonymous actions.
|
|
35
|
+
* `Mapping[str, MarkTenAction]`: a collection of named actions, whose results
|
|
36
|
+
are stored as parameters under the given names.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
ActionGenerator = Callable[..., 'ActionStep']
|
|
40
|
+
"""
|
|
41
|
+
An `ActionGenerator` is a function that may accept any current parameters, and
|
|
42
|
+
must return an `ActionStep`, which is expanded recursively.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
ActionStepItem = Union[
|
|
47
|
+
ActionGenerator,
|
|
48
|
+
GeneratedActions,
|
|
49
|
+
]
|
|
50
|
+
"""
|
|
51
|
+
Each item in a step must either be a function that generates actions, or
|
|
52
|
+
pre-generated actions.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
ActionStep = Union[
|
|
57
|
+
ActionStepItem,
|
|
58
|
+
tuple[ActionStepItem, ...]
|
|
59
|
+
]
|
|
60
|
+
"""
|
|
61
|
+
An `ActionStep` is a collection of items that should be executed in parallel.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
GeneratedActionStep = tuple[
|
|
65
|
+
dict[str, MarkTenAction],
|
|
66
|
+
list[MarkTenAction]
|
|
67
|
+
]
|
|
68
|
+
"""
|
|
69
|
+
An `ActionStep` after running any action generators.
|
|
70
|
+
|
|
71
|
+
This is used internally when running the actions.
|
|
72
|
+
|
|
73
|
+
A tuple of:
|
|
74
|
+
|
|
75
|
+
* `dict[str, MarkTenAction]`: named actions
|
|
76
|
+
* `list[MarkTenAction]`: anonymous actions
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Recipe:
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
recipe_name: str,
|
|
84
|
+
) -> None:
|
|
85
|
+
self.__name = recipe_name
|
|
86
|
+
self.__params: dict[str, Any] = {}
|
|
87
|
+
self.__steps: list[tuple[str, ActionStep]] = []
|
|
88
|
+
|
|
89
|
+
def parameter(self, name: str, values: Iterable[str]) -> None:
|
|
90
|
+
"""
|
|
91
|
+
Add a single parameter to the recipe.
|
|
92
|
+
"""
|
|
93
|
+
self.__params[name] = values
|
|
94
|
+
|
|
95
|
+
def parameters(self, parameters: ParameterPermutations) -> None:
|
|
96
|
+
"""
|
|
97
|
+
Add a collection of parameters for the recipe.
|
|
98
|
+
"""
|
|
99
|
+
self.__params |= dict(parameters)
|
|
100
|
+
|
|
101
|
+
def step(self, name: str, step: ActionStep) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Add a step to the recipe
|
|
104
|
+
"""
|
|
105
|
+
self.__steps.append((name, step))
|
|
106
|
+
|
|
107
|
+
def run(self):
|
|
108
|
+
"""
|
|
109
|
+
Run the marking recipe for each combination given by the generators.
|
|
110
|
+
"""
|
|
111
|
+
asyncio.run(self.__do_run())
|
|
112
|
+
|
|
113
|
+
async def __do_run(self):
|
|
114
|
+
"""Async implementation of running the marking recipe"""
|
|
115
|
+
utils.show_banner()
|
|
116
|
+
print(f"Running recipe '{self.__name}'")
|
|
117
|
+
for params in dict_permutations_iterator(self.__params):
|
|
118
|
+
# Begin marking with the given parameters
|
|
119
|
+
show_current_params(params)
|
|
120
|
+
try:
|
|
121
|
+
await self.__run_recipe(params)
|
|
122
|
+
except Exception as e:
|
|
123
|
+
print("\n\n")
|
|
124
|
+
print_exception(e)
|
|
125
|
+
print()
|
|
126
|
+
|
|
127
|
+
print("Recipe ran for all inputs")
|
|
128
|
+
|
|
129
|
+
async def __run_recipe(self, params: Mapping[str, Any]):
|
|
130
|
+
"""Execute the marking recipe using the given params"""
|
|
131
|
+
params = dict(params)
|
|
132
|
+
|
|
133
|
+
actions_by_step: list[GeneratedActionStep] = []
|
|
134
|
+
"""
|
|
135
|
+
Actions ordered by step, used to ensure that we can run any required
|
|
136
|
+
teardown at the end of the recipe.
|
|
137
|
+
"""
|
|
138
|
+
for i, (name, step) in enumerate(self.__steps):
|
|
139
|
+
# Convert the step into a list of actions to be run in parallel
|
|
140
|
+
actions_to_run = generate_actions_for_step(step, params)
|
|
141
|
+
actions_by_step.append(actions_to_run)
|
|
142
|
+
|
|
143
|
+
spinners = SpinnerManager(f"{i + 1}. {name}")
|
|
144
|
+
|
|
145
|
+
# Run all tasks
|
|
146
|
+
named_tasks: dict[str, asyncio.Task[Any]] = {}
|
|
147
|
+
anonymous_tasks: list[asyncio.Task[Any]] = []
|
|
148
|
+
# Named tasks
|
|
149
|
+
for key, action in actions_to_run[0].items():
|
|
150
|
+
named_tasks[key] = asyncio.create_task(
|
|
151
|
+
action.run(spinners.create_task(action.get_name())))
|
|
152
|
+
# Anonymous tasks
|
|
153
|
+
for action in actions_to_run[1]:
|
|
154
|
+
anonymous_tasks.append(asyncio.create_task(
|
|
155
|
+
action.run(spinners.create_task(action.get_name()))))
|
|
156
|
+
# Start drawing the spinners
|
|
157
|
+
spinner_task = asyncio.create_task(spinners.spin())
|
|
158
|
+
# Now wait for them all to resolve
|
|
159
|
+
results: dict[str, Any] = {}
|
|
160
|
+
task_errors: list[Exception] = []
|
|
161
|
+
for key, task in named_tasks.items():
|
|
162
|
+
try:
|
|
163
|
+
results[key] = await task
|
|
164
|
+
except Exception as e:
|
|
165
|
+
task_errors.append(e)
|
|
166
|
+
for task in anonymous_tasks:
|
|
167
|
+
try:
|
|
168
|
+
await task
|
|
169
|
+
except Exception as e:
|
|
170
|
+
task_errors.append(e)
|
|
171
|
+
|
|
172
|
+
# Cancel the spinner task
|
|
173
|
+
spinner_task.cancel()
|
|
174
|
+
|
|
175
|
+
if len(task_errors):
|
|
176
|
+
raise ExceptionGroup(
|
|
177
|
+
f"Task failed on step {i + 1}",
|
|
178
|
+
task_errors,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# Now merge the results with the params
|
|
182
|
+
params |= results
|
|
183
|
+
|
|
184
|
+
# Now perform the teardown
|
|
185
|
+
for named_actions, anonymous_actions in reversed(actions_by_step):
|
|
186
|
+
for action in named_actions.values():
|
|
187
|
+
await action.cleanup()
|
|
188
|
+
for action in anonymous_actions:
|
|
189
|
+
await action.cleanup()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def show_current_params(params: Mapping[str, Any]):
|
|
193
|
+
"""
|
|
194
|
+
Displays the current params to the user.
|
|
195
|
+
"""
|
|
196
|
+
print()
|
|
197
|
+
print("Running recipe with given parameters:")
|
|
198
|
+
for param_name, param_value in params.items():
|
|
199
|
+
print(f" {param_name} = {param_value}")
|
|
200
|
+
print()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def generate_actions_for_step(
|
|
204
|
+
step: ActionStep,
|
|
205
|
+
params: Mapping[str, Any],
|
|
206
|
+
) -> GeneratedActionStep:
|
|
207
|
+
"""
|
|
208
|
+
Given a step, generate the actions
|
|
209
|
+
"""
|
|
210
|
+
if isinstance(step, tuple):
|
|
211
|
+
result: GeneratedActionStep = ({}, [])
|
|
212
|
+
for step_item in step:
|
|
213
|
+
# Use recursion so that we can simplify the handling of multiple
|
|
214
|
+
# steps
|
|
215
|
+
result = union_generated_action_step_items(
|
|
216
|
+
result,
|
|
217
|
+
generate_actions_for_step(step_item, params)
|
|
218
|
+
)
|
|
219
|
+
return result
|
|
220
|
+
elif isinstance(step, MarkTenAction):
|
|
221
|
+
# Single anonymous action
|
|
222
|
+
return ({}, [step])
|
|
223
|
+
elif isinstance(step, Mapping):
|
|
224
|
+
# Collection of named actions
|
|
225
|
+
return (dict(step), [])
|
|
226
|
+
else:
|
|
227
|
+
# step is an ActionGenerator function
|
|
228
|
+
action_fn_output = execute_action_function(step, params)
|
|
229
|
+
# Parse the result recursively
|
|
230
|
+
return generate_actions_for_step(action_fn_output, params)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def union_generated_action_step_items(
|
|
234
|
+
a: GeneratedActionStep,
|
|
235
|
+
b: GeneratedActionStep,
|
|
236
|
+
) -> GeneratedActionStep:
|
|
237
|
+
"""
|
|
238
|
+
Union a and b.
|
|
239
|
+
"""
|
|
240
|
+
named_actions = a[0] | b[0]
|
|
241
|
+
anonymous_actions = a[1] + b[1]
|
|
242
|
+
return named_actions, anonymous_actions
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def execute_action_function(
|
|
246
|
+
fn: ActionGenerator,
|
|
247
|
+
params: Mapping[str, Any],
|
|
248
|
+
) -> ActionStep:
|
|
249
|
+
"""
|
|
250
|
+
Execute an action generator function, ensuring only the desired parameters
|
|
251
|
+
are passed as kwargs.
|
|
252
|
+
"""
|
|
253
|
+
args = inspect.getfullargspec(fn)
|
|
254
|
+
kwargs_used = args[2] is not None
|
|
255
|
+
if kwargs_used:
|
|
256
|
+
return fn(**params)
|
|
257
|
+
else:
|
|
258
|
+
# Only pass the args used
|
|
259
|
+
named_args = args[0]
|
|
260
|
+
param_subset = {k: v for k, v in params.items() if k in named_args}
|
|
261
|
+
return fn(**param_subset)
|
markten/__spinners.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
# MarkTen / Spinner
|
|
3
|
+
|
|
4
|
+
Class for displaying multiple parallel spinners.
|
|
5
|
+
|
|
6
|
+
This is used to report the progress of tasks that run simultaneously.
|
|
7
|
+
"""
|
|
8
|
+
import asyncio
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from . import __term_tools as term
|
|
11
|
+
from .__term_tools import print_clear
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SPIN_FRAMES = "|/-\\"
|
|
15
|
+
"""
|
|
16
|
+
Spin states to draw
|
|
17
|
+
"""
|
|
18
|
+
SPIN_FRAME_LENGTH = 0.25
|
|
19
|
+
"""
|
|
20
|
+
How often to redraw the spinners
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_frame(i: int) -> str:
|
|
25
|
+
"""Returns frame number for spinner animation"""
|
|
26
|
+
return SPIN_FRAMES[i % len(SPIN_FRAMES)]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TaskStatus(Enum):
|
|
30
|
+
"""Status of a task"""
|
|
31
|
+
Setup = 0
|
|
32
|
+
"""Task is being set up"""
|
|
33
|
+
Running = 1
|
|
34
|
+
"""Task is running"""
|
|
35
|
+
Success = 2
|
|
36
|
+
"""Task resolved successfully"""
|
|
37
|
+
Failure = 3
|
|
38
|
+
"""Task resolved, but failed"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class SpinnerTask:
|
|
42
|
+
"""
|
|
43
|
+
A single task that is associated with a spinner.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, spinners: 'SpinnerManager', name: str) -> None:
|
|
47
|
+
"""
|
|
48
|
+
Create a spinner task.
|
|
49
|
+
|
|
50
|
+
This should only be called by the `SpinnerManager`, which gives a
|
|
51
|
+
reference to `self`. Use `spinners.create_task(task_name)` instead.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
spinners (SpinnerManager): spinner manager
|
|
55
|
+
name (str): name of the task
|
|
56
|
+
"""
|
|
57
|
+
self.__spinners = spinners
|
|
58
|
+
self.__status = TaskStatus.Setup
|
|
59
|
+
self.__name = name
|
|
60
|
+
self.__message: str | None = None
|
|
61
|
+
self.__logs: list[str] = []
|
|
62
|
+
|
|
63
|
+
def log(self, line: str) -> None:
|
|
64
|
+
"""
|
|
65
|
+
Add message to the task logs.
|
|
66
|
+
"""
|
|
67
|
+
self.__logs.append(line.strip())
|
|
68
|
+
self.__spinners.draw_frame()
|
|
69
|
+
|
|
70
|
+
def message(self, msg: str | None) -> None:
|
|
71
|
+
"""
|
|
72
|
+
Set the overall status message of the task.
|
|
73
|
+
"""
|
|
74
|
+
self.__message = msg
|
|
75
|
+
self.__spinners.draw_frame()
|
|
76
|
+
|
|
77
|
+
def running(self, msg: str | None = None) -> None:
|
|
78
|
+
"""
|
|
79
|
+
Set the task status as `Running`
|
|
80
|
+
"""
|
|
81
|
+
self.__status = TaskStatus.Running
|
|
82
|
+
self.message(msg)
|
|
83
|
+
|
|
84
|
+
def succeed(self, msg: str | None = None) -> None:
|
|
85
|
+
"""
|
|
86
|
+
Set the task status as `Success`
|
|
87
|
+
"""
|
|
88
|
+
self.__status = TaskStatus.Success
|
|
89
|
+
self.message(msg)
|
|
90
|
+
|
|
91
|
+
def fail(self, msg: str | None = None) -> None:
|
|
92
|
+
"""
|
|
93
|
+
Set the task status as `Failure`
|
|
94
|
+
"""
|
|
95
|
+
self.__status = TaskStatus.Failure
|
|
96
|
+
self.message(msg)
|
|
97
|
+
|
|
98
|
+
def is_resolved(self) -> bool:
|
|
99
|
+
"""
|
|
100
|
+
Returns whether the task has resolved, meaning it finished
|
|
101
|
+
successfully, or that it failed.
|
|
102
|
+
"""
|
|
103
|
+
return self.__status in [TaskStatus.Success, TaskStatus.Failure]
|
|
104
|
+
|
|
105
|
+
def display(self, i: int) -> list[str]:
|
|
106
|
+
"""
|
|
107
|
+
Return the lines used to display the spinner's state.
|
|
108
|
+
"""
|
|
109
|
+
result: list[str] = []
|
|
110
|
+
msg = f" -- {self.__message}" if self.__message else ""
|
|
111
|
+
match self.__status:
|
|
112
|
+
case TaskStatus.Setup:
|
|
113
|
+
result.append(f"⏳ {get_frame(i)} {self.__name} {msg}")
|
|
114
|
+
case TaskStatus.Running:
|
|
115
|
+
result.append(f"⏱️ {get_frame(i)} {self.__name} {msg}")
|
|
116
|
+
case TaskStatus.Success:
|
|
117
|
+
result.append(f"✅ {self.__name} {msg}")
|
|
118
|
+
case TaskStatus.Failure:
|
|
119
|
+
result.append(f"❌ {self.__name} {msg}")
|
|
120
|
+
|
|
121
|
+
for line in self.__logs:
|
|
122
|
+
result.append(f" | {line}")
|
|
123
|
+
# result.append(" output length:", len(self.__logs))
|
|
124
|
+
return result
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SpinnerManager:
|
|
128
|
+
"""
|
|
129
|
+
A manager for running spinners.
|
|
130
|
+
|
|
131
|
+
Only one spinner manager should be running at once.
|
|
132
|
+
|
|
133
|
+
Usage:
|
|
134
|
+
|
|
135
|
+
spinners = SpinnerManager("Some complex task")
|
|
136
|
+
task1 = spinners.create_task("One parallel action")
|
|
137
|
+
task2 = spinners.create_task("Another action")
|
|
138
|
+
spinner_task = asyncio.create_task(spinners.spin())
|
|
139
|
+
|
|
140
|
+
# Do work...
|
|
141
|
+
|
|
142
|
+
spinner_task.cancel()
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(self, name: str) -> None:
|
|
146
|
+
"""
|
|
147
|
+
Create a spinner manager.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
name (str): name of spinner manager (name of step being executed)
|
|
151
|
+
"""
|
|
152
|
+
self.__name = name
|
|
153
|
+
"""Name of spinner"""
|
|
154
|
+
self.__task_list: list[SpinnerTask] = []
|
|
155
|
+
"""List of tasks, as they appear while rendering"""
|
|
156
|
+
# self.__start_line_num = term.get_position()[0]
|
|
157
|
+
# """Starting line of the output"""
|
|
158
|
+
term.save_cursor()
|
|
159
|
+
|
|
160
|
+
def create_task(self, name: str) -> SpinnerTask:
|
|
161
|
+
"""
|
|
162
|
+
Create a task to be displayed by the spinner.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
name (str): name of the task being executed within the step.
|
|
166
|
+
"""
|
|
167
|
+
task = SpinnerTask(self, name)
|
|
168
|
+
self.__task_list.append(task)
|
|
169
|
+
self.__frame = 0
|
|
170
|
+
return task
|
|
171
|
+
|
|
172
|
+
def __count_complete(self) -> int:
|
|
173
|
+
"""Returns the number of completed tasks"""
|
|
174
|
+
return len(list(filter(
|
|
175
|
+
lambda task: task.is_resolved(),
|
|
176
|
+
self.__task_list
|
|
177
|
+
)))
|
|
178
|
+
|
|
179
|
+
async def spin(self) -> None:
|
|
180
|
+
"""
|
|
181
|
+
Begin the spin task.
|
|
182
|
+
|
|
183
|
+
This will run infinitely, until the task is cancelled.
|
|
184
|
+
"""
|
|
185
|
+
# Move the cursor to the starting position
|
|
186
|
+
while True:
|
|
187
|
+
self.__frame += 1
|
|
188
|
+
self.draw_frame()
|
|
189
|
+
# Wait for the frame duration
|
|
190
|
+
await asyncio.sleep(SPIN_FRAME_LENGTH)
|
|
191
|
+
|
|
192
|
+
def draw_frame(self):
|
|
193
|
+
"""
|
|
194
|
+
Draw a frame of the spinners.
|
|
195
|
+
|
|
196
|
+
This currently redraws all output, which isn't especially efficient.
|
|
197
|
+
|
|
198
|
+
Most of the commented code relies on getting and setting the terminal
|
|
199
|
+
cursor position manually, which appears to break when the number of
|
|
200
|
+
lines in the terminal is too small (causing text to be printed multiple
|
|
201
|
+
times).
|
|
202
|
+
|
|
203
|
+
Since it's impossible to jump to a negative cursor position manually,
|
|
204
|
+
we rely on the save/restore cursor functionality, since at least it
|
|
205
|
+
only massively breaks if the terminal size is extremely tiny, and works
|
|
206
|
+
reasonably well otherwise.
|
|
207
|
+
|
|
208
|
+
I need to find a library that handles all of the terminal outputting so
|
|
209
|
+
I can get nice outputs that update in multiple places without causing
|
|
210
|
+
major headaches and console spamming, but that is a future Maddy
|
|
211
|
+
problem.
|
|
212
|
+
"""
|
|
213
|
+
term.restore_cursor()
|
|
214
|
+
# term_size = os.get_terminal_size()
|
|
215
|
+
# term.set_position((self.__start_line_num, 0))
|
|
216
|
+
completed_tasks = self.__count_complete()
|
|
217
|
+
|
|
218
|
+
output = [f"{self.__name} ({completed_tasks}/{len(self.__task_list)})"]
|
|
219
|
+
|
|
220
|
+
# Draw the spinners
|
|
221
|
+
for task in self.__task_list:
|
|
222
|
+
output.extend(task.display(self.__frame))
|
|
223
|
+
|
|
224
|
+
for line in output:
|
|
225
|
+
print_clear(line)
|
|
226
|
+
|
|
227
|
+
# # Determine the number of lines used, including wrapping
|
|
228
|
+
# lines_used = 0
|
|
229
|
+
# for line in output:
|
|
230
|
+
# lines_used += math.ceil(wcswidth(line) / term_size.columns)
|
|
231
|
+
#
|
|
232
|
+
# # If we exceeded the number of lines in the terminal
|
|
233
|
+
# if self.__start_line_num + lines_used > term_size.lines:
|
|
234
|
+
# # We must update the cursor position to instead be negative,
|
|
235
|
+
# # based on the new scroll position
|
|
236
|
+
# self.__start_line_num = term_size.lines - lines_used
|
markten/__term_tools.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
# MarkTen / term tools
|
|
3
|
+
|
|
4
|
+
Simple functions to handle terminal output.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
if sys.platform == "win32":
|
|
10
|
+
def getch():
|
|
11
|
+
"""
|
|
12
|
+
Getch on Windows.
|
|
13
|
+
|
|
14
|
+
https://stackoverflow.com/a/3523340/6335363
|
|
15
|
+
"""
|
|
16
|
+
import msvcrt
|
|
17
|
+
return msvcrt.getch()
|
|
18
|
+
else:
|
|
19
|
+
def getch():
|
|
20
|
+
"""
|
|
21
|
+
Getch on unix systems.
|
|
22
|
+
|
|
23
|
+
https://stackoverflow.com/a/72825322/6335363
|
|
24
|
+
"""
|
|
25
|
+
import termios
|
|
26
|
+
import tty
|
|
27
|
+
fd = sys.stdin.fileno()
|
|
28
|
+
orig = termios.tcgetattr(fd)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
# or tty.setraw(fd) if you prefer raw mode's behavior.
|
|
32
|
+
tty.setcbreak(fd)
|
|
33
|
+
return sys.stdin.read(1)
|
|
34
|
+
finally:
|
|
35
|
+
termios.tcsetattr(fd, termios.TCSAFLUSH, orig)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_position() -> tuple[int, int]:
|
|
39
|
+
"""
|
|
40
|
+
Returns the position in the terminal, as `(row, col)`.
|
|
41
|
+
|
|
42
|
+
https://stackoverflow.com/a/8353312/6335363
|
|
43
|
+
"""
|
|
44
|
+
print("\033[6n", end='', flush=True)
|
|
45
|
+
assert getch() == "\033"
|
|
46
|
+
assert getch() == "["
|
|
47
|
+
|
|
48
|
+
row = ''
|
|
49
|
+
while (ch := getch()) != ';':
|
|
50
|
+
row += ch
|
|
51
|
+
col = ''
|
|
52
|
+
while (ch := getch()) != 'R':
|
|
53
|
+
col += ch
|
|
54
|
+
|
|
55
|
+
return int(row), int(col)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def set_position(pos: tuple[int, int]) -> None:
|
|
59
|
+
"""
|
|
60
|
+
Set the terminal position to the given state.
|
|
61
|
+
|
|
62
|
+
https://stackoverflow.com/a/54630943/6335363
|
|
63
|
+
"""
|
|
64
|
+
r, c = pos
|
|
65
|
+
print(f"\033[{r};{c}H", end='', flush=True)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def save_cursor():
|
|
69
|
+
"""Instruct the terminal to save the current cursor position."""
|
|
70
|
+
print('\0337', end='', flush=True)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def restore_cursor():
|
|
74
|
+
"""Instruct the terminal to restore the saved cursor position."""
|
|
75
|
+
print('\0338', end='', flush=True)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def clear_line():
|
|
79
|
+
"""
|
|
80
|
+
Clear the current line of output.
|
|
81
|
+
"""
|
|
82
|
+
print('\033[2K', end='', flush=True)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def print_clear(*args: object, **kwargs):
|
|
86
|
+
"""
|
|
87
|
+
Print text after clearing the current line.
|
|
88
|
+
"""
|
|
89
|
+
clear_line()
|
|
90
|
+
print(*args, **kwargs)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == '__main__':
|
|
94
|
+
# Simple test program
|
|
95
|
+
# print("\n" * 100)
|
|
96
|
+
set_position((-10, 0))
|
|
97
|
+
print("What about now?\n" * 10)
|