wexample-wex-addon-process 1.0.4__py3-none-any.whl

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 (31) hide show
  1. wexample_wex_addon_process/__init__.py +0 -0
  2. wexample_wex_addon_process/commands/__init__.py +0 -0
  3. wexample_wex_addon_process/commands/run/__init__.py +0 -0
  4. wexample_wex_addon_process/commands/run/execute.py +53 -0
  5. wexample_wex_addon_process/commands/worker/__init__.py +0 -0
  6. wexample_wex_addon_process/commands/worker/start.py +47 -0
  7. wexample_wex_addon_process/const/__init__.py +0 -0
  8. wexample_wex_addon_process/const/process.py +34 -0
  9. wexample_wex_addon_process/process_addon_manager.py +15 -0
  10. wexample_wex_addon_process/process_type/__init__.py +0 -0
  11. wexample_wex_addon_process/process_type/abstract_process_type.py +34 -0
  12. wexample_wex_addon_process/process_type/family/__init__.py +0 -0
  13. wexample_wex_addon_process/process_type/family/abstract_process_type_family.py +26 -0
  14. wexample_wex_addon_process/process_type/family/file_state_process_type_family.py +61 -0
  15. wexample_wex_addon_process/process_type/filestate/__init__.py +0 -0
  16. wexample_wex_addon_process/process_type/filestate/file_state_option_process_type.py +70 -0
  17. wexample_wex_addon_process/process_type/filestate/file_state_report_process_type.py +43 -0
  18. wexample_wex_addon_process/process_type/process_context.py +46 -0
  19. wexample_wex_addon_process/process_type/process_type_registry.py +43 -0
  20. wexample_wex_addon_process/process_type/selection.py +86 -0
  21. wexample_wex_addon_process/py.typed +0 -0
  22. wexample_wex_addon_process/record/__init__.py +0 -0
  23. wexample_wex_addon_process/record/record_file.py +45 -0
  24. wexample_wex_addon_process/runner/__init__.py +0 -0
  25. wexample_wex_addon_process/runner/process_runner.py +244 -0
  26. wexample_wex_addon_process/service/__init__.py +0 -0
  27. wexample_wex_addon_process/service/process_run_service.py +77 -0
  28. wexample_wex_addon_process-1.0.4.dist-info/METADATA +332 -0
  29. wexample_wex_addon_process-1.0.4.dist-info/RECORD +31 -0
  30. wexample_wex_addon_process-1.0.4.dist-info/WHEEL +4 -0
  31. wexample_wex_addon_process-1.0.4.dist-info/entry_points.txt +4 -0
