donna 0.2.0__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 (85) hide show
  1. donna/__init__.py +1 -0
  2. donna/artifacts/__init__.py +0 -0
  3. donna/artifacts/usage/__init__.py +0 -0
  4. donna/artifacts/usage/artifacts.md +224 -0
  5. donna/artifacts/usage/cli.md +117 -0
  6. donna/artifacts/usage/worlds.md +36 -0
  7. donna/artifacts/work/__init__.py +0 -0
  8. donna/artifacts/work/do_it.md +142 -0
  9. donna/artifacts/work/do_it_fast.md +98 -0
  10. donna/artifacts/work/planning.md +245 -0
  11. donna/cli/__init__.py +0 -0
  12. donna/cli/__main__.py +6 -0
  13. donna/cli/application.py +17 -0
  14. donna/cli/commands/__init__.py +0 -0
  15. donna/cli/commands/artifacts.py +110 -0
  16. donna/cli/commands/projects.py +49 -0
  17. donna/cli/commands/sessions.py +77 -0
  18. donna/cli/types.py +138 -0
  19. donna/cli/utils.py +53 -0
  20. donna/core/__init__.py +0 -0
  21. donna/core/entities.py +27 -0
  22. donna/core/errors.py +126 -0
  23. donna/core/result.py +99 -0
  24. donna/core/utils.py +37 -0
  25. donna/domain/__init__.py +0 -0
  26. donna/domain/errors.py +47 -0
  27. donna/domain/ids.py +497 -0
  28. donna/lib/__init__.py +21 -0
  29. donna/lib/sources.py +5 -0
  30. donna/lib/worlds.py +7 -0
  31. donna/machine/__init__.py +0 -0
  32. donna/machine/action_requests.py +50 -0
  33. donna/machine/artifacts.py +200 -0
  34. donna/machine/changes.py +91 -0
  35. donna/machine/errors.py +122 -0
  36. donna/machine/operations.py +31 -0
  37. donna/machine/primitives.py +77 -0
  38. donna/machine/sessions.py +215 -0
  39. donna/machine/state.py +244 -0
  40. donna/machine/tasks.py +89 -0
  41. donna/machine/templates.py +83 -0
  42. donna/primitives/__init__.py +1 -0
  43. donna/primitives/artifacts/__init__.py +0 -0
  44. donna/primitives/artifacts/specification.py +20 -0
  45. donna/primitives/artifacts/workflow.py +195 -0
  46. donna/primitives/directives/__init__.py +0 -0
  47. donna/primitives/directives/goto.py +44 -0
  48. donna/primitives/directives/task_variable.py +73 -0
  49. donna/primitives/directives/view.py +45 -0
  50. donna/primitives/operations/__init__.py +0 -0
  51. donna/primitives/operations/finish_workflow.py +37 -0
  52. donna/primitives/operations/request_action.py +89 -0
  53. donna/primitives/operations/run_script.py +250 -0
  54. donna/protocol/__init__.py +0 -0
  55. donna/protocol/cell_shortcuts.py +9 -0
  56. donna/protocol/cells.py +44 -0
  57. donna/protocol/errors.py +17 -0
  58. donna/protocol/formatters/__init__.py +0 -0
  59. donna/protocol/formatters/automation.py +25 -0
  60. donna/protocol/formatters/base.py +15 -0
  61. donna/protocol/formatters/human.py +36 -0
  62. donna/protocol/formatters/llm.py +39 -0
  63. donna/protocol/modes.py +40 -0
  64. donna/protocol/nodes.py +59 -0
  65. donna/world/__init__.py +0 -0
  66. donna/world/artifacts.py +122 -0
  67. donna/world/artifacts_discovery.py +90 -0
  68. donna/world/config.py +198 -0
  69. donna/world/errors.py +232 -0
  70. donna/world/initialization.py +42 -0
  71. donna/world/markdown.py +267 -0
  72. donna/world/sources/__init__.py +1 -0
  73. donna/world/sources/base.py +62 -0
  74. donna/world/sources/markdown.py +260 -0
  75. donna/world/templates.py +181 -0
  76. donna/world/tmp.py +33 -0
  77. donna/world/worlds/__init__.py +0 -0
  78. donna/world/worlds/base.py +68 -0
  79. donna/world/worlds/filesystem.py +189 -0
  80. donna/world/worlds/python.py +196 -0
  81. donna-0.2.0.dist-info/METADATA +44 -0
  82. donna-0.2.0.dist-info/RECORD +85 -0
  83. donna-0.2.0.dist-info/WHEEL +4 -0
  84. donna-0.2.0.dist-info/entry_points.txt +3 -0
  85. donna-0.2.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,250 @@
