feat(adapters): ShardAdapter contract + read-only FolderAdapter (WP-0007 T3)

Versioned ShardAdapter ABC (shard_id/profile/keys/read mandatory; optional verbs
raise NotSupported by default = honest absence). FolderAdapter reads a dir of
Markdown into Pages (relpath=key, mtime=source_rev, provenance envelope),
declaring a validated read-only file-store profile. 4 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 09:37:02 +02:00
parent 5a77ea879c
commit 9a4e00a05a
5 changed files with 194 additions and 1 deletions

View File

@@ -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"]

View File

@@ -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")

View File

@@ -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),),
)

View File

@@ -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")

View File

@@ -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"
```