prepare-assignment 0.2.0__tar.gz → 0.3.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.
Files changed (68) hide show
  1. prepare_assignment-0.3.0/.github/workflows/release.yml +30 -0
  2. prepare_assignment-0.3.0/.github/workflows/test.yml +63 -0
  3. prepare_assignment-0.3.0/.gitignore +165 -0
  4. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/PKG-INFO +17 -23
  5. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/cli/main.py +35 -4
  6. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/cli/task.py +1 -1
  7. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/core/command.py +19 -2
  8. prepare_assignment-0.3.0/prepare_assignment/core/expression.py +137 -0
  9. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/core/main.py +11 -5
  10. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/core/preparer.py +80 -10
  11. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/core/runner.py +45 -12
  12. prepare_assignment-0.3.0/prepare_assignment/core/subsituter.py +84 -0
  13. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/core/task_handler.py +3 -4
  14. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/data/constants.py +1 -10
  15. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/data/errors.py +7 -0
  16. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/data/job_environment.py +2 -1
  17. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/data/prepare.py +5 -2
  18. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/data/task_properties.py +2 -2
  19. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/schemas/prepare.schema.json +13 -1
  20. prepare_assignment-0.3.0/pyproject.toml +49 -0
  21. prepare_assignment-0.3.0/tests/__init__.py +0 -0
  22. prepare_assignment-0.3.0/tests/core/__init__.py +0 -0
  23. prepare_assignment-0.3.0/tests/core/test_command.py +121 -0
  24. prepare_assignment-0.3.0/tests/core/test_expression.py +212 -0
  25. prepare_assignment-0.3.0/tests/core/test_preparer.py +159 -0
  26. prepare_assignment-0.3.0/tests/core/test_resolve_version.py +139 -0
  27. prepare_assignment-0.3.0/tests/core/test_runner.py +376 -0
  28. prepare_assignment-0.3.0/tests/core/test_substituter.py +101 -0
  29. prepare_assignment-0.3.0/tests/core/test_validator.py +140 -0
  30. prepare_assignment-0.3.0/tests/test_main.py +66 -0
  31. prepare_assignment-0.3.0/tests/testproject/prepare.yml +22 -0
  32. prepare_assignment-0.3.0/tests/testproject/solution/pom.xml +19 -0
  33. prepare_assignment-0.3.0/tests/testproject/solution/src/main/java/gradelist/AssessmentResult.java +36 -0
  34. prepare_assignment-0.3.0/tests/testproject/solution/src/main/java/gradelist/GradeList.java +40 -0
  35. prepare_assignment-0.3.0/tests/testproject/solution/src/main/java/gradelist/Test.java +3 -0
  36. prepare_assignment-0.3.0/tests/testproject/solution/src/test/java/gradelist/AssessmentResultTest.java +65 -0
  37. prepare_assignment-0.3.0/tests/testproject/solution/src/test/java/gradelist/GradeListTest.java +67 -0
  38. prepare_assignment-0.3.0/tests/utils/__init__.py +0 -0
  39. prepare_assignment-0.3.0/tests/utils/test_default_validator.py +39 -0
  40. prepare_assignment-0.3.0/tests/utils/test_logger.py +90 -0
  41. prepare_assignment-0.3.0/tests/utils/test_paths.py +98 -0
  42. prepare_assignment-0.3.0/uv.lock +1401 -0
  43. prepare_assignment-0.2.0/prepare_assignment/core/subsituter.py +0 -102
  44. prepare_assignment-0.2.0/pyproject.toml +0 -42
  45. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/LICENSE +0 -0
  46. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/README.md +0 -0
  47. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/__init__.py +0 -0
  48. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/__main__.py +0 -0
  49. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/cli/__init__.py +0 -0
  50. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/cli/tasks.py +0 -0
  51. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/core/__init__.py +0 -0
  52. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/core/validator.py +0 -0
  53. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/data/__init__.py +0 -0
  54. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/data/config.py +0 -0
  55. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/data/task_definition.py +0 -0
  56. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/schemas/config.schema.json +0 -0
  57. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/schemas/task.schema.json +0 -0
  58. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/schemas/task.schema.json_template +0 -0
  59. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/utils/__init__.py +0 -0
  60. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/utils/config.py +0 -0
  61. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/utils/default_validator.py +0 -0
  62. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/utils/dependency.py +0 -0
  63. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/utils/executables.py +0 -0
  64. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/utils/logger.py +0 -0
  65. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/utils/paths.py +0 -0
  66. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/utils/tasks.py +0 -0
  67. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/utils/virtual_env.py +0 -0
  68. {prepare_assignment-0.2.0 → prepare_assignment-0.3.0}/prepare_assignment/utils/yml_loader.py +0 -0
