groundskeeping 0.2.1__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.
Files changed (71) hide show
  1. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/PKG-INFO +4 -1
  2. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/README.md +3 -0
  3. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/pages.md +5 -0
  4. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/app.py +18 -15
  5. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/contracts/__init__.py +1 -0
  6. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/contracts/views.py +15 -0
  7. groundskeeping-0.2.2/src/groundskeeping/py.typed +1 -0
  8. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/widgets/workbench.py +11 -7
  9. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/tests/test_app_smoke.py +90 -0
  10. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  11. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  12. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  13. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/.github/ISSUE_TEMPLATE/not_working.yml +0 -0
  14. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  15. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/.github/release-drafter.yml +0 -0
  16. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/.github/workflows/ci.yml +0 -0
  17. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/.github/workflows/docs.yml +0 -0
  18. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/.github/workflows/merge.yml +0 -0
  19. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/.github/workflows/publish.yml +0 -0
  20. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/.gitignore +0 -0
  21. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/CONTRIBUTING.md +0 -0
  22. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/actions.md +0 -0
  23. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/api/actions.md +0 -0
  24. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/api/app.md +0 -0
  25. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/api/configurator.md +0 -0
  26. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/api/jobs.md +0 -0
  27. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/api/pages.md +0 -0
  28. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/api/telemetry.md +0 -0
  29. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/api/views.md +0 -0
  30. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/api/widgets.md +0 -0
  31. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/api/wizards.md +0 -0
  32. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/configuration.md +0 -0
  33. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/contributing.md +0 -0
  34. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/index.md +0 -0
  35. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/ownership.md +0 -0
  36. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/quickstart.md +0 -0
  37. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/static/images/demo-layout-example.png +0 -0
  38. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/telemetry.md +0 -0
  39. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/docs/wizards.md +0 -0
  40. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/examples/demo_app.py +0 -0
  41. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/mkdocs.yml +0 -0
  42. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/pyproject.toml +0 -0
  43. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/__init__.py +0 -0
  44. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/configurator/__init__.py +0 -0
  45. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/configurator/adapter.py +0 -0
  46. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/configurator/models.py +0 -0
  47. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/contracts/actions.py +0 -0
  48. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/contracts/jobs.py +0 -0
  49. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/contracts/pages.py +0 -0
  50. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/contracts/telemetry.py +0 -0
  51. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/contracts/wizards.py +0 -0
  52. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/demo.py +0 -0
  53. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/navigation.py +0 -0
  54. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/__init__.py +0 -0
  55. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/providers/__init__.py +0 -0
  56. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/providers/fake.py +0 -0
  57. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/telemetry/runtime.py +0 -0
  58. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/theme.py +0 -0
  59. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/themes/groundskeeping.tcss +0 -0
  60. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/widgets/__init__.py +0 -0
  61. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/widgets/configurator.py +0 -0
  62. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/widgets/primitives.py +0 -0
  63. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/widgets/telemetry.py +0 -0
  64. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/src/groundskeeping/widgets/wizard.py +0 -0
  65. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/tests/test_actions_and_jobs.py +0 -0
  66. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/tests/test_configurator_adapter.py +0 -0
  67. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/tests/test_dependency_boundaries.py +0 -0
  68. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/tests/test_navigation_contracts.py +0 -0
  69. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/tests/test_telemetry_core.py +0 -0
  70. {groundskeeping-0.2.1 → groundskeeping-0.2.2}/tests/test_wizard_contracts.py +0 -0
  71. {groundskeeping-0.2.1 → 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.1
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
 
@@ -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
 
@@ -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
@@ -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
- page = self._pages[route.key]
184
- if not isinstance(page, Widget):
185
- raise TypeError(f"Page {route.key!r} must be a Textual Widget.")
186
- page.add_class("operator-page")
187
- page.styles.height = 0
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
- if isinstance(previous, Widget):
258
- previous.remove_class("-active")
257
+ self._page_widget(self._active_page).remove_class("-active")
259
258
  self._active_page = page_key
260
- page = next_page
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
- page.activate(self._page_context)
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
 
@@ -69,6 +69,7 @@ from groundskeeping.contracts.views import (
69
69
  TreeView,
70
70
  ViewAction,
71
71
  ViewActionVariant,
72
+ WorkbenchLabels,
72
73
  )
73
74
  from groundskeeping.contracts.wizards import (
74
75
  Choice,
@@ -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
 
@@ -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
- "Select a section to inspect it.", id="result-summary"
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 = "Sections"
72
- self.query_one("#result-panel").border_title = "Rows"
73
- self.query_one("#context-panel").border_title = "Detail"
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"
@@ -317,12 +319,14 @@ class Workbench(Widget):
317
319
  self,
318
320
  rows: Iterable[tuple[str, str]],
319
321
  *,
320
- columns: tuple[str, str] = ("Field", "Value"),
322
+ columns: tuple[str, ...] | None = None,
321
323
  ) -> None:
322
324
  self.query_one("#context", TextArea).styles.display = "none"
323
325
  table = self.query_one("#context-table", DataTable)
324
326
  table.styles.display = "block"
325
327
  table.clear(columns=True)
326
- table.add_columns(*columns)
328
+ table.add_columns(
329
+ *(self.labels.key_value_columns if columns is None else columns)
330
+ )
327
331
  for label, value in rows:
328
332
  table.add_row(label, value)
@@ -3,6 +3,7 @@ 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
8
  from textual.widgets import Button, DataTable
8
9
 
@@ -23,6 +24,7 @@ from groundskeeping.contracts import (
23
24
  ViewAction,
24
25
  WizardResult,
25
26
  WizardResultStatus,
27
+ WorkbenchLabels,
26
28
  )
27
29
  from groundskeeping.demo import build_demo_spec
28
30
 
@@ -64,6 +66,35 @@ class _CountingPage(Widget):
64
66
  return None
65
67
 
66
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
+
67
98
  def test_demo_app_enters_textual_startup_context() -> None:
68
99
  async def run() -> None:
69
100
  app = OperatorApp(build_demo_spec())
@@ -95,6 +126,25 @@ def test_demo_app_enters_textual_startup_context() -> None:
95
126
  asyncio.run(run())
96
127
 
97
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
+
98
148
  def test_single_page_hides_tabs_without_removing_page_registry() -> None:
99
149
  async def run() -> None:
100
150
  demo = build_demo_spec()
@@ -113,6 +163,46 @@ def test_single_page_hides_tabs_without_removing_page_registry() -> None:
113
163
  asyncio.run(run())
114
164
 
115
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
+
116
206
  def test_wizard_result_refreshes_active_page_when_requested() -> None:
117
207
  async def run() -> None:
118
208
  active_route = PageRoute("setup", "Setup", "Setup page")
File without changes