Skip to content

Commit 9dff809

Browse files
authored
Plugins (#19)
1 parent 654aab8 commit 9dff809

5 files changed

Lines changed: 572 additions & 0 deletions

File tree

src/groundskeeping/configurator/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,14 @@
4747
resolve_operation,
4848
)
4949
from groundskeeping.configurator.providers import (
50+
ConfigSchemaAdapter,
51+
ConfigSchemaConflictError,
52+
ConfigSchemaRejectedError,
5053
FakeConfigMutationService,
5154
FakeDialectConfigMutationService,
5255
FakeMutationEvent,
5356
FakeMutationScenario,
57+
SchemaConfigMutationService,
5458
fake_database_workflow,
5559
fake_dialect_database_workflow,
5660
)

src/groundskeeping/configurator/providers/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,9 @@
88
fake_database_workflow,
99
fake_dialect_database_workflow,
1010
)
11+
from groundskeeping.configurator.providers.generic import SchemaConfigMutationService
12+
from groundskeeping.configurator.providers.schema import (
13+
ConfigSchemaAdapter,
14+
ConfigSchemaConflictError,
15+
ConfigSchemaRejectedError,
16+
)
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
"""Generic ConfigMutationService driven by a ConfigSchemaAdapter.
2+
3+
Session bookkeeping, revision-conflict detection, single-use apply tokens,
4+
and diff computation are implemented exactly once here -- the same
5+
mechanics `providers.fake.FakeConfigMutationService` hand-rolls for its own
6+
demo target, generalised over `ConfigSchemaAdapter` instead of two
7+
hardcoded fields. A host wanting a `ConfigMutationService` for one more
8+
configuration target implements that seam; it does not re-implement
9+
sessions, tokens, or conflict detection.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from collections.abc import Mapping
15+
from dataclasses import dataclass, field
16+
from itertools import count
17+
18+
from groundskeeping.configurator.models import ConfigTarget
19+
from groundskeeping.configurator.mutation import (
20+
ConfigApplyIntent,
21+
ConfigApplyResult,
22+
ConfigApplyStatus,
23+
ConfigDraft,
24+
ConfigPlan,
25+
ConfigStepResult,
26+
MutationCapabilities,
27+
MutationOperation,
28+
MutationOperationUnsupported,
29+
UnavailableMutationService,
30+
build_config_diff,
31+
)
32+
from groundskeeping.configurator.providers.schema import (
33+
ConfigSchemaAdapter,
34+
ConfigSchemaConflictError,
35+
ConfigSchemaRejectedError,
36+
)
37+
from groundskeeping.contracts.actions import FieldSpec, ValidationIssue
38+
from groundskeeping.contracts.views import SemanticStatus
39+
40+
41+
@dataclass
42+
class _Session:
43+
target: ConfigTarget
44+
operation: MutationOperation
45+
expected_revision: str
46+
values: dict[str, object] = field(default_factory=dict)
47+
changed_fields: frozenset[str] = frozenset()
48+
apply_token: str | None = None
49+
planned_candidate: dict[str, object] | None = None
50+
51+
52+
class SchemaConfigMutationService:
53+
"""ConfigMutationService for one target, backed by one ConfigSchemaAdapter.
54+
55+
One instance serves exactly one `ConfigTarget` -- the natural shape for
56+
a plugin config page, where each plugin gets its own adapter over its
57+
own configuration. `capabilities()`/`begin()` report a target other than
58+
the one this instance was built for as unsupported rather than raising;
59+
a host juggling several targets constructs one service per target
60+
rather than routing through a single shared instance.
61+
62+
CREATE and UPDATE are both always supported: unlike a database or model
63+
entry, a schema-backed configuration section does not have a meaningful
64+
"does it already exist" distinction when every field can have a usable
65+
default (the same reason `PackageConfigBase.resolve_package_config`
66+
treats an absent section as resolvable, not an error). A host that does
67+
need to distinguish them can still choose which operation to open with;
68+
this provider does not block whichever one it's asked for.
69+
"""
70+
71+
def __init__(self, target: ConfigTarget, schema: ConfigSchemaAdapter) -> None:
72+
self._target = target
73+
self._schema = schema
74+
self._session_ids = count(1)
75+
self._plan_ids = count(1)
76+
self._sessions: dict[str, _Session] = {}
77+
self._apply_tokens: dict[str, str] = {}
78+
79+
def capabilities(
80+
self, target: ConfigTarget, operation: MutationOperation
81+
) -> MutationCapabilities:
82+
if target != self._target:
83+
return MutationCapabilities(
84+
target=target,
85+
operation=operation,
86+
supported=False,
87+
reason="This provider does not serve that target.",
88+
)
89+
# A cheap read that doubles as the availability check every other
90+
# method skips: capabilities() is the call a host makes first, so
91+
# it is where "the schema adapter cannot currently be reached"
92+
# (file unreadable, backing store unreachable) surfaces as
93+
# UnavailableMutationService rather than an unhandled exception
94+
# from whichever method happens to touch the schema next.
95+
try:
96+
self._schema.revision()
97+
except Exception as exc:
98+
raise UnavailableMutationService(str(exc)) from exc
99+
return MutationCapabilities(target=target, operation=operation, supported=True)
100+
101+
def begin(self, target: ConfigTarget, operation: MutationOperation) -> ConfigDraft:
102+
capabilities = self.capabilities(target, operation)
103+
if not capabilities.supported:
104+
raise MutationOperationUnsupported(
105+
capabilities.reason or "That operation is not supported."
106+
)
107+
token = f"schema-session-{next(self._session_ids)}"
108+
revision = self._schema.revision()
109+
self._sessions[token] = _Session(
110+
target=target, operation=operation, expected_revision=revision
111+
)
112+
return ConfigDraft(
113+
target=target,
114+
operation=operation,
115+
session_token=token,
116+
expected_revision=revision,
117+
)
118+
119+
def fields(self, draft: ConfigDraft) -> tuple[FieldSpec, ...]:
120+
self._session_for_draft(draft)
121+
return self._schema.field_specs()
122+
123+
def submit(
124+
self,
125+
draft: ConfigDraft,
126+
step_key: str,
127+
values: Mapping[str, object],
128+
*,
129+
discard_fields: frozenset[str] = frozenset(),
130+
) -> ConfigStepResult:
131+
session = self._session_for_draft(draft)
132+
specs_by_key = {spec.key: spec for spec in self._schema.field_specs()}
133+
issues: list[ValidationIssue] = []
134+
parsed: dict[str, object] = {}
135+
for key, raw in values.items():
136+
spec = specs_by_key.get(key)
137+
if spec is None:
138+
issues.append(ValidationIssue(f"Unknown field {key!r}.", field_key=key))
139+
continue
140+
try:
141+
parsed[key] = spec.parse(raw).value
142+
except ValueError as exc:
143+
issues.append(ValidationIssue(str(exc), field_key=key))
144+
if issues:
145+
return ConfigStepResult(tuple(issues), session.changed_fields)
146+
147+
for key in discard_fields:
148+
session.values.pop(key, None)
149+
session.values.update(parsed)
150+
session.changed_fields = frozenset(session.values)
151+
self._invalidate_apply_token(session)
152+
session.planned_candidate = None
153+
return ConfigStepResult(changed_fields=session.changed_fields)
154+
155+
def plan(self, draft: ConfigDraft) -> ConfigPlan:
156+
session = self._session_for_draft(draft)
157+
# Overlaid on the currently stored values, not just what this session
158+
# touched: an update that only changes one field must still validate
159+
# and diff the complete configuration, not a candidate missing every
160+
# field the operator didn't happen to resubmit.
161+
stored = self._schema.load()
162+
candidate = {**stored, **session.values}
163+
issues = list(self._schema.validate(candidate))
164+
warnings = tuple(
165+
issue.message for issue in issues if issue.status is SemanticStatus.WARNING
166+
)
167+
sensitive_fields = frozenset(
168+
spec.key for spec in self._schema.field_specs() if spec.masks_value
169+
)
170+
diff = build_config_diff(
171+
session.target, stored, candidate, sensitive_fields=sensitive_fields
172+
)
173+
has_error = any(issue.status is SemanticStatus.ERROR for issue in issues)
174+
self._invalidate_apply_token(session)
175+
session.planned_candidate = None
176+
apply_token: str | None = None
177+
if not has_error:
178+
apply_token = f"schema-plan-{next(self._plan_ids)}"
179+
session.apply_token = apply_token
180+
session.planned_candidate = candidate
181+
self._apply_tokens[apply_token] = draft.session_token
182+
return ConfigPlan(
183+
target=session.target,
184+
operation=session.operation,
185+
diff=diff,
186+
issues=tuple(issues),
187+
warnings=warnings,
188+
apply_token=apply_token,
189+
expected_revision=session.expected_revision,
190+
)
191+
192+
def apply(self, intent: ConfigApplyIntent) -> ConfigApplyResult:
193+
session_token = self._apply_tokens.pop(intent.apply_token, None)
194+
if session_token is None:
195+
return ConfigApplyResult(
196+
ConfigApplyStatus.REJECTED, "The apply plan is no longer valid."
197+
)
198+
session = self._sessions[session_token]
199+
session.apply_token = None
200+
if intent.target != session.target or intent.operation != session.operation:
201+
return ConfigApplyResult(
202+
ConfigApplyStatus.REJECTED,
203+
"The apply plan does not match the requested target or operation.",
204+
)
205+
# Checked against what this session captured at begin(), not only the
206+
# store's current value -- otherwise a stale token could be replayed
207+
# against today's revision and slip past the conflict check below
208+
# with a candidate planned against an older configuration.
209+
if intent.expected_revision != session.expected_revision:
210+
return ConfigApplyResult(
211+
ConfigApplyStatus.REJECTED,
212+
"The apply plan was not prepared for that configuration revision.",
213+
)
214+
candidate = session.planned_candidate
215+
if candidate is None:
216+
return ConfigApplyResult(
217+
ConfigApplyStatus.REJECTED,
218+
"The apply plan has no prepared configuration candidate.",
219+
)
220+
try:
221+
self._schema.save(
222+
candidate,
223+
expected_revision=session.expected_revision,
224+
)
225+
except ConfigSchemaConflictError:
226+
return ConfigApplyResult(
227+
ConfigApplyStatus.CONFLICTED,
228+
"Configuration changed before this plan could be applied.",
229+
detail="Reload the configuration and review the change again.",
230+
)
231+
except ConfigSchemaRejectedError:
232+
return ConfigApplyResult(
233+
ConfigApplyStatus.REJECTED,
234+
"The configuration change was rejected.",
235+
detail="Review the current configuration and prepare a new plan.",
236+
)
237+
except Exception: # noqa: BLE001 - translated to a secret-safe provider result
238+
return ConfigApplyResult(
239+
ConfigApplyStatus.FAILED,
240+
"The configuration could not be saved.",
241+
detail="The previous configuration remains authoritative.",
242+
)
243+
self._sessions.pop(session_token, None)
244+
return ConfigApplyResult(
245+
ConfigApplyStatus.APPLIED, "Configuration applied.",
246+
)
247+
248+
def cancel(self, draft: ConfigDraft) -> None:
249+
session = self._sessions.pop(draft.session_token, None)
250+
if session is not None and session.apply_token is not None:
251+
self._apply_tokens.pop(session.apply_token, None)
252+
253+
def _session_for_draft(self, draft: ConfigDraft) -> _Session:
254+
try:
255+
session = self._sessions[draft.session_token]
256+
except KeyError:
257+
raise ValueError("The mutation session is no longer valid.") from None
258+
if draft.target != session.target or draft.operation != session.operation:
259+
raise ValueError("The mutation draft does not match its session.")
260+
return session
261+
262+
def _invalidate_apply_token(self, session: _Session) -> None:
263+
if session.apply_token is not None:
264+
self._apply_tokens.pop(session.apply_token, None)
265+
session.apply_token = None
266+
session.planned_candidate = None
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""The seam a host implements to get a generic ConfigMutationService for free.
2+
3+
Groundskeeping owns the operator sequence, not the configuration candidate
4+
(see `mutation.py`'s module docstring), and editable candidates and
5+
persistence explicitly remain outside groundskeeping (see
6+
`adapter.OAConfiguratorAdapter`'s docstring). `SchemaConfigMutationService`
7+
in `generic.py` is the generic operator-sequence side of that split: it
8+
handles sessions, revision-conflict detection, apply-token single-use, and
9+
diffing exactly once. Everything that requires knowing what the
10+
configuration actually *is* -- field descriptions, current values,
11+
validation, persistence -- is this protocol, implemented by a host.
12+
13+
Nothing here is oa-configurator-shaped. A host backed by `PackageConfigBase`
14+
implements it by reflecting over pydantic fields and calling
15+
`validate_candidate`/its own save path; a host with a completely different
16+
configuration format could implement the same protocol just as easily.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from collections.abc import Mapping
22+
from typing import Protocol
23+
24+
from groundskeeping.contracts.actions import FieldSpec, ValidationIssue
25+
26+
27+
class ConfigSchemaConflictError(RuntimeError):
28+
"""The schema changed after the candidate was prepared."""
29+
30+
31+
class ConfigSchemaRejectedError(ValueError):
32+
"""The schema rejected the prepared candidate before attempting a write."""
33+
34+
35+
class ConfigSchemaAdapter(Protocol):
36+
"""One configuration target's shape, current state, and persistence."""
37+
38+
def field_specs(self) -> tuple[FieldSpec, ...]:
39+
"""Describe every field this schema has, regardless of workflow step.
40+
41+
`SchemaConfigMutationService.fields()` returns this unfiltered, the
42+
same way `ConfigMutationService.fields()` already works for every
43+
existing provider -- step-scoping is a `ConfigWorkflowStep` concern,
44+
not something the provider itself tracks.
45+
"""
46+
...
47+
48+
def load(self) -> Mapping[str, object]:
49+
"""Return the currently persisted values, for diffing against a candidate.
50+
51+
Must be projected the same way `validate`'s candidate view is -- see
52+
`assert_mutation_service_conformance`'s projection-symmetry check,
53+
which fails loudly if the two sides disagree on which fields are
54+
materialised.
55+
"""
56+
...
57+
58+
def revision(self) -> str:
59+
"""Return an opaque marker that changes whenever the persisted values do.
60+
61+
Read fresh, not cached -- this is what lets the generic provider
62+
detect a write from outside its own session.
63+
"""
64+
...
65+
66+
def validate(self, candidate: Mapping[str, object]) -> tuple[ValidationIssue, ...]:
67+
"""Validate a fully-merged candidate. Empty means it is acceptable."""
68+
...
69+
70+
def save(
71+
self,
72+
candidate: Mapping[str, object],
73+
*,
74+
expected_revision: str,
75+
) -> None:
76+
"""Compare and persist candidate as the new stored values.
77+
78+
The comparison and write belong in this one adapter operation. Splitting
79+
them into ``revision()`` followed by ``save()`` leaves a race in which a
80+
second writer can commit between the two calls. Raise
81+
:class:`ConfigSchemaConflictError` when the expected revision is stale,
82+
and :class:`ConfigSchemaRejectedError` when policy or validation rejects
83+
the request before a write is attempted. Other exceptions are treated as
84+
attempted-write failures by the generic provider.
85+
"""
86+
...

0 commit comments

Comments
 (0)