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 ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ # Markten / consts
3
+ """
4
+
5
+ VERSION = "0.1.0"
markten/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """
2
+ # MarkTen
3
+
4
+ A manual marking automation framework.
5
+ """
6
+ from .__recipe import Recipe
7
+ from . import parameters
8
+ from . import actions
9
+
10
+
11
+ __all__ = [
12
+ 'Recipe',
13
+ 'parameters',
14
+ 'actions',
15
+ ]
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
@@ -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)