generated from coulomb/repo-seed
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:
6
src/shard_wiki/adapters/__init__.py
Normal file
6
src/shard_wiki/adapters/__init__.py
Normal 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"]
|
||||
52
src/shard_wiki/adapters/contract.py
Normal file
52
src/shard_wiki/adapters/contract.py
Normal 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")
|
||||
91
src/shard_wiki/adapters/folder.py
Normal file
91
src/shard_wiki/adapters/folder.py
Normal 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),),
|
||||
)
|
||||
44
tests/test_folder_adapter.py
Normal file
44
tests/test_folder_adapter.py
Normal 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")
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user