prepare-assignment 0.4.1__tar.gz → 0.4.3__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 (90) hide show
  1. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/.github/workflows/semantic-release.yml +8 -2
  2. prepare_assignment-0.4.3/.releaserc.json +9 -0
  3. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/PKG-INFO +8 -8
  4. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/README.md +6 -6
  5. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/core/preparer.py +11 -1
  6. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/core/validator.py +24 -1
  7. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/pyproject.toml +2 -2
  8. prepare_assignment-0.4.3/tests/conftest.py +25 -0
  9. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/core/test_command.py +17 -0
  10. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/core/test_preparer.py +34 -0
  11. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/core/test_validator.py +32 -1
  12. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/test_main.py +10 -0
  13. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/uv.lock +4 -4
  14. prepare_assignment-0.4.1/.releaserc.json +0 -9
  15. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  16. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  17. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/.github/dependabot.yml +0 -0
  18. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/.github/workflows/pr-title.yml +0 -0
  19. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/.github/workflows/pypi-publish.yml +0 -0
  20. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/.github/workflows/release.yml +0 -0
  21. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/.github/workflows/test.yml +0 -0
  22. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/.gitignore +0 -0
  23. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/LICENSE +0 -0
  24. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/__init__.py +0 -0
  25. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/__main__.py +0 -0
  26. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/cli/__init__.py +0 -0
  27. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/cli/main.py +0 -0
  28. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/cli/task.py +0 -0
  29. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/cli/tasks.py +0 -0
  30. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/core/__init__.py +0 -0
  31. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/core/check.py +0 -0
  32. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/core/command.py +0 -0
  33. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/core/expression.py +0 -0
  34. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/core/main.py +0 -0
  35. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/core/runner.py +0 -0
  36. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/core/shell.py +0 -0
  37. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/core/subsituter.py +0 -0
  38. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/core/task_handler.py +0 -0
  39. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/core/versions.py +0 -0
  40. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/data/__init__.py +0 -0
  41. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/data/config.py +0 -0
  42. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/data/constants.py +0 -0
  43. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/data/errors.py +0 -0
  44. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/data/job_environment.py +0 -0
  45. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/data/prepare.py +0 -0
  46. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/data/task_definition.py +0 -0
  47. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/data/task_properties.py +0 -0
  48. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/data/types.py +0 -0
  49. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/schemas/config.schema.json +0 -0
  50. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/schemas/prepare.schema.json +0 -0
  51. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/schemas/task.schema.json +0 -0
  52. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/utils/__init__.py +0 -0
  53. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/utils/config.py +0 -0
  54. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/utils/default_validator.py +0 -0
  55. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/utils/dependency.py +0 -0
  56. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/utils/files.py +0 -0
  57. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/utils/logger.py +0 -0
  58. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/utils/paths.py +0 -0
  59. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/utils/resources.py +0 -0
  60. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/utils/tasks.py +0 -0
  61. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/utils/virtual_env.py +0 -0
  62. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/prepare_assignment/utils/yml_loader.py +0 -0
  63. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/__init__.py +0 -0
  64. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/core/__init__.py +0 -0
  65. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/core/test_check.py +0 -0
  66. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/core/test_expression.py +0 -0
  67. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/core/test_resolve_version.py +0 -0
  68. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/core/test_runner.py +0 -0
  69. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/core/test_runner_process.py +0 -0
  70. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/core/test_shell.py +0 -0
  71. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/core/test_substituter.py +0 -0
  72. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/core/test_task_handler.py +0 -0
  73. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/data/__init__.py +0 -0
  74. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/data/test_prepare.py +0 -0
  75. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/data/test_types.py +0 -0
  76. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/test_dependencies.py +0 -0
  77. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/testproject/prepare.yml +0 -0
  78. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/testproject/solution/pom.xml +0 -0
  79. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/testproject/solution/src/main/java/gradelist/AssessmentResult.java +0 -0
  80. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/testproject/solution/src/main/java/gradelist/GradeList.java +0 -0
  81. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/testproject/solution/src/main/java/gradelist/Test.java +0 -0
  82. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/testproject/solution/src/test/java/gradelist/AssessmentResultTest.java +0 -0
  83. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/testproject/solution/src/test/java/gradelist/GradeListTest.java +0 -0
  84. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/utils/__init__.py +0 -0
  85. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/utils/test_config.py +0 -0
  86. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/utils/test_default_validator.py +0 -0
  87. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/utils/test_files.py +0 -0
  88. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/utils/test_logger.py +0 -0
  89. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/utils/test_paths.py +0 -0
  90. {prepare_assignment-0.4.1 → prepare_assignment-0.4.3}/tests/utils/test_resources.py +0 -0