1
+ import os
2
+ import subprocess # noqa: S404
3
+ import tempfile
4
+ from typing import TYPE_CHECKING, ClassVar, Iterator, cast
5
+
6
+ import pydantic
7
+
8
+ from donna.core import errors as core_errors
9
+ from donna.core.errors import ErrorsList
10
+ from donna.core.result import Err, Ok, Result
11
+ from donna.domain.ids import ArtifactSectionId, FullArtifactId
12
+ from donna.machine.artifacts import Artifact, ArtifactSection, ArtifactSectionConfig, ArtifactSectionMeta
13
+ from donna.machine.errors import ArtifactValidationError
14
+ from donna.machine.operations import OperationConfig, OperationKind, OperationMeta
15
+ from donna.world import markdown
16
+ from donna.world.sources.markdown import MarkdownSectionMixin
17
+
18
+ if TYPE_CHECKING:
19
+ from donna.machine.changes import Change
20
+ from donna.machine.tasks import Task, WorkUnit
21
+
22
+
23
+ class InternalError(core_errors.InternalError):
24
+ """Base class for internal errors in donna.primitives.operations.run_script."""
25
+
26
+
27
+ class RunScriptMissingGotoOnSuccess(ArtifactValidationError):
28
+ code: str = "donna.workflows.run_script_missing_goto_on_success"
29
+ message: str = "Run script operation `{error.section_id}` must define `goto_on_success`."
30
+ ways_to_fix: list[str] = [
31
+ 'Add `goto_on_success = "<next_operation_id>"` to the operation config block.',
32
+ ]
33
+
34
+
35
+ class RunScriptMissingGotoOnFailure(ArtifactValidationError):
36
+ code: str = "donna.workflows.run_script_missing_goto_on_failure"
37
+ message: str = "Run script operation `{error.section_id}` must define `goto_on_failure`."
38
+ ways_to_fix: list[str] = [
39
+ 'Add `goto_on_failure = "<next_operation_id>"` to the operation config block.',
40
+ ]
41
+
42
+
43
+ class RunScriptMissingScriptBlock(ArtifactValidationError):
44
+ code: str = "donna.workflows.run_script_missing_script_block"
45
+ message: str = "Run script operation `{error.section_id}` must include a single `donna script` code block."
46
+ ways_to_fix: list[str] = [
47
+ "Add exactly one fenced code block starting with ` ``` donna script ` in the operation body.",
48
+ ]
49
+
50
+
51
+ class RunScriptMultipleScriptBlocks(ArtifactValidationError):
52
+ code: str = "donna.workflows.run_script_multiple_script_blocks"
53
+ message: str = "Run script operation `{error.section_id}` must include exactly one `donna script` code block."
54
+ ways_to_fix: list[str] = [
55
+ "Remove extra `donna script` code blocks so only one remains in the operation body.",
56
+ ]
57
+
58
+
59
+ class RunScriptGotoOnCodeIncludesZero(ArtifactValidationError):
60
+ code: str = "donna.workflows.run_script_goto_on_code_includes_zero"
61
+ message: str = "Run script operation `{error.section_id}` must not map exit code 0 in `goto_on_code`."
62
+ ways_to_fix: list[str] = [
63
+ "Remove the `0` entry from `goto_on_code` and use `goto_on_success` instead.",
64
+ ]
65
+
66
+
67
+ class RunScriptInvalidExitCode(ArtifactValidationError):
68
+ code: str = "donna.workflows.run_script_invalid_exit_code"
69
+ message: str = "Run script operation `{error.section_id}` has invalid exit code `{error.exit_code}`."
70
+ ways_to_fix: list[str] = [
71
+ 'Use integer exit code keys in `goto_on_code` (e.g., `"1" = "next_op"`).',
72
+ ]
73
+ exit_code: str
74
+
75
+
76
+ class RunScriptConfig(OperationConfig):
77
+ save_stdout_to: str | None = None
78
+ save_stderr_to: str | None = None
79
+ goto_on_success: ArtifactSectionId | None = None
80
+ goto_on_failure: ArtifactSectionId | None = None
81
+ goto_on_code: dict[str, ArtifactSectionId] = pydantic.Field(default_factory=dict)
82
+ timeout: int = 60
83
+
84
+
85
+ class RunScriptMeta(OperationMeta):
86
+ script: str | None = None
87
+ save_stdout_to: str | None = None
88
+ save_stderr_to: str | None = None
89
+ goto_on_success: ArtifactSectionId | None = None
90
+ goto_on_failure: ArtifactSectionId | None = None
91
+ goto_on_code: dict[str, ArtifactSectionId] = pydantic.Field(default_factory=dict)
92
+ timeout: int = 60
93
+
94
+ def select_next_operation(self, exit_code: int) -> ArtifactSectionId:
95
+ if exit_code == 0:
96
+ next_operation = self.goto_on_success
97
+ else:
98
+ next_operation = self.goto_on_code.get(str(exit_code))
99
+ if next_operation is None:
100
+ next_operation = self.goto_on_failure
101
+
102
+ assert next_operation is not None
103
+ return next_operation
104
+
105
+
106
+ class RunScript(MarkdownSectionMixin, OperationKind):
107
+ config_class: ClassVar[type[RunScriptConfig]] = RunScriptConfig
108
+
109
+ def markdown_construct_meta(
110
+ self,
111
+ artifact_id: "FullArtifactId",
112
+ source: markdown.SectionSource,
113
+ section_config: ArtifactSectionConfig,
114
+ description: str,
115
+ primary: bool = False,
116
+ ) -> Result[ArtifactSectionMeta, ErrorsList]:
117
+ run_config = cast(RunScriptConfig, section_config)
118
+ scripts = source.scripts()
119
+ if not scripts:
120
+ return Err([RunScriptMissingScriptBlock(artifact_id=artifact_id, section_id=run_config.id)])
121
+ if len(scripts) > 1:
122
+ return Err([RunScriptMultipleScriptBlocks(artifact_id=artifact_id, section_id=run_config.id)])
123
+
124
+ script = scripts[0]
125
+ allowed_transitions: set[ArtifactSectionId] = set()
126
+
127
+ if run_config.goto_on_success is not None:
128
+ allowed_transitions.add(run_config.goto_on_success)
129
+
130
+ if run_config.goto_on_failure is not None:
131
+ allowed_transitions.add(run_config.goto_on_failure)
132
+
133
+ if run_config.goto_on_code:
134
+ allowed_transitions.update(run_config.goto_on_code.values())
135
+
136
+ return Ok(
137
+ RunScriptMeta(
138
+ fsm_mode=run_config.fsm_mode,
139
+ allowed_transtions=allowed_transitions,
140
+ script=script,
141
+ save_stdout_to=run_config.save_stdout_to,
142
+ save_stderr_to=run_config.save_stderr_to,
143
+ goto_on_success=run_config.goto_on_success,
144
+ goto_on_failure=run_config.goto_on_failure,
145
+ goto_on_code=dict(run_config.goto_on_code),
146
+ timeout=run_config.timeout,
147
+ )
148
+ )
149
+
150
+ def execute_section(self, task: "Task", unit: "WorkUnit", operation: ArtifactSection) -> Iterator["Change"]:
151
+ from donna.machine.changes import ChangeAddWorkUnit, ChangeSetTaskContext
152
+
153
+ meta = cast(RunScriptMeta, operation.meta)
154
+
155
+ script = meta.script
156
+ assert script is not None
157
+
158
+ stdout, stderr, exit_code = _run_script(script, meta.timeout)
159
+
160
+ if meta.save_stdout_to is not None:
161
+ yield ChangeSetTaskContext(task_id=task.id, key=meta.save_stdout_to, value=stdout)
162
+
163
+ if meta.save_stderr_to is not None:
164
+ yield ChangeSetTaskContext(task_id=task.id, key=meta.save_stderr_to, value=stderr)
165
+
166
+ next_operation = meta.select_next_operation(exit_code)
167
+ full_operation_id = unit.operation_id.full_artifact_id.to_full_local(next_operation)
168
+
169
+ yield ChangeAddWorkUnit(task_id=task.id, operation_id=full_operation_id)
170
+
171
+ def validate_section( # noqa: CCR001
172
+ self, artifact: Artifact, section_id: ArtifactSectionId
173
+ ) -> Result[None, ErrorsList]:
174
+ section = artifact.get_section(section_id).unwrap()
175
+
176
+ meta = cast(RunScriptMeta, section.meta)
177
+
178
+ errors: ErrorsList = []
179
+
180
+ if meta.goto_on_success is None:
181
+ errors.append(RunScriptMissingGotoOnSuccess(artifact_id=artifact.id, section_id=section_id))
182
+
183
+ if meta.goto_on_failure is None:
184
+ errors.append(RunScriptMissingGotoOnFailure(artifact_id=artifact.id, section_id=section_id))
185
+
186
+ for code in meta.goto_on_code:
187
+ try:
188
+ parsed = int(code)
189
+ except ValueError:
190
+ errors.append(
191
+ RunScriptInvalidExitCode(
192
+ artifact_id=artifact.id,
193
+ section_id=section_id,
194
+ exit_code=code,
195
+ )
196
+ )
197
+ continue
198
+
199
+ if parsed == 0:
200
+ errors.append(RunScriptGotoOnCodeIncludesZero(artifact_id=artifact.id, section_id=section_id))
201
+
202
+ if errors:
203
+ return Err(errors)
204
+
205
+ return Ok(None)
206
+
207
+
208
+ def _run_script(script: str, timeout: int) -> tuple[str, str, int]: # noqa: CCR001
209
+ temp_path = None
210
+
211
+ try:
212
+ with tempfile.NamedTemporaryFile("w", prefix="donna-script-", delete=False) as temp:
213
+ temp.write(script)
214
+ temp.flush()
215
+ temp_path = temp.name
216
+
217
+ os.chmod(temp_path, 0o700)
218
+
219
+ try:
220
+ result = subprocess.run( # noqa: S603
221
+ [temp_path],
222
+ capture_output=True,
223
+ text=True,
224
+ env=os.environ.copy(),
225
+ stdin=subprocess.DEVNULL,
226
+ timeout=timeout,
227
+ check=False,
228
+ )
229
+ except subprocess.TimeoutExpired as exc:
230
+ stdout = _coerce_output(exc.stdout)
231
+ stderr = _coerce_output(exc.stderr)
232
+ return stdout, stderr, 124
233
+
234
+ return _coerce_output(result.stdout), _coerce_output(result.stderr), result.returncode
235
+ finally:
236
+ if temp_path is not None:
237
+ try:
238
+ os.remove(temp_path)
239
+ except FileNotFoundError:
240
+ pass
241
+
242
+
243
+ def _coerce_output(value: str | bytes | None) -> str:
244
+ if value is None:
245
+ return ""
246
+
247
+ if isinstance(value, bytes):
248
+ return value.decode("utf-8", errors="replace")
249
+
250
+ return value
File without changes
@@ -0,0 +1,9 @@
1
+ from donna.protocol.cells import Cell, MetaValue
2
+
3
+
4
+ def operation_succeeded(message: str, **meta: MetaValue) -> Cell:
5
+ return Cell.build(kind="operation_succeeded", media_type="text/markdown", content=message, **meta)
6
+
7
+
8
+ def operation_failed(message: str, **meta: MetaValue) -> Cell:
9
+ return Cell.build(kind="operation_failed", media_type="text/markdown", content=message, **meta)
@@ -0,0 +1,44 @@
1
+ import base64
2
+ import uuid
3
+
4
+ import pydantic
5
+
6
+ from donna.core.entities import BaseEntity
7
+
8
+ MetaValue = str | int | bool | None
9
+
10
+
11
+ def to_meta_value(value: object) -> MetaValue:
12
+ if isinstance(value, (str, int, bool)) or value is None:
13
+ return value
14
+
15
+ return str(value)
16
+
17
+
18
+ class Cell(BaseEntity):
19
+ id: uuid.UUID = pydantic.Field(default_factory=uuid.uuid4)
20
+ kind: str
21
+ media_type: str | None
22
+ content: str | None
23
+ meta: dict[str, MetaValue] = pydantic.Field(default_factory=dict)
24
+
25
+ @classmethod
26
+ def build(cls, kind: str, media_type: str | None, content: str | None, **meta: MetaValue) -> "Cell":
27
+ if media_type is None and content is not None:
28
+ from donna.protocol.errors import ContentWithoutMediaType
29
+
30
+ raise ContentWithoutMediaType()
31
+
32
+ return cls(kind=kind, media_type=media_type, content=content, meta=meta)
33
+
34
+ @classmethod
35
+ def build_meta(cls, kind: str, **meta: MetaValue) -> "Cell":
36
+ return cls.build(kind=kind, media_type=None, content=None, **meta)
37
+
38
+ @classmethod
39
+ def build_markdown(cls, kind: str, content: str, **meta: MetaValue) -> "Cell":
40
+ return cls.build(kind=kind, media_type="text/markdown", content=content, **meta)
41
+
42
+ @property
43
+ def short_id(self) -> str:
44
+ return base64.urlsafe_b64encode(self.id.bytes).rstrip(b"=").decode()
@@ -0,0 +1,17 @@
1
+ from donna.core import errors as core_errors
2
+
3
+
4
+ class InternalError(core_errors.InternalError):
5
+ """Base class for internal errors in donna.protocol."""
6
+
7
+
8
+ class ModeNotSet(InternalError):
9
+ message: str = "Mode is not set. Pass -p <mode> to the CLI."
10
+
11
+
12
+ class UnsupportedFormatterMode(InternalError):
13
+ message: str = "Formatter for mode '{mode}' is not implemented."
14
+
15
+
16
+ class ContentWithoutMediaType(InternalError):
17
+ message: str = "Cannot set content when media_type is None."
File without changes
@@ -0,0 +1,25 @@
1
+ import json
2
+
3
+ from donna.protocol.cells import Cell
4
+ from donna.protocol.formatters.base import Formatter as BaseFormatter
5
+
6
+
7
+ class Formatter(BaseFormatter):
8
+
9
+ def format_cell(self, cell: Cell, single_mode: bool) -> bytes:
10
+ data: dict[str, str | int | bool | None] = {"id": cell.short_id}
11
+
12
+ for meta_key, meta_value in sorted(cell.meta.items()):
13
+ data[meta_key] = meta_value
14
+
15
+ data["content"] = cell.content.strip() if cell.content else None
16
+
17
+ return json.dumps(data, ensure_ascii=False, indent=None, separators=(",", ":"), sort_keys=True).encode()
18
+
19
+ def format_log(self, cell: Cell, single_mode: bool) -> bytes:
20
+ return self.format_cells([cell])
21
+
22
+ def format_cells(self, cells: list[Cell]) -> bytes:
23
+ single_mode = len(cells) == 1
24
+ formatted_cells = [self.format_cell(cell, single_mode=single_mode) for cell in cells]
25
+ return b"\n".join(formatted_cells)
@@ -0,0 +1,15 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from donna.protocol.cells import Cell
4
+
5
+
6
+ class Formatter(ABC):
7
+
8
+ @abstractmethod
9
+ def format_cell(self, cell: Cell, single_mode: bool) -> bytes: ... # noqa: E704
10
+
11
+ @abstractmethod
12
+ def format_log(self, cell: Cell, single_mode: bool) -> bytes: ... # noqa: E704
13
+
14
+ @abstractmethod
15
+ def format_cells(self, cells: list[Cell]) -> bytes: ... # noqa: E704
@@ -0,0 +1,36 @@
1
+ from donna.protocol.cells import Cell
2
+ from donna.protocol.formatters.base import Formatter as BaseFormatter
3
+
4
+
5
+ class Formatter(BaseFormatter):
6
+
7
+ def format_cell(self, cell: Cell, single_mode: bool) -> bytes:
8
+ id = cell.short_id
9
+
10
+ lines = []
11
+
12
+ if not single_mode:
13
+ lines = [f"----- DONNA CELL {id} -----"]
14
+
15
+ lines.append(f"kind = {cell.kind}")
16
+
17
+ if cell.media_type is not None:
18
+ lines.append(f"media_type = {cell.media_type}")
19
+
20
+ for meta_key, meta_value in sorted(cell.meta.items()):
21
+ lines.append(f"{meta_key} = {meta_value}")
22
+
23
+ if cell.content:
24
+ lines.append("")
25
+ lines.append(cell.content.strip())
26
+
27
+ return "\n".join(lines).encode()
28
+
29
+ def format_log(self, cell: Cell, single_mode: bool) -> bytes:
30
+ message = cell.content.strip() if cell.content else ""
31
+ return f"DONNA LOG: {message}".strip().encode()
32
+
33
+ def format_cells(self, cells: list[Cell]) -> bytes:
34
+ single_mode = len(cells) == 1
35
+ formatted_cells = [self.format_cell(cell, single_mode=single_mode) for cell in cells]
36
+ return b"\n\n".join(formatted_cells)
@@ -0,0 +1,39 @@
1
+ from donna.protocol.cells import Cell
2
+ from donna.protocol.formatters.base import Formatter as BaseFormatter
3
+
4
+
5
+ class Formatter(BaseFormatter):
6
+
7
+ def format_cell(self, cell: Cell, single_mode: bool) -> bytes: # noqa: CCR001
8
+ id = cell.short_id
9
+
10
+ lines = []
11
+
12
+ if not single_mode:
13
+ lines = [f"--DONNA-CELL {id} BEGIN--"]
14
+
15
+ lines.append(f"kind={cell.kind}")
16
+
17
+ if cell.media_type is not None:
18
+ lines.append(f"media_type={cell.media_type}")
19
+
20
+ for meta_key, meta_value in sorted(cell.meta.items()):
21
+ lines.append(f"{meta_key}={meta_value}")
22
+
23
+ if cell.content:
24
+ lines.append("")
25
+ lines.append(cell.content.strip())
26
+
27
+ if not single_mode:
28
+ lines.append(f"--DONNA-CELL {id} END--")
29
+
30
+ return "\n".join(lines).strip().encode()
31
+
32
+ def format_log(self, cell: Cell, single_mode: bool) -> bytes:
33
+ message = cell.content.strip() if cell.content else ""
34
+ return f"DONNA LOG: {message}".strip().encode()
35
+
36
+ def format_cells(self, cells: list[Cell]) -> bytes:
37
+ single_mode = len(cells) == 1
38
+ formatted_cells = [self.format_cell(cell, single_mode=single_mode) for cell in cells]
39
+ return b"\n\n".join(formatted_cells)
@@ -0,0 +1,40 @@
1
+ import enum
2
+
3
+ from donna.protocol.errors import ModeNotSet, UnsupportedFormatterMode
4
+ from donna.protocol.formatters.automation import Formatter as AutomationFormatter
5
+ from donna.protocol.formatters.base import Formatter
6
+ from donna.protocol.formatters.human import Formatter as HumanFormatter
7
+ from donna.protocol.formatters.llm import Formatter as LLMFormatter
8
+
9
+
10
+ class Mode(enum.StrEnum):
11
+ human = "human"
12
+ llm = "llm"
13
+ automation = "automation"
14
+
15
+
16
+ _MODE: Mode | None = None
17
+
18
+
19
+ def set_mode(mode: Mode) -> None:
20
+ global _MODE
21
+ _MODE = mode
22
+
23
+
24
+ def mode() -> Mode:
25
+ if _MODE is None:
26
+ raise ModeNotSet()
27
+
28
+ return _MODE
29
+
30
+
31
+ def get_cell_formatter() -> Formatter:
32
+ match mode():
33
+ case Mode.human:
34
+ return HumanFormatter()
35
+ case Mode.llm:
36
+ return LLMFormatter()
37
+ case Mode.automation:
38
+ return AutomationFormatter()
39
+ case _:
40
+ raise UnsupportedFormatterMode(mode=mode())
@@ -0,0 +1,59 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from donna.protocol.cells import Cell
4
+
5
+
6
+ class Node(ABC):
7
+ """Node of Donna's knowledge graph.
8
+
9
+ The concept of knowledge graph is highly experimental and subject to change.
10
+
11
+ Its primary purpose is to simplify navigation through different Donna's entities
12
+ and to provide a unified interface for retrieving information about them.
13
+
14
+ There are two types of child nodes:
15
+
16
+ - References — nodes that are referenced by this artifact, but not a part of it.
17
+ An artifact does not include information from referenced nodes
18
+ in its info.
19
+ An artifact include references in its details and index.
20
+ - Components — nodes that are inseparable parts of this artifact.
21
+ An artifact includes information from them in its info.
22
+ An artifact does not include components in its details and index,
23
+ """
24
+
25
+ __slots__ = ()
26
+
27
+ @abstractmethod
28
+ def status(self) -> Cell:
29
+ """Returns short info about only this node."""
30
+ ...
31
+
32
+ def info(self) -> Cell:
33
+ """Returns full info about only this node."""
34
+ return self.status()
35
+
36
+ def details(self) -> list[Cell]:
37
+ """Returns info about the node and its children.
38
+
39
+ The node decides itself which children to include with what level of detail.
40
+ """
41
+ cells = [self.info()]
42
+ cells.extend(child.info() for child in self.references())
43
+
44
+ return cells
45
+
46
+ def index(self) -> list[Cell]:
47
+ """Returns status of itself and all its children."""
48
+ cells = [self.status()]
49
+ cells.extend(child.status() for child in self.references())
50
+
51
+ return cells
52
+
53
+ def references(self) -> list["Node"]:
54
+ """Return all nodes that are referenced by this one"""
55
+ return []
56
+
57
+ def components(self) -> list["Node"]:
58
+ """Return all nodes that are iseparable parts of this one."""
59
+ return []
File without changes
@@ -0,0 +1,122 @@
1
+ import pathlib
2
+
3
+ from donna.core.entities import BaseEntity
4
+ from donna.core.errors import ErrorsList
5
+ from donna.core.result import Err, Ok, Result, unwrap_to_error
6
+ from donna.domain.ids import FullArtifactId, FullArtifactIdPattern, WorldId
7
+ from donna.machine.artifacts import Artifact
8
+ from donna.machine.tasks import Task, WorkUnit
9
+ from donna.world import errors
10
+ from donna.world.config import config
11
+ from donna.world.templates import RenderMode
12
+
13
+
14
+ class ArtifactRenderContext(BaseEntity):
15
+ primary_mode: RenderMode
16
+ current_task: Task | None = None
17
+ current_work_unit: WorkUnit | None = None
18
+
19
+
20
+ class ArtifactUpdateError(errors.WorldError):
21
+ cell_kind: str = "artifact_update_error"
22
+ artifact_id: FullArtifactId
23
+ path: pathlib.Path
24
+
25
+ def content_intro(self) -> str:
26
+ return f"Error updating artifact '{self.artifact_id}' from the path '{self.path}'"
27
+
28
+
29
+ class CanNotUpdateReadonlyWorld(ArtifactUpdateError):
30
+ code: str = "donna.world.cannot_update_readonly_world"
31
+ message: str = "Cannot upload artifact to the read-only world `{error.world_id}`"
32
+ world_id: WorldId
33
+
34
+
35
+ class InputPathHasNoExtension(ArtifactUpdateError):
36
+ code: str = "donna.world.input_path_has_no_extension"
37
+ message: str = "Input path has no extension to determine artifact source type"
38
+
39
+
40
+ class NoSourceForArtifactExtension(ArtifactUpdateError):
41
+ code: str = "donna.world.no_source_for_artifact_extension"
42
+ message: str = "No source found for artifact extension of input path"
43
+
44
+
45
+ @unwrap_to_error
46
+ def artifact_file_extension(full_id: FullArtifactId) -> Result[str, ErrorsList]:
47
+ world = config().get_world(full_id.world_id).unwrap()
48
+ return Ok(world.file_extension_for(full_id.artifact_id).unwrap().lstrip("."))
49
+
50
+
51
+ @unwrap_to_error
52
+ def fetch_artifact(full_id: FullArtifactId, output: pathlib.Path) -> Result[None, ErrorsList]:
53
+ world = config().get_world(full_id.world_id).unwrap()
54
+ content = world.fetch_source(full_id.artifact_id).unwrap()
55
+
56
+ with output.open("wb") as f:
57
+ f.write(content)
58
+
59
+ return Ok(None)
60
+
61
+
62
+ @unwrap_to_error
63
+ def update_artifact(full_id: FullArtifactId, input: pathlib.Path) -> Result[None, ErrorsList]:
64
+ world = config().get_world(full_id.world_id).unwrap()
65
+
66
+ if world.readonly:
67
+ return Err([CanNotUpdateReadonlyWorld(artifact_id=full_id, path=input, world_id=world.id)])
68
+
69
+ source_suffix = input.suffix.lower()
70
+ content_bytes = input.read_bytes()
71
+
72
+ if not source_suffix:
73
+ return Err([InputPathHasNoExtension(artifact_id=full_id, path=input)])
74
+
75
+ source_config = config().find_source_for_extension(source_suffix)
76
+ if source_config is None:
77
+ return Err([NoSourceForArtifactExtension(artifact_id=full_id, path=input)])
78
+
79
+ render_context = ArtifactRenderContext(primary_mode=RenderMode.view)
80
+ test_artifact = source_config.construct_artifact_from_bytes(full_id, content_bytes, render_context).unwrap()
81
+ validation_result = test_artifact.validate_artifact()
82
+
83
+ validation_result.unwrap()
84
+ world.update(full_id.artifact_id, content_bytes, source_suffix).unwrap()
85
+
86
+ return Ok(None)
87
+
88
+
89
+ @unwrap_to_error
90
+ def load_artifact(
91
+ full_id: FullArtifactId, render_context: ArtifactRenderContext | None = None
92
+ ) -> Result[Artifact, ErrorsList]:
93
+ if render_context is None:
94
+ render_context = ArtifactRenderContext(primary_mode=RenderMode.view)
95
+
96
+ world = config().get_world(full_id.world_id).unwrap()
97
+ return Ok(world.fetch(full_id.artifact_id, render_context).unwrap())
98
+
99
+
100
+ def list_artifacts( # noqa: CCR001
101
+ pattern: FullArtifactIdPattern, render_context: ArtifactRenderContext | None = None
102
+ ) -> Result[list[Artifact], ErrorsList]:
103
+
104
+ if render_context is None:
105
+ render_context = ArtifactRenderContext(primary_mode=RenderMode.view)
106
+
107
+ artifacts: list[Artifact] = []
108
+ errors: ErrorsList = []
109
+
110
+ for world in reversed(config().worlds_instances):
111
+ for artifact_id in world.list_artifacts(pattern):
112
+ full_id = FullArtifactId((world.id, artifact_id))
113
+ artifact_result = load_artifact(full_id, render_context)
114
+ if artifact_result.is_err():
115
+ errors.extend(artifact_result.unwrap_err())
116
+ continue
117
+ artifacts.append(artifact_result.unwrap())
118
+
119
+ if errors:
120
+ return Err(errors)
121
+
122
+ return Ok(artifacts)