@@ -0,0 +1,30 @@
1
+ name: Release Prepare to PyPi
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - 'releases/**'
7
+ tags:
8
+ - 'v*'
9
+
10
+ jobs:
11
+ release:
12
+ strategy:
13
+ matrix:
14
+ python-version: ["3.9"]
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v7
20
+ with:
21
+ enable-cache: true
22
+ cache-dependency-glob: "uv.lock"
23
+ - name: Set up Python ${{ matrix.python-version }}
24
+ run: uv python install ${{ matrix.python-version }}
25
+ - name: Install dependencies
26
+ run: uv sync
27
+ - name: Build
28
+ run: uv build
29
+ - name: Publish
30
+ run: uv publish --token ${{ secrets.PYPI_TOKEN }}
@@ -0,0 +1,63 @@
1
+ name: Test
2
+
3
+ defaults:
4
+ run:
5
+ shell: bash
6
+
7
+ on:
8
+ push:
9
+ branches: [ "main" ]
10
+ pull_request:
11
+ branches: [ "main" ]
12
+
13
+ jobs:
14
+ test:
15
+ strategy:
16
+ fail-fast: false
17
+ matrix:
18
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
19
+ os: ["ubuntu-latest", "windows-latest", "macos-latest"]
20
+ runs-on: ${{ matrix.os }}
21
+ permissions:
22
+ contents: read
23
+ checks: write
24
+ steps:
25
+ - uses: actions/checkout@v4
26
+ - name: Install uv
27
+ uses: astral-sh/setup-uv@v7
28
+ with:
29
+ enable-cache: true
30
+ cache-dependency-glob: "uv.lock"
31
+ - name: Set up Python ${{ matrix.python-version }}
32
+ run: uv python install ${{ matrix.python-version }}
33
+ - name: Install dependencies
34
+ run: uv sync --group test
35
+ - name: Run tests
36
+ env:
37
+ PREPARE_TEST_GIT_MODE: https
38
+ run: uv run pytest --cov --cov-report=xml --junitxml=tests.xml
39
+ - name: Test Report
40
+ uses: mikepenz/action-junit-report@v6
41
+ if: success() || failure()
42
+ with:
43
+ check_name: Test Results (Python version ${{ matrix.python-version }})
44
+ report_paths: tests.xml
45
+
46
+ typecheck:
47
+ strategy:
48
+ matrix:
49
+ python-version: [ "3.12" ]
50
+ runs-on: "ubuntu-latest"
51
+ steps:
52
+ - uses: actions/checkout@v4
53
+ - name: Install uv
54
+ uses: astral-sh/setup-uv@v7
55
+ with:
56
+ enable-cache: true
57
+ cache-dependency-glob: "uv.lock"
58
+ - name: Set up Python ${{ matrix.python-version }}
59
+ run: uv python install ${{ matrix.python-version }}
60
+ - name: Install dependencies
61
+ run: uv sync --group test
62
+ - name: Type checker
63
+ run: uv run mypy prepare_assignment
@@ -0,0 +1,165 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py,cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ #Pipfile.lock
96
+
97
+ # poetry
98
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102
+ #poetry.lock
103
+
104
+ # pdm
105
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
106
+ #pdm.lock
107
+ # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
108
+ # in version control.
109
+ # https://pdm.fming.dev/#use-with-ide
110
+ .pdm.toml
111
+
112
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
113
+ __pypackages__/
114
+
115
+ # Celery stuff
116
+ celerybeat-schedule
117
+ celerybeat.pid
118
+
119
+ # SageMath parsed files
120
+ *.sage.py
121
+
122
+ # Environments
123
+ .env
124
+ .venv
125
+ env/
126
+ venv/
127
+ ENV/
128
+ env.bak/
129
+ venv.bak/
130
+
131
+ # Spyder project settings
132
+ .spyderproject
133
+ .spyproject
134
+
135
+ # Rope project settings
136
+ .ropeproject
137
+
138
+ # mkdocs documentation
139
+ /site
140
+
141
+ # mypy
142
+ .mypy_cache/
143
+ .dmypy.json
144
+ dmypy.json
145
+
146
+ # Pyre type checker
147
+ .pyre/
148
+
149
+ # pytype static type analyzer
150
+ .pytype/
151
+
152
+ # Cython debug symbols
153
+ cython_debug/
154
+
155
+ # PyCharm
156
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
157
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
158
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
159
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
160
+ .idea/
161
+ exampleproject/
162
+ tests.xml
163
+ out.txt
164
+ out/
165
+ .vscode
@@ -1,28 +1,23 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: prepare-assignment
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Prepare assignment
5
+ Author-email: Bonajo <m.bonajo@fontys.nl>
5
6
  License: MIT
