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.
- prepare_assignment-0.1.0/LICENSE +21 -0
- prepare_assignment-0.1.0/PKG-INFO +36 -0
- prepare_assignment-0.1.0/README.md +14 -0
- prepare_assignment-0.1.0/prepare_assignment/__init__.py +0 -0
- prepare_assignment-0.1.0/prepare_assignment/__main__.py +4 -0
- prepare_assignment-0.1.0/prepare_assignment/core/__init__.py +0 -0
- prepare_assignment-0.1.0/prepare_assignment/core/cli.py +81 -0
- prepare_assignment-0.1.0/prepare_assignment/core/preparer.py +232 -0
- prepare_assignment-0.1.0/prepare_assignment/core/runner.py +72 -0
- prepare_assignment-0.1.0/prepare_assignment/core/validator.py +120 -0
- prepare_assignment-0.1.0/prepare_assignment/data/__init__.py +0 -0
- prepare_assignment-0.1.0/prepare_assignment/data/action_definition.py +87 -0
- prepare_assignment-0.1.0/prepare_assignment/data/action_properties.py +8 -0
- prepare_assignment-0.1.0/prepare_assignment/data/errors.py +12 -0
- prepare_assignment-0.1.0/prepare_assignment/schemas/action.schema.json +146 -0
- prepare_assignment-0.1.0/prepare_assignment/schemas/actions.schema.json_template +22 -0
- prepare_assignment-0.1.0/prepare_assignment/schemas/prepare.schema.json +51 -0
- prepare_assignment-0.1.0/prepare_assignment/utils/__init__.py +3 -0
- prepare_assignment-0.1.0/prepare_assignment/utils/cache.py +23 -0
- prepare_assignment-0.1.0/prepare_assignment/utils/default_validator.py +24 -0
- prepare_assignment-0.1.0/prepare_assignment/utils/logger.py +51 -0
- prepare_assignment-0.1.0/pyproject.toml +27 -0
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
@@ -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)}")
|
|
File without changes
|
|
@@ -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,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,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"
|