diff --git a/src/shard_wiki/adapters/__init__.py b/src/shard_wiki/adapters/__init__.py new file mode 100644 index 0000000..9635b25 --- /dev/null +++ b/src/shard_wiki/adapters/__init__.py @@ -0,0 +1,6 @@ +"""adapters/ — the shard adapter contract (bottom waist) and concrete adapters.""" + +from shard_wiki.adapters.contract import CONTRACT_VERSION, ShardAdapter +from shard_wiki.adapters.folder import FolderAdapter + +__all__ = ["ShardAdapter", "FolderAdapter", "CONTRACT_VERSION"] diff --git a/src/shard_wiki/adapters/contract.py b/src/shard_wiki/adapters/contract.py new file mode 100644 index 0000000..ac4da20 --- /dev/null +++ b/src/shard_wiki/adapters/contract.py @@ -0,0 +1,52 @@ +"""The shard adapter contract — the bottom narrow waist (CoreArchitectureBlueprint §6, TSD §A). + +A backend participates by implementing :class:`ShardAdapter`. ``shard_id``, ``profile`` and +``read`` are mandatory; everything else is an optional capability that defaults to raising +:class:`~shard_wiki.model.NotSupported` — so a limited backend is honest about what it can't do +(graceful degradation, I-8) and core never assumes a capability it wasn't given (capability-as- +data, I-3). Declared profiles are verified by the conformance suite (T4), never taken on trust. +""" + +from __future__ import annotations + +import abc +from collections.abc import Iterable + +from shard_wiki.model import CapabilityProfile, NotSupported, Page + +__all__ = ["ShardAdapter", "CONTRACT_VERSION"] + +CONTRACT_VERSION = "0.1" + + +class ShardAdapter(abc.ABC): + """Versioned interface a backend implements to attach as a shard.""" + + contract_version: str = CONTRACT_VERSION + + @property + @abc.abstractmethod + def shard_id(self) -> str: + """Stable id scoping every Identity this shard mints.""" + + @abc.abstractmethod + def profile(self) -> CapabilityProfile: + """The (to-be-verified) capability profile of this binding.""" + + @abc.abstractmethod + def keys(self) -> Iterable[str]: + """The stable page keys this shard offers (the handle half of Identity).""" + + @abc.abstractmethod + def read(self, key: str) -> Page: + """Read one page by its stable key. Raises ``KeyError`` if absent.""" + + # --- optional capability verbs: honest NotSupported by default --- + def write(self, key: str, body: str) -> Page: # noqa: ARG002 + raise NotSupported(f"{type(self).__name__} does not support write") + + def diff(self, key: str, other: str) -> str: # noqa: ARG002 + raise NotSupported(f"{type(self).__name__} does not support diff") + + def notify(self): + raise NotSupported(f"{type(self).__name__} does not support notify") diff --git a/src/shard_wiki/adapters/folder.py b/src/shard_wiki/adapters/folder.py new file mode 100644 index 0000000..3a30c2b --- /dev/null +++ b/src/shard_wiki/adapters/folder.py @@ -0,0 +1,91 @@ +"""FolderAdapter — a read-only file-store shard over a directory of Markdown. + +The home-case substrate: a plain folder of ``.md`` files. The relative path (sans extension, +``/``-separated) is the stable page **key**; the file is the page **body**; mtime gives a +freshness stamp. Read-only in this slice (overlay/write-through come later); declared profile +reflects exactly that (read-only, file-store, path addressing, no native history/query). +""" + +from __future__ import annotations + +from collections.abc import Iterable +from datetime import datetime, timezone +from pathlib import Path + +from shard_wiki.adapters.contract import ShardAdapter +from shard_wiki.model import ( + AccessGrant, + Addressing, + AttachmentMode, + CapabilityProfile, + ContentOpacity, + History, + Identity, + MergeModel, + NativeQuery, + OperationalEnvelope, + Page, + Placement, + Substrate, + Translation, + Verb, + WriteGranularity, +) +from shard_wiki.provenance import Liveness, ProvenanceEnvelope, Staleness + +__all__ = ["FolderAdapter"] + + +class FolderAdapter(ShardAdapter): + def __init__(self, shard_id: str, root: str | Path) -> None: + self._shard_id = shard_id + self._root = Path(root) + + @property + def shard_id(self) -> str: + return self._shard_id + + def profile(self) -> CapabilityProfile: + return CapabilityProfile( + substrate=Substrate.FILES, + attachment_mode=AttachmentMode.FILE_STORE, + write_granularity=WriteGranularity.NONE, + content_opacity=ContentOpacity.TRANSPARENT, + operational_envelope=OperationalEnvelope.LOCAL_UNBOUNDED, + access_grant=AccessGrant.OPEN, + liveness=Liveness.STATIC, + history=History.NONE, + merge_model=MergeModel.NONE, + addressing=Addressing.PATH, + native_query=NativeQuery.NONE, + translation=Translation.NATIVE, + supported_verbs=frozenset({Verb.READ}), + ).validate() + + def _path_for(self, key: str) -> Path: + return self._root / f"{key}.md" + + def keys(self) -> Iterable[str]: + for p in sorted(self._root.rglob("*.md")): + yield p.relative_to(self._root).with_suffix("").as_posix() + + def read(self, key: str) -> Page: + path = self._path_for(key) + if not path.is_file(): + raise KeyError(key) + body = path.read_text(encoding="utf-8") + mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc) + envelope = ProvenanceEnvelope( + source_shard=self._shard_id, + liveness=Liveness.STATIC, + staleness=Staleness.FRESH, + source_rev=mtime.isoformat(), + observed_at=datetime.now(tz=timezone.utc), + ) + rel = path.relative_to(self._root).as_posix() + return Page( + identity=Identity(self._shard_id, key), + body=body, + envelope=envelope, + placements=(Placement(self._shard_id, rel),), + ) diff --git a/tests/test_folder_adapter.py b/tests/test_folder_adapter.py new file mode 100644 index 0000000..c2ab0e5 --- /dev/null +++ b/tests/test_folder_adapter.py @@ -0,0 +1,44 @@ +"""Tests for the FolderAdapter (SHARD-WP-0007 T3).""" + +import pytest + +from shard_wiki.adapters import FolderAdapter +from shard_wiki.model import Identity, NotSupported, Verb + + +def _make_shard(tmp_path, files: dict[str, str]) -> FolderAdapter: + for rel, text in files.items(): + p = tmp_path / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(text, encoding="utf-8") + return FolderAdapter("shardA", tmp_path) + + +def test_keys_and_read(tmp_path): + shard = _make_shard(tmp_path, {"Home.md": "# Home", "sub/Topic.md": "topic body"}) + assert set(shard.keys()) == {"Home", "sub/Topic"} + page = shard.read("sub/Topic") + assert page.identity == Identity("shardA", "sub/Topic") + assert page.body == "topic body" + assert page.envelope.source_shard == "shardA" + assert page.envelope.source_rev is not None # mtime stamp + assert page.placements[0].path == "sub/Topic.md" + + +def test_read_missing_raises_keyerror(tmp_path): + shard = _make_shard(tmp_path, {"Home.md": "x"}) + with pytest.raises(KeyError): + shard.read("Nope") + + +def test_profile_is_valid_and_read_only(tmp_path): + shard = _make_shard(tmp_path, {"Home.md": "x"}) + prof = shard.profile() # .validate() already called inside + assert prof.supports(Verb.READ) + assert not prof.supports(Verb.WRITE) + + +def test_unsupported_write_is_honest(tmp_path): + shard = _make_shard(tmp_path, {"Home.md": "x"}) + with pytest.raises(NotSupported): + shard.write("Home", "new") diff --git a/workplans/SHARD-WP-0007-foundation-implementation.md b/workplans/SHARD-WP-0007-foundation-implementation.md index 129ebbb..db882f6 100644 --- a/workplans/SHARD-WP-0007-foundation-implementation.md +++ b/workplans/SHARD-WP-0007-foundation-implementation.md @@ -71,7 +71,7 @@ identity stability vs content change; profile validation accepts/rejects. ```task id: SHARD-WP-0007-T3 -status: todo +status: done priority: high state_hub_task_id: "f6e35ddb-ab1e-406a-82f8-563244455f6b" ```