@@ -38,11 +38,17 @@ jobs:
38
38
  node-version: 22
39
39
 
40
40
  # Pinned to an exact version: this action publishes no floating major
41
- # tags, so @v6 would not resolve. All three plugins in .releaserc.json
42
- # ship with the semantic-release core it bundles.
41
+ # tags, so @v6 would not resolve. The plugins in .releaserc.json ship
42
+ # with the semantic-release core it bundles, the conventionalcommits
43
+ # preset (which understands `feat!:`) has to be installed. Version 9:
44
+ # version 10 needs a newer changelog writer than the bundled
45
+ # release-notes-generator uses.
43
46
  - name: Semantic release
44
47
  id: semantic
45
48
  uses: cycjimmy/semantic-release-action@v6.0.0
49
+ with:
50
+ extra_plugins: |
51
+ conventional-changelog-conventionalcommits@9
46
52
  env:
47
53
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
48
54
 
@@ -0,0 +1,9 @@
1
+ {
2
+ "branches": ["main"],
3
+ "tagFormat": "v${version}",
4
+ "plugins": [
5
+ ["@semantic-release/commit-analyzer", { "preset": "conventionalcommits" }],
6
+ ["@semantic-release/release-notes-generator", { "preset": "conventionalcommits" }],
7
+ "@semantic-release/github"
8
+ ]
9
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prepare-assignment
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Prepare assignment
5
5
  Author-email: Bonajo <m.bonajo@fontys.nl>
6
6
  License: MIT
@@ -11,7 +11,7 @@ Requires-Dist: gitpython>=3.1.59
11
11
  Requires-Dist: jsonschema>=4.21.1
12
12
  Requires-Dist: multipledispatch>=1.0.0
13
13
  Requires-Dist: packaging>=24.0
14
- Requires-Dist: prepare-toolbox==0.4.0
14
+ Requires-Dist: prepare-toolbox>=1.0.1
15
15
  Requires-Dist: ruamel-yaml>=0.18.5
16
16
  Requires-Dist: simpleeval>=1.0.0
17
17
  Requires-Dist: treelib>=1.7.0
@@ -30,7 +30,7 @@ Prepare assignment is a GitHub Actions inspired helper tool to prepare assignmen
30
30
 
31
31
  ## Installation
32
32
 
33
- Prepare-assignment is available from [PyPI](https://pypi.org/).
33
+ Prepare-assignment is available from [PyPI](https://pypi.org/project/prepare-assignment/).
34
34
 
35
35
 
36
36
  ```bash
@@ -47,13 +47,13 @@ To execute a `prepare.yml` simply run `prepare run` from the same directory.
47
47
 
48
48
  ### Command line interface
49
49
 
50
- Use `prepare --help` to which commands and flags are available.
50
+ Use `prepare --help` to see which commands and flags are available.
51
51
 
52
52
  ## Example `prepare.yml`
53
53
 
54
54
  First we need to have tasks available that can be executed. Take for example a look at the [remove](https://github.com/prepare-assignment/remove) task.
55
55
 
56
- The tests use a [testproject](https://github.com/prepare-assignment/core/tree/tests/testproject), which contains an example of a `prepare.yml`, see below for convenience.
56
+ The tests use a [testproject](https://github.com/prepare-assignment/core/tree/main/tests/testproject), which contains an example of a `prepare.yml`, see below for convenience.
57
57
 
58
58
  ```yaml
59
59
  name: Test project
@@ -200,9 +200,9 @@ Releases are automated with [semantic-release](https://semantic-release.gitbook.
200
200
 
201
201
  | PR title | Release |
202
202
  |----------|---------|
203
- | `fix: ...` | patch (1.2.3 → 1.2.4) |
203
+ | `fix: ...`, `perf: ...` | patch (1.2.3 → 1.2.4) |
204
204
  | `feat: ...` | minor (1.2.3 → 1.3.0) |
205
- | `feat!: ...` or a `BREAKING CHANGE:` footer | major (1.2.3 → 2.0.0) |
206
- | `docs:`, `chore:`, `ci:`, `build:`, `refactor:`, `test:`, `style:`, `perf:` | no release |
205
+ | `!` after the type (e.g. `feat!: ...`, `refactor!: ...`) or a `BREAKING CHANGE:` footer | major (1.2.3 → 2.0.0) |
206
+ | `docs:`, `chore:`, `ci:`, `build:`, `refactor:`, `test:`, `style:`, `revert:` | no release |
207
207
 
208
208
  On every merge to `main` the next version is determined, tagged (`vX.Y.Z`), a GitHub release is created and the package is published to PyPI. The version is set during the build and is not committed, so the version in `pyproject.toml` is not the released version.
@@ -9,7 +9,7 @@ Prepare assignment is a GitHub Actions inspired helper tool to prepare assignmen
9
9
 
10
10
  ## Installation
11
11
 
12
- Prepare-assignment is available from [PyPI](https://pypi.org/).
12
+ Prepare-assignment is available from [PyPI](https://pypi.org/project/prepare-assignment/).
13
13
 
14
14
 
15
15
  ```bash
@@ -26,13 +26,13 @@ To execute a `prepare.yml` simply run `prepare run` from the same directory.
26
26
 
27
27
  ### Command line interface
28
28
 
29
- Use `prepare --help` to which commands and flags are available.
29
+ Use `prepare --help` to see which commands and flags are available.
30
30
 
31
31
  ## Example `prepare.yml`
32
32
 
33
33
  First we need to have tasks available that can be executed. Take for example a look at the [remove](https://github.com/prepare-assignment/remove) task.
34
34
 
35
- The tests use a [testproject](https://github.com/prepare-assignment/core/tree/tests/testproject), which contains an example of a `prepare.yml`, see below for convenience.
35
+ The tests use a [testproject](https://github.com/prepare-assignment/core/tree/main/tests/testproject), which contains an example of a `prepare.yml`, see below for convenience.
36
36
 
37
37
  ```yaml
38
38
  name: Test project
@@ -179,9 +179,9 @@ Releases are automated with [semantic-release](https://semantic-release.gitbook.
179
179
 
180
180
  | PR title | Release |
181
181
  |----------|---------|
182
- | `fix: ...` | patch (1.2.3 → 1.2.4) |
182
+ | `fix: ...`, `perf: ...` | patch (1.2.3 → 1.2.4) |
183
183
  | `feat: ...` | minor (1.2.3 → 1.3.0) |
184
- | `feat!: ...` or a `BREAKING CHANGE:` footer | major (1.2.3 → 2.0.0) |
185
- | `docs:`, `chore:`, `ci:`, `build:`, `refactor:`, `test:`, `style:`, `perf:` | no release |
184
+ | `!` after the type (e.g. `feat!: ...`, `refactor!: ...`) or a `BREAKING CHANGE:` footer | major (1.2.3 → 2.0.0) |
185
+ | `docs:`, `chore:`, `ci:`, `build:`, `refactor:`, `test:`, `style:`, `revert:` | no release |
186
186
 
187
187
  On every merge to `main` the next version is determined, tagged (`vX.Y.Z`), a GitHub release is created and the package is published to PyPI. The version is set during the build and is not committed, so the version in `pyproject.toml` is not the released version.
@@ -12,7 +12,7 @@ from virtualenv import cli_run
12
12
 
13
13
  from prepare_assignment.core.versions import COMMIT_HASH_RE, get_git_url, resolve_version
14
14
  from prepare_assignment.core.validator import validate_task_definition, validate_tasks, load_yaml, \
15
- validate_default_values
15
+ validate_default_values, validate_unique_ids
16
16
  from prepare_assignment.data.errors import DependencyError, ValidationError, PrepareTaskError
17
17
  from prepare_assignment.data.task_definition import TaskDefinition, CompositeTaskDefinition, \
18
18
  PythonTaskDefinition, ValidableTask
@@ -154,10 +154,19 @@ def __sub_tasks(task: TaskDefinition) -> List[Any]:
154
154
  return [step for step in task.tasks if step.get("uses", None) is not None]
155
155
 
156
156
 
157
+ def __validate_sub_task_ids(props: TaskProperties, task: TaskDefinition) -> None:
158
+ if isinstance(task, CompositeTaskDefinition):
159
+ validate_unique_ids(str(props.definition_path), f"task '{task.name}'", task.tasks)
160
+
161
+
157
162
  def __load_task_from_disk(props: TaskProperties, parsed: Dict[str, ValidableTask]) -> None:
158
163
  logger.debug(f"Task '{props}' is already available, loading from disk")
159
164
  task_yaml = load_yaml(props.definition_path)
160
165
  task = TaskDefinition.of(task_yaml, props.task_path)
166
+ try:
167
+ __validate_sub_task_ids(props, task)
168
+ except ValidationError as e:
169
+ raise PrepareTaskError(f"Unable to prepare task '{props}'", e) from e
161
170
  # Always rebuild the schema, so schemas generated by older versions of prepare are updated
162
171
  json_schema = __build_json_schema(props, task)
163
172
  with open(__schema_path(props), "r", encoding="utf-8") as handle:
@@ -181,6 +190,7 @@ def __prepare_task(props: TaskProperties) -> ValidableTask:
181
190
  task_yaml = validate_task_definition(props.definition_path)
182
191
  task: TaskDefinition = TaskDefinition.of(task_yaml, props.task_path)
183
192
  validate_default_values(task)
193
+ __validate_sub_task_ids(props, task)
184
194
  if isinstance(task, PythonTaskDefinition):
185
195
  main_path = os.path.join(props.repo_path, Path(task.main)) # type: ignore
186
196
  if not Path(main_path).resolve().is_relative_to(props.repo_path.resolve()):
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import logging
4
4
  import os
5
5
  from pathlib import Path
6
- from typing import Dict, Any
6
+ from typing import Dict, Any, List
7
7
 
8
8
  from jsonschema.exceptions import ValidationError
9
9
  from jsonschema.validators import validate
@@ -37,9 +37,32 @@ def validate_prepare(prepare_file: str, prepare: Dict[str, Any]) -> None:
37
37
  except ValidationError as ve:
38
38
  message = f"Error in: {prepare_file}, unable to verify '{ve.json_path}'\n\t -> {ve.message}"
39
39
  raise VE(message)
40
+ for job, steps in prepare["jobs"].items():
41
+ validate_unique_ids(prepare_file, f"job '{job}'", steps)
40
42
  logger.debug("✓ Prepare file is valid")
41
43
 
42
44
 
45
+ def validate_unique_ids(file: str, where: str, steps: List[Dict[str, Any]]) -> None:
46
+ """
47
+ Validate that the ids of the steps are unique, the outputs of a step are stored under its id
48
+ NOTE: steps without an id are not checked
49
+ :param file: path/name of the file that contains the steps
50
+ :param where: the job or composite task that contains the steps, used in the error message
51
+ :param steps: the steps of the job or composite task
52
+ :return: None
53
+ :raises: ValidationError: if multiple steps have the same id
54
+ """
55
+ names: Dict[str, List[str]] = {}
56
+ for step in steps:
57
+ step_id = step.get("id", None)
58
+ if step_id is not None:
59
+ names.setdefault(step_id, []).append(step.get("name", ""))
60
+ for step_id, step_names in names.items():
61
+ if len(step_names) > 1:
62
+ quoted = ", ".join(f"'{name}'" for name in step_names)
63
+ raise VE(f"Error in: {file}, {where} has multiple steps with id '{step_id}': {quoted}")
64
+
65
+
43
66
  def validate_tasks(file: str, task: Dict[str, Any], json_schema: Any) -> None:
44
67
  """
45
68
  Validate all tasks based on their respective json schemas
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prepare-assignment"
3
- version = "0.4.1"
3
+ version = "0.4.3"
4
4
  description = "Prepare assignment"
5
5
  authors = [{name = "Bonajo", email = "m.bonajo@fontys.nl"}]
6
6
  license = {text = "MIT"}
@@ -10,7 +10,7 @@ dependencies = [
10
10
  "ruamel-yaml>=0.18.5",
11
11
  "jsonschema>=4.21.1",
12
12
  "gitpython>=3.1.59",
13
- "prepare-toolbox==0.4.0",
13
+ "prepare-toolbox>=1.0.1",
14
14
  "virtualenv>=20.25.0",
15
15
  "typer==0.27.2",
16
16
  "treelib>=1.7.0",
@@ -0,0 +1,25 @@
1
+ import logging
2
+ from typing import Iterator
3
+
4
+ import pytest
5
+
6
+ # Loggers that prepare() configures (level, handlers), see prepare_assignment/core/main.py
7
+ _LOGGERS = ("prepare_assignment", "tasks")
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def reset_loggers() -> Iterator[None]:
12
+ """
13
+ Restore the prepare loggers after every test. prepare() sets their level (e.g. ERROR for verbosity 0), which made
14
+ tests that check log output with caplog fail when they ran after a test that calls prepare().
15
+ """
16
+ saved = {}
17
+ for name in _LOGGERS:
18
+ logger = logging.getLogger(name)
19
+ saved[name] = (logger.level, list(logger.handlers), logger.propagate)
20
+ yield
21
+ for name, (level, handlers, propagate) in saved.items():
22
+ logger = logging.getLogger(name)
23
+ logger.setLevel(level)
24
+ logger.handlers[:] = handlers
25
+ logger.propagate = propagate
@@ -178,3 +178,20 @@ def test_handle_set_output_wrong_item_type(caplog: pytest.LogCaptureFixture) ->
178
178
  current_task_definition=definition, current_task=task)
179
179
  handle_set_output(fresh_env, ["", json.dumps({"l": ["a", 1]})])
180
180
  assert 'l' not in fresh_env.outputs['id']
181
+
182
+
183
+ def test_toolbox_set_output_with_separator_contract(capsys: pytest.CaptureFixture) -> None:
184
+ """Values containing the separator (escaped by prepare-toolbox >= 1.0.1) must arrive unchanged."""
185
+ from pathlib import Path
186
+ from prepare_toolbox.core import set_output
187
+ from prepare_assignment.core import runner
188
+ set_output("files", ["a:PA:b.txt", Path("src") / "A.java"])
189
+ line = capsys.readouterr().out
190
+ output_definition = TaskOutputDefinition(description="files", type="array", items="string")
191
+ definition = PythonTaskDefinition(outputs={"files": output_definition}, description="t", inputs=[], id="t",
192
+ name="t", path="path", main="main.py") # type: ignore
193
+ fresh_env = JobEnvironment(environment={}, outputs={"id": {}}, inputs={},
194
+ current_task_definition=definition, current_task=task)
195
+ runner.__process_output_line(line, fresh_env) # type: ignore[attr-defined]
196
+ assert fresh_env.task_errors == []
197
+ assert fresh_env.outputs["id"]["files"] == ["a:PA:b.txt", str(Path("src") / "A.java")]
@@ -283,3 +283,37 @@ def test_outdated_schema_is_updated_on_load(mocker: MockerFixture) -> None:
283
283
  spy.assert_not_called()
284
284
  with open(schema_file) as handle:
285
285
  assert "if" in json.load(handle)["properties"]
286
+
287
+
288
+ DUPLICATE_ID_COMPOSITE: Final[Dict[str, Any]] = {
289
+ 'id': 'duplicate',
290
+ 'name': 'duplicate',
291
+ 'description': 'Composite task with a duplicate step id',
292
+ 'runs': {
293
+ 'using': 'composite',
294
+ 'tasks': [{'name': 'first', 'id': 'same', 'run': 'echo 1'},
295
+ {'name': 'second', 'id': 'same', 'run': 'echo 2'}]
296
+ }
297
+ }
298
+
299
+
300
+ def test_composite_duplicate_id_is_rejected(mocker: MockerFixture) -> None:
301
+ __clean_cache()
302
+ mocker.patch("prepare_assignment.core.preparer.__download_task")
303
+ mocker.patch("prepare_assignment.core.preparer.validate_task_definition", return_value=DUPLICATE_ID_COMPOSITE)
304
+ remove = mocker.patch("prepare_assignment.core.preparer.remove_tree")
305
+ prepare = {'prepare': [{'name': 'duplicate', 'uses': 'duplicate', 'with': {}}]}
306
+ with pytest.raises(PrepareTaskError) as exc:
307
+ prepare_tasks("prepare.yml", prepare)
308
+ assert "multiple steps with id 'same'" in str(exc.value.cause)
309
+ remove.assert_called()
310
+
311
+
312
+ def test_cached_composite_duplicate_id_is_rejected(mocker: MockerFixture) -> None:
313
+ __clean_cache()
314
+ mocker.patch("prepare_assignment.core.preparer.__is_installed", return_value=True)
315
+ mocker.patch("prepare_assignment.core.preparer.load_yaml", return_value=DUPLICATE_ID_COMPOSITE)
316
+ prepare = {'prepare': [{'name': 'duplicate', 'uses': 'duplicate', 'with': {}}]}
317
+ with pytest.raises(PrepareTaskError) as exc:
318
+ prepare_tasks("prepare.yml", prepare)
319
+ assert "multiple steps with id 'same'" in str(exc.value.cause)
@@ -5,7 +5,8 @@ import pytest
5
5
  from pytest_mock import MockerFixture
6
6
 
7
7
  from prepare_assignment.core.validator import (validate_prepare, validate_tasks, load_yaml,
8
- validate_task_definition, validate_default_values)
8
+ validate_task_definition, validate_default_values,
9
+ validate_unique_ids)
9
10
  from prepare_assignment.data.task_definition import PythonTaskDefinition
10
11
  from prepare_assignment.data.errors import ValidationError
11
12
 
@@ -179,3 +180,33 @@ def test_validate_default_values_bool_for_integer_invalid() -> None:
179
180
  task_copy["inputs"]["num"] = {'description': 'n', 'type': 'integer', 'default': True, 'required': False}
180
181
  with pytest.raises(ValidationError):
181
182
  validate_default_values(PythonTaskDefinition.of(task_copy, "test.yml"))
183
+
184
+
185
+ def test_validate_unique_ids_valid() -> None:
186
+ steps = [{'name': 'a', 'id': 'a', 'run': 'echo'}, {'name': 'b', 'id': 'b', 'run': 'echo'},
187
+ {'name': 'no id', 'run': 'echo'}, {'name': 'no id', 'run': 'echo'}]
188
+ validate_unique_ids("prepare.yml", "job 'build'", steps)
189
+
190
+
191
+ def test_validate_unique_ids_duplicate() -> None:
192
+ steps = [{'name': 'Remove old artefacts', 'id': 'remove', 'uses': 'remove'},
193
+ {'name': 'other', 'id': 'other', 'uses': 'remove'},
194
+ {'name': 'Remove old temp directory', 'id': 'remove', 'uses': 'remove'}]
195
+ with pytest.raises(ValidationError) as exc:
196
+ validate_unique_ids("prepare.yml", "job 'build'", steps)
197
+ assert exc.value.message == ("Error in: prepare.yml, job 'build' has multiple steps with id 'remove': "
198
+ "'Remove old artefacts', 'Remove old temp directory'")
199
+
200
+
201
+ def test_validate_prepare_duplicate_id() -> None:
202
+ prepare = {'name': 'Duplicate', 'jobs': {'build': [{'name': 'a', 'id': 'same', 'run': 'echo'},
203
+ {'name': 'b', 'id': 'same', 'run': 'echo'}]}}
204
+ with pytest.raises(ValidationError) as exc:
205
+ validate_prepare("prepare.yml", prepare)
206
+ assert "job 'build'" in exc.value.message
207
+
208
+
209
+ def test_validate_prepare_same_id_in_different_jobs() -> None:
210
+ prepare = {'name': 'Different jobs', 'jobs': {'build': [{'name': 'a', 'id': 'same', 'run': 'echo'}],
211
+ 'test': [{'name': 'b', 'id': 'same', 'run': 'echo'}]}}
212
+ validate_prepare("prepare.yml", prepare)
@@ -86,3 +86,13 @@ def test_multiple_prepare_files_message(monkeypatch: pytest.MonkeyPatch, tmp_pat
86
86
  with pytest.raises(AssertionError) as exc:
87
87
  prepare(None)
88
88
  assert "both a prepare.yml and a prepare.yaml" in str(exc.value)
89
+
90
+
91
+ def test_prepare_file_in_directory_with_special_characters(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
92
+ """With prepare-toolbox < 0.4.4 the directory name was interpreted as part of the glob"""
93
+ from prepare_assignment.core.main import get_prepare_file
94
+ project = tmp_path / "[week 1] assignment"
95
+ project.mkdir()
96
+ (project / "prepare.yml").write_text("name: a\njobs: {}\n")
97
+ monkeypatch.chdir(project)
98
+ assert get_prepare_file(None) == "prepare.yml"
@@ -582,7 +582,7 @@ requires-dist = [
582
582
  { name = "jsonschema", specifier = ">=4.21.1" },
583
583
  { name = "multipledispatch", specifier = ">=1.0.0" },
584
584
  { name = "packaging", specifier = ">=24.0" },
585
- { name = "prepare-toolbox", specifier = "==0.4.0" },
585
+ { name = "prepare-toolbox", specifier = ">=1.0.1" },
586
586
  { name = "ruamel-yaml", specifier = ">=0.18.5" },
587
587
  { name = "simpleeval", specifier = ">=1.0.0" },
588
588
  { name = "treelib", specifier = ">=1.7.0" },
@@ -601,14 +601,14 @@ test = [
601
601
 
602
602
  [[package]]
603
603
  name = "prepare-toolbox"
604
- version = "0.4.0"
604
+ version = "1.0.1"
605
605
  source = { registry = "https://pypi.org/simple" }
606
606
  dependencies = [
607
607
  { name = "braceexpand" },
608
608
  ]
609
- sdist = { url = "https://files.pythonhosted.org/packages/1d/c3/b6d5ad3819fa8f9149a3cbd3653dbcb9208103f91fec3a379d528602e7eb/prepare_toolbox-0.4.0.tar.gz", hash = "sha256:825f50b9bccefcaf091d0e29b7881c311ce7c9a2f510ec481951beffd29871b9", size = 4952, upload-time = "2023-11-01T19:10:30.85Z" }
609
+ sdist = { url = "https://files.pythonhosted.org/packages/80/67/85a18c89af943534c23ce825de8e6e17550481f8c90e02db1c3a2d8a865e/prepare_toolbox-1.0.1.tar.gz", hash = "sha256:c11f499018b181ae4bc548669de698ee50dfbd309d9080e9538920c046798959", size = 55292, upload-time = "2026-09-22T06:15:35.074Z" }
610
610
  wheels = [
611
- { url = "https://files.pythonhosted.org/packages/81/3d/9569c6585515e637f32e3ca691ce5df85a01903ca7aa0ce9e4f75580640e/prepare_toolbox-0.4.0-py3-none-any.whl", hash = "sha256:15f694af2883463ec83cb568885f85bca4a4225816d86846b296da69c027c5e8", size = 6836, upload-time = "2023-11-01T19:10:29.84Z" },
611
+ { url = "https://files.pythonhosted.org/packages/b4/35/dd4cacca4d58361b66394160f9ca0f4062c2b5e55c0a8aa1dc557d347a63/prepare_toolbox-1.0.1-py3-none-any.whl", hash = "sha256:41e10ac9fa61e0cfe53d5c04b69baf2b0ee8a607dda2c9825c6ce0293055fd57", size = 7443, upload-time = "2026-09-22T06:15:34.015Z" },
612
612
  ]
613
613
 
614
614
  [[package]]
@@ -1,9 +0,0 @@
1
- {
2
- "branches": ["main"],
3
- "tagFormat": "v${version}",
4
- "plugins": [
5
- "@semantic-release/commit-analyzer",
6
- "@semantic-release/release-notes-generator",
7
- "@semantic-release/github"
8
- ]
9
- }