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.
- donna/__init__.py +1 -0
- donna/artifacts/__init__.py +0 -0
- donna/artifacts/usage/__init__.py +0 -0
- donna/artifacts/usage/artifacts.md +224 -0
- donna/artifacts/usage/cli.md +117 -0
- donna/artifacts/usage/worlds.md +36 -0
- donna/artifacts/work/__init__.py +0 -0
- donna/artifacts/work/do_it.md +142 -0
- donna/artifacts/work/do_it_fast.md +98 -0
- donna/artifacts/work/planning.md +245 -0
- donna/cli/__init__.py +0 -0
- donna/cli/__main__.py +6 -0
- donna/cli/application.py +17 -0
- donna/cli/commands/__init__.py +0 -0
- donna/cli/commands/artifacts.py +110 -0
- donna/cli/commands/projects.py +49 -0
- donna/cli/commands/sessions.py +77 -0
- donna/cli/types.py +138 -0
- donna/cli/utils.py +53 -0
- donna/core/__init__.py +0 -0
- donna/core/entities.py +27 -0
- donna/core/errors.py +126 -0
- donna/core/result.py +99 -0
- donna/core/utils.py +37 -0
- donna/domain/__init__.py +0 -0
- donna/domain/errors.py +47 -0
- donna/domain/ids.py +497 -0
- donna/lib/__init__.py +21 -0
- donna/lib/sources.py +5 -0
- donna/lib/worlds.py +7 -0
- donna/machine/__init__.py +0 -0
- donna/machine/action_requests.py +50 -0
- donna/machine/artifacts.py +200 -0
- donna/machine/changes.py +91 -0
- donna/machine/errors.py +122 -0
- donna/machine/operations.py +31 -0
- donna/machine/primitives.py +77 -0
- donna/machine/sessions.py +215 -0
- donna/machine/state.py +244 -0
- donna/machine/tasks.py +89 -0
- donna/machine/templates.py +83 -0
- donna/primitives/__init__.py +1 -0
- donna/primitives/artifacts/__init__.py +0 -0
- donna/primitives/artifacts/specification.py +20 -0
- donna/primitives/artifacts/workflow.py +195 -0
- donna/primitives/directives/__init__.py +0 -0
- donna/primitives/directives/goto.py +44 -0
- donna/primitives/directives/task_variable.py +73 -0
- donna/primitives/directives/view.py +45 -0
- donna/primitives/operations/__init__.py +0 -0
- donna/primitives/operations/finish_workflow.py +37 -0
- donna/primitives/operations/request_action.py +89 -0
- donna/primitives/operations/run_script.py +250 -0
- donna/protocol/__init__.py +0 -0
- donna/protocol/cell_shortcuts.py +9 -0
- donna/protocol/cells.py +44 -0
- donna/protocol/errors.py +17 -0
- donna/protocol/formatters/__init__.py +0 -0
- donna/protocol/formatters/automation.py +25 -0
- donna/protocol/formatters/base.py +15 -0
- donna/protocol/formatters/human.py +36 -0
- donna/protocol/formatters/llm.py +39 -0
- donna/protocol/modes.py +40 -0
- donna/protocol/nodes.py +59 -0
- donna/world/__init__.py +0 -0
- donna/world/artifacts.py +122 -0
- donna/world/artifacts_discovery.py +90 -0
- donna/world/config.py +198 -0
- donna/world/errors.py +232 -0
- donna/world/initialization.py +42 -0
- donna/world/markdown.py +267 -0
- donna/world/sources/__init__.py +1 -0
- donna/world/sources/base.py +62 -0
- donna/world/sources/markdown.py +260 -0
- donna/world/templates.py +181 -0
- donna/world/tmp.py +33 -0
- donna/world/worlds/__init__.py +0 -0
- donna/world/worlds/base.py +68 -0
- donna/world/worlds/filesystem.py +189 -0
- donna/world/worlds/python.py +196 -0
- donna-0.2.0.dist-info/METADATA +44 -0
- donna-0.2.0.dist-info/RECORD +85 -0
- donna-0.2.0.dist-info/WHEEL +4 -0
- donna-0.2.0.dist-info/entry_points.txt +3 -0
- donna-0.2.0.dist-info/licenses/LICENSE +28 -0
donna/world/templates.py
ADDED
|
@@ -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
|
+
)
|