markten 0.1.0__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.
markten-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 COMP1010 UNSW
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
markten-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.1
2
+ Name: markten
3
+ Version: 0.1.0
4
+ Summary: A framework for automating the process of manual marking in bulk
5
+ License: MIT
6
+ Author: Maddy Guthridge
7
+ Author-email: hello@maddyguthridge.com
8
+ Requires-Python: >=3.11,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Description-Content-Type: text/markdown
14
+
15
+ # MarkTen
16
+
17
+ Assess your students' work with all of the delight and none of the tedium.
18
+
19
+ ## Installing
20
+
21
+ ```bash
22
+ $ pip install markten
23
+ ...
24
+ Successfully installed markten-0.1.0
25
+ ```
26
+
27
+ Or to install in an independent environment, you can use `pipx`:
28
+
29
+ ```bash
30
+ $ pipx install markten
31
+ installed package markten 0.1.0, installed using Python 3.12.6
32
+ These apps are now globally available
33
+ - markten
34
+ done! ✨ 🌟 ✨
35
+ ```
36
+
37
+ ## How it works
38
+
39
+ Define your recipe parameters. For example, this recipe takes in git repo names
40
+ from stdin.
41
+
42
+ ```py
43
+ from markten import Recipe, parameters, actions
44
+
45
+ marker = Recipe("COMP2511 Lab Marking")
46
+
47
+ marker.parameter("repo", parameters.stdin("Repo name"))
48
+ ```
49
+
50
+ Write simple marking recipes by defining simple functions for each step.
51
+
52
+ ```py
53
+ # Functions can take arbitrary parameters
54
+ def setup(repo: str):
55
+ """Set up marking environment"""
56
+ # Clone the given git repo to a temporary directory
57
+ directory = actions.git.clone(f"git@github.com:COMP1010UNSW/{repo}.git")
58
+ return {
59
+ "directory": directory,
60
+ }
61
+
62
+ marker.step("Clone repo", setup)
63
+ ```
64
+
65
+ The parameters returned by your previous steps can be used in later steps, just
66
+ by giving the function parameters the same name.
67
+
68
+ ```py
69
+ def open_code(directory: Path):
70
+ """Open the cloned git repo in VS Code"""
71
+ return actions.editor.vs_code(directory)
72
+
73
+ marker.step("View in VS Code", open_code)
74
+ ```
75
+
76
+ Then run the recipe. It'll run for every permutation of your parameters, making
77
+ it easy to mark in bulk.
78
+
79
+ ```py
80
+ marker.run()
81
+ ```
82
+
83
+ For more examples, see the examples directory.
84
+
@@ -0,0 +1,69 @@
1
+ # MarkTen
2
+
3
+ Assess your students' work with all of the delight and none of the tedium.
4
+
5
+ ## Installing
6
+
7
+ ```bash
8
+ $ pip install markten
9
+ ...
10
+ Successfully installed markten-0.1.0
11
+ ```
12
+
13
+ Or to install in an independent environment, you can use `pipx`:
14
+
15
+ ```bash
16
+ $ pipx install markten
17
+ installed package markten 0.1.0, installed using Python 3.12.6
18
+ These apps are now globally available
19
+ - markten
20
+ done! ✨ 🌟 ✨
21
+ ```
22
+
23
+ ## How it works
24
+
25
+ Define your recipe parameters. For example, this recipe takes in git repo names
26
+ from stdin.
27
+
28
+ ```py
29
+ from markten import Recipe, parameters, actions
30
+
31
+ marker = Recipe("COMP2511 Lab Marking")
32
+
33
+ marker.parameter("repo", parameters.stdin("Repo name"))
34
+ ```
35
+
36
+ Write simple marking recipes by defining simple functions for each step.
37
+
38
+ ```py
39
+ # Functions can take arbitrary parameters
40
+ def setup(repo: str):
41
+ """Set up marking environment"""
42
+ # Clone the given git repo to a temporary directory
43
+ directory = actions.git.clone(f"git@github.com:COMP1010UNSW/{repo}.git")
44
+ return {
45
+ "directory": directory,
46
+ }
47
+
48
+ marker.step("Clone repo", setup)
49
+ ```
50
+
51
+ The parameters returned by your previous steps can be used in later steps, just
52
+ by giving the function parameters the same name.
53
+
54
+ ```py
55
+ def open_code(directory: Path):
56
+ """Open the cloned git repo in VS Code"""
57
+ return actions.editor.vs_code(directory)
58
+
59
+ marker.step("View in VS Code", open_code)
60
+ ```
61
+
62
+ Then run the recipe. It'll run for every permutation of your parameters, making
63
+ it easy to mark in bulk.
64
+
65
+ ```py
66
+ marker.run()
67
+ ```
68
+
69
+ For more examples, see the examples directory.
@@ -0,0 +1,5 @@
1
+ """
2
+ # Markten / consts
3
+ """
4
+
5
+ VERSION = "0.1.0"
@@ -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
+ ]
@@ -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)
@@ -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)