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.
- groundskeeping/__init__.py +17 -0
- groundskeeping/app.py +233 -0
- groundskeeping/configurator/__init__.py +18 -0
- groundskeeping/configurator/adapter.py +227 -0
- groundskeeping/configurator/models.py +110 -0
- groundskeeping/contracts/__init__.py +57 -0
- groundskeeping/contracts/actions.py +360 -0
- groundskeeping/contracts/jobs.py +245 -0
- groundskeeping/contracts/pages.py +112 -0
- groundskeeping/contracts/views.py +111 -0
- groundskeeping/demo.py +255 -0
- groundskeeping/navigation.py +21 -0
- groundskeeping/telemetry/__init__.py +10 -0
- groundskeeping/telemetry/contracts.py +17 -0
- groundskeeping/telemetry/models.py +55 -0
- groundskeeping/telemetry/providers/__init__.py +3 -0
- groundskeeping/telemetry/providers/fake.py +80 -0
- groundskeeping/telemetry/runtime.py +72 -0
- groundskeeping/theme.py +67 -0
- groundskeeping/themes/groundskeeping.tcss +148 -0
- groundskeeping/widgets/__init__.py +4 -0
- groundskeeping/widgets/configurator.py +39 -0
- groundskeeping/widgets/primitives.py +50 -0
- groundskeeping/widgets/telemetry.py +26 -0
- groundskeeping/widgets/workbench.py +236 -0
- groundskeeping-0.1.0.dist-info/METADATA +133 -0
- groundskeeping-0.1.0.dist-info/RECORD +29 -0
- groundskeeping-0.1.0.dist-info/WHEEL +4 -0
- groundskeeping-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -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
|
+
)
|