diff --git a/.donna/project/core/top_level_architecture.md b/.donna/project/core/top_level_architecture.md index b3a1b18..0f49382 100644 --- a/.donna/project/core/top_level_architecture.md +++ b/.donna/project/core/top_level_architecture.md @@ -20,9 +20,18 @@ The code is separated by layers/subsystems into subpackages: - `donna.core` — code that not in the Donna's domain, but required to its functioning: domain-independent utils, basic classes for errors, exceptions and other entities, etc. - `donna.domain` — code that is required by all Donna'specific logic: ID classes, common types, etc. - `donna.machine` — code that implements the core Donna's logic — how Donna works regardless of external environments, i.e. pure domain behavior. +- `donna.context` — code that stores and provides execution-scoped runtime context for Donna's domain logic: artifact/state/primitive caches and scoped values like current actor/work unit/operation identifiers. - `donna.world` — code that implements various worlds where Donna can find and manage artifacts: from artifacts discovery to their loading, parsing, updating. Also contains code related to configuration of Donna. - `donna.protocol` — code that implements protocol via which Donna's core domain logic interacts with external environments: CLI, API, etc. Includes basic classes for information representing (for the external environments) and its formatting. - `donna.cli` — code that implements the `donna` CLI tool, its commands, arguments parsing, etc. - `donna.primitives` — code that implements basic building blocks for Donna's behavior: concrete implementations of various classes from the `donna.machine`. - `donna.lib` — module that contains constructed primitives to be used in donna artifacts by referencing them by python import path. Like `donna.lib.workflow`, `donna.lib.goto`, etc. - `donna.artifacts` — artifacts that are distributed with Donna itself: specifications of how it works, predefined workflows, etc. + +## Data structures + +- Do not use `dataclass` for data structures. Use `donna.core.entities.BaseEntity` (subclass of the `pydantic.BaseModel`) for complex data structures and Python classes with `__slots__` for very simple ones (like cache keys). + +## Autotests + +- No autotests in the project for now. diff --git a/changes/next_release.md b/changes/next_release.md new file mode 100644 index 0000000..5297868 --- /dev/null +++ b/changes/next_release.md @@ -0,0 +1,7 @@ +### Changes + +- ff-57 Added context storage/cache for globally accessible entities like artifacts, primitives, and session state. + - Added `donna.machine.context` module. + - Refactored `World` interface. +- gh-62 Added `donna version` command that prints the version of the tool. +- gh-60 Fixed CLI artifact extension recognition for `donna artifacts update`. diff --git a/changes/unreleased.md b/changes/unreleased.md deleted file mode 100644 index 16e3058..0000000 --- a/changes/unreleased.md +++ /dev/null @@ -1,4 +0,0 @@ -### Changes - -- gh-62 Added `donna version` command that prints the version of the tool. -- gh-60 Fixed CLI artifact extension recognition for `donna artifacts update`. diff --git a/donna/cli/commands/artifacts.py b/donna/cli/commands/artifacts.py index ac5bccf..938460e 100644 --- a/donna/cli/commands/artifacts.py +++ b/donna/cli/commands/artifacts.py @@ -16,15 +16,15 @@ TagOption, ) from donna.cli.utils import cells_cli +from donna.context.context import context from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result from donna.domain.ids import FullArtifactIdPattern from donna.machine import journal as machine_journal -from donna.machine.sessions import load_state from donna.protocol.cell_shortcuts import operation_succeeded from donna.protocol.cells import Cell -from donna.workspaces import artifacts as world_artifacts from donna.workspaces import tmp as world_tmp +from donna.workspaces.artifacts import RENDER_CONTEXT_VIEW artifacts_cli = typer.Typer() @@ -44,20 +44,7 @@ def _parse_slug_with_extension(value: str) -> Result[tuple[str, str], ErrorsList def _log_artifact_operation(message: str) -> None: - state_result = load_state() - - if state_result.is_err(): - # log nothing if we have no session state - return - - state = state_result.unwrap() - - machine_journal.add( - message=message, - current_task_id=str(state.current_task.id) if state.current_task else None, - current_work_unit_id=None, - current_operation_id=None, - ) + machine_journal.add(message=message) def _log_operation_on_artifacts(message: str, pattern: FullArtifactIdPattern, tags: TagOption | None) -> None: @@ -79,7 +66,7 @@ def list( ) -> Iterable[Cell]: _log_operation_on_artifacts("List artifacts", pattern, tags) - artifacts = world_artifacts.list_artifacts(pattern, tags=tags).unwrap() + artifacts = context().artifacts.list(pattern, RENDER_CONTEXT_VIEW, tags=tags).unwrap() return [artifact.node().status() for artifact in artifacts] @@ -92,7 +79,7 @@ def view( ) -> Iterable[Cell]: _log_operation_on_artifacts("View artifacts", pattern, tags) - artifacts = world_artifacts.list_artifacts(pattern, tags=tags).unwrap() + artifacts = context().artifacts.list(pattern, RENDER_CONTEXT_VIEW, tags=tags).unwrap() return [artifact.node().info() for artifact in artifacts] @@ -105,12 +92,12 @@ def view( @cells_cli def fetch(id: FullArtifactIdArgument, output: OutputPathOption = None) -> Iterable[Cell]: if output is None: - extension = world_artifacts.artifact_file_extension(id).unwrap() + extension = context().artifacts.file_extension(id).unwrap() output = world_tmp.file_for_artifact(id, extension) _log_artifact_operation(f"Fetch artifact `{id}` to '{output}'") - world_artifacts.fetch_artifact(id, output).unwrap() + context().artifacts.fetch(id, output).unwrap() return [ operation_succeeded(f"Artifact `{id}` fetched to '{output}'", artifact_id=str(id), output_path=str(output)) @@ -155,7 +142,7 @@ def update( _log_artifact_operation(f"Update artifact `{id}` from '{input_display}'") - world_artifacts.update_artifact(id, input_path, extension=extension).unwrap() + context().artifacts.update(id, input_path, extension=extension).unwrap() return [ operation_succeeded( f"Artifact `{id}` updated from '{input_display}'", @@ -170,7 +157,7 @@ def update( def copy(source_id: FullArtifactIdArgument, target_id: FullArtifactIdArgument) -> Iterable[Cell]: _log_artifact_operation(f"Copy artifact from `{source_id}` to `{target_id}`") - world_artifacts.copy_artifact(source_id, target_id).unwrap() + context().artifacts.copy(source_id, target_id).unwrap() return [ operation_succeeded( f"Artifact `{source_id}` copied to `{target_id}`", @@ -185,7 +172,7 @@ def copy(source_id: FullArtifactIdArgument, target_id: FullArtifactIdArgument) - def move(source_id: FullArtifactIdArgument, target_id: FullArtifactIdArgument) -> Iterable[Cell]: _log_artifact_operation(f"Move artifact from `{source_id}` to `{target_id}`") - world_artifacts.move_artifact(source_id, target_id).unwrap() + context().artifacts.move(source_id, target_id).unwrap() return [ operation_succeeded( f"Artifact `{source_id}` moved to `{target_id}`", @@ -203,11 +190,11 @@ def remove( ) -> Iterable[Cell]: _log_operation_on_artifacts("Remove artifacts", pattern, tags) - artifacts = world_artifacts.list_artifacts(pattern, tags=tags).unwrap() + artifacts = context().artifacts.list(pattern, RENDER_CONTEXT_VIEW, tags=tags).unwrap() cells: builtins.list[Cell] = [] for artifact in artifacts: - world_artifacts.remove_artifact(artifact.id).unwrap() + context().artifacts.remove(artifact.id).unwrap() cells.append(operation_succeeded(f"Artifact `{artifact.id}` removed", artifact_id=str(artifact.id))) return cells @@ -221,7 +208,7 @@ def validate( ) -> Iterable[Cell]: # noqa: CCR001 _log_operation_on_artifacts("Validate artifacts", pattern, tags) - artifacts = world_artifacts.list_artifacts(pattern, tags=tags).unwrap() + artifacts = context().artifacts.list(pattern, RENDER_CONTEXT_VIEW, tags=tags).unwrap() errors = [] diff --git a/donna/cli/commands/journal.py b/donna/cli/commands/journal.py index 3a14cc5..4cca9bb 100644 --- a/donna/cli/commands/journal.py +++ b/donna/cli/commands/journal.py @@ -6,7 +6,6 @@ from donna.cli.application import app from donna.cli.utils import cells_cli, output_cells from donna.machine import journal as machine_journal -from donna.machine.sessions import load_state from donna.protocol.cell_shortcuts import operation_succeeded from donna.protocol.cells import Cell from donna.protocol.modes import get_cell_formatter @@ -19,14 +18,7 @@ def write( message: str = typer.Argument(..., help="Single-line message to append to journal (newlines are not allowed)."), ) -> Iterable[Cell]: - state = load_state().unwrap() - - machine_journal.add( - message=message, - current_task_id=str(state.current_task.id) if state.current_task else None, - current_work_unit_id=None, - current_operation_id=None, - ).unwrap() + machine_journal.add(message=message).unwrap() return [operation_succeeded("Journal record appended.")] diff --git a/donna/cli/utils.py b/donna/cli/utils.py index 5acea8a..b617ae1 100644 --- a/donna/cli/utils.py +++ b/donna/cli/utils.py @@ -27,22 +27,12 @@ def output_cells(cells: Iterable[Cell]) -> None: def _write_errors_to_journal(errors: ErrorsList) -> None: from donna.machine import journal as machine_journal - from donna.machine import sessions as machine_sessions - - state_result = machine_sessions.load_state() - current_task_id = None - if state_result.is_ok(): - state = state_result.unwrap() - current_task_id = str(state.current_task.id) if state.current_task else None for error in errors: message = f"Error: {error.node().journal_message()} [{error.code}]" machine_journal.add( message=message, - current_task_id=current_task_id, - current_work_unit_id=None, - current_operation_id=None, actor_id="donna", ) @@ -80,6 +70,8 @@ def _is_workspace_init_command() -> bool: def try_initialize_donna(project_dir: pathlib.Path | None, protocol: Mode) -> None: + from donna.context import Context, set_context + if _is_workspace_init_command(): workspace_config.protocol.set(protocol) if project_dir is not None: @@ -89,6 +81,7 @@ def try_initialize_donna(project_dir: pathlib.Path | None, protocol: Mode) -> No result = initialize_runtime(root_dir=project_dir, protocol=protocol) if result.is_ok(): + set_context(Context()) return errors = result.unwrap_err() diff --git a/donna/context/__init__.py b/donna/context/__init__.py new file mode 100644 index 0000000..1bb672b --- /dev/null +++ b/donna/context/__init__.py @@ -0,0 +1,3 @@ +from donna.context.context import Context, context, reset_context, set_context + +__all__ = ("Context", "context", "set_context", "reset_context") diff --git a/donna/context/artifacts.py b/donna/context/artifacts.py new file mode 100644 index 0000000..3741263 --- /dev/null +++ b/donna/context/artifacts.py @@ -0,0 +1,205 @@ +from pathlib import Path +from typing import TYPE_CHECKING + +from donna.context.entity_cache import TimedCache, TimedCacheValue +from donna.core.errors import ErrorsList +from donna.core.result import Err, Ok, Result, unwrap_to_error +from donna.domain.ids import FullArtifactId, FullArtifactIdPattern, FullArtifactSectionId +from donna.domain.types import Milliseconds +from donna.machine.artifacts import Artifact, ArtifactSection +from donna.workspaces.templates import RenderMode + +if TYPE_CHECKING: + from donna.workspaces.artifacts import ArtifactRenderContext + from donna.workspaces.worlds.base import RawArtifact + + +class _ArtifactCacheValue(TimedCacheValue): + __slots__ = ("raw_artifact", "rendered_artifacts") + + def __init__( + self, + raw_artifact: "RawArtifact", + rendered_artifacts: dict[RenderMode, Artifact], + loaded_at_ms: Milliseconds, + checked_at_ms: Milliseconds, + ) -> None: + super().__init__(loaded_at_ms=loaded_at_ms, checked_at_ms=checked_at_ms) + self.raw_artifact = raw_artifact + self.rendered_artifacts = rendered_artifacts + + +class ArtifactsCache(TimedCache): + __slots__ = ("_cache",) + + def __init__(self) -> None: + self._cache: dict[FullArtifactId, _ArtifactCacheValue] = {} + + @unwrap_to_error + def _is_cache_stale(self, full_id: FullArtifactId, loaded_at_ms: Milliseconds) -> Result[bool, ErrorsList]: + from donna.workspaces.config import config + + world = config().get_world(full_id.world_id).unwrap() + return Ok(world.has_artifact_changed(full_id.artifact_id, since=loaded_at_ms).unwrap()) + + @staticmethod + @unwrap_to_error + def _load_raw_artifact(full_id: FullArtifactId) -> Result["RawArtifact", ErrorsList]: + from donna.workspaces.config import config + + world = config().get_world(full_id.world_id).unwrap() + return Ok(world.fetch(full_id.artifact_id).unwrap()) + + @unwrap_to_error + def _refresh_cache_value( + self, full_id: FullArtifactId, now_ms: Milliseconds + ) -> Result[_ArtifactCacheValue, ErrorsList]: + raw_artifact = self._load_raw_artifact(full_id).unwrap() + refreshed = _ArtifactCacheValue( + raw_artifact=raw_artifact, + rendered_artifacts={}, + loaded_at_ms=now_ms, + checked_at_ms=now_ms, + ) + self._cache[full_id] = refreshed + return Ok(refreshed) + + @unwrap_to_error + def _get_cache_value(self, full_id: FullArtifactId) -> Result[_ArtifactCacheValue, ErrorsList]: + cached = self._cache.get(full_id) + now_ms = self._now_ms() + + if cached is None: + return Ok(self._refresh_cache_value(full_id, now_ms).unwrap()) + + # Skip expensive world checks when cache lifetime has not elapsed yet. + if self._is_within_lifetime(cached, now_ms): + return Ok(cached) + + cache_stale = self._is_cache_stale(full_id, cached.loaded_at_ms).unwrap() + self._mark_checked(cached, now_ms) + if not cache_stale: + return Ok(cached) + + return Ok(self._refresh_cache_value(full_id, now_ms).unwrap()) + + def invalidate(self, full_id: FullArtifactId) -> None: + self._cache.pop(full_id, None) + + @unwrap_to_error + def load( # noqa: CCR001 + self, + full_id: FullArtifactId, + render_context: "ArtifactRenderContext", + ) -> Result[Artifact, ErrorsList]: + cached = self._get_cache_value(full_id).unwrap() + + if render_context.primary_mode == RenderMode.execute: + return Ok(cached.raw_artifact.render(full_id, render_context).unwrap()) + + cached_artifact = cached.rendered_artifacts.get(render_context.primary_mode) + if cached_artifact is not None: + return Ok(cached_artifact) + + artifact = cached.raw_artifact.render(full_id, render_context).unwrap() + cached.rendered_artifacts[render_context.primary_mode] = artifact + + return Ok(artifact) + + @unwrap_to_error + def resolve_section( + self, + target_id: FullArtifactSectionId, + render_context: "ArtifactRenderContext", + ) -> Result[ArtifactSection, ErrorsList]: + artifact = self.load(target_id.full_artifact_id, render_context).unwrap() + return Ok(artifact.get_section(target_id.local_id).unwrap()) + + @unwrap_to_error + def update( + self, + full_id: FullArtifactId, + input_path: Path, + extension: str | None = None, + ) -> Result[None, ErrorsList]: + from donna.workspaces import artifacts as workspace_artifacts + + workspace_artifacts.update_artifact(full_id, input_path, extension=extension).unwrap() + self.invalidate(full_id) + return Ok(None) + + @unwrap_to_error + def copy(self, source_id: FullArtifactId, target_id: FullArtifactId) -> Result[None, ErrorsList]: + from donna.workspaces import artifacts as workspace_artifacts + + workspace_artifacts.copy_artifact(source_id, target_id).unwrap() + self.invalidate(target_id) + return Ok(None) + + @unwrap_to_error + def move(self, source_id: FullArtifactId, target_id: FullArtifactId) -> Result[None, ErrorsList]: + from donna.workspaces import artifacts as workspace_artifacts + + workspace_artifacts.move_artifact(source_id, target_id).unwrap() + self.invalidate(source_id) + self.invalidate(target_id) + return Ok(None) + + @unwrap_to_error + def remove(self, full_id: FullArtifactId) -> Result[None, ErrorsList]: + from donna.workspaces import artifacts as workspace_artifacts + + workspace_artifacts.remove_artifact(full_id).unwrap() + self.invalidate(full_id) + return Ok(None) + + @unwrap_to_error + def file_extension(self, full_id: FullArtifactId) -> Result[str, ErrorsList]: + from donna.workspaces import artifacts as workspace_artifacts + + return Ok(workspace_artifacts.artifact_file_extension(full_id).unwrap()) + + @unwrap_to_error + def fetch(self, full_id: FullArtifactId, output: Path) -> Result[None, ErrorsList]: + from donna.workspaces import artifacts as workspace_artifacts + + workspace_artifacts.fetch_artifact(full_id, output).unwrap() + return Ok(None) + + @unwrap_to_error + def list( # noqa: CCR001 + self, + pattern: FullArtifactIdPattern, + render_context: "ArtifactRenderContext", + tags: list[str] | None = None, + ) -> Result[list[Artifact], ErrorsList]: + from donna.workspaces.config import config + + tag_filters = tags or [] + + artifacts: list[Artifact] = [] + errors: ErrorsList = [] + + for world in reversed(config().worlds_instances): + for artifact_id in world.list_artifacts(pattern): + full_id = FullArtifactId((world.id, artifact_id)) + artifact_result = self.load(full_id, render_context) + if artifact_result.is_err(): + errors.extend(artifact_result.unwrap_err()) + continue + + artifact = artifact_result.unwrap() + if tag_filters: + primary_result = artifact.primary_section() + if primary_result.is_err(): + continue + primary = primary_result.unwrap() + if not all(tag in primary.tags for tag in tag_filters): + continue + + artifacts.append(artifact) + + if errors: + return Err(errors) + + return Ok(artifacts) diff --git a/donna/context/context.py b/donna/context/context.py new file mode 100644 index 0000000..5883d51 --- /dev/null +++ b/donna/context/context.py @@ -0,0 +1,55 @@ +import contextvars + +from donna.context.artifacts import ArtifactsCache +from donna.context.primitives import PrimitivesCache +from donna.context.state import StateCache +from donna.context.value_scope import ValueScope +from donna.domain.ids import FullArtifactSectionId, WorkUnitId + + +class Context: + __slots__ = ( + "_artifacts", + "_state", + "_primitives", + "current_work_unit_id", + "current_operation_id", + ) + + def __init__(self) -> None: + self._artifacts = ArtifactsCache() + self._state = StateCache() + self._primitives = PrimitivesCache() + self.current_work_unit_id: ValueScope[WorkUnitId] = ValueScope() + self.current_operation_id: ValueScope[FullArtifactSectionId] = ValueScope() + + @property + def artifacts(self) -> ArtifactsCache: + return self._artifacts + + @property + def state(self) -> StateCache: + return self._state + + @property + def primitives(self) -> PrimitivesCache: + return self._primitives + + +_context_var: contextvars.ContextVar[Context | None] = contextvars.ContextVar("donna_machine_context", default=None) + + +def set_context(new_context: Context) -> contextvars.Token[Context | None]: + return _context_var.set(new_context) + + +def reset_context(token: contextvars.Token[Context | None]) -> None: + _context_var.reset(token) + + +def context() -> Context: + current = _context_var.get() + if current is None: + raise RuntimeError("Donna machine context is not initialized") + + return current diff --git a/donna/context/entity_cache.py b/donna/context/entity_cache.py new file mode 100644 index 0000000..569ff1a --- /dev/null +++ b/donna/context/entity_cache.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import time +from abc import ABC + +from donna.domain.types import Milliseconds +from donna.workspaces.config import config + + +class TimedCacheValue: + __slots__ = ("loaded_at_ms", "checked_at_ms") + + def __init__(self, loaded_at_ms: Milliseconds, checked_at_ms: Milliseconds) -> None: + self.loaded_at_ms = loaded_at_ms + self.checked_at_ms = checked_at_ms + + +class TimedCache(ABC): + @staticmethod + def _now_ms() -> Milliseconds: + return Milliseconds(time.time_ns() // 1_000_000) + + @staticmethod + def _cache_lifetime_ms() -> Milliseconds: + return Milliseconds(max(0, int(config().cache_lifetime * 1000))) + + def _is_within_lifetime(self, cached: TimedCacheValue, now_ms: Milliseconds) -> bool: + return (now_ms - cached.checked_at_ms) < self._cache_lifetime_ms() + + @staticmethod + def _mark_checked(cached: TimedCacheValue, now_ms: Milliseconds) -> None: + cached.checked_at_ms = now_ms diff --git a/donna/context/primitives.py b/donna/context/primitives.py new file mode 100644 index 0000000..d5a4664 --- /dev/null +++ b/donna/context/primitives.py @@ -0,0 +1,68 @@ +import importlib +from typing import TYPE_CHECKING + +from donna.context.entity_cache import TimedCache, TimedCacheValue +from donna.core.errors import ErrorsList +from donna.core.result import Err, Ok, Result, unwrap_to_error +from donna.domain.ids import PythonImportPath +from donna.domain.types import Milliseconds +from donna.machine import errors as machine_errors + +if TYPE_CHECKING: + from donna.machine.primitives import Primitive + + +class _PrimitiveCacheValue(TimedCacheValue): + __slots__ = ("primitive",) + + def __init__(self, primitive: "Primitive", loaded_at_ms: Milliseconds, checked_at_ms: Milliseconds) -> None: + super().__init__(loaded_at_ms=loaded_at_ms, checked_at_ms=checked_at_ms) + self.primitive = primitive + + +class PrimitivesCache(TimedCache): + __slots__ = ("_cache",) + + def __init__(self) -> None: + self._cache: dict[PythonImportPath, _PrimitiveCacheValue] = {} + + @unwrap_to_error + def resolve(self, primitive_id: PythonImportPath) -> Result["Primitive", ErrorsList]: # noqa: CCR001 + from donna.machine.primitives import Primitive + + cached = self._cache.get(primitive_id) + now_ms = self._now_ms() + if cached is not None and self._is_within_lifetime(cached, now_ms): + return Ok(cached.primitive) + + import_path_str = str(primitive_id) + + if "." not in import_path_str: + return Err([machine_errors.PrimitiveInvalidImportPath(import_path=import_path_str)]) + + module_path, primitive_name = import_path_str.rsplit(".", maxsplit=1) + + try: + module = importlib.import_module(module_path) + except ModuleNotFoundError: + return Err([machine_errors.PrimitiveModuleNotImportable(module_path=module_path)]) + + try: + primitive = getattr(module, primitive_name) + except AttributeError: + return Err([machine_errors.PrimitiveNotAvailable(import_path=import_path_str, module_path=module_path)]) + + if not isinstance(primitive, Primitive): + return Err([machine_errors.PrimitiveNotPrimitive(import_path=import_path_str)]) + + if cached is not None: + previous_primitive = cached.primitive + cached.primitive = primitive + self._mark_checked(cached, now_ms) + if previous_primitive is not primitive: + cached.loaded_at_ms = now_ms + else: + cached = _PrimitiveCacheValue(primitive=primitive, loaded_at_ms=now_ms, checked_at_ms=now_ms) + self._cache[primitive_id] = cached + + return Ok(cached.primitive) diff --git a/donna/context/state.py b/donna/context/state.py new file mode 100644 index 0000000..c69d08d --- /dev/null +++ b/donna/context/state.py @@ -0,0 +1,75 @@ +from typing import TYPE_CHECKING + +from donna.context.entity_cache import TimedCache, TimedCacheValue +from donna.core.errors import ErrorsList +from donna.core.result import Err, Ok, Result, unwrap_to_error +from donna.domain.types import Milliseconds +from donna.machine import errors as machine_errors + +if TYPE_CHECKING: + from donna.machine.state import ConsistentState + + +class _StateCacheValue(TimedCacheValue): + __slots__ = ("state", "state_json") + + def __init__( + self, + state: "ConsistentState", + state_json: bytes, + loaded_at_ms: Milliseconds, + checked_at_ms: Milliseconds, + ) -> None: + super().__init__(loaded_at_ms=loaded_at_ms, checked_at_ms=checked_at_ms) + self.state = state + self.state_json = state_json + + +class StateCache(TimedCache): + __slots__ = ("_session_state",) + + def __init__(self) -> None: + self._session_state: _StateCacheValue | None = None + + @unwrap_to_error + def load(self) -> Result["ConsistentState", ErrorsList]: + from donna.machine.state import ConsistentState + from donna.workspaces import utils as workspace_utils + + now_ms = self._now_ms() + cached = self._session_state + + if cached is not None and self._is_within_lifetime(cached, now_ms): + return Ok(cached.state) + + content = workspace_utils.session_world().unwrap().read_state("state.json").unwrap() + if content is None: + return Err([machine_errors.SessionStateNotInitialized()]) + + if cached is not None and cached.state_json == content: + self._mark_checked(cached, now_ms) + return Ok(cached.state) + + state = ConsistentState.from_json(content.decode("utf-8")) + self._session_state = _StateCacheValue( + state=state, + state_json=content, + loaded_at_ms=now_ms, + checked_at_ms=now_ms, + ) + return Ok(state) + + @unwrap_to_error + def save(self, state: "ConsistentState") -> Result[None, ErrorsList]: + from donna.workspaces import utils as workspace_utils + + content = state.to_json().encode("utf-8") + workspace_utils.session_world().unwrap().write_state("state.json", content).unwrap() + now_ms = self._now_ms() + self._session_state = _StateCacheValue( + state=state, + state_json=content, + loaded_at_ms=now_ms, + checked_at_ms=now_ms, + ) + return Ok(None) diff --git a/donna/context/value_scope.py b/donna/context/value_scope.py new file mode 100644 index 0000000..beac9bf --- /dev/null +++ b/donna/context/value_scope.py @@ -0,0 +1,24 @@ +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Generic, TypeVar + +V = TypeVar("V") + + +class ValueScope(Generic[V]): + __slots__ = ("_value",) + + def __init__(self, initial: V | None = None) -> None: + self._value: V | None = initial + + def get(self) -> V | None: + return self._value + + @contextmanager + def scope(self, value: V | None) -> Iterator[None]: + previous = self._value + self._value = value + try: + yield + finally: + self._value = previous diff --git a/donna/domain/types.py b/donna/domain/types.py new file mode 100644 index 0000000..a5b1a5c --- /dev/null +++ b/donna/domain/types.py @@ -0,0 +1,3 @@ +from typing import NewType + +Milliseconds = NewType("Milliseconds", int) diff --git a/donna/machine/artifacts.py b/donna/machine/artifacts.py index 919c2ac..3c531dc 100644 --- a/donna/machine/artifacts.py +++ b/donna/machine/artifacts.py @@ -1,11 +1,11 @@ -from typing import TYPE_CHECKING, Any, Union +from typing import Any import pydantic from donna.core.entities import BaseEntity from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error -from donna.domain.ids import ArtifactSectionId, FullArtifactId, FullArtifactSectionId, PythonImportPath +from donna.domain.ids import ArtifactSectionId, FullArtifactId, PythonImportPath from donna.machine.errors import ( ArtifactPrimarySectionMissing, ArtifactSectionNotFound, @@ -14,9 +14,6 @@ from donna.protocol.cells import Cell from donna.protocol.nodes import Node -if TYPE_CHECKING: - from donna.workspaces.artifacts import ArtifactRenderContext - class ArtifactSectionConfig(BaseEntity): id: ArtifactSectionId @@ -71,7 +68,7 @@ def primary_section(self) -> Result[ArtifactSection, ErrorsList]: return Ok(primary_sections[0]) def validate_artifact(self) -> Result[None, ErrorsList]: # noqa: CCR001 - from donna.machine.primitives import resolve_primitive + from donna.context.context import context primary_sections = self._primary_sections() @@ -88,7 +85,7 @@ def validate_artifact(self) -> Result[None, ErrorsList]: # noqa: CCR001 ) for section in self.sections: - primitive_result = resolve_primitive(section.kind) + primitive_result = context().primitives.resolve(section.kind) if primitive_result.is_err(): errors.extend(primitive_result.unwrap_err()) continue @@ -187,16 +184,3 @@ def status(self) -> Cell: section_primary=self._section.primary, **self._section.meta.cells_meta(), ) - - -@unwrap_to_error -def resolve( - target_id: FullArtifactSectionId, render_context: Union["ArtifactRenderContext", None] = None -) -> Result[ArtifactSection, ErrorsList]: - from donna.workspaces import artifacts as world_artifacts - - artifact = world_artifacts.load_artifact(target_id.full_artifact_id, render_context).unwrap() - - section = artifact.get_section(target_id.local_id).unwrap() - - return Ok(section) diff --git a/donna/machine/journal.py b/donna/machine/journal.py index 31f09f6..589e24c 100644 --- a/donna/machine/journal.py +++ b/donna/machine/journal.py @@ -1,7 +1,6 @@ import datetime import json from collections.abc import Iterable -from typing import Any import pydantic @@ -71,26 +70,24 @@ def smart_agent_id() -> str: @unwrap_to_error -def add( +def add( # noqa: CCR001 message: str, - current_task_id: str | None, - current_work_unit_id: str | None, - current_operation_id: str | None, - **kwargs: Any, + actor_id: str | None = None, ) -> Result[JournalRecord, ErrorsList]: + from donna.context.context import context from donna.protocol.utils import instant_output_journal if message_has_newlines(message): return Err([machine_errors.JournalMessageContainsNewlines()]) - if "actor_id" in kwargs: - actor_id = kwargs["actor_id"] - else: + if actor_id is None: actor_id = smart_agent_id() - parsed_task_id = TaskId(current_task_id) if current_task_id is not None else None - parsed_work_unit_id = WorkUnitId(current_work_unit_id) if current_work_unit_id is not None else None - parsed_operation_id = FullArtifactSectionId(current_operation_id) if current_operation_id is not None else None + ctx = context() + state = ctx.state.load().unwrap() + parsed_task_id: TaskId | None = state.current_task.id if state.current_task else None + parsed_work_unit_id: WorkUnitId | None = ctx.current_work_unit_id.get() + parsed_operation_id: FullArtifactSectionId | None = ctx.current_operation_id.get() record = JournalRecord( timestamp=now(), diff --git a/donna/machine/sessions.py b/donna/machine/sessions.py index eef8de8..7da257e 100644 --- a/donna/machine/sessions.py +++ b/donna/machine/sessions.py @@ -1,6 +1,7 @@ import functools from typing import Callable, ParamSpec +from donna.context.context import context from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error from donna.domain.ids import ActionRequestId, FullArtifactId, FullArtifactSectionId @@ -10,23 +11,19 @@ from donna.machine.state import ConsistentState, MutableState from donna.protocol.cell_shortcuts import operation_succeeded from donna.protocol.cells import Cell -from donna.workspaces import artifacts from donna.workspaces import tmp as world_tmp from donna.workspaces import utils as workspace_utils +from donna.workspaces.artifacts import RENDER_CONTEXT_VIEW @unwrap_to_error def load_state() -> Result[ConsistentState, ErrorsList]: - content = workspace_utils.session_world().unwrap().read_state("state.json").unwrap() - if content is None: - return Err([machine_errors.SessionStateNotInitialized()]) - - return Ok(ConsistentState.from_json(content.decode("utf-8"))) + return Ok(context().state.load().unwrap()) @unwrap_to_error def _save_state(state: ConsistentState) -> Result[None, ErrorsList]: - workspace_utils.session_world().unwrap().write_state("state.json", state.to_json().encode("utf-8")).unwrap() + context().state.save(state).unwrap() return Ok(None) @@ -68,15 +65,10 @@ def start() -> Result[list[Cell], ErrorsList]: workspace_utils.session_world().unwrap().initialize(reset=True) machine_journal.reset().unwrap() - - machine_journal.add( - message="Started new session.", - current_task_id=None, - current_work_unit_id=None, - current_operation_id=None, - ).unwrap() _save_state(MutableState.build().freeze()).unwrap() + machine_journal.add(message="Started new session.").unwrap() + return Ok([operation_succeeded("Started new session.")]) @@ -117,7 +109,7 @@ def details() -> Result[list[Cell], ErrorsList]: @unwrap_to_error def start_workflow(artifact_id: FullArtifactId) -> Result[list[Cell], ErrorsList]: # noqa: CCR001 static_state = load_state().unwrap() - workflow = artifacts.load_artifact(artifact_id).unwrap() + workflow = context().artifacts.load(artifact_id, RENDER_CONTEXT_VIEW).unwrap() primary_section = workflow.primary_section().unwrap() mutator = static_state.mutator() mutator.start_workflow(workflow.id.to_full_local(primary_section.id)).unwrap() @@ -131,7 +123,7 @@ def _validate_operation_transition( state: MutableState, request_id: ActionRequestId, next_operation_id: FullArtifactSectionId ) -> Result[None, ErrorsList]: operation_id = state.get_action_request(request_id).unwrap().operation_id - workflow = artifacts.load_artifact(operation_id.full_artifact_id).unwrap() + workflow = context().artifacts.load(operation_id.full_artifact_id, RENDER_CONTEXT_VIEW).unwrap() operation = workflow.get_section(operation_id.local_id).unwrap() assert isinstance(operation.meta, OperationMeta) diff --git a/donna/machine/state.py b/donna/machine/state.py index 6b6e47c..121812b 100644 --- a/donna/machine/state.py +++ b/donna/machine/state.py @@ -4,20 +4,14 @@ import pydantic +from donna.context.context import context from donna.core.entities import BaseEntity from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error -from donna.domain.ids import ( - ActionRequestId, - FullArtifactSectionId, - InternalId, - TaskId, - WorkUnitId, -) +from donna.domain.ids import ActionRequestId, FullArtifactSectionId, InternalId, TaskId, WorkUnitId from donna.machine import errors as machine_errors from donna.machine import journal as machine_journal from donna.machine.action_requests import ActionRequest -from donna.machine.artifacts import resolve from donna.machine.changes import ( Change, ChangeAddTask, @@ -29,6 +23,7 @@ from donna.machine.tasks import Task, WorkUnit from donna.protocol.cells import Cell from donna.protocol.nodes import Node +from donna.workspaces.artifacts import RENDER_CONTEXT_VIEW class BaseState(BaseEntity): @@ -131,9 +126,6 @@ def add_action_request(self, action_request: ActionRequest) -> None: machine_journal.add( actor_id="donna", message=f"Request agent action `{full_request.title}`", - current_task_id=str(self.current_task.id) if self.current_task else None, - current_work_unit_id=None, - current_operation_id=None, ).unwrap() self.action_requests.append(full_request) @@ -171,9 +163,6 @@ def complete_action_request( action_request = self.get_action_request(request_id).unwrap() machine_journal.add( message=f"Complete agent action `{action_request.title}`", - current_task_id=str(current_task.id), - current_work_unit_id=None, - current_operation_id=None, ).unwrap() changes = [ @@ -185,13 +174,10 @@ def complete_action_request( @unwrap_to_error def start_workflow(self, full_operation_id: FullArtifactSectionId) -> Result[None, ErrorsList]: - workflow = resolve(full_operation_id).unwrap() + workflow = context().artifacts.resolve_section(full_operation_id, RENDER_CONTEXT_VIEW).unwrap() machine_journal.add( message=f"Start workflow `{workflow.title}`", - current_task_id=str(self.current_task.id) if self.current_task else None, - current_work_unit_id=None, - current_operation_id=None, ).unwrap() changes = [ChangeAddTask(operation_id=full_operation_id)] @@ -201,13 +187,10 @@ def start_workflow(self, full_operation_id: FullArtifactSectionId) -> Result[Non def finish_workflow(self, task_id: TaskId) -> None: task = self.current_task assert task is not None - workflow = resolve(task.workflow_id).unwrap() + workflow = context().artifacts.resolve_section(task.workflow_id, RENDER_CONTEXT_VIEW).unwrap() machine_journal.add( message=f"Finish workflow `{workflow.title}`", - current_task_id=str(self.current_task.id) if self.current_task else None, - current_work_unit_id=None, - current_operation_id=None, ).unwrap() changes = [ChangeRemoveTask(task_id=task_id)] @@ -220,7 +203,8 @@ def execute_next_work_unit(self) -> Result[None, ErrorsList]: current_task = self.current_task assert current_task is not None - changes = next_work_unit.run(current_task).unwrap() + with context().current_work_unit_id.scope(next_work_unit.id): + changes = next_work_unit.run(current_task).unwrap() changes.append(ChangeRemoveWorkUnit(work_unit_id=next_work_unit.id)) self.apply_changes(changes) diff --git a/donna/machine/tasks.py b/donna/machine/tasks.py index 3a10d8f..45da554 100644 --- a/donna/machine/tasks.py +++ b/donna/machine/tasks.py @@ -53,9 +53,8 @@ def build( @unwrap_to_error def run(self, task: Task) -> Result[list["Change"], ErrorsList]: - from donna.machine import artifacts as machine_artifacts + from donna.context.context import context from donna.machine import journal as machine_journal - from donna.machine.primitives import resolve_primitive from donna.workspaces.artifacts import ArtifactRenderContext from donna.workspaces.templates import RenderMode @@ -64,17 +63,16 @@ def run(self, task: Task) -> Result[list["Change"], ErrorsList]: current_task=task, current_work_unit=self, ) - operation = machine_artifacts.resolve(self.operation_id, render_context).unwrap() - operation_kind = resolve_primitive(operation.kind).unwrap() - - machine_journal.add( - actor_id="donna", - message=operation.title, - current_task_id=str(task.id), - current_work_unit_id=str(self.id), - current_operation_id=str(self.operation_id), - ).unwrap() - - changes = operation_kind.execute_section(task, self, operation).unwrap() + ctx = context() + with ctx.current_operation_id.scope(self.operation_id): + operation = ctx.artifacts.resolve_section(self.operation_id, render_context).unwrap() + operation_kind = ctx.primitives.resolve(operation.kind).unwrap() + + machine_journal.add( + actor_id="donna", + message=operation.title, + ).unwrap() + + changes = operation_kind.execute_section(task, self, operation).unwrap() return Ok(changes) diff --git a/donna/primitives/operations/run_script.py b/donna/primitives/operations/run_script.py index c856a71..917ca7f 100644 --- a/donna/primitives/operations/run_script.py +++ b/donna/primitives/operations/run_script.py @@ -164,9 +164,6 @@ def execute_section( machine_journal.add( actor_id="donna", message=f"Run script `{operation.title}`", - current_task_id=str(task.id), - current_work_unit_id=str(unit.id), - current_operation_id=unit.operation_id, ).unwrap() stdout, stderr, exit_code = _run_script( @@ -181,9 +178,6 @@ def execute_section( f"Script finished `{operation.title}`, exit code: {exit_code}, " f"has stdout: {bool(stdout)}, has stderr: {bool(stderr)}`" ), - current_task_id=str(task.id), - current_work_unit_id=str(unit.id), - current_operation_id=unit.operation_id, ).unwrap() changes: list["Change"] = [] diff --git a/donna/workspaces/artifacts.py b/donna/workspaces/artifacts.py index 5f8cf57..f1a6b00 100644 --- a/donna/workspaces/artifacts.py +++ b/donna/workspaces/artifacts.py @@ -3,8 +3,7 @@ from donna.core.entities import BaseEntity from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error -from donna.domain.ids import FullArtifactId, FullArtifactIdPattern, WorldId -from donna.machine.artifacts import Artifact +from donna.domain.ids import FullArtifactId, WorldId from donna.machine.tasks import Task, WorkUnit from donna.workspaces import errors from donna.workspaces.config import config @@ -17,6 +16,9 @@ class ArtifactRenderContext(BaseEntity): current_work_unit: WorkUnit | None = None +RENDER_CONTEXT_VIEW = ArtifactRenderContext(primary_mode=RenderMode.view) + + class ArtifactUpdateError(errors.WorkspaceError): cell_kind: str = "artifact_update_error" artifact_id: FullArtifactId @@ -105,10 +107,10 @@ def artifact_file_extension(full_id: FullArtifactId) -> Result[str, ErrorsList]: @unwrap_to_error def fetch_artifact(full_id: FullArtifactId, output: pathlib.Path) -> Result[None, ErrorsList]: world = config().get_world(full_id.world_id).unwrap() - content = world.fetch_source(full_id.artifact_id).unwrap() + raw_artifact = world.fetch(full_id.artifact_id).unwrap() with output.open("wb") as f: - f.write(content) + f.write(raw_artifact.get_bytes()) return Ok(None) @@ -197,7 +199,7 @@ def mismatch_error(a: str, b: str) -> Result[None, ErrorsList]: if source_config is None: return Err([NoSourceForArtifactExtension(artifact_id=full_id, path=input, extension=normalized_source_suffix)]) - render_context = ArtifactRenderContext(primary_mode=RenderMode.view) + render_context = RENDER_CONTEXT_VIEW test_artifact = source_config.construct_artifact_from_bytes(full_id, content_bytes, render_context).unwrap() validation_result = test_artifact.validate_artifact() @@ -223,7 +225,8 @@ def copy_artifact(source_id: FullArtifactId, target_id: FullArtifactId) -> Resul ] ) - content_bytes = source_world.fetch_source(source_id.artifact_id).unwrap() + source_raw_artifact = source_world.fetch(source_id.artifact_id).unwrap() + content_bytes = source_raw_artifact.get_bytes() source_extension = source_world.file_extension_for(source_id.artifact_id).unwrap() if not source_extension: @@ -242,7 +245,7 @@ def copy_artifact(source_id: FullArtifactId, target_id: FullArtifactId) -> Resul ] ) - render_context = ArtifactRenderContext(primary_mode=RenderMode.view) + render_context = RENDER_CONTEXT_VIEW test_artifact = source_config.construct_artifact_from_bytes(target_id, content_bytes, render_context).unwrap() test_artifact.validate_artifact().unwrap() @@ -268,57 +271,3 @@ def remove_artifact(full_id: FullArtifactId) -> Result[None, ErrorsList]: world.remove(full_id.artifact_id).unwrap() return Ok(None) - - -@unwrap_to_error -def load_artifact( - full_id: FullArtifactId, render_context: ArtifactRenderContext | None = None -) -> Result[Artifact, ErrorsList]: - if render_context is None: - render_context = ArtifactRenderContext(primary_mode=RenderMode.view) - - world = config().get_world(full_id.world_id).unwrap() - return Ok(world.fetch(full_id.artifact_id, render_context).unwrap()) - - -def list_artifacts( # noqa: CCR001 - pattern: FullArtifactIdPattern, - render_context: ArtifactRenderContext | None = None, - tags: list[str] | None = None, -) -> Result[list[Artifact], ErrorsList]: - if render_context is None: - render_context = ArtifactRenderContext(primary_mode=RenderMode.view) - - tag_filters = tags or [] - - artifacts: list[Artifact] = [] - errors: ErrorsList = [] - - for world in reversed(config().worlds_instances): - for artifact_id in world.list_artifacts(pattern): - full_id = FullArtifactId((world.id, artifact_id)) - artifact_result = load_artifact(full_id, render_context) - if artifact_result.is_err(): - errors.extend(artifact_result.unwrap_err()) - continue - artifact = artifact_result.unwrap() - if tag_filters and not _artifact_matches_tags(artifact, tag_filters): - continue - artifacts.append(artifact) - - if errors: - return Err(errors) - - return Ok(artifacts) - - -def _artifact_matches_tags(artifact: Artifact, tags: list[str]) -> bool: - if not tags: - return True - - primary_result = artifact.primary_section() - if primary_result.is_err(): - return False - - primary = primary_result.unwrap() - return all(tag in primary.tags for tag in tags) diff --git a/donna/workspaces/config.py b/donna/workspaces/config.py index f19adf7..05b95ca 100644 --- a/donna/workspaces/config.py +++ b/donna/workspaces/config.py @@ -103,6 +103,7 @@ class Config(BaseEntity): _sources_instances: list[SourceConfigValue] = pydantic.PrivateAttr(default_factory=list) tmp_dir: pathlib.Path = pathlib.Path("./tmp") + cache_lifetime: float = 1.0 def model_post_init(self, __context: Any) -> None: # noqa: CCR001 worlds: list[BaseWorld] = [] diff --git a/donna/workspaces/worlds/base.py b/donna/workspaces/worlds/base.py index 6d1949f..25e69ff 100644 --- a/donna/workspaces/worlds/base.py +++ b/donna/workspaces/worlds/base.py @@ -7,7 +7,8 @@ from donna.core.entities import BaseEntity from donna.core.errors import ErrorsList from donna.core.result import Result -from donna.domain.ids import ArtifactId, FullArtifactIdPattern, WorldId +from donna.domain.ids import ArtifactId, FullArtifactId, FullArtifactIdPattern, WorldId +from donna.domain.types import Milliseconds from donna.machine.artifacts import Artifact from donna.machine.primitives import Primitive @@ -16,6 +17,17 @@ from donna.workspaces.config import WorldConfig +class RawArtifact(BaseEntity, ABC): + source_id: str + + @abstractmethod + def get_bytes(self) -> bytes: ... # noqa: E704 + + @abstractmethod + def render(self, full_id: FullArtifactId, render_context: "ArtifactRenderContext") -> Result[Artifact, ErrorsList]: + pass + + class World(BaseEntity, ABC): id: WorldId readonly: bool = True @@ -25,12 +37,11 @@ class World(BaseEntity, ABC): def has(self, artifact_id: ArtifactId) -> bool: ... # noqa: E704 @abstractmethod - def fetch( # noqa: E704 - self, artifact_id: ArtifactId, render_context: "ArtifactRenderContext" - ) -> Result[Artifact, ErrorsList]: ... + def fetch(self, artifact_id: ArtifactId) -> Result[RawArtifact, ErrorsList]: ... # noqa: E704 @abstractmethod - def fetch_source(self, artifact_id: ArtifactId) -> Result[bytes, ErrorsList]: ... # noqa: E704 + def has_artifact_changed(self, artifact_id: ArtifactId, since: Milliseconds) -> Result[bool, ErrorsList]: + pass @abstractmethod def update( # noqa: E704 diff --git a/donna/workspaces/worlds/filesystem.py b/donna/workspaces/worlds/filesystem.py index 827f5a2..c4a2953 100644 --- a/donna/workspaces/worlds/filesystem.py +++ b/donna/workspaces/worlds/filesystem.py @@ -9,15 +9,33 @@ from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error from donna.domain.ids import ArtifactId, FullArtifactId, FullArtifactIdPattern -from donna.machine.artifacts import Artifact +from donna.domain.types import Milliseconds from donna.workspaces import errors as world_errors -from donna.workspaces.artifacts import ArtifactRenderContext from donna.workspaces.artifacts_discovery import ArtifactListingNode, list_artifacts_by_pattern +from donna.workspaces.worlds.base import RawArtifact from donna.workspaces.worlds.base import World as BaseWorld from donna.workspaces.worlds.base import WorldConstructor if TYPE_CHECKING: - from donna.workspaces.config import SourceConfigValue, WorldConfig + from donna.machine.artifacts import Artifact + from donna.workspaces.artifacts import ArtifactRenderContext + from donna.workspaces.config import WorldConfig + + +class FilesystemRawArtifact(RawArtifact): + path: pathlib.Path + + def get_bytes(self) -> bytes: + return self.path.read_bytes() + + @unwrap_to_error + def render( + self, full_id: FullArtifactId, render_context: "ArtifactRenderContext" + ) -> Result["Artifact", ErrorsList]: + from donna.workspaces.config import config + + source_config = config().get_source_config(self.source_id).unwrap() + return Ok(source_config.construct_artifact_from_bytes(full_id, self.get_bytes(), render_context).unwrap()) class World(BaseWorld): @@ -60,26 +78,6 @@ def _resolve_artifact_file(self, artifact_id: ArtifactId) -> Result[pathlib.Path return Ok(matches[0]) - def _get_source_by_filename( - self, artifact_id: ArtifactId, filename: str - ) -> Result["SourceConfigValue", ErrorsList]: - from donna.workspaces.config import config - - extension = pathlib.Path(filename).suffix - source_config = config().find_source_for_extension(extension) - if source_config is None: - return Err( - [ - world_errors.UnsupportedArtifactSourceExtension( - artifact_id=artifact_id, - world_id=self.id, - extension=extension, - ) - ] - ) - - return Ok(source_config) - def has(self, artifact_id: ArtifactId) -> bool: resolve_result = self._resolve_artifact_file(artifact_id) if resolve_result.is_err(): @@ -88,38 +86,40 @@ def has(self, artifact_id: ArtifactId) -> bool: return resolve_result.unwrap() is not None @unwrap_to_error - def fetch(self, artifact_id: ArtifactId, render_context: ArtifactRenderContext) -> Result[Artifact, ErrorsList]: + def fetch(self, artifact_id: ArtifactId) -> Result[RawArtifact, ErrorsList]: path = self._resolve_artifact_file(artifact_id).unwrap() if path is None: return Err([world_errors.ArtifactNotFound(artifact_id=artifact_id, world_id=self.id)]) - content_bytes = path.read_bytes() - full_id = FullArtifactId((self.id, artifact_id)) - - extension = pathlib.Path(path.name).suffix from donna.workspaces.config import config - source_config = config().find_source_for_extension(extension) + source_config = config().find_source_for_extension(path.suffix) if source_config is None: return Err( [ world_errors.UnsupportedArtifactSourceExtension( artifact_id=artifact_id, world_id=self.id, - extension=extension, + extension=path.suffix, ) ] ) - return Ok(source_config.construct_artifact_from_bytes(full_id, content_bytes, render_context).unwrap()) + return Ok( + FilesystemRawArtifact( + source_id=source_config.kind, + path=path, + ) + ) @unwrap_to_error - def fetch_source(self, artifact_id: ArtifactId) -> Result[bytes, ErrorsList]: + def has_artifact_changed(self, artifact_id: ArtifactId, since: Milliseconds) -> Result[bool, ErrorsList]: path = self._resolve_artifact_file(artifact_id).unwrap() + if path is None: - return Err([world_errors.ArtifactNotFound(artifact_id=artifact_id, world_id=self.id)]) + return Ok(True) - return Ok(path.read_bytes()) + return Ok((path.stat().st_mtime_ns // 1_000_000) > since) def update(self, artifact_id: ArtifactId, content: bytes, extension: str) -> Result[None, ErrorsList]: if self.readonly: diff --git a/donna/workspaces/worlds/python.py b/donna/workspaces/worlds/python.py index 2c67153..54af009 100644 --- a/donna/workspaces/worlds/python.py +++ b/donna/workspaces/worlds/python.py @@ -7,15 +7,33 @@ from donna.core.errors import ErrorsList from donna.core.result import Err, Ok, Result, unwrap_to_error from donna.domain.ids import ArtifactId, FullArtifactId, FullArtifactIdPattern, WorldId -from donna.machine.artifacts import Artifact +from donna.domain.types import Milliseconds from donna.workspaces import errors as world_errors -from donna.workspaces.artifacts import ArtifactRenderContext from donna.workspaces.artifacts_discovery import ArtifactListingNode, list_artifacts_by_pattern +from donna.workspaces.worlds.base import RawArtifact from donna.workspaces.worlds.base import World as BaseWorld from donna.workspaces.worlds.base import WorldConstructor if TYPE_CHECKING: - from donna.workspaces.config import SourceConfigValue, WorldConfig + from donna.machine.artifacts import Artifact + from donna.workspaces.artifacts import ArtifactRenderContext + from donna.workspaces.config import WorldConfig + + +class PythonRawArtifact(RawArtifact): + content: bytes + + def get_bytes(self) -> bytes: + return self.content + + @unwrap_to_error + def render( + self, full_id: FullArtifactId, render_context: "ArtifactRenderContext" + ) -> Result["Artifact", ErrorsList]: + from donna.workspaces.config import config + + source_config = config().get_source_config(self.source_id).unwrap() + return Ok(source_config.construct_artifact_from_bytes(full_id, self.get_bytes(), render_context).unwrap()) class Python(BaseWorld): @@ -79,26 +97,6 @@ def _resolve_artifact_file( return Ok(matches[0]) - def _get_source_by_filename( - self, artifact_id: ArtifactId, filename: str - ) -> Result["SourceConfigValue", ErrorsList]: - from donna.workspaces.config import config - - extension = pathlib.Path(filename).suffix - source_config = config().find_source_for_extension(extension) - if source_config is None: - return Err( - [ - world_errors.UnsupportedArtifactSourceExtension( - artifact_id=artifact_id, - world_id=self.id, - extension=extension, - ) - ] - ) - - return Ok(source_config) - def has(self, artifact_id: ArtifactId) -> bool: resolve_result = self._resolve_artifact_file(artifact_id) if resolve_result.is_err(): @@ -107,14 +105,11 @@ def has(self, artifact_id: ArtifactId) -> bool: return resolve_result.unwrap() is not None @unwrap_to_error - def fetch(self, artifact_id: ArtifactId, render_context: ArtifactRenderContext) -> Result[Artifact, ErrorsList]: + def fetch(self, artifact_id: ArtifactId) -> Result[RawArtifact, ErrorsList]: # noqa: CCR001 resource_path = self._resolve_artifact_file(artifact_id).unwrap() if resource_path is None: return Err([world_errors.ArtifactNotFound(artifact_id=artifact_id, world_id=self.id)]) - content_bytes = resource_path.read_bytes() - full_id = FullArtifactId((self.id, artifact_id)) - extension = pathlib.Path(resource_path.name).suffix from donna.workspaces.config import config @@ -130,15 +125,16 @@ def fetch(self, artifact_id: ArtifactId, render_context: ArtifactRenderContext) ] ) - return Ok(source_config.construct_artifact_from_bytes(full_id, content_bytes, render_context).unwrap()) + return Ok( + PythonRawArtifact( + source_id=source_config.kind, + content=resource_path.read_bytes(), + ) + ) @unwrap_to_error - def fetch_source(self, artifact_id: ArtifactId) -> Result[bytes, ErrorsList]: # noqa: CCR001 - resource_path = self._resolve_artifact_file(artifact_id).unwrap() - if resource_path is None: - return Err([world_errors.ArtifactNotFound(artifact_id=artifact_id, world_id=self.id)]) - - return Ok(resource_path.read_bytes()) + def has_artifact_changed(self, artifact_id: ArtifactId, since: Milliseconds) -> Result[bool, ErrorsList]: + return Ok(False) def update(self, artifact_id: ArtifactId, content: bytes, extension: str) -> Result[None, ErrorsList]: return Err([world_errors.WorldReadonly(world_id=self.id)])