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,181 @@
1
+ from __future__ import annotations
2
+
3
+ import enum
4
+ import importlib
5
+ import importlib.util
6
+ from typing import TYPE_CHECKING
7
+
8
+ import jinja2
9
+
10
+ from donna.core import errors as core_errors
11
+ from donna.core.errors import EnvironmentErrorsProxy, ErrorsList
12
+ from donna.core.result import Err, Ok, Result
13
+ from donna.domain.ids import FullArtifactId
14
+ from donna.machine.templates import Directive
15
+ from donna.world import errors as world_errors
16
+
17
+ if TYPE_CHECKING:
18
+ from donna.world.artifacts import ArtifactRenderContext
19
+
20
+
21
+ class RenderMode(enum.Enum):
22
+ """Modes for rendering artifacts.
23
+
24
+ Donna could render artifacts for different purposes, for example:
25
+
26
+ - to be displayed to the agent when Donna is used via CLI
27
+ - TODO: to be displayed to the agent when Donna is used as an agent tool
28
+ - TODO: to be displayed to the agent when Donna is used as an MCP server
29
+ - to be used for analysis by Donna itself
30
+
31
+ In each mode Donna can produce different outputs.
32
+
33
+ For example, it can output CLI commands in view/execute mode, tool specifications in tool mode,
34
+ special markup in analyze mode, etc.
35
+ """
36
+
37
+ view = "view"
38
+ execute = "execute"
39
+ analysis = "analysis"
40
+
41
+
42
+ _ENVIRONMENT = None
43
+
44
+
45
+ def _is_importable_module(name: str) -> bool:
46
+ return importlib.util.find_spec(name) is not None
47
+
48
+
49
+ class DirectivePathBuilder:
50
+ def __init__(self, parts: tuple[str, ...]) -> None:
51
+ self._parts = parts
52
+
53
+ def __getattr__(self, name: str) -> "DirectivePathBuilder":
54
+ return DirectivePathBuilder(self._parts + (name,))
55
+
56
+ def __getitem__(self, name: str) -> "DirectivePathBuilder":
57
+ return DirectivePathBuilder(self._parts + (name,))
58
+
59
+ @jinja2.pass_context
60
+ def __call__(self, context: jinja2.runtime.Context, *argv: object) -> object: # noqa: CCR001
61
+ artifact_id = context.get("artifact_id")
62
+ directive_path = ".".join(self._parts)
63
+ if len(self._parts) < 2:
64
+ raise EnvironmentErrorsProxy(
65
+ [world_errors.DirectivePathIncomplete(path=directive_path, artifact_id=artifact_id)]
66
+ )
67
+
68
+ module_path = ".".join(self._parts[:-1])
69
+ directive_name = self._parts[-1]
70
+
71
+ try:
72
+ module = importlib.import_module(module_path)
73
+ except ModuleNotFoundError as exc:
74
+ raise EnvironmentErrorsProxy(
75
+ [world_errors.DirectiveModuleNotImportable(module_path=module_path, artifact_id=artifact_id)]
76
+ ) from exc
77
+ except core_errors.InternalError:
78
+ raise
79
+ except Exception as exc:
80
+ raise EnvironmentErrorsProxy(
81
+ [
82
+ world_errors.DirectiveUnexpectedError(
83
+ directive_path=directive_path,
84
+ details=str(exc),
85
+ artifact_id=artifact_id,
86
+ )
87
+ ]
88
+ ) from exc
89
+
90
+ try:
91
+ directive = getattr(module, directive_name)
92
+ except AttributeError as exc:
93
+ raise EnvironmentErrorsProxy(
94
+ [
95
+ world_errors.DirectiveNotAvailable(
96
+ module_path=module_path,
97
+ directive_name=directive_name,
98
+ artifact_id=artifact_id,
99
+ )
100
+ ]
101
+ ) from exc
102
+
103
+ if not isinstance(directive, Directive):
104
+ raise EnvironmentErrorsProxy(
105
+ [
106
+ world_errors.DirectiveNotDirective(
107
+ module_path=module_path,
108
+ directive_name=directive_name,
109
+ artifact_id=artifact_id,
110
+ )
111
+ ]
112
+ )
113
+
114
+ try:
115
+ result = directive.apply_directive(context, *argv)
116
+ except EnvironmentErrorsProxy:
117
+ raise
118
+ except core_errors.InternalError:
119
+ raise
120
+ except Exception as exc:
121
+ raise EnvironmentErrorsProxy(
122
+ [
123
+ world_errors.DirectiveUnexpectedError(
124
+ directive_path=directive_path,
125
+ details=str(exc),
126
+ artifact_id=artifact_id,
127
+ )
128
+ ]
129
+ ) from exc
130
+
131
+ if result.is_err():
132
+ raise EnvironmentErrorsProxy(result.unwrap_err())
133
+
134
+ return result.unwrap()
135
+
136
+
137
+ class DirectivePathUndefined(jinja2.Undefined):
138
+ def __getattr__(self, name: str) -> object:
139
+ if not self._undefined_name or not _is_importable_module(self._undefined_name):
140
+ return jinja2.Undefined(name=f"{self._undefined_name}.{name}")
141
+
142
+ return DirectivePathBuilder((self._undefined_name, name))
143
+
144
+
145
+ def env() -> jinja2.Environment:
146
+ global _ENVIRONMENT
147
+
148
+ if _ENVIRONMENT is not None:
149
+ return _ENVIRONMENT
150
+
151
+ _ENVIRONMENT = jinja2.Environment(
152
+ loader=None,
153
+ # we render into markdown, not into HTML
154
+ # i.e. before (possible) displaying in the browser,
155
+ # the result of the jinja2 render will be rendered by markdown renderer
156
+ # markdown renderer should take care of escaping
157
+ autoescape=jinja2.select_autoescape(default=False, default_for_string=False),
158
+ auto_reload=False,
159
+ extensions=["jinja2.ext.do", "jinja2.ext.loopcontrols", "jinja2.ext.debug"],
160
+ undefined=DirectivePathUndefined,
161
+ )
162
+
163
+ return _ENVIRONMENT
164
+
165
+
166
+ def render(
167
+ artifact_id: FullArtifactId, template: str, render_context: "ArtifactRenderContext"
168
+ ) -> Result[str, ErrorsList]:
169
+ context = {"render_mode": render_context.primary_mode, "artifact_id": artifact_id}
170
+
171
+ if render_context.current_task is not None:
172
+ context["current_task"] = render_context.current_task
173
+
174
+ if render_context.current_work_unit is not None:
175
+ context["current_work_unit"] = render_context.current_work_unit
176
+
177
+ try:
178
+ template_obj = env().from_string(template)
179
+ return Ok(template_obj.render(**context))
180
+ except EnvironmentErrorsProxy as exc:
181
+ return Err(exc.arguments["errors"])
donna/world/tmp.py ADDED
@@ -0,0 +1,33 @@
1
+ import pathlib
2
+ import shutil
3
+ import time
4
+
5
+ from donna.cli.types import FullArtifactIdArgument
6
+ from donna.world.config import config, config_dir
7
+
8
+
9
+ def dir() -> pathlib.Path:
10
+ cfg = config()
11
+ tmp_path = cfg.tmp_dir
12
+
13
+ if not cfg.tmp_dir.is_absolute():
14
+ tmp_path = config_dir() / tmp_path
15
+
16
+ tmp_path.mkdir(parents=True, exist_ok=True)
17
+
18
+ return tmp_path
19
+
20
+
21
+ def file_for_artifact(artifact_id: FullArtifactIdArgument, extention: str) -> pathlib.Path:
22
+ directory = dir()
23
+
24
+ directory.mkdir(parents=True, exist_ok=True)
25
+
26
+ normalized_extension = extention.lstrip(".")
27
+ artifact_file_name = f"{str(artifact_id).replace('/', '.')}.{int(time.time() * 1000)}.{normalized_extension}"
28
+
29
+ return directory / artifact_file_name
30
+
31
+
32
+ def clear() -> None:
33
+ shutil.rmtree(dir())
File without changes
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import TYPE_CHECKING
5
+
6
+ from donna.core.entities import BaseEntity
7
+ from donna.core.errors import ErrorsList
8
+ from donna.core.result import Result
9
+ from donna.domain.ids import ArtifactId, FullArtifactIdPattern, WorldId
10
+ from donna.machine.artifacts import Artifact
11
+ from donna.machine.primitives import Primitive
12
+
13
+ if TYPE_CHECKING:
14
+ from donna.world.artifacts import ArtifactRenderContext
15
+ from donna.world.config import WorldConfig
16
+
17
+
18
+ class World(BaseEntity, ABC):
19
+ id: WorldId
20
+ readonly: bool = True
21
+ session: bool = False
22
+
23
+ @abstractmethod
24
+ def has(self, artifact_id: ArtifactId) -> bool: ... # noqa: E704
25
+
26
+ @abstractmethod
27
+ def fetch( # noqa: E704
28
+ self, artifact_id: ArtifactId, render_context: "ArtifactRenderContext"
29
+ ) -> Result[Artifact, ErrorsList]: ...
30
+
31
+ @abstractmethod
32
+ def fetch_source(self, artifact_id: ArtifactId) -> Result[bytes, ErrorsList]: ... # noqa: E704
33
+
34
+ @abstractmethod
35
+ def update( # noqa: E704
36
+ self, artifact_id: ArtifactId, content: bytes, extension: str
37
+ ) -> Result[None, ErrorsList]: ... # noqa: E704
38
+
39
+ @abstractmethod
40
+ def file_extension_for(self, artifact_id: ArtifactId) -> Result[str, ErrorsList]: ... # noqa: E704
41
+
42
+ @abstractmethod
43
+ def list_artifacts(self, pattern: FullArtifactIdPattern) -> list[ArtifactId]: ... # noqa: E704
44
+
45
+ # These two methods are intended for storing world state (e.g., session data)
46
+ # It is an open question if the world state is an artifact itself or something else
47
+ # For the artifact: uniform API for storing/loading data
48
+ # Against the artifact:
49
+ # - session data MUST be accessible only by Donna => no one should be able to read/write/list it
50
+ # - session data will require an additonal kind(s) of artifact(s) just for that purpose
51
+ # - session data may change more frequently than regular artifacts
52
+
53
+ @abstractmethod
54
+ def read_state(self, name: str) -> Result[bytes | None, ErrorsList]: ... # noqa: E704
55
+
56
+ @abstractmethod
57
+ def write_state(self, name: str, content: bytes) -> Result[None, ErrorsList]: ... # noqa: E704
58
+
59
+ def initialize(self, reset: bool = False) -> None:
60
+ pass
61
+
62
+ @abstractmethod
63
+ def is_initialized(self) -> bool: ... # noqa: E704
64
+
65
+
66
+ class WorldConstructor(Primitive, ABC):
67
+ @abstractmethod
68
+ def construct_world(self, config: WorldConfig) -> World: ... # noqa: E704
@@ -0,0 +1,189 @@
1
+ import pathlib
2
+ import shutil
3
+ from typing import TYPE_CHECKING, cast
4
+
5
+ from donna.core.errors import ErrorsList
6
+ from donna.core.result import Err, Ok, Result, unwrap_to_error
7
+ from donna.domain.ids import ArtifactId, FullArtifactId, FullArtifactIdPattern
8
+ from donna.machine.artifacts import Artifact
9
+ from donna.world import errors as world_errors
10
+ from donna.world.artifacts import ArtifactRenderContext
11
+ from donna.world.artifacts_discovery import ArtifactListingNode, list_artifacts_by_pattern
12
+ from donna.world.worlds.base import World as BaseWorld
13
+ from donna.world.worlds.base import WorldConstructor
14
+
15
+ if TYPE_CHECKING:
16
+ from donna.world.config import SourceConfigValue, WorldConfig
17
+
18
+
19
+ class World(BaseWorld):
20
+ path: pathlib.Path
21
+
22
+ def _artifact_listing_root(self) -> ArtifactListingNode | None:
23
+ if not self.path.exists():
24
+ return None
25
+
26
+ return cast(ArtifactListingNode, self.path)
27
+
28
+ def _artifact_path(self, artifact_id: ArtifactId, extension: str) -> pathlib.Path:
29
+ return self.path / f"{artifact_id.replace(':', '/')}{extension}"
30
+
31
+ def _resolve_artifact_file(self, artifact_id: ArtifactId) -> Result[pathlib.Path | None, ErrorsList]:
32
+ artifact_path = self.path / artifact_id.replace(":", "/")
33
+ parent = artifact_path.parent
34
+
35
+ if not parent.exists():
36
+ return Ok(None)
37
+
38
+ from donna.world.config import config
39
+
40
+ supported_extensions = config().supported_extensions()
41
+ matches = [
42
+ path
43
+ for path in parent.glob(f"{artifact_path.name}.*")
44
+ if path.is_file() and path.suffix.lower() in supported_extensions
45
+ ]
46
+
47
+ if not matches:
48
+ return Ok(None)
49
+
50
+ if len(matches) > 1:
51
+ return Err([world_errors.ArtifactMultipleFiles(artifact_id=artifact_id, world_id=self.id)])
52
+
53
+ return Ok(matches[0])
54
+
55
+ def _get_source_by_filename(
56
+ self, artifact_id: ArtifactId, filename: str
57
+ ) -> Result["SourceConfigValue", ErrorsList]:
58
+ from donna.world.config import config
59
+
60
+ extension = pathlib.Path(filename).suffix
61
+ source_config = config().find_source_for_extension(extension)
62
+ if source_config is None:
63
+ return Err(
64
+ [
65
+ world_errors.UnsupportedArtifactSourceExtension(
66
+ artifact_id=artifact_id,
67
+ world_id=self.id,
68
+ extension=extension,
69
+ )
70
+ ]
71
+ )
72
+
73
+ return Ok(source_config)
74
+
75
+ def has(self, artifact_id: ArtifactId) -> bool:
76
+ resolve_result = self._resolve_artifact_file(artifact_id)
77
+ if resolve_result.is_err():
78
+ return True
79
+
80
+ return resolve_result.unwrap() is not None
81
+
82
+ @unwrap_to_error
83
+ def fetch(self, artifact_id: ArtifactId, render_context: ArtifactRenderContext) -> Result[Artifact, ErrorsList]:
84
+ path = self._resolve_artifact_file(artifact_id).unwrap()
85
+ if path is None:
86
+ return Err([world_errors.ArtifactNotFound(artifact_id=artifact_id, world_id=self.id)])
87
+
88
+ content_bytes = path.read_bytes()
89
+ full_id = FullArtifactId((self.id, artifact_id))
90
+
91
+ extension = pathlib.Path(path.name).suffix
92
+ from donna.world.config import config
93
+
94
+ source_config = config().find_source_for_extension(extension)
95
+ if source_config is None:
96
+ return Err(
97
+ [
98
+ world_errors.UnsupportedArtifactSourceExtension(
99
+ artifact_id=artifact_id,
100
+ world_id=self.id,
101
+ extension=extension,
102
+ )
103
+ ]
104
+ )
105
+
106
+ return Ok(source_config.construct_artifact_from_bytes(full_id, content_bytes, render_context).unwrap())
107
+
108
+ @unwrap_to_error
109
+ def fetch_source(self, artifact_id: ArtifactId) -> Result[bytes, ErrorsList]:
110
+ path = self._resolve_artifact_file(artifact_id).unwrap()
111
+ if path is None:
112
+ return Err([world_errors.ArtifactNotFound(artifact_id=artifact_id, world_id=self.id)])
113
+
114
+ return Ok(path.read_bytes())
115
+
116
+ def update(self, artifact_id: ArtifactId, content: bytes, extension: str) -> Result[None, ErrorsList]:
117
+ if self.readonly:
118
+ return Err([world_errors.WorldReadonly(world_id=self.id)])
119
+
120
+ path = self._artifact_path(artifact_id, extension)
121
+
122
+ path.parent.mkdir(parents=True, exist_ok=True)
123
+ path.write_bytes(content)
124
+ return Ok(None)
125
+
126
+ @unwrap_to_error
127
+ def file_extension_for(self, artifact_id: ArtifactId) -> Result[str, ErrorsList]:
128
+ path = self._resolve_artifact_file(artifact_id).unwrap()
129
+ if path is None:
130
+ return Err([world_errors.ArtifactNotFound(artifact_id=artifact_id, world_id=self.id)])
131
+
132
+ return Ok(path.suffix)
133
+
134
+ def read_state(self, name: str) -> Result[bytes | None, ErrorsList]:
135
+ if not self.session:
136
+ return Err([world_errors.WorldStateStorageUnsupported(world_id=self.id)])
137
+
138
+ path = self.path / name
139
+
140
+ if not path.exists():
141
+ return Ok(None)
142
+
143
+ return Ok(path.read_bytes())
144
+
145
+ def write_state(self, name: str, content: bytes) -> Result[None, ErrorsList]:
146
+ if self.readonly:
147
+ return Err([world_errors.WorldReadonly(world_id=self.id)])
148
+
149
+ if not self.session:
150
+ return Err([world_errors.WorldStateStorageUnsupported(world_id=self.id)])
151
+
152
+ path = self.path / name
153
+ path.parent.mkdir(parents=True, exist_ok=True)
154
+ path.write_bytes(content)
155
+ return Ok(None)
156
+
157
+ def list_artifacts(self, pattern: FullArtifactIdPattern) -> list[ArtifactId]: # noqa: CCR001
158
+ return list_artifacts_by_pattern(
159
+ world_id=self.id,
160
+ root=self._artifact_listing_root(),
161
+ pattern=pattern,
162
+ )
163
+
164
+ def initialize(self, reset: bool = False) -> None:
165
+ if self.readonly:
166
+ return
167
+
168
+ if self.path.exists() and reset:
169
+ shutil.rmtree(self.path)
170
+
171
+ self.path.mkdir(parents=True, exist_ok=True)
172
+
173
+ def is_initialized(self) -> bool:
174
+ return self.path.exists()
175
+
176
+
177
+ class FilesystemWorldConstructor(WorldConstructor):
178
+ def construct_world(self, config: "WorldConfig") -> World:
179
+ path_value = getattr(config, "path", None)
180
+
181
+ if path_value is None:
182
+ raise ValueError(f"World config '{config.id}' does not define a filesystem path")
183
+
184
+ return World(
185
+ id=config.id,
186
+ path=pathlib.Path(path_value),
187
+ readonly=config.readonly,
188
+ session=config.session,
189
+ )
@@ -0,0 +1,196 @@
1
+ import importlib
2
+ import importlib.resources
3
+ import pathlib
4
+ from typing import TYPE_CHECKING, cast
5
+
6
+ from donna.core.errors import ErrorsList
7
+ from donna.core.result import Err, Ok, Result, unwrap_to_error
8
+ from donna.domain.ids import ArtifactId, FullArtifactId, FullArtifactIdPattern, WorldId
9
+ from donna.machine.artifacts import Artifact
10
+ from donna.world import errors as world_errors
11
+ from donna.world.artifacts import ArtifactRenderContext
12
+ from donna.world.artifacts_discovery import ArtifactListingNode, list_artifacts_by_pattern
13
+ from donna.world.worlds.base import World as BaseWorld
14
+ from donna.world.worlds.base import WorldConstructor
15
+
16
+ if TYPE_CHECKING:
17
+ from donna.world.config import SourceConfigValue, WorldConfig
18
+
19
+
20
+ class Python(BaseWorld):
21
+ id: WorldId
22
+ readonly: bool = True
23
+ session: bool = False
24
+ package: str
25
+ artifacts_root: str
26
+
27
+ def _resource_root(self) -> importlib.resources.abc.Traversable | None:
28
+ package = self.artifacts_root
29
+
30
+ try:
31
+ return importlib.resources.files(package)
32
+ except ModuleNotFoundError:
33
+ return None
34
+
35
+ def _artifact_listing_root(self) -> ArtifactListingNode | None:
36
+ root = self._resource_root()
37
+ if root is None:
38
+ return None
39
+
40
+ return cast(ArtifactListingNode, root)
41
+
42
+ def _resolve_artifact_file(
43
+ self, artifact_id: ArtifactId
44
+ ) -> Result[importlib.resources.abc.Traversable | None, ErrorsList]:
45
+ parts = str(artifact_id).split(":")
46
+ if not parts:
47
+ return Ok(None)
48
+
49
+ resource_root = self._resource_root()
50
+ if resource_root is None:
51
+ return Ok(None)
52
+
53
+ *dirs, file_name = parts
54
+ resource_dir = resource_root
55
+
56
+ for part in dirs:
57
+ resource_dir = resource_dir.joinpath(part)
58
+
59
+ if not resource_dir.is_dir():
60
+ return Ok(None)
61
+
62
+ from donna.world.config import config
63
+
64
+ supported_extensions = config().supported_extensions()
65
+ matches = [
66
+ entry
67
+ for entry in resource_dir.iterdir()
68
+ if entry.is_file()
69
+ and entry.name.startswith(f"{file_name}.")
70
+ and pathlib.Path(entry.name).suffix.lower() in supported_extensions
71
+ ]
72
+
73
+ if not matches:
74
+ return Ok(None)
75
+
76
+ if len(matches) > 1:
77
+ return Err([world_errors.ArtifactMultipleFiles(artifact_id=artifact_id, world_id=self.id)])
78
+
79
+ return Ok(matches[0])
80
+
81
+ def _get_source_by_filename(
82
+ self, artifact_id: ArtifactId, filename: str
83
+ ) -> Result["SourceConfigValue", ErrorsList]:
84
+ from donna.world.config import config
85
+
86
+ extension = pathlib.Path(filename).suffix
87
+ source_config = config().find_source_for_extension(extension)
88
+ if source_config is None:
89
+ return Err(
90
+ [
91
+ world_errors.UnsupportedArtifactSourceExtension(
92
+ artifact_id=artifact_id,
93
+ world_id=self.id,
94
+ extension=extension,
95
+ )
96
+ ]
97
+ )
98
+
99
+ return Ok(source_config)
100
+
101
+ def has(self, artifact_id: ArtifactId) -> bool:
102
+ resolve_result = self._resolve_artifact_file(artifact_id)
103
+ if resolve_result.is_err():
104
+ return True
105
+
106
+ return resolve_result.unwrap() is not None
107
+
108
+ @unwrap_to_error
109
+ def fetch(self, artifact_id: ArtifactId, render_context: ArtifactRenderContext) -> Result[Artifact, ErrorsList]:
110
+ resource_path = self._resolve_artifact_file(artifact_id).unwrap()
111
+ if resource_path is None:
112
+ return Err([world_errors.ArtifactNotFound(artifact_id=artifact_id, world_id=self.id)])
113
+
114
+ content_bytes = resource_path.read_bytes()
115
+ full_id = FullArtifactId((self.id, artifact_id))
116
+
117
+ extension = pathlib.Path(resource_path.name).suffix
118
+ from donna.world.config import config
119
+
120
+ source_config = config().find_source_for_extension(extension)
121
+ if source_config is None:
122
+ return Err(
123
+ [
124
+ world_errors.UnsupportedArtifactSourceExtension(
125
+ artifact_id=artifact_id,
126
+ world_id=self.id,
127
+ extension=extension,
128
+ )
129
+ ]
130
+ )
131
+
132
+ return Ok(source_config.construct_artifact_from_bytes(full_id, content_bytes, render_context).unwrap())
133
+
134
+ @unwrap_to_error
135
+ def fetch_source(self, artifact_id: ArtifactId) -> Result[bytes, ErrorsList]: # noqa: CCR001
136
+ resource_path = self._resolve_artifact_file(artifact_id).unwrap()
137
+ if resource_path is None:
138
+ return Err([world_errors.ArtifactNotFound(artifact_id=artifact_id, world_id=self.id)])
139
+
140
+ return Ok(resource_path.read_bytes())
141
+
142
+ def update(self, artifact_id: ArtifactId, content: bytes, extension: str) -> Result[None, ErrorsList]:
143
+ return Err([world_errors.WorldReadonly(world_id=self.id)])
144
+
145
+ @unwrap_to_error
146
+ def file_extension_for(self, artifact_id: ArtifactId) -> Result[str, ErrorsList]:
147
+ resource_path = self._resolve_artifact_file(artifact_id).unwrap()
148
+ if resource_path is None:
149
+ return Err([world_errors.ArtifactNotFound(artifact_id=artifact_id, world_id=self.id)])
150
+
151
+ return Ok(pathlib.Path(resource_path.name).suffix)
152
+
153
+ def list_artifacts(self, pattern: FullArtifactIdPattern) -> list[ArtifactId]: # noqa: CCR001
154
+ return list_artifacts_by_pattern(
155
+ world_id=self.id,
156
+ root=self._artifact_listing_root(),
157
+ pattern=pattern,
158
+ )
159
+
160
+ # TODO: How can the state be represented in the Python world?
161
+ def read_state(self, name: str) -> Result[bytes | None, ErrorsList]:
162
+ return Err([world_errors.WorldStateStorageUnsupported(world_id=self.id)])
163
+
164
+ def write_state(self, name: str, content: bytes) -> Result[None, ErrorsList]:
165
+ return Err([world_errors.WorldStateStorageUnsupported(world_id=self.id)])
166
+
167
+ def initialize(self, reset: bool = False) -> None:
168
+ pass
169
+
170
+ def is_initialized(self) -> bool:
171
+ return True
172
+
173
+
174
+ class PythonWorldConstructor(WorldConstructor):
175
+ def construct_world(self, config: "WorldConfig") -> Python:
176
+ package = getattr(config, "package", None)
177
+
178
+ if package is None:
179
+ raise ValueError(f"World config '{config.id}' does not define a python package")
180
+
181
+ module = importlib.import_module(str(package))
182
+ artifacts_root = getattr(module, "donna_artifacts_root", None)
183
+
184
+ if artifacts_root is None:
185
+ raise ValueError(f"Package '{package}' does not define donna_artifacts_root")
186
+
187
+ if not isinstance(artifacts_root, str):
188
+ raise ValueError(f"Package '{package}' defines invalid donna_artifacts_root")
189
+
190
+ return Python(
191
+ id=config.id,
192
+ package=str(package),
193
+ artifacts_root=artifacts_root,
194
+ readonly=config.readonly,
195
+ session=config.session,
196
+ )