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,17 @@
1
+ """Reusable operator TUI shell contracts.
2
+
3
+ The package top level intentionally avoids importing Textual. Headless contracts,
4
+ configuration inspection, and telemetry sampling should be importable in worker and test
5
+ processes that do not construct an application.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from importlib.metadata import PackageNotFoundError, version
11
+
12
+ try:
13
+ __version__ = version("groundskeeping")
14
+ except PackageNotFoundError: # pragma: no cover - source checkout without install
15
+ __version__ = "0.0.0"
16
+
17
+ __all__ = ["__version__"]
groundskeeping/app.py ADDED
@@ -0,0 +1,233 @@
1
+ """Textual application shell composed from consumer-owned pages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from typing import ClassVar
8
+
9
+ from rich.text import Text
10
+ from textual.app import App, ComposeResult
11
+ from textual.binding import Binding
12
+ from textual.containers import Vertical
13
+ from textual.widget import Widget
14
+ from textual.widgets import DataTable, Footer, Header, Label, Tab, Tabs, Tree
15
+
16
+ from groundskeeping.contracts.actions import (
17
+ ActionRegistry,
18
+ AllowAllOperationPolicy,
19
+ DefaultResultPresenter,
20
+ OperationPolicy,
21
+ ResultPresenter,
22
+ )
23
+ from groundskeeping.contracts.jobs import (
24
+ JobManager,
25
+ JobPolicy,
26
+ SingleForegroundJobPolicy,
27
+ )
28
+ from groundskeeping.contracts.pages import (
29
+ NotifySeverity,
30
+ OperatorPage,
31
+ PageContext,
32
+ PageRegistration,
33
+ PageRegistry,
34
+ PageRoute,
35
+ PageSurfacePort,
36
+ )
37
+ from groundskeeping.contracts.views import (
38
+ CatalogueItem,
39
+ DetailView,
40
+ EmptyView,
41
+ SurfaceView,
42
+ )
43
+ from groundskeeping.navigation import SurfaceLease
44
+ from groundskeeping.theme import GROUNDSKEEPING_THEME
45
+ from groundskeeping.widgets import Workbench
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class OperatorAppSpec:
50
+ """Immutable composition root for a Groundskeeping application."""
51
+
52
+ app_id: str
53
+ title: str
54
+ subtitle: str | None
55
+ pages: tuple[PageRegistration, ...]
56
+ actions: ActionRegistry = field(default_factory=lambda: ActionRegistry(()))
57
+ operation_policy: OperationPolicy = field(default_factory=AllowAllOperationPolicy)
58
+ job_policy: JobPolicy = field(default_factory=SingleForegroundJobPolicy)
59
+ result_presenter: ResultPresenter = field(default_factory=DefaultResultPresenter)
60
+ default_page: str | None = None
61
+ metadata: Mapping[str, object] | None = None
62
+
63
+ def validate(self) -> PageRegistry:
64
+ routes = tuple(registration.route for registration in self.pages)
65
+ registry = PageRegistry(routes)
66
+ if len({id(registration.factory) for registration in self.pages}) != len(self.pages):
67
+ raise ValueError("Page registrations must use distinct factories.")
68
+ ActionRegistry(tuple(self.actions), page_keys=registry.keys())
69
+ if self.default_page is not None:
70
+ registry.get(self.default_page)
71
+ return registry
72
+
73
+
74
+ class _WorkbenchSurface(PageSurfacePort):
75
+ def __init__(self, workbench: Workbench) -> None:
76
+ self._workbench = workbench
77
+ self._lease_by_page: dict[str, SurfaceLease] = {}
78
+ self._generation = 0
79
+
80
+ def show_catalogue(self, page_key: str, items: Sequence[CatalogueItem]) -> None:
81
+ self._workbench.populate_catalogue(items)
82
+ self._lease(page_key, "catalogue")
83
+
84
+ def show_view(self, page_key: str, view: SurfaceView) -> None:
85
+ self._workbench.show_surface(view)
86
+ self._lease(page_key, view.title)
87
+
88
+ def show_detail(self, page_key: str, detail: DetailView) -> None:
89
+ self._workbench.show_detail(detail)
90
+ self._lease(page_key, "detail")
91
+
92
+ def _lease(self, page_key: str, source_key: str) -> SurfaceLease:
93
+ self._generation += 1
94
+ lease = SurfaceLease(page_key=page_key, source_key=source_key, generation=self._generation)
95
+ self._lease_by_page[page_key] = lease
96
+ return lease
97
+
98
+
99
+ class _AppPageContext(PageContext):
100
+ def __init__(self, app: OperatorApp, surface: PageSurfacePort) -> None:
101
+ self._app = app
102
+ self._surface = surface
103
+
104
+ @property
105
+ def surface(self) -> PageSurfacePort:
106
+ return self._surface
107
+
108
+ def refresh_bindings(self) -> None:
109
+ self._app.refresh_bindings()
110
+
111
+ def request_navigation(self, page_key: str) -> None:
112
+ self._app.show_page(page_key)
113
+
114
+ def notify(self, message: str, *, severity: NotifySeverity = "information") -> None:
115
+ self._app.notify(message, severity=severity)
116
+
117
+
118
+ class OperatorApp(App[None]):
119
+ """Reusable application frame: header, tabs, mounted pages, and workbench."""
120
+
121
+ CSS_PATH: ClassVar[str] = "themes/groundskeeping.tcss"
122
+ BINDINGS: ClassVar[list[Binding]] = [
123
+ Binding("q", "quit", "Quit"),
124
+ ]
125
+
126
+ def __init__(self, spec: OperatorAppSpec) -> None:
127
+ self.spec = spec
128
+ self.registry = spec.validate()
129
+ super().__init__()
130
+ self.title = spec.title
131
+ self.sub_title = spec.subtitle or ""
132
+ self.jobs = JobManager(spec.job_policy)
133
+ self._active_page = spec.default_page or self.registry[0].key
134
+ self._workbench = Workbench()
135
+ self._surface = _WorkbenchSurface(self._workbench)
136
+ # Textual's App already owns an internal `_context()` method used during
137
+ # startup. Keep the page-facing context under a distinct name so the shell does
138
+ # not accidentally shadow framework internals.
139
+ self._page_context = _AppPageContext(self, self._surface)
140
+ self._pages: dict[str, OperatorPage] = {
141
+ registration.route.key: registration.factory(self._page_context)
142
+ for registration in spec.pages
143
+ }
144
+ self._catalogue_by_key: dict[str, CatalogueItem] = {}
145
+
146
+ def compose(self) -> ComposeResult:
147
+ yield Header(show_clock=True)
148
+ with Vertical(id="body"):
149
+ yield Tabs(
150
+ *(Tab(route.label, id=route.key) for route in self.registry),
151
+ active=self._active_page,
152
+ id="page-tabs",
153
+ )
154
+ with Vertical(id="workspace"):
155
+ yield Label("", id="workspace-title")
156
+ for route in self.registry:
157
+ page = self._pages[route.key]
158
+ if not isinstance(page, Widget):
159
+ raise TypeError(f"Page {route.key!r} must be a Textual Widget.")
160
+ page.add_class("operator-page")
161
+ yield page
162
+ yield self._workbench
163
+ yield Footer()
164
+
165
+ def on_mount(self) -> None:
166
+ self.register_theme(GROUNDSKEEPING_THEME)
167
+ self.theme = GROUNDSKEEPING_THEME.name
168
+ self.show_page(self._active_page)
169
+
170
+ def on_tabs_tab_activated(self, event: Tabs.TabActivated) -> None:
171
+ if event.tabs.id == "page-tabs" and event.tab.id:
172
+ self.show_page(event.tab.id)
173
+
174
+ def on_tree_node_selected(self, event: Tree.NodeSelected[CatalogueItem]) -> None:
175
+ if event.control.id != "catalogue":
176
+ return
177
+ data = event.node.data
178
+ if isinstance(data, CatalogueItem):
179
+ self._select_catalogue_item(data)
180
+
181
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
182
+ if event.data_table.id == "result-table":
183
+ self._active_widget().row_highlighted(str(event.row_key.value), self._page_context)
184
+
185
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
186
+ if event.data_table.id == "result-table":
187
+ self._active_widget().row_selected(str(event.row_key.value), self._page_context)
188
+
189
+ def show_page(self, page_key: str) -> None:
190
+ route = self.registry.get(page_key)
191
+ previous = self._pages.get(self._active_page)
192
+ next_page = self._pages[page_key]
193
+ if previous is not None and previous is not next_page:
194
+ previous.deactivate(self._page_context)
195
+ if isinstance(previous, Widget):
196
+ previous.remove_class("-active")
197
+ self._active_page = page_key
198
+ page = next_page
199
+ if isinstance(page, Widget):
200
+ page.add_class("-active")
201
+ tabs = self.query_one("#page-tabs", Tabs)
202
+ if tabs.active != page_key:
203
+ tabs.active = page_key
204
+ page.activate(self._page_context)
205
+ self._render_page(route)
206
+
207
+ def _render_page(self, route: PageRoute) -> None:
208
+ heading = Text(route.label, style="bold")
209
+ heading.append(" - ", style="grey62")
210
+ heading.append(route.purpose, style="grey62")
211
+ self.query_one("#workspace-title", Label).update(heading)
212
+ page = self._active_widget()
213
+ items = tuple(page.build_catalogue(self._page_context))
214
+ self._catalogue_by_key = {item.key: item for item in self._walk_catalogue(items)}
215
+ self._workbench.populate_catalogue(items)
216
+ try:
217
+ view = page.landing_view(self._page_context)
218
+ except Exception as exc: # noqa: BLE001
219
+ view = EmptyView(title=route.label, message=f"Unable to render page: {exc}")
220
+ self._workbench.show_surface(view)
221
+
222
+ def _active_widget(self) -> OperatorPage:
223
+ return self._pages[self._active_page]
224
+
225
+ def _select_catalogue_item(self, item: CatalogueItem) -> None:
226
+ self._active_widget().catalogue_selected(item, self._page_context)
227
+
228
+ def _walk_catalogue(self, items: tuple[CatalogueItem, ...]) -> tuple[CatalogueItem, ...]:
229
+ walked: list[CatalogueItem] = []
230
+ for item in items:
231
+ walked.append(item)
232
+ walked.extend(self._walk_catalogue(item.children))
233
+ return tuple(walked)
@@ -0,0 +1,18 @@
1
+ """Read-only configuration inspection models and adapters."""
2
+
3
+ from groundskeeping.configurator.adapter import (
4
+ NativeConfigResourceAdapter,
5
+ OAConfiguratorAdapter,
6
+ )
7
+ from groundskeeping.configurator.models import (
8
+ ConfigApplyIntent,
9
+ ConfigDiff,
10
+ ConfigDiffEntry,
11
+ ConfigDraft,
12
+ ConfigResourceAdapter,
13
+ ConfigSectionView,
14
+ ConfigTarget,
15
+ ConfiguratorSnapshot,
16
+ EffectRef,
17
+ RedactedValue,
18
+ )
@@ -0,0 +1,227 @@
1
+ """Read-only adapter over the public shape of `oa-configurator` stack models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable, Mapping
6
+ from dataclasses import is_dataclass
7
+ from pathlib import Path
8
+
9
+ from groundskeeping.configurator.models import (
10
+ ConfigDiff,
11
+ ConfigDiffEntry,
12
+ ConfigDraft,
13
+ ConfigSectionView,
14
+ ConfigTarget,
15
+ ConfiguratorSnapshot,
16
+ RedactedValue,
17
+ )
18
+ from groundskeeping.contracts.actions import FieldSpec, ValidationIssue
19
+ from groundskeeping.contracts.views import SemanticStatus, TreeNode, TreeView
20
+
21
+ _SECRET_FIELD_NAMES = frozenset({"password", "secret", "token", "api_key"})
22
+
23
+
24
+ class OAConfiguratorAdapter:
25
+ """Build safe, read-only views from public `oa-configurator` model objects.
26
+
27
+ The adapter is deliberately structural: tests and demos can pass fakes, while real
28
+ consumers pass `StackConfig` and `PackageConfigBase` instances from `oa-configurator`.
29
+ Editable drafts and persistence are intentionally left for a later phase that can use
30
+ a public revision-aware mutation API.
31
+ """
32
+
33
+ def snapshot(
34
+ self,
35
+ stack_config: object,
36
+ *,
37
+ config_path: str | Path | None = None,
38
+ package_configs: Iterable[object] = (),
39
+ title: str = "Stack configuration",
40
+ ) -> ConfiguratorSnapshot:
41
+ profile = self._string_attr(stack_config, "active_profile", "profile")
42
+ sections = (
43
+ self._mapping_section("database", "Databases", getattr(stack_config, "databases", None)),
44
+ self._mapping_section("resource", "Resources", getattr(stack_config, "resources", None)),
45
+ self._mapping_section("profile", "Profiles", getattr(stack_config, "profiles", None)),
46
+ self._mapping_section("alias", "Aliases", getattr(stack_config, "aliases", None)),
47
+ self._package_section(tuple(package_configs)),
48
+ )
49
+ return ConfiguratorSnapshot(
50
+ title=title,
51
+ profile=profile,
52
+ path=str(config_path) if config_path is not None else self._string_attr(stack_config, "path"),
53
+ sections=tuple(section for section in sections if section is not None),
54
+ )
55
+
56
+ def as_tree_view(self, snapshot: ConfiguratorSnapshot) -> TreeView:
57
+ rows = tuple(self._section_to_node(section) for section in snapshot.sections)
58
+ details = []
59
+ if snapshot.path:
60
+ details.append(f"path: {snapshot.path}")
61
+ if snapshot.profile:
62
+ details.append(f"profile: {snapshot.profile}")
63
+ return TreeView(
64
+ title=snapshot.title,
65
+ message="; ".join(details) if details else "read-only inspection",
66
+ status=SemanticStatus.INFO,
67
+ rows=rows,
68
+ )
69
+
70
+ def diff(
71
+ self,
72
+ draft: ConfigDraft,
73
+ *,
74
+ sensitive_fields: frozenset[str] = frozenset(),
75
+ ) -> ConfigDiff:
76
+ """Build a redacted structural diff for confirmation surfaces."""
77
+ fields = sorted(set(draft.original_fields) | set(draft.candidate_fields))
78
+ entries: list[ConfigDiffEntry] = []
79
+ for field in fields:
80
+ before = draft.original_fields.get(field)
81
+ after = draft.candidate_fields.get(field)
82
+ if before == after:
83
+ continue
84
+ sensitive = field in sensitive_fields or isinstance(before, RedactedValue) or isinstance(after, RedactedValue)
85
+ entries.append(
86
+ ConfigDiffEntry(
87
+ field=field,
88
+ before=RedactedValue() if sensitive else before,
89
+ after=RedactedValue() if sensitive else after,
90
+ sensitive=sensitive,
91
+ )
92
+ )
93
+ return ConfigDiff(target=draft.target, entries=tuple(entries))
94
+
95
+ def _mapping_section(
96
+ self,
97
+ kind: str,
98
+ title: str,
99
+ values: object,
100
+ ) -> ConfigSectionView | None:
101
+ mapping = self._as_mapping(values)
102
+ if not mapping:
103
+ return None
104
+ children = tuple(
105
+ ConfigSectionView(
106
+ target=ConfigTarget(kind=kind, key=str(key), title=str(key)),
107
+ fields=self._safe_fields(value),
108
+ )
109
+ for key, value in sorted(mapping.items(), key=lambda item: str(item[0]))
110
+ )
111
+ return ConfigSectionView(
112
+ target=ConfigTarget(kind=f"{kind}-group", key=kind, title=title),
113
+ fields={"count": len(children)},
114
+ children=children,
115
+ )
116
+
117
+ def _package_section(self, package_configs: tuple[object, ...]) -> ConfigSectionView | None:
118
+ if not package_configs:
119
+ return None
120
+ children = []
121
+ for package_config in package_configs:
122
+ key = self._string_attr(package_config, "package_key", "package_name", "name")
123
+ title = key or type(package_config).__name__
124
+ children.append(
125
+ ConfigSectionView(
126
+ target=ConfigTarget(kind="package", key=title, title=title),
127
+ fields=self._safe_fields(package_config),
128
+ )
129
+ )
130
+ return ConfigSectionView(
131
+ target=ConfigTarget(kind="package-group", key="packages", title="Packages"),
132
+ fields={"count": len(children)},
133
+ children=tuple(children),
134
+ )
135
+
136
+ def _section_to_node(self, section: ConfigSectionView) -> TreeNode:
137
+ return TreeNode(
138
+ label=section.target.title,
139
+ status=section.target.status,
140
+ fields=section.fields,
141
+ children=tuple(self._section_to_node(child) for child in section.children),
142
+ )
143
+
144
+ def _safe_fields(self, value: object) -> Mapping[str, object]:
145
+ raw = self._object_mapping(value)
146
+ safe: dict[str, object] = {}
147
+ for key, item in raw.items():
148
+ name = str(key)
149
+ if name.lower() in _SECRET_FIELD_NAMES:
150
+ safe[name] = RedactedValue()
151
+ elif self._looks_scalar(item):
152
+ safe[name] = item
153
+ else:
154
+ safe[name] = self._summarize(item)
155
+ return safe
156
+
157
+ def _object_mapping(self, value: object) -> Mapping[str, object]:
158
+ mapping = self._as_mapping(value)
159
+ if mapping:
160
+ return {str(key): item for key, item in mapping.items()}
161
+ model_dump = getattr(value, "model_dump", None)
162
+ if callable(model_dump):
163
+ dumped = model_dump()
164
+ if isinstance(dumped, Mapping):
165
+ return {str(key): item for key, item in dumped.items()}
166
+ if is_dataclass(value):
167
+ return {
168
+ key: getattr(value, key)
169
+ for key in getattr(value, "__dataclass_fields__", {})
170
+ }
171
+ if hasattr(value, "__dict__"):
172
+ return {
173
+ key: item
174
+ for key, item in vars(value).items()
175
+ if not key.startswith("_")
176
+ }
177
+ return {"value": value}
178
+
179
+ def _as_mapping(self, value: object) -> Mapping[object, object]:
180
+ if isinstance(value, Mapping):
181
+ items: dict[object, object] = {key: item for key, item in value.items()}
182
+ return items
183
+ return {}
184
+
185
+ def _looks_scalar(self, value: object) -> bool:
186
+ return value is None or isinstance(value, str | int | float | bool | RedactedValue)
187
+
188
+ def _summarize(self, value: object) -> str:
189
+ mapping = self._as_mapping(value)
190
+ if mapping:
191
+ return f"{len(mapping)} entries"
192
+ if isinstance(value, (list, tuple, set, frozenset)):
193
+ return f"{len(value)} items"
194
+ return type(value).__name__
195
+
196
+ def _string_attr(self, value: object, *names: str) -> str | None:
197
+ for name in names:
198
+ item = getattr(value, name, None)
199
+ if item is not None:
200
+ return str(item)
201
+ return None
202
+
203
+
204
+ class NativeConfigResourceAdapter:
205
+ """Fallback adapter for ordinary configuration sections.
206
+
207
+ Consumers can register exact adapters for richer resource semantics. This fallback is
208
+ intentionally plain: it offers display fields and validates nothing beyond the model
209
+ layer that `oa-configurator` will run during a real apply.
210
+ """
211
+
212
+ key = "native"
213
+
214
+ def supports(self, target: ConfigTarget) -> bool:
215
+ return True
216
+
217
+ def describe(self, target: ConfigTarget) -> ConfigSectionView:
218
+ return ConfigSectionView(target=target)
219
+
220
+ def fields(self, target: ConfigTarget) -> tuple[FieldSpec, ...]:
221
+ return ()
222
+
223
+ def validate(self, draft: ConfigDraft) -> tuple[ValidationIssue, ...]:
224
+ return ()
225
+
226
+ def post_apply_effects(self, draft: ConfigDraft) -> tuple[str, ...]:
227
+ return ()
@@ -0,0 +1,110 @@
1
+ """Textual-free models for presenting stack configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Protocol
8
+
9
+ from groundskeeping.contracts.actions import FieldSpec, ValidationIssue
10
+ from groundskeeping.contracts.views import SemanticStatus
11
+
12
+ EffectRef = str
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class RedactedValue:
17
+ """Marker used when a known secret must not enter ordinary view models."""
18
+
19
+ label: str = "<redacted>"
20
+
21
+ def __str__(self) -> str:
22
+ return self.label
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ConfigTarget:
27
+ """One selectable configuration target."""
28
+
29
+ kind: str
30
+ key: str
31
+ title: str
32
+ status: SemanticStatus = SemanticStatus.INFO
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ConfigSectionView:
37
+ """Read-only view of one configuration section."""
38
+
39
+ target: ConfigTarget
40
+ fields: Mapping[str, object] = field(default_factory=dict)
41
+ children: tuple[ConfigSectionView, ...] = ()
42
+ notes: tuple[str, ...] = ()
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class ConfiguratorSnapshot:
47
+ """Read-only snapshot of an effective `oa-configurator` stack."""
48
+
49
+ title: str
50
+ profile: str | None
51
+ path: str | None
52
+ sections: tuple[ConfigSectionView, ...]
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class ConfigDraft:
57
+ """Candidate edit against one configuration target.
58
+
59
+ The expected revision is opaque on purpose. `oa-configurator` owns how a real file or
60
+ in-memory stack is fingerprinted; the widget only carries the token back when asking
61
+ a public mutation service to apply the draft.
62
+ """
63
+
64
+ target: ConfigTarget
65
+ original_fields: Mapping[str, object]
66
+ candidate_fields: Mapping[str, object]
67
+ expected_revision: str | None = None
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ConfigDiffEntry:
72
+ field: str
73
+ before: object
74
+ after: object
75
+ sensitive: bool = False
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class ConfigDiff:
80
+ target: ConfigTarget
81
+ entries: tuple[ConfigDiffEntry, ...]
82
+
83
+ @property
84
+ def changed(self) -> bool:
85
+ return bool(self.entries)
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class ConfigApplyIntent:
90
+ """UI-level request to apply a validated draft through `oa-configurator`."""
91
+
92
+ draft: ConfigDraft
93
+ diff: ConfigDiff
94
+ effects: tuple[EffectRef, ...] = ()
95
+
96
+
97
+ class ConfigResourceAdapter(Protocol):
98
+ """Consumer extension point for richer resource-specific presentation."""
99
+
100
+ key: str
101
+
102
+ def supports(self, target: ConfigTarget) -> bool: ...
103
+
104
+ def describe(self, target: ConfigTarget) -> ConfigSectionView: ...
105
+
106
+ def fields(self, target: ConfigTarget) -> tuple[FieldSpec, ...]: ...
107
+
108
+ def validate(self, draft: ConfigDraft) -> tuple[ValidationIssue, ...]: ...
109
+
110
+ def post_apply_effects(self, draft: ConfigDraft) -> tuple[EffectRef, ...]: ...
@@ -0,0 +1,57 @@
1
+ """Public headless contracts for composing a Groundskeeping application."""
2
+
3
+ from groundskeeping.contracts.actions import (
4
+ ActionContext,
5
+ ActionOutcome,
6
+ ActionRegistry,
7
+ ActionSpec,
8
+ AllowAllOperationPolicy,
9
+ ChoiceOption,
10
+ Confirmation,
11
+ DefaultResultPresenter,
12
+ ExecutionKind,
13
+ FieldKind,
14
+ FieldSpec,
15
+ OperationPolicy,
16
+ ResultPresenter,
17
+ ValidationIssue,
18
+ run_action_sync,
19
+ )
20
+ from groundskeeping.contracts.jobs import (
21
+ BlockDecision,
22
+ CancellationMode,
23
+ CancellationRequested,
24
+ CancellationToken,
25
+ JobManager,
26
+ JobPolicy,
27
+ JobSnapshot,
28
+ JobSpec,
29
+ ProgressEvent,
30
+ ProgressSink,
31
+ RecordingProgressSink,
32
+ SingleForegroundJobPolicy,
33
+ ThreadCancellationToken,
34
+ )
35
+ from groundskeeping.contracts.pages import (
36
+ NotifySeverity,
37
+ OperatorPage,
38
+ PageContext,
39
+ PageRegistration,
40
+ PageRegistry,
41
+ PageRoute,
42
+ PageSurfacePort,
43
+ )
44
+ from groundskeeping.contracts.views import (
45
+ CatalogueItem,
46
+ DetailView,
47
+ EmptyView,
48
+ KeyValueView,
49
+ LoadingView,
50
+ SemanticStatus,
51
+ SurfaceView,
52
+ TableRow,
53
+ TableView,
54
+ TextView,
55
+ TreeNode,
56
+ TreeView,
57
+ )