6
- Author: Bonajo
7
- Author-email: m.bonajo@fontys.nl
8
- Requires-Python: >=3.9,<4.0
9
- Classifier: License :: OSI Approved :: MIT License
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Programming Language :: Python :: 3.9
12
- Classifier: Programming Language :: Python :: 3.10
13
- Classifier: Programming Language :: Python :: 3.11
14
- Classifier: Programming Language :: Python :: 3.12
15
- Classifier: Programming Language :: Python :: 3.13
16
- Requires-Dist: dacite (>=1.9.2,<2.0.0)
17
- Requires-Dist: gitpython (>=3.1.41,<4.0.0)
18
- Requires-Dist: importlib-resources (>=6.1.1,<7.0.0)
19
- Requires-Dist: jsonschema (>=4.21.1,<5.0.0)
20
- Requires-Dist: multipledispatch (>=1.0.0,<2.0.0)
21
- Requires-Dist: prepare-toolbox (==0.4.0)
22
- Requires-Dist: ruamel-yaml (>=0.18.5,<0.19.0)
23
- Requires-Dist: treelib (>=1.7.0,<2.0.0)
24
- Requires-Dist: typer[all] (>=0.9.0,<0.10.0)
25
- Requires-Dist: virtualenv (>=20.25.0,<21.0.0)
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: dacite>=1.9.2
10
+ Requires-Dist: gitpython>=3.1.41
11
+ Requires-Dist: importlib-resources>=6.1.1
12
+ Requires-Dist: jsonschema>=4.21.1
13
+ Requires-Dist: multipledispatch>=1.0.0
14
+ Requires-Dist: packaging>=24.0
15
+ Requires-Dist: prepare-toolbox==0.4.0
16
+ Requires-Dist: ruamel-yaml>=0.18.5
17
+ Requires-Dist: simpleeval>=1.0.0
18
+ Requires-Dist: treelib>=1.7.0
19
+ Requires-Dist: typer==0.17.3
20
+ Requires-Dist: virtualenv>=20.25.0
26
21
  Description-Content-Type: text/markdown
27
22
 
28
23
  # Prepare assignment
