prepare-assignment 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 prepare-assignment
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.
@@ -0,0 +1,36 @@
1
+ Metadata-Version: 2.1
2
+ Name: prepare-assignment
3
+ Version: 0.1.0
4
+ Summary: Prepare assignment
5
+ License: MIT
6
+ Author: Bonajo
7
+ Author-email: m.bonajo@fontys.nl
8
+ Requires-Python: >=3.8,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Requires-Dist: gitpython (>=3.1.31,<4.0.0)
16
+ Requires-Dist: importlib-resources (>=5.12.0,<6.0.0)
17
+ Requires-Dist: jsonschema (>=4.17.3,<5.0.0)
18
+ Requires-Dist: prepare-toolbox (>=0.2.0,<0.3.0)
19
+ Requires-Dist: ruamel-yaml (>=0.17.21,<0.18.0)
20
+ Requires-Dist: virtualenv (>=20.24.5,<21.0.0)
21
+ Description-Content-Type: text/markdown
22
+
23
+ # Prepare assignment
24
+
25
+ Prepare assignment is a helper tool to prepare assignments at Fontys Venlo.
26
+
27
+ ## Dependencies
28
+
29
+ - Git
30
+ - Python >=3.8
31
+ - Docker (if using Docker based actions)
32
+
33
+ ## Limitations
34
+
35
+ For now cloning the action repositories is done using SSH,
36
+ so if you don't have that setup it will not work
@@ -0,0 +1,14 @@
1
+ # Prepare assignment
2
+
3
+ Prepare assignment is a helper tool to prepare assignments at Fontys Venlo.
4
+
5
+ ## Dependencies
6
+
7
+ - Git
8
+ - Python >=3.8
9
+ - Docker (if using Docker based actions)
10
+
11
+ ## Limitations
12
+
13
+ For now cloning the action repositories is done using SSH,
14
+ so if you don't have that setup it will not work
@@ -0,0 +1,4 @@
1
+ from prepare_assignment.core.cli import main
2
+
3
+ if __name__ == '__main__':
4
+ main()
@@ -0,0 +1,81 @@
1
+ import argparse
2
+ import logging
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from prepare_toolbox.file import get_matching_files
8
+ from ruamel.yaml import YAML
9
+
10
+ from prepare_assignment.core.preparer import prepare_actions
11
+ from prepare_assignment.core.runner import run
12
+ from prepare_assignment.core.validator import validate_prepare
13
+ from prepare_assignment.data.errors import ValidationError, DependencyError
14
+ from prepare_assignment.utils import set_logger_level
15
+
16
+
17
+ def add_commandline_arguments(parser: argparse.ArgumentParser) -> None:
18
+ """
19
+ Add command line arguments to the argparser
20
+ :param parser: The parser to add the arguments to
21
+ """
22
+ parser.add_argument("-f", "--file", action="store", help="Configuration file")
23
+ parser.add_argument("-v", "--verbosity", action="count", help="increase output verbosity", default=0)
24
+
25
+
26
+ def __get_prepare_file(file: Optional[str]) -> str:
27
+ """
28
+ Try and find the correct prepare_assignment.y(a)ml
29
+ :param file: file name provided by the user
30
+ :return: path to file
31
+ :raises FileNotFoundError: if file doesn't exist
32
+ :raises AssertionError: if there is both a prepare_assignment.yml and a prepare_assignment.yml and no file is provided by the user
33
+ :raises FileNotFoundError: if the provided 'file' is not a file
34
+ """
35
+ if file is None:
36
+ files = get_matching_files("prepare_assignment.y{,a}ml")
37
+ if len(files) == 0:
38
+ raise FileNotFoundError("No prepare_assignment.yml file found in working directory")
39
+ elif len(files) > 1:
40
+ raise AssertionError("There is both a prepare_assignment.yml and a prepare_assignment.yml,"
41
+ " use the -f flag to specify which file to use")
42
+ file = files[0]
43
+ else:
44
+ file = str(Path(os.path.join(os.getcwd(), file)))
45
+ if not os.path.isfile(file):
46
+ raise FileNotFoundError(f"Supplied file: '{file}' is not a file")
47
+ return file
48
+
49
+
50
+ def main() -> None:
51
+ """
52
+ Parse 'prepare_assignment.y(a)ml' and execute all steps
53
+ """
54
+ # Handle command line arguments
55
+ parser = argparse.ArgumentParser()
56
+ add_commandline_arguments(parser)
57
+ args = parser.parse_args()
58
+
59
+ # Set the logger
60
+ logger = logging.getLogger("prepare_assignment")
61
+ set_logger_level(logger, args.verbosity)
62
+
63
+ # Get the prepare_assignment.yml file
64
+ file = __get_prepare_file(args.file)
65
+ logger.info(f"Found prepare_assignment config file at: {file}")
66
+
67
+ # Load the file
68
+ yaml = YAML(typ='safe')
69
+ path = Path(file)
70
+ prepare = yaml.load(path)
71
+
72
+ # Execute all steps
73
+ os.chdir(os.path.dirname(path))
74
+ try:
75
+ validate_prepare(file, prepare)
76
+ mapping = prepare_actions(file, prepare['steps'])
77
+ run(prepare, mapping)
78
+ except ValidationError as ve:
79
+ logger.error(ve.message)
80
+ except DependencyError as de:
81
+ logger.error(de.message)
@@ -0,0 +1,232 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Dict, Any, List, Optional, TypedDict
9
+
10
+ from git import Repo
11
+ from importlib_resources import files
12
+ from virtualenv import cli_run # type: ignore
13
+
14
+ from prepare_assignment.core.validator import validate_action_definition, validate_action, load_yaml, \
15
+ validate_default_values
16
+ from prepare_assignment.data.action_definition import ActionDefinition, CompositeActionDefinition, \
17
+ PythonActionDefinition
18
+ from prepare_assignment.data.action_properties import ActionProperties
19
+ from prepare_assignment.data.errors import DependencyError, ValidationError
20
+ from prepare_assignment.utils.cache import get_cache_path
21
+
22
+ # Set the cache path
23
+ cache_path = get_cache_path()
24
+ # Get the logger
25
+ logger = logging.getLogger("prepare_assignment")
26
+ # Load the actions template file
27
+ template_file = files().joinpath('../schemas/actions.schema.json_template')
28
+ template: str = template_file.read_text()
29
+
30
+
31
+ def __repo_path(props: ActionProperties) -> Path:
32
+ return Path(os.path.join(cache_path, props.organization, props.name, props.version, "repo"))
33
+
34
+
35
+ def __download_action(props: ActionProperties) -> Path:
36
+ """
37
+ Download (using git clone) the action
38
+ :param organization: GitHub organization/username
39
+ :param action: action name
40
+ :returns str: the path where the repo is checked out
41
+ """
42
+ path: Path = __repo_path(props)
43
+ path.mkdir(parents=True, exist_ok=True)
44
+ # For now use ssh protocol, need to figure out how to use system defined one
45
+ git_url: str = f"git@github.com:{props.organization}/{props.name}.git"
46
+ logger.debug(f"Cloning repository: {git_url}")
47
+ repo = Repo.clone_from(git_url, path)
48
+ if props.version != "latest":
49
+ logger.debug(f"Checking out correct version of repository: {props.version}")
50
+ repo.git.checkout(props.version)
51
+ return path
52
+
53
+
54
+ def __build_json_schema(organization: str, action: ActionDefinition) -> str:
55
+ logger.debug(f"Building json schema for '{action.id}'")
56
+ schema = template.replace("{{action-id}}", action.id)
57
+ schema = schema.replace("{{organization}}", organization)
58
+ schema = schema.replace("{{action-name}}", action.name)
59
+ schema = schema.replace("{{action-description}}", action.description)
60
+ required: List[str] = []
61
+ properties: List[str] = []
62
+ for inp in action.inputs:
63
+ properties.append(inp.to_schema_definition())
64
+ if inp.required:
65
+ required.append(inp.name)
66
+ if len(properties) > 0:
67
+ output = ', \n"with": {\n "type": "object",\n "additionalProperties": false,\n "properties": {\n'
68
+ output += ",\n".join(properties) + "\n }"
69
+ if len(required) > 0:
70
+ schema = schema.replace("{{required}}", ', "with"')
71
+ output += ',\n "required": [' + ", ".join(map(lambda x: f'"{x}"', required)) + ']\n }'
72
+ else:
73
+ schema = schema.replace("{{required}}", "")
74
+ output += "\n}"
75
+ schema = schema.replace("{{with}}", output)
76
+ return schema
77
+
78
+
79
+ def __action_properties(action: str) -> ActionProperties:
80
+ parts = action.split("/")
81
+ if len(parts) > 2:
82
+ raise AssertionError("Actions cannot have more than one slash")
83
+ elif len(parts) == 1:
84
+ parts.insert(0, "prepare_assignment-assignment")
85
+ organization: str = parts[0]
86
+ name = parts[1]
87
+ split = name.split("@")
88
+ version: str = "latest"
89
+ action_name: str = name
90
+ if len(split) > 2:
91
+ raise AssertionError("Cannot have multiple '@' symbols in the name")
92
+ elif len(split) == 2:
93
+ action_name = split[0]
94
+ version = split[1]
95
+ return ActionProperties(organization, action_name, version)
96
+
97
+
98
+ def __action_dict_to_definition(action: Any, path: str) -> ActionDefinition:
99
+ if action["runs"]["using"] == "composite":
100
+ return CompositeActionDefinition.of(action, path)
101
+ else:
102
+ return PythonActionDefinition.of(action, path)
103
+
104
+
105
+ def __action_install_dependencies(action_path: str) -> None:
106
+ venv_path = os.path.join(action_path, "venv", "bin", "python")
107
+ repo_path = os.path.join(action_path, "repo")
108
+ requirements_path = os.path.join(repo_path, "requirements.txt")
109
+ pyproject_path = os.path.join(repo_path, "pyproject.toml")
110
+ has_requirements = os.path.isfile(requirements_path)
111
+ has_pyproject = os.path.isfile(pyproject_path)
112
+
113
+ if not has_requirements and not has_pyproject:
114
+ return
115
+
116
+ result: Optional[subprocess.CompletedProcess[Any]] = None
117
+ if has_requirements:
118
+ logger.debug(f"Installing dependencies from '{requirements_path}'")
119
+ args = [venv_path] + f"-m pip install -r {requirements_path}".split(" ")
120
+ result = subprocess.run(args, capture_output=True)
121
+ elif has_pyproject:
122
+ logger.debug(f"Installing dependencies from '{pyproject_path}'")
123
+ args = [venv_path] + f"-m pip install .".split()
124
+ result = subprocess.run(args, capture_output=True, cwd=repo_path)
125
+
126
+ if result is not None and result.returncode == 1:
127
+ log_path = os.path.join(cache_path, "logs")
128
+ timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
129
+ file = os.path.join(log_path, f'{timestamp}-dependencies.log')
130
+ Path(log_path).mkdir(parents=True, exist_ok=True)
131
+ with open(file, 'wb') as handle:
132
+ handle.write(result.stderr)
133
+ raise DependencyError(f"Unable to install dependencies for '{repo_path}', see '{file}' for more info")
134
+
135
+
136
+ class ActionStuff(TypedDict):
137
+ schema: Any
138
+ action: ActionDefinition
139
+
140
+
141
+ def __prepare_actions(file: str, actions: List[Any], parsed: Optional[Dict[str, ActionStuff]] = None) \
142
+ -> Dict[str, ActionStuff]:
143
+ # Unfortunately we cannot do this as a default value, see:
144
+ # https://docs.python-guide.org/writing/gotchas/#mutable-default-arguments
145
+ if parsed is None:
146
+ parsed = {}
147
+ if len(actions) == 0:
148
+ logger.debug("All actions prepared")
149
+ return parsed
150
+
151
+ action_def = actions.pop()
152
+ act: str = action_def["uses"]
153
+ json_schema: Optional[Any] = None
154
+ # Check if we have already loaded the action
155
+ if parsed.get(act, None) is None:
156
+ logger.debug(f"Action '{act}' has not been loaded in this run")
157
+ props = __action_properties(act)
158
+
159
+ # Check if action (therefore the path) has already been downloaded in previous run
160
+ action_path = os.path.join(cache_path, props.organization, props.name, props.version)
161
+ action: Optional[ActionDefinition] = None
162
+ repo_path = __repo_path(props)
163
+ yaml_path = os.path.join(repo_path, "action.yml")
164
+ if os.path.isdir(action_path):
165
+ logger.debug(f"Action '{act}' is already available, loading from disk")
166
+ with open(os.path.join(action_path, f"{props.name}.schema.json"), "r") as handle:
167
+ json_schema = json.load(handle)
168
+ action_yaml = load_yaml(yaml_path)
169
+ action = __action_dict_to_definition(action_yaml, action_path)
170
+ else:
171
+ logger.debug(f"Action '{act}' is not available on this system")
172
+ # Download the action (clone the repository)
173
+ __download_action(props)
174
+ # Validate that the action.yml is valid
175
+ action_yaml = validate_action_definition(yaml_path)
176
+ action = __action_dict_to_definition(action_yaml, action_path)
177
+ validate_default_values(action)
178
+ # Check if it is a composite action, in that case we might need to retrieve more actions
179
+ if isinstance(action, CompositeActionDefinition):
180
+ logger.debug(f"Action '{act}' is a composite action, preparing sub-actions")
181
+ all_actions: List[Any] = []
182
+ for step in action.steps:
183
+ name = step.get("uses", None)
184
+ if name is not None:
185
+ all_actions.append(step)
186
+ parsed = __prepare_actions(str(repo_path), all_actions, parsed)
187
+ else:
188
+ main_path = os.path.join(repo_path, action.main) # type: ignore
189
+ if not os.path.isfile(main_path):
190
+ raise ValidationError(f"Main file '{action.main}' does not exist for action '{action.name}'") # type: ignore
191
+ # Now we can build a schema for this action
192
+ schema = __build_json_schema(props.organization, action)
193
+ json_schema = json.loads(schema)
194
+ with open(os.path.join(action_path, f"{props.name}.schema.json"), 'w') as handle:
195
+ handle.write(schema)
196
+ # Create a virtualenv for this action
197
+ cli_run([os.path.join(action_path, "venv")])
198
+ # Install dependencies
199
+ __action_install_dependencies(action_path)
200
+ parsed[act] = {"schema": json_schema, "action": action}
201
+ else:
202
+ json_schema = parsed[act]["schema"]
203
+ if action_def.get("with", None) is None:
204
+ action_def["with"] = {}
205
+ validate_action(file, action_def, json_schema)
206
+ return __prepare_actions(file, actions, parsed)
207
+
208
+
209
+ def prepare_actions(prepare_file: str, steps: Dict[str, Any]) -> Dict[str, ActionDefinition]:
210
+ """
211
+ Make sure that the action is available.
212
+ If not available:
213
+ 1. Clone the repository
214
+ 2. Checkout the correct version
215
+ 3. Generate json schema for validation
216
+ :param steps: The actions to prepare_assignment
217
+ :param prepare_file
218
+ :return: None
219
+ """
220
+ logger.debug("========== Preparing actions")
221
+ all_actions: List[Any] = []
222
+ # DON'T FORGET TO REMOVE, ONLY FOR DEVELOPMENT
223
+ # shutil.rmtree(cache_path, ignore_errors=True)
224
+ # Iterate through all the actions to make sure that they are available
225
+ for step, actions in steps.items():
226
+ for action in actions:
227
+ # If the action is a run command, we don't need to do anything
228
+ if action.get("uses", None) is not None:
229
+ all_actions.append(action)
230
+ mapping = __prepare_actions(prepare_file, all_actions)
231
+ logger.debug("✓ All actions downloaded and valid")
232
+ return {k: v["action"] for k, v in mapping.items()}
@@ -0,0 +1,72 @@
1
+ import json
2
+ import logging
3
+ import os.path
4
+ import shlex
5
+ import subprocess
6
+ import sys
7
+ from typing import Any, Dict
8
+
9
+ from importlib_resources import files
10
+
11
+ from prepare_assignment.data.action_definition import ActionDefinition, PythonActionDefinition
12
+
13
+ # Get the logger
14
+ logger = logging.getLogger("prepare_assignment")
15
+
16
+
17
+ def __execute_action(action: PythonActionDefinition, inputs: Dict[str, str]) -> None:
18
+ venv_path = os.path.join(action.path, "venv")
19
+ main_path = os.path.join(action.path, "repo", action.main)
20
+ executable = os.path.join(venv_path, "bin", "python")
21
+ env = os.environ.copy()
22
+ env["VIRTUAL_ENV"] = venv_path
23
+ for key, value in inputs.items():
24
+ sanitized = "PREPARE_" + key.replace(" ", "_").upper()
25
+ env[sanitized] = value
26
+ result = subprocess.run([executable, main_path], capture_output=True, env=env)
27
+ if result.returncode == 1:
28
+ logger.error(f"Failed to execute '{action.name}', action failed with status code {result.returncode}")
29
+ if result.stderr:
30
+ logger.error(result.stderr.decode("utf-8"))
31
+ if not result.stderr and result.stdout:
32
+ logger.error(result.stdout.decode("utf-8"))
33
+
34
+
35
+ def __execute_shell_command(command: str) -> None:
36
+ args = shlex.split(f"bash -c {shlex.quote(command)}")
37
+ result = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
38
+ if result.returncode == 1:
39
+ print(result.stderr)
40
+ else:
41
+ print(result)
42
+
43
+
44
+ def __handle_action(mapping: Dict[str, ActionDefinition], action: Any, inputs: Dict[str, Any]) -> None:
45
+ # TODO: Command substitution
46
+ for key, value in inputs.items():
47
+ inputs[key] = json.dumps(value)
48
+ # Check what kind of actions it is
49
+ action_type = action.get("uses", None)
50
+ if action_type is None:
51
+ command = action.get("run")
52
+ __execute_shell_command(command)
53
+ else:
54
+ uses = action.get("uses", None)
55
+ action_definition = mapping.get(uses)
56
+
57
+ if isinstance(action_definition, PythonActionDefinition):
58
+ __execute_action(action_definition, inputs)
59
+ else:
60
+ for act in action_definition.steps: # type: ignore
61
+ __handle_action(mapping, act, inputs)
62
+
63
+
64
+ def run(prepare: Dict[str, Any], mapping: Dict[str, ActionDefinition]) -> None:
65
+ logger.debug("========== Running prepare_assignment assignment")
66
+ for step, actions in prepare["steps"].items():
67
+ logger.debug(f"Running step: {step}")
68
+ for action in actions:
69
+ inputs = action.get("with", {})
70
+ __handle_action(mapping, action, inputs)
71
+
72
+ logger.debug("✓ Prepared :)")
@@ -0,0 +1,120 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Dict, Any, Type
8
+
9
+ from importlib_resources import files
10
+ from jsonschema.exceptions import ValidationError
11
+ from jsonschema.validators import validate
12
+ from ruamel.yaml import YAML
13
+
14
+ from prepare_assignment.data.action_definition import ActionDefinition
15
+ from prepare_assignment.data.errors import ValidationError as VE
16
+ from prepare_assignment.utils.default_validator import DefaultValidatingValidator
17
+
18
+ logger = logging.getLogger("prepare_assignment")
19
+ yaml = YAML(typ='safe')
20
+
21
+ type_map: Dict[str, Type] = {
22
+ "string": type(''),
23
+ "integer": type(1),
24
+ "number": type(1.23),
25
+ "array": type([]),
26
+ "boolean": type(True)
27
+ }
28
+
29
+
30
+ def validate_prepare(prepare_file: str, prepare: Dict[str, Any]) -> None:
31
+ """
32
+ Validate that the prepare_assignment.y(a)ml file has the correct syntax
33
+ NOTE: this does not validate all actions, this is done in the
34
+ validate_actions function
35
+ :param prepare: The parsed yaml
36
+ :param prepare_file
37
+ :return: None
38
+ :raises: ValidationError: if schema is not valid
39
+ """
40
+ logger.debug("========== Validating config file")
41
+ # Load the validation jsonschema
42
+ schema_path = files().joinpath('../schemas/prepare.schema.json')
43
+ schema: Dict[str, Any] = json.loads(schema_path.read_text())
44
+
45
+ # Validate prepare_assignment.y(a)ml
46
+ try:
47
+ validate(prepare, schema, cls=DefaultValidatingValidator)
48
+ except ValidationError as ve:
49
+ message = f"Error in: {prepare_file}, unable to verify '{ve.json_path}'\n\t -> {ve.message}"
50
+ raise VE(message)
51
+ logger.debug("✓ Prepare file is valid")
52
+
53
+
54
+ def validate_action(file: str, action: Dict[str, Any], json_schema: Any) -> None:
55
+ """
56
+ Validate all actions based on their respective json schemas
57
+ NOTE: this assumes that all actions are available and that it's json schema has been generated
58
+ :param action The action definition
59
+ :param json_schema
60
+ :param file
61
+ :return: None
62
+ :raises: ValidationError if an action cannot be validated against its respective schema
63
+ """
64
+ name = action["uses"]
65
+ logger.debug(f"Validating '{name}'")
66
+ try:
67
+ # validate(action, json_schema, cls=DefaultValidatingValidator)
68
+ DefaultValidatingValidator(json_schema).validate(action)
69
+ except ValidationError as ve:
70
+ message = f"Error in: {file}, unable to verify action '{name}'\n\t -> {ve.json_path}: {ve.message}"
71
+ raise VE(message)
72
+
73
+
74
+ def load_yaml(path: str | os.PathLike[str] | os.PathLike) -> Any:
75
+ path = Path(path)
76
+ return yaml.load(path)
77
+
78
+
79
+ def validate_action_definition(path: str | os.PathLike[str] | os.PathLike) -> Any:
80
+ logger.debug("Validating action definition")
81
+
82
+ # Load the validation jsonschema
83
+ schema_path = files().joinpath('../schemas/action.schema.json')
84
+ schema: Dict[str, Any] = json.loads(schema_path.read_text())
85
+
86
+ action_definition = load_yaml(path)
87
+
88
+ try:
89
+ validate(action_definition, schema, cls=DefaultValidatingValidator)
90
+ # Overwrite the action.yml file as we might have added default values
91
+ with open(path, 'w') as handle:
92
+ yaml.dump(action_definition, handle)
93
+ except ValidationError as ve:
94
+ message = f"Unable to verify: {path}\n\t -> {ve.json_path}: {ve.message}"
95
+ raise VE(message)
96
+
97
+ return action_definition
98
+
99
+
100
+ def validate_default_values(action: ActionDefinition) -> None:
101
+ for input in action.inputs:
102
+ if input.default is None:
103
+ continue
104
+
105
+ # Check that the default type is of the type we expect
106
+ if not isinstance(input.default, type_map[input.type]):
107
+ raise VE(
108
+ f"Unable to verify action '{action.name}', default value for input '{input.name}' is of the wrong type"
109
+ f", expected '{input.type}', but got '{type(input.default)}'")
110
+
111
+ # If we expect an array, validate that all elements are of the correct type
112
+ if input.type == "array":
113
+ # we need to ignore the type here as both PyCharm and mypy don't know we validated the file already and
114
+ # we know that there myst be option.items when the type is array
115
+ item_type = type_map[input.items] # type: ignore
116
+ # noinspection PyTypeChecker
117
+ for item in input.default:
118
+ if item_type != type(item):
119
+ raise VE(f"Default item: {item}, should be of type: {item_type}, "
120
+ f"but is of type: {type(item)}")
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from abc import ABC
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any, Dict, Optional, List, TypedDict
8
+
9
+
10
+ @dataclass
11
+ class PythonActionDefinitionInput:
12
+ name: str
13
+ description: str
14
+ required: bool
15
+ type: str
16
+ default: Optional[str] | Optional[List[Any]] = None
17
+ items: Optional[str] = None
18
+
19
+ @classmethod
20
+ def of(cls, name: str, yaml: Dict[str, Any]) -> PythonActionDefinitionInput:
21
+ default = yaml.get("default", None)
22
+ items = yaml.get("items", None)
23
+ return cls(
24
+ name=name,
25
+ description=yaml["description"],
26
+ required=yaml["required"],
27
+ type=yaml["type"],
28
+ default=default,
29
+ items=items
30
+ )
31
+
32
+ def to_schema_definition(self) -> str:
33
+
34
+ properties = [f'"type": "{self.type}"']
35
+ if self.type == "array":
36
+ properties.append(f'"items": {{ "type": "{self.items}" }}')
37
+ if self.default is not None:
38
+ properties.append(f'"default": {json.dumps(self.default)}')
39
+ joined = ",\n ".join(properties)
40
+ return f'"{self.name}": {{\n {joined}\n}}'
41
+
42
+
43
+ @dataclass
44
+ class ActionDefinition(ABC):
45
+ id: str
46
+ name: str
47
+ description: str
48
+ inputs: List[PythonActionDefinitionInput]
49
+ path: str
50
+
51
+ @staticmethod
52
+ def _dict_to_inputs(dictionary: Dict[str, Any]) -> List[PythonActionDefinitionInput]:
53
+ return [PythonActionDefinitionInput.of(key, value) for key, value in dictionary.items()]
54
+
55
+
56
+ @dataclass
57
+ class PythonActionDefinition(ActionDefinition):
58
+ main: str
59
+
60
+ @classmethod
61
+ def of(cls, yaml: Dict[str, Any], path: str) -> PythonActionDefinition:
62
+ inputs = yaml.get("inputs", {})
63
+ return cls(
64
+ id=yaml["id"],
65
+ name=yaml["name"],
66
+ description=yaml["description"],
67
+ inputs=ActionDefinition._dict_to_inputs(inputs),
68
+ main=yaml["runs"]["main"],
69
+ path=path
70
+ )
71
+
72
+
73
+ @dataclass
74
+ class CompositeActionDefinition(ActionDefinition):
75
+ steps: List[Any]
76
+
77
+ @classmethod
78
+ def of(cls, yaml: Dict[str, Any], path: str) -> CompositeActionDefinition:
79
+ inputs = yaml.get("inputs", {})
80
+ return cls(
81
+ id=yaml["id"],
82
+ name=yaml["name"],
83
+ description=yaml["description"],
84
+ steps=yaml["runs"]["steps"],
85
+ inputs=ActionDefinition._dict_to_inputs(inputs),
86
+ path=path
87
+ )
@@ -0,0 +1,8 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class ActionProperties:
6
+ organization: str
7
+ name: str
8
+ version: str
@@ -0,0 +1,12 @@
1
+ class ValidationError(Exception):
2
+
3
+ def __init__(self, message: str):
4
+ super().__init__(message)
5
+ self.message = message
6
+
7
+
8
+ class DependencyError(Exception):
9
+
10
+ def __init__(self, message: str):
11
+ super().__init__(message)
12
+ self.message = message
@@ -0,0 +1,146 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "additionalProperties": false,
4
+ "title": "prepare-assignment",
5
+ "type": "object",
6
+ "properties": {
7
+ "id": {
8
+ "type": "string",
9
+ "pattern": "^[_a-zA-Z][a-zA-Z0-9_-]*$"
10
+ },
11
+ "name": {
12
+ "description": "Name of the action",
13
+ "type": "string"
14
+ },
15
+ "description": {
16
+ "description": "Description of what the action does",
17
+ "type": "string"
18
+ },
19
+ "inputs": {
20
+ "description": "Input parameters passed to the action",
21
+ "type": "object",
22
+ "patternProperties": {
23
+ "^[_a-zA-Z][a-zA-Z0-9_-]*$": {
24
+ "type": "object",
25
+ "properties": {
26
+ "description": {
27
+ "description": "Description of the input parameter",
28
+ "type": "string"
29
+ },
30
+ "required": {
31
+ "description": "A boolean to indicate if the input parameter is required",
32
+ "type": "boolean",
33
+ "default": false
34
+ },
35
+ "default": {
36
+ "description": "The default value of the input parameter"
37
+ },
38
+ "type": {
39
+ "description": "Type of the default value",
40
+ "type": "string",
41
+ "enum": [
42
+ "string",
43
+ "number",
44
+ "integer",
45
+ "array",
46
+ "boolean"
47
+ ],
48
+ "default": "string"
49
+ },
50
+ "items": {
51
+ "description": "If the default value is an array, specify the type of the items",
52
+ "type": "string",
53
+ "enum": [
54
+ "string",
55
+ "number",
56
+ "integer",
57
+ "boolean"
58
+ ]
59
+ }
60
+ },
61
+ "allOf": [
62
+ {
63
+ "if": {
64
+ "properties": {
65
+ "type": {
66
+ "const": "array"
67
+ }
68
+ },
69
+ "required": [
70
+ "type"
71
+ ]
72
+ },
73
+ "then": {
74
+ "required": [
75
+ "description",
76
+ "type",
77
+ "items"
78
+ ]
79
+ },
80
+ "else": {
81
+ "required": [
82
+ "description"
83
+ ],
84
+ "properties": {
85
+ "items": {
86
+ "not": {}
87
+ }
88
+ }
89
+ }
90
+ }
91
+ ],
92
+ "additionalProperties": false
93
+ }
94
+ }
95
+ },
96
+ "runs": {
97
+ "type": "object",
98
+ "additionalProperties": false,
99
+ "properties": {
100
+ "using": {
101
+ "type": "string",
102
+ "enum": [
103
+ "python",
104
+ "composite"
105
+ ]
106
+ },
107
+ "main": {
108
+ "type": "string"
109
+ },
110
+ "steps": { "type": "array"}
111
+ },
112
+ "allOf": [
113
+ {
114
+ "if": {
115
+ "properties": {
116
+ "using": {
117
+ "const": "python"
118
+ }
119
+ },
120
+ "required": [
121
+ "using"
122
+ ]
123
+ },
124
+ "then": {
125
+ "required": [
126
+ "using",
127
+ "main"
128
+ ]
129
+ },
130
+ "else": {
131
+ "required": [
132
+ "using",
133
+ "steps"
134
+ ]
135
+ }
136
+ }
137
+ ]
138
+ }
139
+ },
140
+ "required": [
141
+ "id",
142
+ "name",
143
+ "description",
144
+ "runs"
145
+ ]
146
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://github.com/{{organization}}/{{action-id}}/{{action-id}}.schema.json",
4
+ "additionalProperties": false,
5
+ "title": "{{action-name}}",
6
+ "description": "{{action-description}}",
7
+ "type": "object",
8
+ "properties": {
9
+ "name": {
10
+ "type": "string"
11
+ },
12
+ "id": {
13
+ "type": "string"
14
+ },
15
+ "uses": {
16
+ "const": "{{action-id}}"
17
+ }{{with}}
18
+ },
19
+ "required": [
20
+ "uses"{{required}}
21
+ ]
22
+ }
@@ -0,0 +1,51 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "title": "prepare-assignment",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "properties": {
7
+ "name": {
8
+ "type": "string"
9
+ },
10
+ "steps": {
11
+ "type": "object",
12
+ "patternProperties": {
13
+ "^[_a-zA-Z][a-zA-Z0-9_-]*$": {
14
+ "type": "array",
15
+ "items": {
16
+ "anyOf": [
17
+ {
18
+ "type": "object",
19
+ "properties": {
20
+ "name": {
21
+ "type": "string"
22
+ },
23
+ "uses": {
24
+ "type": "string"
25
+ }
26
+ },
27
+ "required": ["name", "uses"]
28
+ },
29
+ {
30
+ "type": "object",
31
+ "properties": {
32
+ "name": {
33
+ "type": "string"
34
+ },
35
+ "run": {
36
+ "type": "string"
37
+ }
38
+ },
39
+ "required": ["name", "run"]
40
+ }
41
+ ]
42
+ }
43
+ }
44
+ }
45
+ }
46
+ },
47
+ "required": [
48
+ "name",
49
+ "steps"
50
+ ]
51
+ }
@@ -0,0 +1,3 @@
1
+ from prepare_assignment.utils.cache import get_cache_path
2
+ from prepare_assignment.utils.default_validator import DefaultValidatingValidator
3
+ from prepare_assignment.utils.logger import set_logger_level
@@ -0,0 +1,23 @@
1
+ import os
2
+ import sys
3
+
4
+ from pathlib import Path
5
+
6
+
7
+ def get_cache_path() -> Path:
8
+ """
9
+ Get the path to the default cache location for applications
10
+ :return: Path to the OS specific application cache
11
+ :raises: AssertionError: if OS is not one of Linux, macOS or Windows
12
+ """
13
+ if sys.platform == "linux":
14
+ cache = os.environ.get("XDG_CACHE_HOME")
15
+ if cache is None:
16
+ cache = "~/.cache"
17
+ return Path(f"{cache}/prepare_assignment").expanduser()
18
+ elif sys.platform == "darwin":
19
+ return Path("~/Library/Caches/prepare_assignment").expanduser()
20
+ elif sys.platform == "win32":
21
+ lad = f"{os.environ.get('LOCALAPPDATA')}"
22
+ return Path(os.path.join(lad, "prepare_assignment", "cache"))
23
+ raise AssertionError("Unsupported OS")
@@ -0,0 +1,24 @@
1
+ from jsonschema import validators, Draft7Validator
2
+
3
+
4
+ # Adapted from:
5
+ # https://python-jsonschema.readthedocs.io/en/stable/faq/#why-doesn-t-my-schema-s-default-property-set-the-default-on-my-instance
6
+ def extend_with_default(validator_class):
7
+ validate_properties = validator_class.VALIDATORS["properties"]
8
+
9
+ def set_defaults(validator, properties, instance, schema):
10
+ for property, subschema in properties.items():
11
+ if "default" in subschema:
12
+ instance.setdefault(property, subschema["default"])
13
+
14
+ for error in validate_properties(
15
+ validator, properties, instance, schema,
16
+ ):
17
+ yield error
18
+
19
+ return validators.extend(
20
+ validator_class, {"properties": set_defaults},
21
+ )
22
+
23
+
24
+ DefaultValidatingValidator = extend_with_default(Draft7Validator)
@@ -0,0 +1,51 @@
1
+ import logging
2
+ from typing import Dict
3
+
4
+
5
+ class ColourFormatter(logging.Formatter):
6
+ """
7
+ Custom logger that adds color based on level
8
+ Taken from: https://stackoverflow.com/questions/384076/how-can-i-color-python-logging-output
9
+ """
10
+
11
+ light_blue = "\x1b[1;34m"
12
+ grey = "\x1b[38;20m"
13
+ yellow = "\x1b[33;20m"
14
+ red = "\x1b[31;20m"
15
+ bold_red = "\x1b[31;1m"
16
+ reset = "\x1b[0m"
17
+ format_log_debug = "%(levelname)s - %(message)s (%(filename)s:%(lineno)d)"
18
+ format_log = "%(levelname)s - %(message)s"
19
+
20
+ FORMATS: Dict[int, str] = {
21
+ logging.DEBUG: grey + format_log_debug + reset,
22
+ logging.INFO: light_blue + format_log + reset,
23
+ logging.WARNING: yellow + format_log + reset,
24
+ logging.ERROR: red + format_log + reset,
25
+ logging.CRITICAL: bold_red + format_log + reset
26
+ }
27
+
28
+ def format(self, record: logging.LogRecord) -> str:
29
+ log_fmt = self.FORMATS.get(record.levelno)
30
+ formatter = logging.Formatter(log_fmt)
31
+ return formatter.format(record)
32
+
33
+
34
+ def set_logger_level(logger: logging.Logger, verbosity: int = 0, add_colours: bool = True) -> None:
35
+ handler = logging.StreamHandler()
36
+ if add_colours:
37
+ handler.setFormatter(ColourFormatter())
38
+ if verbosity == 0:
39
+ logger.setLevel(logging.ERROR)
40
+ handler.setLevel(logging.ERROR)
41
+ elif verbosity == 1:
42
+ logger.setLevel(logging.WARNING)
43
+ handler.setLevel(logging.WARNING)
44
+ elif verbosity == 2:
45
+ logger.setLevel(logging.INFO)
46
+ handler.setLevel(logging.INFO)
47
+ elif verbosity >= 3:
48
+ logger.setLevel(logging.DEBUG)
49
+ handler.setLevel(logging.DEBUG)
50
+ logger.addHandler(handler)
51
+ logger.propagate = False
@@ -0,0 +1,27 @@
1
+ [tool.poetry]
2
+ name = "prepare-assignment"
3
+ version = "0.1.0"
4
+ description = "Prepare assignment"
5
+ authors = ["Bonajo <m.bonajo@fontys.nl>"]
6
+ license = "MIT"
7
+ readme = "README.md"
8
+ packages = [{include = "prepare_assignment"}]
9
+
10
+ [build-system]
11
+ requires = ["poetry-core"]
12
+ build-backend = "poetry.core.masonry.api"
13
+
14
+ [tool.poetry.dependencies]
15
+ python = "^3.8"
16
+ ruamel-yaml = "^0.17.21"
17
+ jsonschema = "^4.17.3"
18
+ importlib-resources = "^5.12.0"
19
+ gitpython = "^3.1.31"
20
+ prepare-toolbox = "^0.2.0"
21
+ virtualenv = "^20.24.5"
22
+
23
+ [tool.poetry.group.test.dependencies]
24
+ mypy = "^1.1.1"
25
+ types-jsonschema = "^4.17.0.6"
26
+ pytest = "^7.4.0"
27
+ pytest-cov = "^4.1.0"