groundskeeping 0.2.0__tar.gz → 0.2.2__tar.gz
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-0.2.0 → groundskeeping-0.2.2}/PKG-INFO +5 -2
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/README.md +4 -1
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/pages.md +8 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/app.py +18 -15
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/__init__.py +1 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/views.py +16 -1
- groundskeeping-0.2.2/src/groundskeeping/py.typed +1 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/widgets/wizard.py +39 -7
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/widgets/workbench.py +29 -7
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_app_smoke.py +150 -1
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_wizard_contracts.py +147 -1
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/ISSUE_TEMPLATE/not_working.yml +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/release-drafter.yml +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/workflows/ci.yml +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/workflows/docs.yml +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/workflows/merge.yml +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.github/workflows/publish.yml +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/.gitignore +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/CONTRIBUTING.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/actions.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/actions.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/app.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/configurator.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/jobs.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/pages.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/telemetry.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/views.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/widgets.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/api/wizards.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/configuration.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/contributing.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/index.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/ownership.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/quickstart.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/static/images/demo-layout-example.png +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/telemetry.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/docs/wizards.md +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/examples/demo_app.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/mkdocs.yml +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/pyproject.toml +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/__init__.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/configurator/__init__.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/configurator/adapter.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/configurator/models.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/actions.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/jobs.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/pages.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/telemetry.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/contracts/wizards.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/demo.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/navigation.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/__init__.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/providers/__init__.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/providers/fake.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/runtime.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/theme.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/themes/groundskeeping.tcss +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/widgets/__init__.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/widgets/configurator.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/widgets/primitives.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/widgets/telemetry.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_actions_and_jobs.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_configurator_adapter.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_dependency_boundaries.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_navigation_contracts.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/tests/test_telemetry_core.py +0 -0
- {groundskeeping-0.2.0 → groundskeeping-0.2.2}/uv.lock +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: groundskeeping
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: Reusable Textual operator shell, workbench, configuration browser, and telemetry primitives.
|
|
5
5
|
Author: UNSW CAVA platform tooling
|
|
6
6
|
Requires-Python: >=3.12
|
|
@@ -75,6 +75,9 @@ OperatorApp(spec).run()
|
|
|
75
75
|
|
|
76
76
|
Groundskeeping owns the frame around those pages. `DatabaseSetupPage`,
|
|
77
77
|
`EmbeddingsSetupPage`, and the services behind them stay in the application.
|
|
78
|
+
Use `workbench_labels` on `OperatorAppSpec` when the shared pane chrome needs different
|
|
79
|
+
language, such as naming the upper-right pane **Checks** instead of **Rows**. Page navigation
|
|
80
|
+
and view contracts still supply their own content titles.
|
|
78
81
|
|
|
79
82
|
The default page surface is the workbench:
|
|
80
83
|
|
|
@@ -91,7 +94,7 @@ Read the screenshot from left to right:
|
|
|
91
94
|
| Top tabs | The registered pages in the app | `PageRoute`, `PageRegistration` |
|
|
92
95
|
| Left pane | Things the operator can move between inside the current page | `SectionNavigation` + `SectionItem`, or `CatalogueNavigation` + `CatalogueItem` |
|
|
93
96
|
| Upper-right pane | The main content for the selected section or catalogue item | `TableView`, `TreeView`, `EmptyView`, `LoadingView` |
|
|
94
|
-
| Lower-right pane | Extra context for the highlighted row or item | `KeyValueView`, `TextView` |
|
|
97
|
+
| Lower-right pane | Extra context for the highlighted row or item | `KeyValueView`, `TextView`, `TableView` |
|
|
95
98
|
| Buttons above the view | Commands for the current view only | `ViewAction`, routed to `action_selected` |
|
|
96
99
|
|
|
97
100
|
Pages choose `SectionNavigation` for peer areas or `CatalogueNavigation` for hierarchical
|
|
@@ -58,6 +58,9 @@ OperatorApp(spec).run()
|
|
|
58
58
|
|
|
59
59
|
Groundskeeping owns the frame around those pages. `DatabaseSetupPage`,
|
|
60
60
|
`EmbeddingsSetupPage`, and the services behind them stay in the application.
|
|
61
|
+
Use `workbench_labels` on `OperatorAppSpec` when the shared pane chrome needs different
|
|
62
|
+
language, such as naming the upper-right pane **Checks** instead of **Rows**. Page navigation
|
|
63
|
+
and view contracts still supply their own content titles.
|
|
61
64
|
|
|
62
65
|
The default page surface is the workbench:
|
|
63
66
|
|
|
@@ -74,7 +77,7 @@ Read the screenshot from left to right:
|
|
|
74
77
|
| Top tabs | The registered pages in the app | `PageRoute`, `PageRegistration` |
|
|
75
78
|
| Left pane | Things the operator can move between inside the current page | `SectionNavigation` + `SectionItem`, or `CatalogueNavigation` + `CatalogueItem` |
|
|
76
79
|
| Upper-right pane | The main content for the selected section or catalogue item | `TableView`, `TreeView`, `EmptyView`, `LoadingView` |
|
|
77
|
-
| Lower-right pane | Extra context for the highlighted row or item | `KeyValueView`, `TextView` |
|
|
80
|
+
| Lower-right pane | Extra context for the highlighted row or item | `KeyValueView`, `TextView`, `TableView` |
|
|
78
81
|
| Buttons above the view | Commands for the current view only | `ViewAction`, routed to `action_selected` |
|
|
79
82
|
|
|
80
83
|
Pages choose `SectionNavigation` for peer areas or `CatalogueNavigation` for hierarchical
|
|
@@ -23,6 +23,11 @@ Translate domain objects before they reach the workbench. The workbench understa
|
|
|
23
23
|
`SectionItem`, `CatalogueItem`, `TableView`, `TreeView`, and friends; it does not need to know
|
|
24
24
|
what a Groundworkers resource or `cava-nlp-shard` evaluation object is.
|
|
25
25
|
|
|
26
|
+
Use `OperatorAppSpec.workbench_labels` to rename shared pane chrome such as the result and
|
|
27
|
+
detail panel labels. Page-owned titles still live on navigation and view contracts: for
|
|
28
|
+
example, `SectionNavigation.title` names the left pane for a specific page, and
|
|
29
|
+
`TableView.title` names the current result content.
|
|
30
|
+
|
|
26
31
|
## Routing
|
|
27
32
|
|
|
28
33
|
`PageRoute` is the navigation identity for one page: a `key`, an operator-facing `label`, and
|
|
@@ -49,6 +54,9 @@ startup rather than when an operator clicks a tab.
|
|
|
49
54
|
Row events return to the active page. The workbench renders generic models; the page decides
|
|
50
55
|
what a highlighted row means.
|
|
51
56
|
|
|
57
|
+
Detail panes can render `TextView`, `KeyValueView`, or `TableView`. Use a detail `TableView`
|
|
58
|
+
when the selected item has its own repeated data, such as available LLM models for a provider.
|
|
59
|
+
|
|
52
60
|
If `landing_view` raises, the shell catches the exception and renders an `EmptyView`
|
|
53
61
|
explaining that the page could not be rendered. A page that cannot build its landing content
|
|
54
62
|
degrades to a message instead of taking down the app.
|
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from collections.abc import Mapping
|
|
6
6
|
from dataclasses import dataclass, field
|
|
7
|
-
from typing import ClassVar
|
|
7
|
+
from typing import ClassVar, cast
|
|
8
8
|
|
|
9
9
|
from rich.text import Text
|
|
10
10
|
from textual.app import App, ComposeResult
|
|
@@ -52,6 +52,7 @@ from groundskeeping.contracts.views import (
|
|
|
52
52
|
PageNavigation,
|
|
53
53
|
SectionItem,
|
|
54
54
|
SurfaceView,
|
|
55
|
+
WorkbenchLabels,
|
|
55
56
|
)
|
|
56
57
|
from groundskeeping.contracts.wizards import (
|
|
57
58
|
WizardController,
|
|
@@ -76,6 +77,7 @@ class OperatorAppSpec:
|
|
|
76
77
|
operation_policy: OperationPolicy = field(default_factory=AllowAllOperationPolicy)
|
|
77
78
|
job_policy: JobPolicy = field(default_factory=SingleForegroundJobPolicy)
|
|
78
79
|
result_presenter: ResultPresenter = field(default_factory=DefaultResultPresenter)
|
|
80
|
+
workbench_labels: WorkbenchLabels = field(default_factory=WorkbenchLabels)
|
|
79
81
|
default_page: str | None = None
|
|
80
82
|
metadata: Mapping[str, object] | None = None
|
|
81
83
|
|
|
@@ -157,7 +159,7 @@ class OperatorApp(App[None]):
|
|
|
157
159
|
self.sub_title = spec.subtitle or ""
|
|
158
160
|
self.jobs = JobManager(spec.job_policy)
|
|
159
161
|
self._active_page = spec.default_page or self.registry[0].key
|
|
160
|
-
self._workbench = Workbench()
|
|
162
|
+
self._workbench = Workbench(spec.workbench_labels)
|
|
161
163
|
self._surface = _WorkbenchSurface(self._workbench)
|
|
162
164
|
# Textual's App already owns an internal `_context()` method used during
|
|
163
165
|
# startup. Keep the page-facing context under a distinct name so the shell does
|
|
@@ -180,13 +182,11 @@ class OperatorApp(App[None]):
|
|
|
180
182
|
with Vertical(id="workspace"):
|
|
181
183
|
yield Label("", id="workspace-title")
|
|
182
184
|
for route in self.registry:
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
page.styles.max_height = 0
|
|
189
|
-
yield page
|
|
185
|
+
page_widget = self._page_widget(route.key)
|
|
186
|
+
page_widget.add_class("operator-page")
|
|
187
|
+
page_widget.styles.height = 0
|
|
188
|
+
page_widget.styles.max_height = 0
|
|
189
|
+
yield page_widget
|
|
190
190
|
yield self._workbench
|
|
191
191
|
yield Footer()
|
|
192
192
|
|
|
@@ -254,16 +254,13 @@ class OperatorApp(App[None]):
|
|
|
254
254
|
next_page = self._pages[page_key]
|
|
255
255
|
if previous is not None and previous is not next_page:
|
|
256
256
|
previous.deactivate(self._page_context)
|
|
257
|
-
|
|
258
|
-
previous.remove_class("-active")
|
|
257
|
+
self._page_widget(self._active_page).remove_class("-active")
|
|
259
258
|
self._active_page = page_key
|
|
260
|
-
|
|
261
|
-
if isinstance(page, Widget):
|
|
262
|
-
page.add_class("-active")
|
|
259
|
+
self._page_widget(page_key).add_class("-active")
|
|
263
260
|
tabs = self.query_one("#page-tabs", Tabs)
|
|
264
261
|
if tabs.active != page_key:
|
|
265
262
|
tabs.active = page_key
|
|
266
|
-
|
|
263
|
+
next_page.activate(self._page_context)
|
|
267
264
|
self._render_page(route)
|
|
268
265
|
|
|
269
266
|
def _render_page(self, route: PageRoute) -> None:
|
|
@@ -286,6 +283,12 @@ class OperatorApp(App[None]):
|
|
|
286
283
|
def _active_widget(self) -> OperatorPage:
|
|
287
284
|
return self._pages[self._active_page]
|
|
288
285
|
|
|
286
|
+
def _page_widget(self, page_key: str) -> Widget:
|
|
287
|
+
page = self._pages[page_key]
|
|
288
|
+
if not isinstance(page, Widget):
|
|
289
|
+
raise TypeError(f"Page {page_key!r} must be a Textual Widget.")
|
|
290
|
+
return cast(Widget, page)
|
|
291
|
+
|
|
289
292
|
def _select_navigation_item(self, item: NavigationItem) -> None:
|
|
290
293
|
self._active_widget().navigation_selected(item, self._page_context)
|
|
291
294
|
|
|
@@ -12,6 +12,21 @@ type ViewActionVariant = Literal["default", "primary", "success", "warning", "er
|
|
|
12
12
|
MAX_VIEW_ACTIONS = 6
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class WorkbenchLabels:
|
|
17
|
+
"""Operator-facing labels for reusable workbench chrome.
|
|
18
|
+
|
|
19
|
+
Page-owned content still supplies its own titles through navigation and view contracts.
|
|
20
|
+
These labels only cover structural defaults in the shared shell.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
navigation_panel: str = "Sections"
|
|
24
|
+
result_panel: str = "Rows"
|
|
25
|
+
detail_panel: str = "Detail"
|
|
26
|
+
initial_result_summary: str = "Select a section to inspect it."
|
|
27
|
+
key_value_columns: tuple[str, str] = ("Field", "Value")
|
|
28
|
+
|
|
29
|
+
|
|
15
30
|
class SemanticStatus(StrEnum):
|
|
16
31
|
"""Small health/status vocabulary understood by shared widgets."""
|
|
17
32
|
|
|
@@ -160,5 +175,5 @@ class KeyValueView:
|
|
|
160
175
|
title: str = "Detail"
|
|
161
176
|
|
|
162
177
|
|
|
163
|
-
type DetailView = TextView | KeyValueView
|
|
178
|
+
type DetailView = TextView | KeyValueView | TableView
|
|
164
179
|
type SurfaceView = TableView | TreeView | EmptyView | LoadingView
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -14,10 +14,12 @@ from textual.widgets import (
|
|
|
14
14
|
DataTable,
|
|
15
15
|
Input,
|
|
16
16
|
Label,
|
|
17
|
+
OptionList,
|
|
17
18
|
Select,
|
|
18
19
|
Static,
|
|
19
20
|
TextArea,
|
|
20
21
|
)
|
|
22
|
+
from textual.widgets.option_list import Option
|
|
21
23
|
|
|
22
24
|
from groundskeeping.contracts.actions import FieldKind, FieldSpec
|
|
23
25
|
from groundskeeping.contracts.wizards import (
|
|
@@ -42,6 +44,7 @@ class WizardScreen(ModalScreen[WizardResult]):
|
|
|
42
44
|
self._snapshot: WizardSnapshot | None = None
|
|
43
45
|
self._field_by_widget_id: dict[str, FieldSpec] = {}
|
|
44
46
|
self._choice_widget_id = "wizard-choice"
|
|
47
|
+
self._blank_choice_id = "__groundskeeping_blank_choice__"
|
|
45
48
|
|
|
46
49
|
def compose(self) -> ComposeResult:
|
|
47
50
|
with Vertical(id="wizard-frame"):
|
|
@@ -149,21 +152,45 @@ class WizardScreen(ModalScreen[WizardResult]):
|
|
|
149
152
|
for warning in step.review.warnings:
|
|
150
153
|
await body.mount(Static(f"Warning: {warning}", classes="wizard-warning"))
|
|
151
154
|
|
|
152
|
-
def _field_widget(
|
|
155
|
+
def _field_widget(
|
|
156
|
+
self, widget_id: str, field: FieldSpec, value: object
|
|
157
|
+
) -> Input | TextArea | Checkbox | OptionList:
|
|
153
158
|
disabled = field.disabled or field.read_only
|
|
154
159
|
if field.kind is FieldKind.BOOLEAN:
|
|
155
160
|
return Checkbox(value=bool(value if value is not None else field.default), id=widget_id, disabled=disabled)
|
|
156
161
|
if field.kind is FieldKind.CHOICE:
|
|
157
|
-
options = tuple((choice.label, choice.value) for choice in field.choices)
|
|
158
162
|
current = value if value is not None else field.default
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
+
options = []
|
|
164
|
+
if not field.required:
|
|
165
|
+
options.append(
|
|
166
|
+
Option(field.placeholder or "No selection", id=self._blank_choice_id)
|
|
167
|
+
)
|
|
168
|
+
options.extend(
|
|
169
|
+
Option(
|
|
170
|
+
choice.label
|
|
171
|
+
if choice.description is None
|
|
172
|
+
else f"{choice.label} — {choice.description}",
|
|
173
|
+
id=choice.value,
|
|
174
|
+
)
|
|
175
|
+
for choice in field.choices
|
|
176
|
+
)
|
|
177
|
+
if not options:
|
|
178
|
+
options.append(Option(field.placeholder or "No choices", disabled=True))
|
|
179
|
+
widget = OptionList(
|
|
180
|
+
*options,
|
|
163
181
|
id=widget_id,
|
|
164
|
-
allow_blank=not field.required,
|
|
165
182
|
disabled=disabled,
|
|
183
|
+
classes="wizard-choice-list",
|
|
166
184
|
)
|
|
185
|
+
widget.styles.height = min(max(len(options), 2), 8)
|
|
186
|
+
choice_ids = tuple(option.id for option in options)
|
|
187
|
+
if current is None and not field.required:
|
|
188
|
+
widget.highlighted = 0
|
|
189
|
+
elif current in choice_ids:
|
|
190
|
+
widget.highlighted = choice_ids.index(current)
|
|
191
|
+
elif field.required and field.choices:
|
|
192
|
+
widget.highlighted = 0
|
|
193
|
+
return widget
|
|
167
194
|
if field.kind is FieldKind.MULTILINE:
|
|
168
195
|
widget = TextArea("" if value is None else str(value), id=widget_id)
|
|
169
196
|
widget.read_only = disabled
|
|
@@ -238,6 +265,11 @@ def _widget_value(widget: object) -> object:
|
|
|
238
265
|
return widget.text
|
|
239
266
|
if isinstance(widget, Checkbox):
|
|
240
267
|
return widget.value
|
|
268
|
+
if isinstance(widget, OptionList):
|
|
269
|
+
if widget.highlighted is None:
|
|
270
|
+
return None
|
|
271
|
+
selected = widget.get_option_at_index(widget.highlighted)
|
|
272
|
+
return None if selected.id == "__groundskeeping_blank_choice__" else selected.id
|
|
241
273
|
if isinstance(widget, Select):
|
|
242
274
|
selected = widget.value
|
|
243
275
|
return None if selected is Select.BLANK else selected
|
|
@@ -29,6 +29,7 @@ from groundskeeping.contracts.views import (
|
|
|
29
29
|
TreeNode,
|
|
30
30
|
TreeView,
|
|
31
31
|
ViewAction,
|
|
32
|
+
WorkbenchLabels,
|
|
32
33
|
)
|
|
33
34
|
from groundskeeping.theme import node_label, status_style
|
|
34
35
|
from groundskeeping.widgets.primitives import EmptyState, LoadingState
|
|
@@ -37,8 +38,9 @@ from groundskeeping.widgets.primitives import EmptyState, LoadingState
|
|
|
37
38
|
class Workbench(Widget):
|
|
38
39
|
"""Shared page surface with navigation left and rows/detail right."""
|
|
39
40
|
|
|
40
|
-
def __init__(self) -> None:
|
|
41
|
+
def __init__(self, labels: WorkbenchLabels | None = None) -> None:
|
|
41
42
|
super().__init__(id="workbench")
|
|
43
|
+
self.labels = labels or WorkbenchLabels()
|
|
42
44
|
self._loading_timer: Timer | None = None
|
|
43
45
|
self._loading_frame = 0
|
|
44
46
|
self._loading_view: LoadingView | None = None
|
|
@@ -54,7 +56,7 @@ class Workbench(Widget):
|
|
|
54
56
|
with Horizontal(id="result-header"):
|
|
55
57
|
yield Static("", id="result-status")
|
|
56
58
|
yield Static(
|
|
57
|
-
|
|
59
|
+
self.labels.initial_result_summary, id="result-summary"
|
|
58
60
|
)
|
|
59
61
|
with Horizontal(id="result-actions"):
|
|
60
62
|
for index in range(MAX_VIEW_ACTIONS):
|
|
@@ -68,9 +70,9 @@ class Workbench(Widget):
|
|
|
68
70
|
yield DataTable(id="context-table")
|
|
69
71
|
|
|
70
72
|
def on_mount(self) -> None:
|
|
71
|
-
self.query_one("#catalogue-panel").border_title =
|
|
72
|
-
self.query_one("#result-panel").border_title =
|
|
73
|
-
self.query_one("#context-panel").border_title =
|
|
73
|
+
self.query_one("#catalogue-panel").border_title = self.labels.navigation_panel
|
|
74
|
+
self.query_one("#result-panel").border_title = self.labels.result_panel
|
|
75
|
+
self.query_one("#context-panel").border_title = self.labels.detail_panel
|
|
74
76
|
self.catalogue.show_root = False
|
|
75
77
|
self.catalogue.root.expand()
|
|
76
78
|
self.catalogue.styles.display = "none"
|
|
@@ -284,27 +286,47 @@ class Workbench(Widget):
|
|
|
284
286
|
self.query_one("#result-loading", LoadingState).hide_loading()
|
|
285
287
|
|
|
286
288
|
def show_detail(self, detail: DetailView) -> None:
|
|
289
|
+
panel = self.query_one("#context-panel")
|
|
290
|
+
panel.border_subtitle = ""
|
|
287
291
|
if isinstance(detail, TextView):
|
|
288
292
|
self.query_one("#context-table", DataTable).styles.display = "none"
|
|
289
293
|
context = self.query_one("#context", TextArea)
|
|
290
294
|
context.styles.display = "block"
|
|
291
295
|
context.load_text(detail.body)
|
|
296
|
+
panel.border_title = detail.title
|
|
292
297
|
return
|
|
293
298
|
if isinstance(detail, KeyValueView):
|
|
299
|
+
panel.border_title = detail.title
|
|
294
300
|
self.show_context_table(detail.rows)
|
|
295
301
|
return
|
|
302
|
+
if isinstance(detail, TableView):
|
|
303
|
+
self.show_detail_table(detail)
|
|
304
|
+
return
|
|
296
305
|
raise TypeError(f"Unsupported detail view: {type(detail).__name__}")
|
|
297
306
|
|
|
307
|
+
def show_detail_table(self, detail: TableView) -> None:
|
|
308
|
+
self.query_one("#context", TextArea).styles.display = "none"
|
|
309
|
+
table = self.query_one("#context-table", DataTable)
|
|
310
|
+
table.styles.display = "block"
|
|
311
|
+
table.clear(columns=True)
|
|
312
|
+
table.add_columns(*detail.columns)
|
|
313
|
+
for row in detail.rows:
|
|
314
|
+
table.add_row(*row.cells, key=row.key)
|
|
315
|
+
self.query_one("#context-panel").border_title = detail.title
|
|
316
|
+
self.query_one("#context-panel").border_subtitle = f"{len(detail.rows)} rows"
|
|
317
|
+
|
|
298
318
|
def show_context_table(
|
|
299
319
|
self,
|
|
300
320
|
rows: Iterable[tuple[str, str]],
|
|
301
321
|
*,
|
|
302
|
-
columns: tuple[str,
|
|
322
|
+
columns: tuple[str, ...] | None = None,
|
|
303
323
|
) -> None:
|
|
304
324
|
self.query_one("#context", TextArea).styles.display = "none"
|
|
305
325
|
table = self.query_one("#context-table", DataTable)
|
|
306
326
|
table.styles.display = "block"
|
|
307
327
|
table.clear(columns=True)
|
|
308
|
-
table.add_columns(
|
|
328
|
+
table.add_columns(
|
|
329
|
+
*(self.labels.key_value_columns if columns is None else columns)
|
|
330
|
+
)
|
|
309
331
|
for label, value in rows:
|
|
310
332
|
table.add_row(label, value)
|
|
@@ -3,22 +3,28 @@ from __future__ import annotations
|
|
|
3
3
|
import asyncio
|
|
4
4
|
from dataclasses import replace
|
|
5
5
|
|
|
6
|
+
import pytest
|
|
6
7
|
from textual.widget import Widget
|
|
7
|
-
from textual.widgets import Button
|
|
8
|
+
from textual.widgets import Button, DataTable
|
|
8
9
|
|
|
9
10
|
from groundskeeping.app import OperatorApp, OperatorAppSpec
|
|
10
11
|
from groundskeeping.contracts import (
|
|
11
12
|
MAX_VIEW_ACTIONS,
|
|
12
13
|
EmptyView,
|
|
14
|
+
KeyValueView,
|
|
13
15
|
NavigationItem,
|
|
14
16
|
PageContext,
|
|
15
17
|
PageRegistration,
|
|
16
18
|
PageRoute,
|
|
17
19
|
SectionNavigation,
|
|
18
20
|
SurfaceView,
|
|
21
|
+
TableRow,
|
|
22
|
+
TableView,
|
|
23
|
+
TextView,
|
|
19
24
|
ViewAction,
|
|
20
25
|
WizardResult,
|
|
21
26
|
WizardResultStatus,
|
|
27
|
+
WorkbenchLabels,
|
|
22
28
|
)
|
|
23
29
|
from groundskeeping.demo import build_demo_spec
|
|
24
30
|
|
|
@@ -60,6 +66,35 @@ class _CountingPage(Widget):
|
|
|
60
66
|
return None
|
|
61
67
|
|
|
62
68
|
|
|
69
|
+
class _NonWidgetPage:
|
|
70
|
+
def __init__(self, route: PageRoute) -> None:
|
|
71
|
+
self.route = route
|
|
72
|
+
|
|
73
|
+
def activate(self, context: PageContext) -> None:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
def deactivate(self, context: PageContext) -> None:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def build_navigation(self, context: PageContext) -> SectionNavigation:
|
|
80
|
+
return SectionNavigation(items=())
|
|
81
|
+
|
|
82
|
+
def landing_view(self, context: PageContext) -> SurfaceView:
|
|
83
|
+
return EmptyView(title=self.route.label, message="render")
|
|
84
|
+
|
|
85
|
+
def navigation_selected(self, item: NavigationItem, context: PageContext) -> None:
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def action_selected(self, action_key: str, context: PageContext) -> None:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
def row_highlighted(self, row_key: str, context: PageContext) -> None:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def row_selected(self, row_key: str, context: PageContext) -> None:
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
63
98
|
def test_demo_app_enters_textual_startup_context() -> None:
|
|
64
99
|
async def run() -> None:
|
|
65
100
|
app = OperatorApp(build_demo_spec())
|
|
@@ -91,6 +126,25 @@ def test_demo_app_enters_textual_startup_context() -> None:
|
|
|
91
126
|
asyncio.run(run())
|
|
92
127
|
|
|
93
128
|
|
|
129
|
+
def test_operator_app_rejects_non_widget_pages() -> None:
|
|
130
|
+
async def run() -> None:
|
|
131
|
+
route = PageRoute("setup", "Setup", "Setup page")
|
|
132
|
+
app = OperatorApp(
|
|
133
|
+
OperatorAppSpec(
|
|
134
|
+
app_id="non-widget-test",
|
|
135
|
+
title="Non Widget Test",
|
|
136
|
+
subtitle=None,
|
|
137
|
+
pages=(PageRegistration(route, lambda context: _NonWidgetPage(route)),),
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
with pytest.raises(TypeError, match="must be a Textual Widget"):
|
|
142
|
+
async with app.run_test():
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
asyncio.run(run())
|
|
146
|
+
|
|
147
|
+
|
|
94
148
|
def test_single_page_hides_tabs_without_removing_page_registry() -> None:
|
|
95
149
|
async def run() -> None:
|
|
96
150
|
demo = build_demo_spec()
|
|
@@ -109,6 +163,46 @@ def test_single_page_hides_tabs_without_removing_page_registry() -> None:
|
|
|
109
163
|
asyncio.run(run())
|
|
110
164
|
|
|
111
165
|
|
|
166
|
+
def test_app_spec_can_override_workbench_chrome_labels() -> None:
|
|
167
|
+
async def run() -> None:
|
|
168
|
+
demo = build_demo_spec()
|
|
169
|
+
spec = replace(
|
|
170
|
+
demo,
|
|
171
|
+
workbench_labels=WorkbenchLabels(
|
|
172
|
+
navigation_panel="Setup areas",
|
|
173
|
+
result_panel="Checks",
|
|
174
|
+
detail_panel="Inspector",
|
|
175
|
+
initial_result_summary="Choose a setup area.",
|
|
176
|
+
key_value_columns=("Setting", "Value"),
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
app = OperatorApp(spec)
|
|
180
|
+
|
|
181
|
+
async with app.run_test() as pilot:
|
|
182
|
+
assert app.query_one("#result-panel").border_title == "Checks"
|
|
183
|
+
assert app.query_one("#context-panel").border_title == "Inspector"
|
|
184
|
+
|
|
185
|
+
app._workbench.show_navigation(SectionNavigation(items=()))
|
|
186
|
+
assert app.query_one("#catalogue-panel").border_title == "Sections"
|
|
187
|
+
|
|
188
|
+
app._workbench.show_context_table((("provider", "ollama"),))
|
|
189
|
+
await pilot.pause()
|
|
190
|
+
|
|
191
|
+
table = app.query_one("#context-table", DataTable)
|
|
192
|
+
assert tuple(str(column.label) for column in table.ordered_columns) == (
|
|
193
|
+
"Setting",
|
|
194
|
+
"Value",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
app._workbench.show_context_table((), columns=())
|
|
198
|
+
await pilot.pause()
|
|
199
|
+
|
|
200
|
+
assert len(table.ordered_columns) == 0
|
|
201
|
+
await pilot.press("q")
|
|
202
|
+
|
|
203
|
+
asyncio.run(run())
|
|
204
|
+
|
|
205
|
+
|
|
112
206
|
def test_wizard_result_refreshes_active_page_when_requested() -> None:
|
|
113
207
|
async def run() -> None:
|
|
114
208
|
active_route = PageRoute("setup", "Setup", "Setup page")
|
|
@@ -202,3 +296,58 @@ def test_workbench_clamps_extra_view_actions_without_crashing() -> None:
|
|
|
202
296
|
await pilot.press("q")
|
|
203
297
|
|
|
204
298
|
asyncio.run(run())
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def test_workbench_renders_table_view_in_detail_pane() -> None:
|
|
302
|
+
async def run() -> None:
|
|
303
|
+
app = OperatorApp(build_demo_spec())
|
|
304
|
+
|
|
305
|
+
async with app.run_test(size=(120, 40)) as pilot:
|
|
306
|
+
detail = TableView(
|
|
307
|
+
title="Model inventory",
|
|
308
|
+
columns=("Model", "Context", "Status"),
|
|
309
|
+
rows=tuple(
|
|
310
|
+
TableRow(
|
|
311
|
+
key=f"model-{index}",
|
|
312
|
+
cells=(f"model-{index}", f"{index * 1000}", "available"),
|
|
313
|
+
)
|
|
314
|
+
for index in range(12)
|
|
315
|
+
),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
app._workbench.show_detail(detail)
|
|
319
|
+
await pilot.pause()
|
|
320
|
+
|
|
321
|
+
context = app.query_one("#context")
|
|
322
|
+
table = app.query_one("#context-table", DataTable)
|
|
323
|
+
|
|
324
|
+
assert context.styles.display == "none"
|
|
325
|
+
assert table.styles.display == "block"
|
|
326
|
+
assert table.row_count == 12
|
|
327
|
+
assert len(table.ordered_columns) == 3
|
|
328
|
+
assert app.query_one("#context-panel").border_title == "Model inventory"
|
|
329
|
+
assert app.query_one("#context-panel").border_subtitle == "12 rows"
|
|
330
|
+
|
|
331
|
+
app._workbench.show_detail(TextView("Model notes", "Use qwen locally."))
|
|
332
|
+
await pilot.pause()
|
|
333
|
+
|
|
334
|
+
assert context.styles.display == "block"
|
|
335
|
+
assert table.styles.display == "none"
|
|
336
|
+
assert app.query_one("#context-panel").border_title == "Model notes"
|
|
337
|
+
assert app.query_one("#context-panel").border_subtitle == ""
|
|
338
|
+
|
|
339
|
+
app._workbench.show_detail(
|
|
340
|
+
KeyValueView(
|
|
341
|
+
rows=(("Provider", "Ollama"),),
|
|
342
|
+
title="Model facts",
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
await pilot.pause()
|
|
346
|
+
|
|
347
|
+
assert context.styles.display == "none"
|
|
348
|
+
assert table.styles.display == "block"
|
|
349
|
+
assert app.query_one("#context-panel").border_title == "Model facts"
|
|
350
|
+
assert app.query_one("#context-panel").border_subtitle == ""
|
|
351
|
+
await pilot.press("q")
|
|
352
|
+
|
|
353
|
+
asyncio.run(run())
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
|
+
from collections.abc import Mapping
|
|
4
5
|
|
|
5
6
|
import pytest
|
|
6
|
-
from textual.widgets import Button
|
|
7
|
+
from textual.widgets import Button, OptionList
|
|
7
8
|
|
|
8
9
|
from groundskeeping.app import OperatorApp
|
|
9
10
|
from groundskeeping.configurator import (
|
|
@@ -14,19 +15,117 @@ from groundskeeping.configurator import (
|
|
|
14
15
|
)
|
|
15
16
|
from groundskeeping.contracts import (
|
|
16
17
|
Choice,
|
|
18
|
+
ChoiceOption,
|
|
17
19
|
ChoiceStep,
|
|
20
|
+
FieldKind,
|
|
18
21
|
FieldSpec,
|
|
19
22
|
FormStep,
|
|
20
23
|
ReviewChange,
|
|
21
24
|
ReviewStep,
|
|
22
25
|
WizardDefinitionError,
|
|
26
|
+
WizardResult,
|
|
23
27
|
WizardResultStatus,
|
|
24
28
|
WizardReview,
|
|
25
29
|
WizardSnapshot,
|
|
26
30
|
WizardSpec,
|
|
31
|
+
WizardTransition,
|
|
27
32
|
validate_wizard_steps,
|
|
28
33
|
)
|
|
29
34
|
from groundskeeping.demo import ConfigPage, _DemoConfigWizardController, build_demo_spec
|
|
35
|
+
from groundskeeping.widgets.wizard import WizardScreen
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _DynamicChoiceWizard:
|
|
39
|
+
spec = WizardSpec(
|
|
40
|
+
key="model-picker",
|
|
41
|
+
title="Pick a model",
|
|
42
|
+
purpose="Choose one model from inventory.",
|
|
43
|
+
apply_label="Apply",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __init__(self) -> None:
|
|
47
|
+
self.submitted: dict[str, object] = {}
|
|
48
|
+
self.step = FormStep(
|
|
49
|
+
key="model",
|
|
50
|
+
title="Model inventory",
|
|
51
|
+
fields=(
|
|
52
|
+
FieldSpec(
|
|
53
|
+
key="model",
|
|
54
|
+
label="Model",
|
|
55
|
+
kind=FieldKind.CHOICE,
|
|
56
|
+
choices=tuple(
|
|
57
|
+
ChoiceOption(
|
|
58
|
+
value=f"model-{index:02d}",
|
|
59
|
+
label=f"Model {index:02d}",
|
|
60
|
+
description=f"{index * 1000} context",
|
|
61
|
+
)
|
|
62
|
+
for index in range(12)
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
self.review_step = ReviewStep("review", "Review", WizardReview())
|
|
68
|
+
|
|
69
|
+
def start(self) -> WizardSnapshot:
|
|
70
|
+
return WizardSnapshot(
|
|
71
|
+
spec=self.spec,
|
|
72
|
+
step=self.step,
|
|
73
|
+
step_index=0,
|
|
74
|
+
step_count=2,
|
|
75
|
+
values={"model": "model-04"},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def submit(self, values: Mapping[str, object]) -> WizardTransition:
|
|
79
|
+
self.submitted = dict(values)
|
|
80
|
+
return WizardTransition(
|
|
81
|
+
WizardSnapshot(
|
|
82
|
+
spec=self.spec,
|
|
83
|
+
step=self.review_step,
|
|
84
|
+
step_index=1,
|
|
85
|
+
step_count=2,
|
|
86
|
+
can_back=True,
|
|
87
|
+
can_next=False,
|
|
88
|
+
can_apply=True,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def back(self) -> WizardSnapshot:
|
|
93
|
+
return self.start()
|
|
94
|
+
|
|
95
|
+
def review(self) -> WizardTransition:
|
|
96
|
+
return self.submit(self.submitted)
|
|
97
|
+
|
|
98
|
+
def apply(self) -> WizardResult:
|
|
99
|
+
return WizardResult(WizardResultStatus.APPLIED, "Applied.")
|
|
100
|
+
|
|
101
|
+
def cancel(self) -> WizardResult:
|
|
102
|
+
return WizardResult(WizardResultStatus.CANCELLED, "Cancelled.")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class _NoChoiceWizard(_DynamicChoiceWizard):
|
|
106
|
+
def __init__(self) -> None:
|
|
107
|
+
super().__init__()
|
|
108
|
+
self.step = FormStep(
|
|
109
|
+
key="model",
|
|
110
|
+
title="Model inventory",
|
|
111
|
+
fields=(
|
|
112
|
+
FieldSpec(
|
|
113
|
+
key="model",
|
|
114
|
+
label="Model",
|
|
115
|
+
kind=FieldKind.CHOICE,
|
|
116
|
+
choices=(),
|
|
117
|
+
),
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
def start(self) -> WizardSnapshot:
|
|
122
|
+
return WizardSnapshot(
|
|
123
|
+
spec=self.spec,
|
|
124
|
+
step=self.step,
|
|
125
|
+
step_index=0,
|
|
126
|
+
step_count=2,
|
|
127
|
+
values={"model": None},
|
|
128
|
+
)
|
|
30
129
|
|
|
31
130
|
|
|
32
131
|
def test_wizard_definitions_require_unique_keys() -> None:
|
|
@@ -166,6 +265,53 @@ def test_demo_config_wizard_reports_stale_revision_conflict() -> None:
|
|
|
166
265
|
assert result.refresh_pages == frozenset({"config"})
|
|
167
266
|
|
|
168
267
|
|
|
268
|
+
def test_wizard_choice_field_renders_dynamic_options_inline() -> None:
|
|
269
|
+
async def run() -> None:
|
|
270
|
+
controller = _DynamicChoiceWizard()
|
|
271
|
+
app = OperatorApp(build_demo_spec())
|
|
272
|
+
|
|
273
|
+
async with app.run_test(size=(120, 40)) as pilot:
|
|
274
|
+
app.push_screen(WizardScreen(controller))
|
|
275
|
+
await pilot.pause()
|
|
276
|
+
|
|
277
|
+
picker = app.screen.query_one("#wizard-field-0", OptionList)
|
|
278
|
+
assert picker.option_count == 12
|
|
279
|
+
assert picker.region.height > 1
|
|
280
|
+
assert picker.highlighted == 4
|
|
281
|
+
|
|
282
|
+
picker.focus()
|
|
283
|
+
await pilot.press("down")
|
|
284
|
+
await pilot.pause()
|
|
285
|
+
|
|
286
|
+
assert picker.highlighted == 5
|
|
287
|
+
|
|
288
|
+
await pilot.click("#wizard-next")
|
|
289
|
+
await pilot.pause()
|
|
290
|
+
|
|
291
|
+
assert controller.submitted["model"] == "model-05"
|
|
292
|
+
await pilot.press("escape")
|
|
293
|
+
await pilot.press("q")
|
|
294
|
+
|
|
295
|
+
asyncio.run(run())
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def test_wizard_choice_field_handles_empty_required_choices() -> None:
|
|
299
|
+
async def run() -> None:
|
|
300
|
+
app = OperatorApp(build_demo_spec())
|
|
301
|
+
|
|
302
|
+
async with app.run_test(size=(120, 40)) as pilot:
|
|
303
|
+
app.push_screen(WizardScreen(_NoChoiceWizard()))
|
|
304
|
+
await pilot.pause()
|
|
305
|
+
|
|
306
|
+
picker = app.screen.query_one("#wizard-field-0", OptionList)
|
|
307
|
+
assert picker.option_count == 1
|
|
308
|
+
assert picker.highlighted == 0
|
|
309
|
+
await pilot.press("escape")
|
|
310
|
+
await pilot.press("q")
|
|
311
|
+
|
|
312
|
+
asyncio.run(run())
|
|
313
|
+
|
|
314
|
+
|
|
169
315
|
def test_config_draft_and_apply_intent_are_safe_and_revision_aware() -> None:
|
|
170
316
|
target = ConfigTarget(kind="database", key="metadata", title="metadata")
|
|
171
317
|
draft = ConfigDraft(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/providers/__init__.py
RENAMED
|
File without changes
|
{groundskeeping-0.2.0 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/providers/fake.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|