@@ -124,4 +119,3 @@ It is possible to create custom (python/composite) tasks.
124
119
  3. Validate that the task definition is correct against the [json schema](https://github.com/prepare-assignment/core/blob/main/prepare_assignment/schemas/task.schema.json)
125
120
  4. If python task, create a script that implements desired functionality
126
121
 
127
-
@@ -1,6 +1,6 @@
1
1
  import os.path
2
2
  import sys
3
- from typing import Optional
3
+ from typing import Dict, List, Optional
4
4
 
5
5
  import typer
6
6
  from typing_extensions import Annotated
@@ -31,8 +31,9 @@ def main(ctx: typer.Context):
31
31
  typer.echo(f"Virtual env: {get_virtualenv_name()}")
32
32
 
33
33
 
34
- @app.command()
34
+ @app.command(context_settings={"allow_extra_args": True, "ignore_unknown_options": True})
35
35
  def run(
36
+ ctx: typer.Context,
36
37
  file_name: Annotated[
37
38
  Optional[str],
38
39
  typer.Option("--file", "-f", help="Configuration file")
@@ -48,7 +49,11 @@ def run(
48
49
  verbose: Annotated[
49
50
  int,
50
51
  typer.Option("--verbose", "-v", count=True, help="increase task output verbosity")
51
- ] = CONFIG.core.verbose
52
+ ] = CONFIG.core.verbose,
53
+ env: Annotated[
54
+ Optional[List[str]],
55
+ typer.Option("-e", "--env", help="Set environment variable (KEY=VALUE)")
56
+ ] = None,
52
57
  ):
53
58
  """
54
59
  Parse 'prepare_assignment.y(a)ml' and execute all jobs
@@ -57,5 +62,31 @@ def run(
57
62
  CONFIG.core.git_mode = git # type: ignore
58
63
  CONFIG.core.verbose = verbose # type: ignore
59
64
 
60
- prepare(file_name)
65
+ env_vars: Dict[str, str] = {}
66
+ for item in (env or []):
67
+ if "=" not in item:
68
+ raise typer.BadParameter("Environment variables must be in KEY=VALUE format.", param_hint="-e/--env")
69
+ key, value = item.split("=", 1)
70
+ if not key:
71
+ raise typer.BadParameter("Environment variable name cannot be empty.", param_hint="-e/--env")
72
+ env_vars[key] = value
73
+ # ctx.args only contains arguments unknown to Click (known options like --debug,
74
+ # --verbose are consumed by the parser and never appear here)
75
+ for arg in ctx.args:
76
+ if not arg.startswith("--"):
77
+ continue
78
+ key_value = arg[2:]
79
+ if not key_value:
80
+ continue
81
+ if "=" in key_value:
82
+ key, value = key_value.split("=", 1)
83
+ if not key:
84
+ continue
85
+ else:
86
+ key, value = key_value, "true"
87
+ env_vars[key] = value
61
88
 
89
+ try:
90
+ prepare(file_name, env_vars)
91
+ except Exception:
92
+ raise typer.Exit(code=1)
@@ -50,7 +50,7 @@ def display_update(
50
50
  update(task, recursive)
51
51
  except Exception as e:
52
52
  logger.exception(e)
53
- typer.Abort()
53
+ raise typer.Abort()
54
54
 
55
55
 
56
56
  @app.command("add")
@@ -21,6 +21,9 @@ def handle_set_failed(environment: JobEnvironment, params: List[str]) -> None:
21
21
 
22
22
 
23
23
  def handle_set_output(environment: JobEnvironment, params: List[str]) -> None:
24
+ if environment.current_task_definition is None:
25
+ logger.warning("'set-output' is not supported for shell commands and will be ignored")
26
+ return
24
27
  if len(params) < 2:
25
28
  raise AssertionError(f"Missing required params for 'set_failed'")
26
29
  try:
@@ -33,11 +36,11 @@ def handle_set_output(environment: JobEnvironment, params: List[str]) -> None:
33
36
  definition = environment.current_task_definition.outputs.get(key, None) # type: ignore
34
37
  if definition is None:
35
38
  logger.warning(f"Trying to set output '{key}', but is not defined in task. Skipping for now.")
36
- break
39
+ continue
37
40
  expected_type = TYPE_MAPPING.get(definition.type, None)
38
41
  if expected_type is None or not isinstance(value, expected_type):
39
42
  logger.warning(f"Output '{key}' is of type '{type(value)}', but expected '{definition.type}'")
40
- break
43
+ continue
41
44
  environment.outputs[environment.current_task.key][key] = value # type: ignore
42
45
 
43
46
 
@@ -65,9 +68,23 @@ def handle_debug(environment: JobEnvironment, params: List[str]) -> None:
65
68
  logger.debug(__handle_message(params[0]))
66
69
 
67
70
 
71
+ def handle_set_env(environment: JobEnvironment, params: List[str]) -> None:
72
+ if len(params) < 2:
73
+ raise AssertionError(f"Missing required params for 'set-env'")
74
+ name = unquote_plus(params[0])
75
+ try:
76
+ value = json.loads(params[1])
77
+ except JSONDecodeError:
78
+ raise AssertionError(f"'set-env' expects a JSON-encoded value as second param")
79
+ if not isinstance(value, str):
80
+ raise AssertionError(f"'set-env' expects a string value")
81
+ environment.environment[name] = value
82
+
83
+
68
84
  COMMAND_MAPPING = {
69
85
  "set-failed": handle_set_failed,
70
86
  "set-output": handle_set_output,
87
+ "set-env": handle_set_env,
71
88
  "error": handle_error,
72
89
  "warning": handle_warning,
73
90
  "info": handle_info,
@@ -0,0 +1,137 @@
1
+ import re
2
+ from typing import Any, Dict
3
+
4
+ from simpleeval import EvalWithCompoundTypes, InvalidExpression
5
+
6
+ from prepare_assignment.data.job_environment import JobEnvironment
7
+
8
+ _OP_RE = re.compile(r'&&|\|\||!(?!=)')
9
+ # Match attribute access where the name contains hyphens (not valid Python identifiers)
10
+ # e.g. .stripped-files → ["stripped-files"]
11
+ _HYPHEN_ATTR_RE = re.compile(r'\.([a-zA-Z_][a-zA-Z0-9_]*(?:-[a-zA-Z0-9_]+)+)')
12
+ # Matches quoted strings (to preserve them) OR bare true/false literals to capitalise.
13
+ # Group 1 is set only for bare boolean literals; quoted strings are captured but not replaced.
14
+ _BOOL_RE = re.compile(r"'[^']*'|\"[^\"]*\"|\b(true|false)\b")
15
+
16
+
17
+ def _preprocess(expr: str) -> str:
18
+ def replace_op(m: re.Match) -> str: # type: ignore
19
+ token = m.group(0)
20
+ if token == "&&":
21
+ return " and "
22
+ if token == "||":
23
+ return " or "
24
+ return "not "
25
+
26
+ def replace_bool(m: re.Match) -> str: # type: ignore
27
+ # Only replace when group 1 matched (bare literal, not inside quotes)
28
+ if m.group(1) is not None:
29
+ return str(m.group(1)).capitalize()
30
+ return str(m.group(0))
31
+
32
+ expr = _OP_RE.sub(replace_op, expr)
33
+ expr = _HYPHEN_ATTR_RE.sub(lambda m: f'["{m.group(1)}"]', expr)
34
+ expr = _BOOL_RE.sub(replace_bool, expr)
35
+ return expr
36
+
37
+
38
+ class _Namespace:
39
+ """Wraps a dict to allow attribute-style access (e.g. inputs.foo, tasks.step.outputs.bar)."""
40
+
41
+ def __init__(self, d: Dict[str, Any]) -> None:
42
+ object.__setattr__(self, "_d", d)
43
+
44
+ def __getattr__(self, name: str) -> Any:
45
+ if name.startswith("_"):
46
+ raise AttributeError(name)
47
+ d: Dict[str, Any] = object.__getattribute__(self, "_d")
48
+ _MISSING = object()
49
+ val = d.get(name, _MISSING)
50
+ if val is _MISSING:
51
+ raise AttributeError(name)
52
+ return _Namespace(val) if isinstance(val, dict) else val
53
+
54
+ def __getitem__(self, key: str) -> Any:
55
+ d: Dict[str, Any] = object.__getattribute__(self, "_d")
56
+ val = d[key]
57
+ return _Namespace(val) if isinstance(val, dict) else val
58
+
59
+ def __contains__(self, item: Any) -> bool:
60
+ d: Dict[str, Any] = object.__getattribute__(self, "_d")
61
+ return item in d
62
+
63
+
64
+ def _coerce_env(env: Dict[str, str]) -> Dict[str, Any]:
65
+ """Convert string "true"/"false" env var values to Python booleans."""
66
+ result: Dict[str, Any] = {}
67
+ for k, v in env.items():
68
+ if v.lower() == "true":
69
+ result[k] = True
70
+ elif v.lower() == "false":
71
+ result[k] = False
72
+ else:
73
+ result[k] = v
74
+ return result
75
+
76
+
77
+ def _build_names(environment: JobEnvironment) -> Dict[str, Any]:
78
+ tasks_ctx = {
79
+ step: {"outputs": outputs}
80
+ for step, outputs in environment.outputs.items()
81
+ }
82
+ return {
83
+ "inputs": _Namespace(environment.inputs),
84
+ "env": _Namespace(_coerce_env(environment.environment)),
85
+ "tasks": _Namespace(tasks_ctx),
86
+ }
87
+
88
+
89
+ def _contains(collection: Any, item: Any) -> bool:
90
+ return item in collection
91
+
92
+
93
+ def _starts_with(s: str, prefix: str) -> bool:
94
+ return s.startswith(prefix)
95
+
96
+
97
+ def _ends_with(s: str, suffix: str) -> bool:
98
+ return s.endswith(suffix)
99
+
100
+
101
+ def _build_functions(environment: JobEnvironment) -> Dict[str, Any]:
102
+ return {
103
+ "contains": _contains,
104
+ "startsWith": _starts_with,
105
+ "endsWith": _ends_with,
106
+ "success": lambda: not environment.job_failed,
107
+ "failure": lambda: environment.job_failed,
108
+ "always": lambda: True,
109
+ "never": lambda: False,
110
+ }
111
+
112
+
113
+ def evaluate(expr: str, environment: JobEnvironment) -> Any:
114
+ """
115
+ Evaluate an expression, optionally wrapped in ${{ }}.
116
+ Returns the typed result.
117
+ """
118
+ stripped = expr.strip()
119
+ m = re.fullmatch(r'\${{\s*(.*?)\s*}}', stripped, re.DOTALL)
120
+ if m:
121
+ stripped = m.group(1).strip()
122
+
123
+ processed = _preprocess(stripped)
124
+ names = _build_names(environment)
125
+ evaluator = EvalWithCompoundTypes(names=names, functions=_build_functions(environment))
126
+ return evaluator.eval(processed)
127
+
128
+
129
+ def evaluate_condition(expr: str, environment: JobEnvironment) -> bool:
130
+ """
131
+ Evaluate an expression as a boolean condition.
132
+ """
133
+ try:
134
+ result = evaluate(expr, environment)
135
+ return bool(result)
136
+ except (InvalidExpression, Exception):
137
+ return False
@@ -1,7 +1,7 @@
1
1
  import logging
2
2
  import os
3
3
  from pathlib import Path
4
- from typing import Optional
4
+ from typing import Dict, Optional
5
5
 
6
6
  from prepare_toolbox.file import get_matching_files
7
7
 
@@ -9,7 +9,7 @@ from prepare_assignment.core.preparer import prepare_tasks
9
9
  from prepare_assignment.core.runner import run
10
10
  from prepare_assignment.core.validator import validate_prepare
11
11
  from prepare_assignment.data.constants import CONFIG
12
- from prepare_assignment.data.errors import PrepareTaskError, PrepareError
12
+ from prepare_assignment.data.errors import PrepareTaskError, PrepareError, TaskExecutionError
13
13
  from prepare_assignment.data.prepare import Prepare
14
14
  from prepare_assignment.utils.logger import add_logging_level, set_logger_level
15
15
  from prepare_assignment.utils.yml_loader import YAML_LOADER
@@ -40,7 +40,8 @@ def __get_prepare_file(file: Optional[str]) -> str:
40
40
  return file
41
41
 
42
42
 
43
- def prepare(file_name: Optional[str]) -> None:
43
+ def prepare(file_name: Optional[str], env_vars: Optional[Dict[str, str]] = None) -> None:
44
+ env_vars = env_vars or {}
44
45
  # Set the logger
45
46
  add_logging_level("TRACE", logging.DEBUG - 5, "trace")
46
47
  logger = logging.getLogger("prepare_assignment")
@@ -68,12 +69,17 @@ def prepare(file_name: Optional[str]) -> None:
68
69
  prepare = Prepare.of(yaml)
69
70
 
70
71
  # Execute
71
- run(prepare, mapping)
72
+ run(prepare, mapping, env_vars)
73
+ except TaskExecutionError as e:
74
+ logger.error(e.message)
75
+ raise
72
76
  except PrepareTaskError as PE:
73
77
  logger.error(PE.message)
74
78
  if isinstance(PE.cause, PrepareError):
75
79
  logger.error(PE.cause.message)
76
80
  else:
77
81
  logger.error(str(PE.cause))
82
+ raise
78
83
  except Exception as e:
79
- logger.error(str(e))
84
+ logger.error(str(e))
85
+ raise