File without changes
File without changes
File without changes
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from wexample_cli.const.tags import ScopeTag
6
+ from wexample_cli.decorator.command import command
7
+ from wexample_cli.decorator.middleware import middleware
8
+ from wexample_cli.decorator.option import option
9
+ from wexample_wex_addon_app.middleware.app_middleware import AppMiddleware
10
+ from wexample_wex_core.const.globals import COMMAND_TYPE_ADDON
11
+
12
+ if TYPE_CHECKING:
13
+ from wexample_app.response.abstract_response import AbstractResponse
14
+ from wexample_cli.context.execution_context import ExecutionContext
15
+ from wexample_wex_addon_app.workdir.managed_workdir import ManagedWorkdir
16
+
17
+
18
+ @option(
19
+ name="run",
20
+ type=str,
21
+ required=True,
22
+ description="Identity of the run to execute, which is the name of its record under `.wex/data/process_run/`.",
23
+ )
24
+ @middleware(middleware=AppMiddleware)
25
+ @command(
26
+ type=COMMAND_TYPE_ADDON,
27
+ description="Run one process run, here and now, without a queue. What the worker does for a message, done by hand — which is what makes a process runnable on a repository that has no board.",
28
+ tags=[
29
+ ScopeTag.APP,
30
+ ScopeTag.LOCAL,
31
+ ],
32
+ )
33
+ def process__run__execute(
34
+ context: ExecutionContext,
35
+ run: str,
36
+ app_workdir: ManagedWorkdir | None = None,
37
+ ) -> AbstractResponse | None:
38
+ from wexample_app.response.success_response import SuccessResponse
39
+
40
+ from wexample_wex_addon_process.process_type.process_type_registry import (
41
+ ProcessTypeRegistry,
42
+ )
43
+ from wexample_wex_addon_process.runner.process_runner import ProcessRunner
44
+
45
+ runner = ProcessRunner(io=context.io, registry=ProcessTypeRegistry.default())
46
+
47
+ return SuccessResponse(
48
+ kernel=context.kernel,
49
+ data=runner.execute(
50
+ workdir=app_workdir.get_path(),
51
+ run_id=run,
52
+ ),
53
+ )
File without changes
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from wexample_cli.const.tags import EffectTag, ScopeTag
6
+ from wexample_cli.decorator.command import command
7
+ from wexample_cli.decorator.option import option
8
+ from wexample_wex_core.const.globals import COMMAND_TYPE_ADDON
9
+
10
+ if TYPE_CHECKING:
11
+ from wexample_app.response.abstract_response import AbstractResponse
12
+ from wexample_cli.context.execution_context import ExecutionContext
13
+
14
+
15
+ @option(
16
+ name="queue",
17
+ type=str,
18
+ required=False,
19
+ description="Queue to consume. The default is the one a board rings on to ask for a run; another one is how a treatment with its own appetite gets its own workers.",
20
+ )
21
+ @command(
22
+ type=COMMAND_TYPE_ADDON,
23
+ description="Consume runs from a queue until stopped. Blocking — this is the process a worker container runs, and there may be as many as the parallelism asked for.",
24
+ tags=[
25
+ EffectTag.LONG_RUNNING,
26
+ ScopeTag.LOCAL,
27
+ ],
28
+ )
29
+ def process__worker__start(
30
+ context: ExecutionContext,
31
+ queue: str | None = None,
32
+ ) -> AbstractResponse | None:
33
+ from wexample_wex_addon_process.service.process_run_service import (
34
+ QUEUE_PROCESS_RUN,
35
+ ProcessRunService,
36
+ )
37
+
38
+ service = ProcessRunService(
39
+ kernel=context.kernel,
40
+ queue_name=queue or QUEUE_PROCESS_RUN,
41
+ )
42
+
43
+ # Blocks until the process is signalled: the container's whole life is this
44
+ # call, and a worker that returns is a worker that has been asked to stop.
45
+ service.start()
46
+
47
+ return None
File without changes
@@ -0,0 +1,34 @@
1
+ """What a process, a selection and a run are called on disk.
2
+
3
+ Spelled once here because wex is not the only reader: the board writes these very
4
+ keys into the same files, so a name changed on one side has to be changed on the
5
+ other or the two stop understanding each other.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # filestate: python-constant-sort
11
+ KIND_PROCESS: str = "process"
12
+ KIND_PROCESS_RUN: str = "process_run"
13
+ KIND_SELECTION: str = "selection"
14
+
15
+ PROCESS_KEY_OPTIONS: str = "options"
16
+ PROCESS_KEY_SELECTION_ID: str = "selection_id"
17
+ PROCESS_KEY_TITLE: str = "title"
18
+ PROCESS_KEY_TYPE: str = "type"
19
+
20
+ RUN_KEY_DATA: str = "data"
21
+ RUN_KEY_DATE_ENDED: str = "date_ended"
22
+ RUN_KEY_DATE_STARTED: str = "date_started"
23
+ RUN_KEY_ITEMS_DONE: str = "items_done"
24
+ RUN_KEY_ITEMS_TOTAL: str = "items_total"
25
+ RUN_KEY_PROCESS_ID: str = "process_id"
26
+ RUN_KEY_STATE: str = "state"
27
+
28
+ SELECTION_KEY_PATTERNS: str = "patterns"
29
+ SELECTION_KEY_TITLE: str = "title"
30
+
31
+ STATE_COMPLETE: str = "complete"
32
+ STATE_FAILED: str = "failed"
33
+ STATE_PENDING: str = "pending"
34
+ STATE_RUNNING: str = "running"
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from wexample_helpers.decorator.base_class import base_class
6
+ from wexample_wex_core.common.abstract_addon_manager import AbstractAddonManager
7
+
8
+
9
+ @base_class
10
+ class ProcessAddonManager(AbstractAddonManager):
11
+ @classmethod
12
+ def get_package_module(cls) -> Any:
13
+ import wexample_wex_addon_process
14
+
15
+ return wexample_wex_addon_process
File without changes
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from wexample_helpers.classes.abstract_method import abstract_method
6
+
7
+ if TYPE_CHECKING:
8
+ from wexample_wex_addon_process.process_type.process_context import ProcessContext
9
+
10
+
11
+ class AbstractProcessType:
12
+ """One treatment a process can name.
13
+
14
+ A type is a name and a `run()`. It is deliberately an ordinary class with no
15
+ kernel and no state: what it needs comes in the context, and a type
16
+ contributed from a bundle should be readable by whoever wrote the bundle
17
+ rather than by whoever wrote wex.
18
+
19
+ The name is prefixed by the family the type belongs to — `filestate:mode`,
20
+ `agent:reviewer` — so that what runs a treatment is read off the name before
21
+ anything is loaded. A type is free to set it on the class when there is one
22
+ of it, or on the instance when a family makes many.
23
+ """
24
+ label: str
25
+ name: str
26
+
27
+ @abstract_method
28
+ def run(self, context: ProcessContext) -> dict[str, Any]:
29
+ """Does the work, and answers what is worth keeping about it.
30
+
31
+ What is returned goes into the run's `data`, as it is. Raising is how a
32
+ type says it could not go on: the runner writes the failure down, so
33
+ nothing here has to know what a failed run looks like.
34
+ """
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar
4
+
5
+ from wexample_helpers.classes.abstract_method import abstract_method
6
+
7
+ if TYPE_CHECKING:
8
+ from wexample_wex_addon_process.process_type.abstract_process_type import (
9
+ AbstractProcessType,
10
+ )
11
+
12
+
13
+ class AbstractProcessTypeFamily:
14
+ """What a whole way of treating things brings, under one prefix.
15
+
16
+ A family is not a branch taken at run time: the name written in a process
17
+ record already says which family answers for it. A family only declares
18
+ names, which is why it can declare a great many of them without anything
19
+ being loaded.
20
+ """
21
+
22
+ prefix: ClassVar[str]
23
+
24
+ @abstract_method
25
+ def types(self) -> list[AbstractProcessType]:
26
+ """Every type this family answers for, named with its prefix."""
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, ClassVar
4
+
5
+ from wexample_wex_addon_process.process_type.family.abstract_process_type_family import (
6
+ AbstractProcessTypeFamily,
7
+ )
8
+
9
+ if TYPE_CHECKING:
10
+ from wexample_config.config_option.abstract_config_option import (
11
+ AbstractConfigOption,
12
+ )
13
+
14
+ from wexample_wex_addon_process.process_type.abstract_process_type import (
15
+ AbstractProcessType,
16
+ )
17
+
18
+
19
+ class FileStateProcessTypeFamily(AbstractProcessTypeFamily):
20
+ """Everything filestate can do to a file, as treatments a process may name.
21
+
22
+ The list is not written: it is read off filestate's own options, so an option
23
+ added there is a treatment available here the same day, under the very name
24
+ filestate gives it.
25
+ """
26
+
27
+ prefix: ClassVar[str] = "filestate"
28
+
29
+ def types(self) -> list[AbstractProcessType]:
30
+ from wexample_filestate.options_provider.default_options_provider import (
31
+ DefaultOptionsProvider,
32
+ )
33
+
34
+ from wexample_wex_addon_process.process_type.filestate.file_state_option_process_type import (
35
+ FileStateOptionProcessType,
36
+ )
37
+ from wexample_wex_addon_process.process_type.filestate.file_state_report_process_type import (
38
+ FileStateReportProcessType,
39
+ )
40
+
41
+ types: list[AbstractProcessType] = [FileStateReportProcessType()]
42
+
43
+ for option_class in DefaultOptionsProvider.get_options():
44
+ if self._treats(option_class):
45
+ types.append(FileStateOptionProcessType(option_class))
46
+
47
+ return types
48
+
49
+ def _treats(self, option_class: type[AbstractConfigOption]) -> bool:
50
+ """Whether that option is a treatment rather than a piece of structure.
51
+
52
+ Asked of the option itself: one that can produce an operation is
53
+ something that can be done to a file, one that cannot is how a tree is
54
+ described. `children` and `class` are not treatments; `mode` is.
55
+ """
56
+ from wexample_filestate.option.mixin.option_mixin import OptionMixin
57
+
58
+ return (
59
+ option_class.create_required_operation
60
+ is not OptionMixin.create_required_operation
61
+ )
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from wexample_wex_addon_process.process_type.abstract_process_type import (
6
+ AbstractProcessType,
7
+ )
8
+
9
+ if TYPE_CHECKING:
10
+ from wexample_config.config_option.abstract_config_option import (
11
+ AbstractConfigOption,
12
+ )
13
+
14
+ from wexample_wex_addon_process.process_type.process_context import ProcessContext
15
+
16
+ OPTION_KEY_DRY_RUN: str = "dry_run"
17
+ """Written in a process's options to see what a run would do, and do none of it."""
18
+
19
+
20
+ class FileStateOptionProcessType(AbstractProcessType):
21
+ """One filestate option, asked of every file a selection takes.
22
+
23
+ There is no code here per treatment. A filestate option already says what a
24
+ file should be — `mode: '644'`, `should_exist: false` — and already knows how
25
+ to turn the difference with the disk into an operation that can be described,
26
+ applied and undone. So a type of this family is that option under a name, and
27
+ the process's own options are the configuration handed to each file.
28
+ """
29
+
30
+ def __init__(self, option_class: type[AbstractConfigOption]) -> None:
31
+ self.option_class = option_class
32
+ self.name = f"filestate:{option_class.get_name()}"
33
+ self.label = option_class.get_name().replace("_", " ").capitalize()
34
+
35
+ def run(self, context: ProcessContext) -> dict[str, Any]:
36
+ from wexample_filestate.enum.scopes import Scope
37
+
38
+ pattern = dict(context.options)
39
+ dry_run = bool(pattern.pop(OPTION_KEY_DRY_RUN, False))
40
+
41
+ if self.option_class.get_name() not in pattern:
42
+ raise ValueError(
43
+ f"« {self.name} » wants « {self.option_class.get_name()} » in its "
44
+ f"options, which is what it would ask of each file."
45
+ )
46
+
47
+ target = context.selection.build_target(pattern)
48
+
49
+ # Counted before anything is done to them: a run says how far it has got
50
+ # against what it set out to do, and filestate reports only what it had
51
+ # to change.
52
+ context.advance(len(context.files))
53
+
54
+ result = (
55
+ target.dry_run(scopes=Scope.default())
56
+ if dry_run
57
+ else target.apply()
58
+ )
59
+
60
+ return {
61
+ "dry_run": dry_run,
62
+ "operations": [
63
+ {
64
+ "name": operation.get_name(),
65
+ "path": str(operation.target.get_path()),
66
+ "description": str(operation.description),
67
+ }
68
+ for operation in result.operations
69
+ ],
70
+ }
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ from wexample_wex_addon_process.process_type.abstract_process_type import (
6
+ AbstractProcessType,
7
+ )
8
+
9
+ if TYPE_CHECKING:
10
+ from wexample_wex_addon_process.process_type.process_context import ProcessContext
11
+
12
+
13
+ class FileStateReportProcessType(AbstractProcessType):
14
+ """Says what a selection takes, and changes nothing.
15
+
16
+ The first type on purpose: it exercises the whole chain — a run asked for, a
17
+ selection read, files walked, progress reported, a result written back — and
18
+ the worst it can do to a repository is read it.
19
+
20
+ It belongs to the filestate family because it walks the same tree as its
21
+ siblings, but it emits no operation: filestate's own rule is that a scope
22
+ names something observable on disk, and a report changes nothing observable.
23
+ An operation with no scope would be an operation nothing could ever run.
24
+ """
25
+ label = "Report"
26
+ name = "filestate:report"
27
+
28
+ def run(self, context: ProcessContext) -> dict[str, Any]:
29
+ by_extension: dict[str, int] = {}
30
+ total_bytes = 0
31
+
32
+ for path in context.files:
33
+ extension = path.suffix or "(none)"
34
+ by_extension[extension] = by_extension.get(extension, 0) + 1
35
+ total_bytes += path.stat().st_size
36
+
37
+ context.advance()
38
+
39
+ return {
40
+ "files": len(context.files),
41
+ "bytes": total_bytes,
42
+ "by_extension": dict(sorted(by_extension.items())),
43
+ }
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+
9
+ from wexample_wex_addon_process.process_type.selection import Selection
10
+
11
+
12
+ class ProcessContext:
13
+ """What a type is given to do its work: the selection, the options, and a way
14
+ to say how far it has got.
15
+
16
+ It holds no run and no record. A type that could reach the record could write
17
+ anywhere in it, and what a run is in is decided by the runner alone — a type
18
+ says what it did, not that it is finished.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ selection: Selection,
24
+ options: dict[str, Any],
25
+ advance: Callable[[int], None],
26
+ ) -> None:
27
+ self.selection = selection
28
+ self.options = options
29
+ self._advance = advance
30
+ self._files: list[Path] | None = None
31
+
32
+ @property
33
+ def files(self) -> list[Path]:
34
+ """The files the selection takes, read once for the length of the run."""
35
+ if self._files is None:
36
+ self._files = self.selection.files()
37
+
38
+ return self._files
39
+
40
+ @property
41
+ def workdir(self) -> Path:
42
+ return self.selection.workdir
43
+
44
+ def advance(self, count: int = 1) -> None:
45
+ """Says that many more files have been dealt with."""
46
+ self._advance(count)
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from wexample_wex_addon_process.process_type.abstract_process_type import (
7
+ AbstractProcessType,
8
+ )
9
+ from wexample_wex_addon_process.process_type.family.abstract_process_type_family import (
10
+ AbstractProcessTypeFamily,
11
+ )
12
+
13
+
14
+ class ProcessTypeRegistry:
15
+ """The types a run may name, by the name it names them with.
16
+
17
+ A collection of families rather than a list of types: a family declares its
18
+ own names — filestate declares one per option it can enforce — so adding a
19
+ way of treating things is adding a family and not a hundred entries. A
20
+ process naming a type nothing declares is an error the runner reports rather
21
+ than something to guess at.
22
+ """
23
+
24
+ def __init__(self, families: list[AbstractProcessTypeFamily]) -> None:
25
+ self._types: dict[str, AbstractProcessType] = {}
26
+
27
+ for family in families:
28
+ for process_type in family.types():
29
+ self._types[process_type.name] = process_type
30
+
31
+ @classmethod
32
+ def default(cls) -> ProcessTypeRegistry:
33
+ from wexample_wex_addon_process.process_type.family.file_state_process_type_family import (
34
+ FileStateProcessTypeFamily,
35
+ )
36
+
37
+ return cls([FileStateProcessTypeFamily()])
38
+
39
+ def get(self, name: str) -> AbstractProcessType | None:
40
+ return self._types.get(name)
41
+
42
+ def names(self) -> list[str]:
43
+ return sorted(self._types)
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from pathlib import Path
7
+
8
+ from wexample_filestate.utils.file_state_manager import FileStateManager
9
+ from wexample_prompt.mixin.with_io_methods import WithIoMethods
10
+
11
+
12
+ class Selection:
13
+ """The files a process runs on, as the patterns that name them.
14
+
15
+ Nothing here holds a list: a selection is a rule, and the files it takes are
16
+ read from disk each time they are asked for. What turns the rule into a tree
17
+ is filestate, so a type that only wants to read them and a type that wants to
18
+ change them walk the very same thing.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ io: WithIoMethods,
24
+ workdir: Path,
25
+ patterns: list[str],
26
+ ) -> None:
27
+ from wexample_file.common.path_matcher import PathMatcher
28
+
29
+ self.io = io
30
+ self.workdir = workdir
31
+ self.matcher = PathMatcher(patterns)
32
+
33
+ def build_target(self, pattern: dict[str, Any]) -> FileStateManager:
34
+ """A filestate tree of the app, holding the selected files and that config.
35
+
36
+ The config is what every taken file is asked to be — `mode: '644'`,
37
+ `should_exist: false` — and it is filestate that turns the difference with
38
+ the disk into operations.
39
+ """
40
+ from wexample_filestate.const.disk import DiskItemType
41
+ from wexample_filestate.option.children_filter_option import (
42
+ ChildrenFilterOption,
43
+ )
44
+ from wexample_filestate.utils.file_state_manager import FileStateManager
45
+
46
+ manager = FileStateManager.create_from_path(
47
+ io=self.io,
48
+ path=self.workdir,
49
+ configure=False,
50
+ )
51
+
52
+ manager.configure(
53
+ {
54
+ "children": [
55
+ ChildrenFilterOption(
56
+ pattern={"type": DiskItemType.FILE, **pattern},
57
+ filter=self._takes,
58
+ recursive=True,
59
+ )
60
+ ]
61
+ }
62
+ )
63
+
64
+ return manager
65
+
66
+ def files(self) -> list[Path]:
67
+ """The files taken, asking for nothing to be done to them."""
68
+ return sorted(
69
+ child.get_path()
70
+ for child in self._leaves(self.build_target({}))
71
+ if child.get_path().is_file()
72
+ )
73
+
74
+ def _leaves(self, item: Any) -> list[Any]:
75
+ leaves = []
76
+
77
+ for child in item.get_children_list():
78
+ if child.is_directory():
79
+ leaves.extend(self._leaves(child))
80
+ else:
81
+ leaves.append(child)
82
+
83
+ return leaves
84
+
85
+ def _takes(self, path: Path) -> bool:
86
+ return self.matcher.matches(str(path.relative_to(self.workdir)))
File without changes
File without changes
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from pathlib import Path
7
+
8
+
9
+ def read_record(path: Path) -> dict[str, Any]:
10
+ """The values one record holds."""
11
+ import yaml
12
+
13
+ return yaml.safe_load(path.read_text()) or {}
14
+
15
+
16
+ def record_path(app_path: Path, kind: str, identity: str) -> Path:
17
+ """Where the record of that kind bearing that identity sits."""
18
+ from wexample_wex_core.const.data import RECORD_EXTENSION
19
+ from wexample_wex_core.helper.data_record import data_kind_dir
20
+
21
+ return data_kind_dir(app_path, kind) / f"{identity}{RECORD_EXTENSION}"
22
+
23
+
24
+ def write_record(path: Path, values: dict[str, Any]) -> None:
25
+ """Merges those values into the record and leaves the rest alone.
26
+
27
+ A run record is written from both ends — the board opens it, a worker fills
28
+ it in — so it is read, merged and dumped rather than rebuilt. Two spaces, no
29
+ flow style and unicode left alone, like every `.wex` file the board writes,
30
+ so a rewrite does not read as a change nobody made.
31
+ """
32
+ import yaml
33
+
34
+ current = read_record(path) if path.is_file() else {}
35
+ current.update(values)
36
+
37
+ path.write_text(
38
+ yaml.safe_dump(
39
+ current,
40
+ default_flow_style=False,
41
+ indent=2,
42
+ sort_keys=False,
43
+ allow_unicode=True,
44
+ )
45
+ )
File without changes