groundskeeping 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,360 @@
1
+ """Generic action, field, and result contracts.
2
+
3
+ Consumers own the verbs and the effects. Groundskeeping owns the repeatable operator
4
+ sequence around those verbs: describe fields, parse input, ask policy whether to proceed,
5
+ run with progress/cancellation context, and render a generic outcome.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Iterator, Mapping, Sequence
11
+ from dataclasses import dataclass, field
12
+ from decimal import Decimal, InvalidOperation
13
+ from enum import StrEnum
14
+ from pathlib import Path
15
+ from typing import Protocol
16
+
17
+ from groundskeeping.contracts.jobs import (
18
+ CancellationMode,
19
+ CancellationToken,
20
+ ProgressEvent,
21
+ ProgressSink,
22
+ RecordingProgressSink,
23
+ )
24
+ from groundskeeping.contracts.views import EmptyView, SemanticStatus, SurfaceView
25
+
26
+
27
+ class FieldKind(StrEnum):
28
+ TEXT = "text"
29
+ INTEGER = "integer"
30
+ DECIMAL = "decimal"
31
+ BOOLEAN = "boolean"
32
+ CHOICE = "choice"
33
+ EXISTING_PATH = "existing_path"
34
+ OUTPUT_PATH = "output_path"
35
+ SECRET = "secret"
36
+ MULTILINE = "multiline"
37
+
38
+
39
+ class ExecutionKind(StrEnum):
40
+ QUICK = "quick"
41
+ BACKGROUND = "background"
42
+ LONG_RUNNING = "long_running"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ChoiceOption:
47
+ value: str
48
+ label: str
49
+ description: str | None = None
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ValidationIssue:
54
+ message: str
55
+ field_key: str | None = None
56
+ status: SemanticStatus = SemanticStatus.ERROR
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ParsedField:
61
+ key: str
62
+ value: object
63
+ redacted: object
64
+
65
+
66
+ Validator = Callable[[object], ValidationIssue | None]
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class FieldSpec:
71
+ """Declarative input field used by forms and headless tests."""
72
+
73
+ key: str
74
+ label: str
75
+ kind: FieldKind = FieldKind.TEXT
76
+ required: bool = True
77
+ default: object | None = None
78
+ help: str | None = None
79
+ choices: tuple[ChoiceOption, ...] = ()
80
+ minimum: Decimal | int | None = None
81
+ maximum: Decimal | int | None = None
82
+ sensitive: bool = False
83
+ validator: Validator | None = field(default=None, compare=False, repr=False)
84
+
85
+ @property
86
+ def masks_value(self) -> bool:
87
+ return self.sensitive or self.kind is FieldKind.SECRET
88
+
89
+ def parse(self, raw: object) -> ParsedField:
90
+ """Parse one field value and return both real and presentation-safe values."""
91
+ value = self.default if raw in (None, "") else raw
92
+ if value in (None, ""):
93
+ if self.required:
94
+ raise ValueError(f"{self.label} is required.")
95
+ return ParsedField(self.key, None, "<redacted>" if self.masks_value else None)
96
+
97
+ parsed = self._parse_value(value)
98
+ issue = self.validator(parsed) if self.validator is not None else None
99
+ if issue is not None:
100
+ raise ValueError(issue.message)
101
+ return ParsedField(
102
+ key=self.key,
103
+ value=parsed,
104
+ redacted="<redacted>" if self.masks_value and parsed is not None else parsed,
105
+ )
106
+
107
+ def _parse_value(self, value: object) -> object:
108
+ if self.kind in {FieldKind.TEXT, FieldKind.SECRET, FieldKind.MULTILINE}:
109
+ return str(value)
110
+ if self.kind is FieldKind.INTEGER:
111
+ if not isinstance(value, str | int | float | Decimal):
112
+ raise ValueError(f"{self.label} must be a whole number.")
113
+ try:
114
+ parsed = int(value)
115
+ except ValueError as exc:
116
+ raise ValueError(f"{self.label} must be a whole number.") from exc
117
+ self._check_bounds(parsed)
118
+ return parsed
119
+ if self.kind is FieldKind.DECIMAL:
120
+ try:
121
+ parsed = Decimal(str(value))
122
+ except InvalidOperation as exc:
123
+ raise ValueError(f"{self.label} must be a decimal number.") from exc
124
+ self._check_bounds(parsed)
125
+ return parsed
126
+ if self.kind is FieldKind.BOOLEAN:
127
+ if isinstance(value, bool):
128
+ return value
129
+ normalized = str(value).strip().lower()
130
+ if normalized in {"1", "true", "t", "yes", "y", "on"}:
131
+ return True
132
+ if normalized in {"0", "false", "f", "no", "n", "off"}:
133
+ return False
134
+ raise ValueError(f"{self.label} must be true or false.")
135
+ if self.kind is FieldKind.CHOICE:
136
+ parsed = str(value)
137
+ allowed = {choice.value for choice in self.choices}
138
+ if allowed and parsed not in allowed:
139
+ raise ValueError(f"{self.label} must be one of: {', '.join(sorted(allowed))}.")
140
+ return parsed
141
+ if self.kind is FieldKind.EXISTING_PATH:
142
+ path = Path(str(value)).expanduser()
143
+ if not path.exists():
144
+ raise ValueError(f"{self.label} does not exist: {path}")
145
+ return path
146
+ if self.kind is FieldKind.OUTPUT_PATH:
147
+ return Path(str(value)).expanduser()
148
+ raise ValueError(f"Unsupported field kind: {self.kind}")
149
+
150
+ def _check_bounds(self, value: Decimal | int) -> None:
151
+ if self.minimum is not None and value < self.minimum:
152
+ raise ValueError(f"{self.label} must be at least {self.minimum}.")
153
+ if self.maximum is not None and value > self.maximum:
154
+ raise ValueError(f"{self.label} must be at most {self.maximum}.")
155
+
156
+
157
+ @dataclass(frozen=True)
158
+ class Confirmation:
159
+ title: str
160
+ message: str
161
+ confirm_label: str = "Run"
162
+ dangerous: bool = False
163
+
164
+
165
+ @dataclass(frozen=True)
166
+ class ActionOutcome:
167
+ status: SemanticStatus
168
+ summary: str
169
+ view: SurfaceView
170
+ detail: object | None = None
171
+ refresh_pages: frozenset[str] = frozenset()
172
+
173
+
174
+ @dataclass(frozen=True)
175
+ class ActionContext:
176
+ """Everything a runner may touch while the action is in flight.
177
+
178
+ Deliberately narrow: a runner reports progress and checks for cancellation through this
179
+ object, so long-running domain work never needs to import a widget or reach the app.
180
+ """
181
+
182
+ progress: ProgressSink
183
+ cancellation: CancellationToken
184
+ action_id: str
185
+
186
+ def emit(
187
+ self,
188
+ event: str,
189
+ *,
190
+ phase: str | None = None,
191
+ completed: int = 0,
192
+ total: int | None = None,
193
+ unit: str | None = None,
194
+ message: str | None = None,
195
+ ) -> None:
196
+ self.progress.emit(
197
+ ProgressEvent(
198
+ event=event,
199
+ phase=phase,
200
+ completed=completed,
201
+ total=total,
202
+ unit=unit,
203
+ message=message,
204
+ )
205
+ )
206
+
207
+
208
+ class ActionRunner(Protocol):
209
+ def __call__(self, params: Mapping[str, object], context: ActionContext) -> object: ...
210
+
211
+
212
+ Preflight = Callable[[Mapping[str, object]], Sequence[ValidationIssue]]
213
+
214
+
215
+ @dataclass(frozen=True)
216
+ class ActionSpec:
217
+ """Declaration of one operator-facing command and the runner that performs it.
218
+
219
+ The spec carries everything the shell needs to present, gate, and execute the command
220
+ without knowing what it does: fields to parse, resources and effects for the operation
221
+ policy to reason about, and the cancellation mode the runner honours.
222
+ """
223
+
224
+ key: str
225
+ page_key: str
226
+ label: str
227
+ summary: str
228
+ runner: ActionRunner
229
+ command_hint: str | None = None
230
+ fields: tuple[FieldSpec, ...] = ()
231
+ execution: ExecutionKind = ExecutionKind.QUICK
232
+ cancellation: CancellationMode = CancellationMode.UNSUPPORTED
233
+ effect_refs: frozenset[str] = frozenset()
234
+ resource_refs: frozenset[str] = frozenset()
235
+ preflight: Preflight | None = field(default=None, compare=False, repr=False)
236
+
237
+ @property
238
+ def needs_params(self) -> bool:
239
+ return bool(self.fields)
240
+
241
+ @property
242
+ def long_running(self) -> bool:
243
+ return self.execution is ExecutionKind.LONG_RUNNING
244
+
245
+ def parse_params(self, raw: Mapping[str, object] | None = None) -> tuple[dict[str, object], dict[str, object]]:
246
+ raw = raw or {}
247
+ params: dict[str, object] = {}
248
+ redacted: dict[str, object] = {}
249
+ for spec in self.fields:
250
+ parsed = spec.parse(raw.get(spec.key))
251
+ params[spec.key] = parsed.value
252
+ redacted[spec.key] = parsed.redacted
253
+ return params, redacted
254
+
255
+
256
+ class ResultPresenter(Protocol):
257
+ def present(self, action: ActionSpec, result: object) -> ActionOutcome: ...
258
+
259
+
260
+ class DefaultResultPresenter:
261
+ """Present plain runner results without imposing a consumer report schema."""
262
+
263
+ def present(self, action: ActionSpec, result: object) -> ActionOutcome:
264
+ if isinstance(result, ActionOutcome):
265
+ return result
266
+ return ActionOutcome(
267
+ status=SemanticStatus.OK,
268
+ summary=f"{action.label} completed.",
269
+ view=EmptyView(title=action.label, message=str(result) if result is not None else "Completed."),
270
+ )
271
+
272
+
273
+ class OperationPolicy(Protocol):
274
+ def describe_effects(self, action: ActionSpec, params: Mapping[str, object]) -> str: ...
275
+
276
+ def confirmation(self, action: ActionSpec, params: Mapping[str, object]) -> Confirmation | None: ...
277
+
278
+ def blocked_reason(
279
+ self,
280
+ action: ActionSpec,
281
+ params: Mapping[str, object],
282
+ active_jobs: Sequence[object],
283
+ ) -> str | None: ...
284
+
285
+
286
+ class AllowAllOperationPolicy:
287
+ """Default policy for demos and tests; production consumers should be explicit."""
288
+
289
+ def describe_effects(self, action: ActionSpec, params: Mapping[str, object]) -> str:
290
+ if not action.effect_refs:
291
+ return "read-only"
292
+ return ", ".join(sorted(action.effect_refs))
293
+
294
+ def confirmation(self, action: ActionSpec, params: Mapping[str, object]) -> Confirmation | None:
295
+ if not action.effect_refs:
296
+ return None
297
+ return Confirmation(
298
+ title=action.label,
299
+ message=f"Effects: {self.describe_effects(action, params)}.",
300
+ dangerous=False,
301
+ )
302
+
303
+ def blocked_reason(
304
+ self,
305
+ action: ActionSpec,
306
+ params: Mapping[str, object],
307
+ active_jobs: Sequence[object],
308
+ ) -> str | None:
309
+ return None
310
+
311
+
312
+ class ActionRegistry:
313
+ """Exact-key lookup for actions with startup validation."""
314
+
315
+ def __init__(self, actions: Sequence[ActionSpec], *, page_keys: Sequence[str] | None = None) -> None:
316
+ self._actions = tuple(actions)
317
+ self._by_key = {action.key: action for action in self._actions}
318
+ if len(self._actions) != len(self._by_key):
319
+ raise ValueError("Action keys must be unique.")
320
+ if page_keys is not None:
321
+ allowed = set(page_keys)
322
+ missing = sorted({action.page_key for action in self._actions} - allowed)
323
+ if missing:
324
+ raise ValueError(f"Actions reference unknown pages: {', '.join(missing)}")
325
+
326
+ def get(self, key: str) -> ActionSpec:
327
+ try:
328
+ return self._by_key[key]
329
+ except KeyError as exc:
330
+ raise KeyError(f"Unknown action: {key}") from exc
331
+
332
+ def for_page(self, page_key: str) -> tuple[ActionSpec, ...]:
333
+ return tuple(action for action in self._actions if action.page_key == page_key)
334
+
335
+ def __iter__(self) -> Iterator[ActionSpec]:
336
+ return iter(self._actions)
337
+
338
+
339
+ def run_action_sync(
340
+ action: ActionSpec,
341
+ raw_params: Mapping[str, object] | None,
342
+ cancellation: CancellationToken,
343
+ *,
344
+ presenter: ResultPresenter | None = None,
345
+ action_id: str | None = None,
346
+ ) -> ActionOutcome:
347
+ """Run an action synchronously for tests, demos, and simple quick actions."""
348
+ params, _redacted = action.parse_params(raw_params)
349
+ if action.preflight is not None:
350
+ issues = tuple(action.preflight(params))
351
+ errors = [issue.message for issue in issues if issue.status is SemanticStatus.ERROR]
352
+ if errors:
353
+ raise ValueError("; ".join(errors))
354
+ context = ActionContext(
355
+ progress=RecordingProgressSink(),
356
+ cancellation=cancellation,
357
+ action_id=action_id or action.key,
358
+ )
359
+ result = action.runner(params, context)
360
+ return (presenter or DefaultResultPresenter()).present(action, result)
@@ -0,0 +1,245 @@
1
+ """Portable progress, cancellation, and shell-job contracts.
2
+
3
+ Shell jobs are deliberately smaller than consumer domain queues. A job here is work that
4
+ this TUI process started and can present, gate, or cancel. Durable processing queues,
5
+ leases, retries, and run records stay with the consuming application.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping, Sequence
11
+ from dataclasses import dataclass, field
12
+ from datetime import UTC, datetime
13
+ from enum import StrEnum
14
+ from threading import Event
15
+ from time import monotonic
16
+ from typing import Protocol
17
+ from uuid import uuid4
18
+
19
+
20
+ class CancellationRequested(RuntimeError):
21
+ """Raised by cooperative runners when the operator has requested cancellation."""
22
+
23
+
24
+ class CancellationMode(StrEnum):
25
+ """How the shell should present and route cancellation for an action."""
26
+
27
+ UNSUPPORTED = "unsupported"
28
+ COOPERATIVE = "cooperative"
29
+ IMMEDIATE = "immediate"
30
+
31
+
32
+ class CancellationToken(Protocol):
33
+ """Structural cancellation contract passed into consumer runners."""
34
+
35
+ @property
36
+ def requested(self) -> bool: ...
37
+
38
+ def raise_if_requested(self) -> None: ...
39
+
40
+
41
+ class ThreadCancellationToken:
42
+ """Thread-safe default cancellation token for ordinary shell workers."""
43
+
44
+ def __init__(self) -> None:
45
+ self._event = Event()
46
+
47
+ @property
48
+ def requested(self) -> bool:
49
+ return self._event.is_set()
50
+
51
+ def request(self) -> None:
52
+ self._event.set()
53
+
54
+ def raise_if_requested(self) -> None:
55
+ if self.requested:
56
+ raise CancellationRequested("Operation cancelled by the operator.")
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ProgressEvent:
61
+ """One portable progress update emitted by a runner."""
62
+
63
+ event: str
64
+ phase: str | None = None
65
+ completed: int = 0
66
+ total: int | None = None
67
+ unit: str | None = None
68
+ message: str | None = None
69
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
70
+
71
+ @property
72
+ def fraction(self) -> float | None:
73
+ if self.total in (None, 0):
74
+ return None
75
+ return max(0.0, min(1.0, self.completed / self.total))
76
+
77
+
78
+ class ProgressSink(Protocol):
79
+ """Consumer runners call this; widgets never need to know the runner type."""
80
+
81
+ def emit(self, event: ProgressEvent) -> None: ...
82
+
83
+
84
+ class RecordingProgressSink:
85
+ """Small sink used by tests, demos, and synchronous action execution."""
86
+
87
+ def __init__(self) -> None:
88
+ self.events: list[ProgressEvent] = []
89
+
90
+ def emit(self, event: ProgressEvent) -> None:
91
+ self.events.append(event)
92
+
93
+
94
+ @dataclass(frozen=True)
95
+ class JobSpec:
96
+ """What the shell needs to decide whether work may start."""
97
+
98
+ key: str
99
+ label: str
100
+ resources: frozenset[str] = frozenset()
101
+ effects: frozenset[str] = frozenset()
102
+ cancellation: CancellationMode = CancellationMode.UNSUPPORTED
103
+ foreground: bool = True
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class JobSnapshot:
108
+ """Immutable view of a shell job for policy, bars, and tests."""
109
+
110
+ job_id: str
111
+ spec: JobSpec
112
+ started_at: float
113
+ progress: ProgressEvent | None = None
114
+ cancelling: bool = False
115
+ metadata: Mapping[str, object] = field(default_factory=dict)
116
+
117
+ @property
118
+ def fraction(self) -> float | None:
119
+ return None if self.progress is None else self.progress.fraction
120
+
121
+ @property
122
+ def cancellable(self) -> bool:
123
+ return self.spec.cancellation is not CancellationMode.UNSUPPORTED
124
+
125
+ @property
126
+ def elapsed(self) -> float:
127
+ return max(0.0, monotonic() - self.started_at)
128
+
129
+ def describe(self) -> str:
130
+ if self.progress and self.progress.message:
131
+ return f"{self.spec.label}: {self.progress.message}"
132
+ if self.progress and self.progress.phase:
133
+ return f"{self.spec.label}: {self.progress.phase}"
134
+ return self.spec.label
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class BlockDecision:
139
+ """Policy answer for whether a candidate job can start."""
140
+
141
+ allowed: bool
142
+ reason: str | None = None
143
+
144
+ @classmethod
145
+ def allow(cls) -> BlockDecision:
146
+ return cls(True)
147
+
148
+ @classmethod
149
+ def block(cls, reason: str) -> BlockDecision:
150
+ return cls(False, reason)
151
+
152
+
153
+ class JobPolicy(Protocol):
154
+ """In-process gate for shell jobs."""
155
+
156
+ def can_start(self, candidate: JobSpec, active: Sequence[JobSnapshot]) -> BlockDecision: ...
157
+
158
+
159
+ class SingleForegroundJobPolicy:
160
+ """Default policy: one foreground job, plus no overlapping explicit resources."""
161
+
162
+ def can_start(self, candidate: JobSpec, active: Sequence[JobSnapshot]) -> BlockDecision:
163
+ if candidate.foreground and any(job.spec.foreground for job in active):
164
+ return BlockDecision.block("another foreground job is already running")
165
+ for job in active:
166
+ overlap = candidate.resources & job.spec.resources
167
+ if overlap:
168
+ resources = ", ".join(sorted(overlap))
169
+ return BlockDecision.block(f"resource already in use: {resources}")
170
+ return BlockDecision.allow()
171
+
172
+
173
+ class JobManager:
174
+ """Multi-job-capable manager with a conservative default policy.
175
+
176
+ The manager does not execute work itself. Textual workers, threads, or synchronous test
177
+ harnesses own execution and report lifecycle changes back here.
178
+ """
179
+
180
+ def __init__(self, policy: JobPolicy | None = None) -> None:
181
+ self.policy = policy or SingleForegroundJobPolicy()
182
+ self._active: dict[str, tuple[JobSnapshot, ThreadCancellationToken]] = {}
183
+
184
+ @property
185
+ def active(self) -> tuple[JobSnapshot, ...]:
186
+ return tuple(snapshot for snapshot, _token in self._active.values())
187
+
188
+ @property
189
+ def foreground(self) -> JobSnapshot | None:
190
+ return next((job for job in self.active if job.spec.foreground), None)
191
+
192
+ def can_start(self, spec: JobSpec) -> BlockDecision:
193
+ return self.policy.can_start(spec, self.active)
194
+
195
+ def start(
196
+ self,
197
+ spec: JobSpec,
198
+ *,
199
+ job_id: str | None = None,
200
+ metadata: Mapping[str, object] | None = None,
201
+ ) -> tuple[JobSnapshot, ThreadCancellationToken]:
202
+ decision = self.can_start(spec)
203
+ if not decision.allowed:
204
+ raise RuntimeError(decision.reason or "Job cannot start.")
205
+ resolved_id = job_id or f"{spec.key}:{uuid4().hex}"
206
+ token = ThreadCancellationToken()
207
+ snapshot = JobSnapshot(
208
+ job_id=resolved_id,
209
+ spec=spec,
210
+ started_at=monotonic(),
211
+ metadata=dict(metadata or {}),
212
+ )
213
+ self._active[resolved_id] = (snapshot, token)
214
+ return snapshot, token
215
+
216
+ def update(self, job_id: str, progress: ProgressEvent) -> JobSnapshot:
217
+ snapshot, token = self._active[job_id]
218
+ updated = JobSnapshot(
219
+ job_id=snapshot.job_id,
220
+ spec=snapshot.spec,
221
+ started_at=snapshot.started_at,
222
+ progress=progress,
223
+ cancelling=snapshot.cancelling,
224
+ metadata=snapshot.metadata,
225
+ )
226
+ self._active[job_id] = (updated, token)
227
+ return updated
228
+
229
+ def request_cancel(self, job_id: str) -> JobSnapshot:
230
+ snapshot, token = self._active[job_id]
231
+ token.request()
232
+ updated = JobSnapshot(
233
+ job_id=snapshot.job_id,
234
+ spec=snapshot.spec,
235
+ started_at=snapshot.started_at,
236
+ progress=snapshot.progress,
237
+ cancelling=True,
238
+ metadata=snapshot.metadata,
239
+ )
240
+ self._active[job_id] = (updated, token)
241
+ return updated
242
+
243
+ def complete(self, job_id: str) -> JobSnapshot:
244
+ snapshot, _token = self._active.pop(job_id)
245
+ return snapshot