generated from coulomb/repo-seed
feat(adapters): writable FolderAdapter + positive write conformance (WP-0008 T1)
FolderAdapter(writable=True) declares WRITE+PER_PAGE, implements write() and current_rev() (mtime token for drift detection). Conformance gains a content-preserving positive write probe for WRITE-claiming adapters. 5 tests green, full suite green, pyflakes clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,22 @@ def run_conformance(adapter: ShardAdapter) -> ConformanceReport:
|
|||||||
if profile.supports(Verb.READ):
|
if profile.supports(Verb.READ):
|
||||||
checks.append(_safe(_read_round_trips, "read-round-trips"))
|
checks.append(_safe(_read_round_trips, "read-round-trips"))
|
||||||
|
|
||||||
|
# WRITE positive probe: a claimed-writable shard must actually round-trip a write. The probe
|
||||||
|
# is content-preserving (rewrite an existing page with its own body) so it is non-destructive.
|
||||||
|
def _write_round_trips() -> Check:
|
||||||
|
keys = list(adapter.keys())
|
||||||
|
if not keys:
|
||||||
|
return Check("write-round-trips", True, "empty shard")
|
||||||
|
k = keys[0]
|
||||||
|
original = adapter.read(k).body
|
||||||
|
adapter.write(k, original)
|
||||||
|
if adapter.read(k).body != original:
|
||||||
|
return Check("write-round-trips", False, "rewrite did not preserve body")
|
||||||
|
return Check("write-round-trips", True)
|
||||||
|
|
||||||
|
if profile.supports(Verb.WRITE):
|
||||||
|
checks.append(_safe(_write_round_trips, "write-round-trips"))
|
||||||
|
|
||||||
# Honest absence: an *unclaimed* optional verb must raise NotSupported when invoked.
|
# Honest absence: an *unclaimed* optional verb must raise NotSupported when invoked.
|
||||||
for verb in _HONEST_ABSENCE_VERBS:
|
for verb in _HONEST_ABSENCE_VERBS:
|
||||||
if profile.supports(verb):
|
if profile.supports(verb):
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from shard_wiki.model import (
|
|||||||
Identity,
|
Identity,
|
||||||
MergeModel,
|
MergeModel,
|
||||||
NativeQuery,
|
NativeQuery,
|
||||||
|
NotSupported,
|
||||||
OperationalEnvelope,
|
OperationalEnvelope,
|
||||||
Page,
|
Page,
|
||||||
Placement,
|
Placement,
|
||||||
@@ -37,19 +38,22 @@ __all__ = ["FolderAdapter"]
|
|||||||
|
|
||||||
|
|
||||||
class FolderAdapter(ShardAdapter):
|
class FolderAdapter(ShardAdapter):
|
||||||
def __init__(self, shard_id: str, root: str | Path) -> None:
|
def __init__(self, shard_id: str, root: str | Path, writable: bool = False) -> None:
|
||||||
self._shard_id = shard_id
|
self._shard_id = shard_id
|
||||||
self._root = Path(root)
|
self._root = Path(root)
|
||||||
|
self._writable = writable
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def shard_id(self) -> str:
|
def shard_id(self) -> str:
|
||||||
return self._shard_id
|
return self._shard_id
|
||||||
|
|
||||||
def profile(self) -> CapabilityProfile:
|
def profile(self) -> CapabilityProfile:
|
||||||
|
verbs = {Verb.READ, Verb.WRITE} if self._writable else {Verb.READ}
|
||||||
|
granularity = WriteGranularity.PER_PAGE if self._writable else WriteGranularity.NONE
|
||||||
return CapabilityProfile(
|
return CapabilityProfile(
|
||||||
substrate=Substrate.FILES,
|
substrate=Substrate.FILES,
|
||||||
attachment_mode=AttachmentMode.FILE_STORE,
|
attachment_mode=AttachmentMode.FILE_STORE,
|
||||||
write_granularity=WriteGranularity.NONE,
|
write_granularity=granularity,
|
||||||
content_opacity=ContentOpacity.TRANSPARENT,
|
content_opacity=ContentOpacity.TRANSPARENT,
|
||||||
operational_envelope=OperationalEnvelope.LOCAL_UNBOUNDED,
|
operational_envelope=OperationalEnvelope.LOCAL_UNBOUNDED,
|
||||||
access_grant=AccessGrant.OPEN,
|
access_grant=AccessGrant.OPEN,
|
||||||
@@ -59,12 +63,28 @@ class FolderAdapter(ShardAdapter):
|
|||||||
addressing=Addressing.PATH,
|
addressing=Addressing.PATH,
|
||||||
native_query=NativeQuery.NONE,
|
native_query=NativeQuery.NONE,
|
||||||
translation=Translation.NATIVE,
|
translation=Translation.NATIVE,
|
||||||
supported_verbs=frozenset({Verb.READ}),
|
supported_verbs=frozenset(verbs),
|
||||||
).validate()
|
).validate()
|
||||||
|
|
||||||
def _path_for(self, key: str) -> Path:
|
def _path_for(self, key: str) -> Path:
|
||||||
return self._root / f"{key}.md"
|
return self._root / f"{key}.md"
|
||||||
|
|
||||||
|
def current_rev(self, key: str) -> str | None:
|
||||||
|
"""The shard's current revision token for ``key`` (mtime iso), or ``None`` if absent.
|
||||||
|
Used for apply-under-drift comparison (blueprint §8.6)."""
|
||||||
|
path = self._path_for(key)
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat()
|
||||||
|
|
||||||
|
def write(self, key: str, body: str) -> Page:
|
||||||
|
if not self._writable:
|
||||||
|
raise NotSupported(f"{type(self).__name__} is read-only")
|
||||||
|
path = self._path_for(key)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(body, encoding="utf-8")
|
||||||
|
return self.read(key)
|
||||||
|
|
||||||
def keys(self) -> Iterable[str]:
|
def keys(self) -> Iterable[str]:
|
||||||
for p in sorted(self._root.rglob("*.md")):
|
for p in sorted(self._root.rglob("*.md")):
|
||||||
yield p.relative_to(self._root).with_suffix("").as_posix()
|
yield p.relative_to(self._root).with_suffix("").as_posix()
|
||||||
|
|||||||
41
tests/test_writable_adapter.py
Normal file
41
tests/test_writable_adapter.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""Tests for the writable FolderAdapter + positive write conformance (SHARD-WP-0008 T1)."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from shard_wiki.adapters import FolderAdapter, assert_conformant, run_conformance
|
||||||
|
from shard_wiki.model import NotSupported, Verb
|
||||||
|
|
||||||
|
|
||||||
|
def test_writable_round_trip(tmp_path):
|
||||||
|
shard = FolderAdapter("w", tmp_path, writable=True)
|
||||||
|
rev_before = shard.current_rev("New")
|
||||||
|
assert rev_before is None
|
||||||
|
page = shard.write("New", "hello")
|
||||||
|
assert page.body == "hello"
|
||||||
|
assert shard.read("New").body == "hello"
|
||||||
|
assert shard.current_rev("New") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_writable_profile_supports_write(tmp_path):
|
||||||
|
prof = FolderAdapter("w", tmp_path, writable=True).profile()
|
||||||
|
assert prof.supports(Verb.WRITE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_only_still_rejects_write(tmp_path):
|
||||||
|
(tmp_path / "Home.md").write_text("x", encoding="utf-8")
|
||||||
|
with pytest.raises(NotSupported):
|
||||||
|
FolderAdapter("ro", tmp_path).write("Home", "y")
|
||||||
|
|
||||||
|
|
||||||
|
def test_conformance_passes_for_writable(tmp_path):
|
||||||
|
(tmp_path / "Home.md").write_text("body", encoding="utf-8")
|
||||||
|
report = assert_conformant(FolderAdapter("w", tmp_path, writable=True))
|
||||||
|
assert report.ok
|
||||||
|
assert any(c.name == "write-round-trips" and c.ok for c in report.checks)
|
||||||
|
|
||||||
|
|
||||||
|
def test_conformance_write_probe_is_content_preserving(tmp_path):
|
||||||
|
(tmp_path / "Home.md").write_text("keep me", encoding="utf-8")
|
||||||
|
shard = FolderAdapter("w", tmp_path, writable=True)
|
||||||
|
run_conformance(shard)
|
||||||
|
assert shard.read("Home").body == "keep me" # probe did not alter content
|
||||||
@@ -11,6 +11,7 @@ created: "2026-06-15"
|
|||||||
updated: "2026-06-15"
|
updated: "2026-06-15"
|
||||||
depends_on:
|
depends_on:
|
||||||
- SHARD-WP-0007
|
- SHARD-WP-0007
|
||||||
|
state_hub_workstream_id: "12bed418-39d6-47fa-a359-ff04bae6ec99"
|
||||||
---
|
---
|
||||||
|
|
||||||
# SHARD-WP-0008 — Write path
|
# SHARD-WP-0008 — Write path
|
||||||
@@ -41,8 +42,9 @@ propagation, network API, lossy native-syntax overlays. Those are later.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: SHARD-WP-0008-T1
|
id: SHARD-WP-0008-T1
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "80492f8e-125c-4015-b3c0-821fbec038e0"
|
||||||
```
|
```
|
||||||
|
|
||||||
Make `FolderAdapter` optionally **writable** (`writable=True`): declare `WRITE` +
|
Make `FolderAdapter` optionally **writable** (`writable=True`): declare `WRITE` +
|
||||||
@@ -58,6 +60,7 @@ folder still rejects write; conformance passes for both.
|
|||||||
id: SHARD-WP-0008-T2
|
id: SHARD-WP-0008-T2
|
||||||
status: todo
|
status: todo
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "cc6bf9a3-667d-468d-972d-dae51931a657"
|
||||||
```
|
```
|
||||||
|
|
||||||
`coordination/overlay.py`: an `Overlay` value type (id, target identity, base_rev, body, state)
|
`coordination/overlay.py`: an `Overlay` value type (id, target identity, base_rev, body, state)
|
||||||
@@ -71,6 +74,7 @@ overlays. Tests: draft recorded + retrievable via fold; overlay id stable.
|
|||||||
id: SHARD-WP-0008-T3
|
id: SHARD-WP-0008-T3
|
||||||
status: todo
|
status: todo
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "90d98c16-ed3b-414f-802c-b0400eca6ede"
|
||||||
```
|
```
|
||||||
|
|
||||||
Render an overlay as a reviewable **patch** (a `Patch` with a unified diff of base→overlay body,
|
Render an overlay as a reviewable **patch** (a `Patch` with a unified diff of base→overlay body,
|
||||||
@@ -83,6 +87,7 @@ empty patch when unchanged.
|
|||||||
id: SHARD-WP-0008-T4
|
id: SHARD-WP-0008-T4
|
||||||
status: todo
|
status: todo
|
||||||
priority: high
|
priority: high
|
||||||
|
state_hub_task_id: "2a0179b1-802e-44e6-883d-9f1babefee80"
|
||||||
```
|
```
|
||||||
|
|
||||||
`OverlayEngine.apply(overlay_id)` with §8.6 semantics: compare overlay `base_rev` to the
|
`OverlayEngine.apply(overlay_id)` with §8.6 semantics: compare overlay `base_rev` to the
|
||||||
@@ -97,6 +102,7 @@ Tests: ff apply mutates the shard; drift refuses; read-only keeps draft.
|
|||||||
id: SHARD-WP-0008-T5
|
id: SHARD-WP-0008-T5
|
||||||
status: todo
|
status: todo
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "4536d74f-3860-4b4c-82d2-e8d20e6e2125"
|
||||||
```
|
```
|
||||||
|
|
||||||
When resolving a page that has an **open overlay**, surface it: the read reflects
|
When resolving a page that has an **open overlay**, surface it: the read reflects
|
||||||
@@ -110,6 +116,7 @@ overlay. Tests: page with a draft reads with overlay_state DRAFT; applied/none r
|
|||||||
id: SHARD-WP-0008-T6
|
id: SHARD-WP-0008-T6
|
||||||
status: todo
|
status: todo
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "ab01fffb-61ad-416c-9f13-fdfbfd503153"
|
||||||
```
|
```
|
||||||
|
|
||||||
Add `InformationSpace.edit(name, body)` (write-through if the resolved shard supports WRITE,
|
Add `InformationSpace.edit(name, body)` (write-through if the resolved shard supports WRITE,
|
||||||
|
|||||||
Reference in New Issue
Block a user