qapu-cli 0.76.0__tar.gz → 0.77.0__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 (47) hide show
  1. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/PKG-INFO +2 -2
  2. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/README.md +1 -1
  3. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/pyproject.toml +1 -1
  4. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/equipment_form.py +35 -0
  5. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/infrastructure.py +7 -3
  6. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli.egg-info/PKG-INFO +2 -2
  7. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli.egg-info/SOURCES.txt +1 -0
  8. qapu_cli-0.77.0/tests/test_tui_electrical_parent.py +62 -0
  9. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/__init__.py +0 -0
  10. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/client.py +0 -0
  11. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/command_packages.py +0 -0
  12. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/main.py +0 -0
  13. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/__init__.py +0 -0
  14. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/app.py +0 -0
  15. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/app.tcss +0 -0
  16. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/data.py +0 -0
  17. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/rendering.py +0 -0
  18. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/__init__.py +0 -0
  19. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/catalog.py +0 -0
  20. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/catalog_form.py +0 -0
  21. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/catalog_picker.py +0 -0
  22. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/confirm.py +0 -0
  23. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/dashboard.py +0 -0
  24. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/device_authorization.py +0 -0
  25. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/device_edit.py +0 -0
  26. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/device_list.py +0 -0
  27. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/group_detail.py +0 -0
  28. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/group_form.py +0 -0
  29. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/screens/group_list.py +0 -0
  30. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/widgets/__init__.py +0 -0
  31. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/tui/widgets/device_table.py +0 -0
  32. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli/visibility.py +0 -0
  33. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli.egg-info/dependency_links.txt +0 -0
  34. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli.egg-info/entry_points.txt +0 -0
  35. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli.egg-info/requires.txt +0 -0
  36. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/qapu_cli.egg-info/top_level.txt +0 -0
  37. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/setup.cfg +0 -0
  38. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/tests/test_command_packages_complete.py +0 -0
  39. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/tests/test_login.py +0 -0
  40. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/tests/test_logout.py +0 -0
  41. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/tests/test_tui_authorization.py +0 -0
  42. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/tests/test_tui_catalog.py +0 -0
  43. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/tests/test_tui_device_edit.py +0 -0
  44. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/tests/test_tui_groups.py +0 -0
  45. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/tests/test_tui_infrastructure.py +0 -0
  46. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/tests/test_tui_smoke.py +0 -0
  47. {qapu_cli-0.76.0 → qapu_cli-0.77.0}/tests/test_visibility.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qapu-cli
3
- Version: 0.76.0
3
+ Version: 0.77.0
4
4
  Summary: CLI client for the Qapu API - built for the Hermes agent, but usable by anyone talking to api.ovoo.com.tr from outside the Swarm.
5
5
  Author: OVOO Technology
6
6
  Classifier: Programming Language :: Python :: 3
@@ -730,7 +730,7 @@ Live-tested: a real 404 (`synthesis get 99999`) rendered as a red panel with the
730
730
 
731
731
  Getting set up: `qapu login` (Username+Password, the default - prompts for whatever's missing), then `qapu auth-token create <label> --packages catalog:read,telemetry:read,...` (must be a subset of your own account's granted packages - ask an admin if a `403` says you're missing one) - that command offers to save the freshly-minted token as this machine's active one right there, since the raw value is only ever shown once. `qapu auth-token use <token>` saves one you already have (e.g. minted on another machine, or printed by an earlier run you didn't save at the time) without minting a new one. The saved value lives at `~/.config/qapu/credentials.json`'s `Hermes_API_Token` key (mode 600) - a SEPARATE key from `Access_Token` (the `qapu login`-issued JWT, deliberately scope-limited to token management only and not itself a valid Hermes credential) - `qapu logout` clears the login session but deliberately leaves a saved Hermes token alone, since it has its own independent expiry/revocation lifecycle. `qapu whoami` shows whether each of the two is currently configured.
732
732
 
733
- This is the exact same token `services/mcp`'s full-access instance and `services/cinga_ai` now use to reach Hermes too (forwarded/minted per-caller rather than a shared secret) - see those services' own READMEs.
733
+ This is the exact same token `services/mcp`'s full-access instance and `Cinga_AI/runtime/service` now use to reach Hermes too (forwarded/minted per-caller rather than a shared secret) - see those services' own READMEs.
734
734
 
735
735
  ## Running locally
736
736
 
@@ -713,7 +713,7 @@ Live-tested: a real 404 (`synthesis get 99999`) rendered as a red panel with the
713
713
 
714
714
  Getting set up: `qapu login` (Username+Password, the default - prompts for whatever's missing), then `qapu auth-token create <label> --packages catalog:read,telemetry:read,...` (must be a subset of your own account's granted packages - ask an admin if a `403` says you're missing one) - that command offers to save the freshly-minted token as this machine's active one right there, since the raw value is only ever shown once. `qapu auth-token use <token>` saves one you already have (e.g. minted on another machine, or printed by an earlier run you didn't save at the time) without minting a new one. The saved value lives at `~/.config/qapu/credentials.json`'s `Hermes_API_Token` key (mode 600) - a SEPARATE key from `Access_Token` (the `qapu login`-issued JWT, deliberately scope-limited to token management only and not itself a valid Hermes credential) - `qapu logout` clears the login session but deliberately leaves a saved Hermes token alone, since it has its own independent expiry/revocation lifecycle. `qapu whoami` shows whether each of the two is currently configured.
715
715
 
716
- This is the exact same token `services/mcp`'s full-access instance and `services/cinga_ai` now use to reach Hermes too (forwarded/minted per-caller rather than a shared secret) - see those services' own READMEs.
716
+ This is the exact same token `services/mcp`'s full-access instance and `Cinga_AI/runtime/service` now use to reach Hermes too (forwarded/minted per-caller rather than a shared secret) - see those services' own READMEs.
717
717
 
718
718
  ## Running locally
719
719
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "qapu-cli"
3
- version = "0.76.0"
3
+ version = "0.77.0"
4
4
  description = "CLI client for the Qapu API - built for the Hermes agent, but usable by anyone talking to api.ovoo.com.tr from outside the Swarm."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -24,6 +24,30 @@ from textual.screen import ModalScreen
24
24
  from textual.widgets import Button, Select, Static, Input
25
25
 
26
26
 
27
+ # Electrical Parent Choices (2026-10-21) - Every Item on the Same Site/Tree
28
+ # Except the Item Itself and Anything it (Transitively) Feeds, Since
29
+ # Picking One of Those Would Close a Loop. Pure, so it's Testable Without
30
+ # Textual; the API Refuses a Loop on its own Too.
31
+ def electrical_parent_candidates(items: list[dict[str, Any]], equipment_id: Optional[int] = None) -> list[tuple[str, Any]]:
32
+ fed_by: dict[Any, list[Any]] = {}
33
+ for item in items:
34
+ fed_by.setdefault(item.get("Electrical_Parent_ID"), []).append(item.get("ID"))
35
+ excluded: set = set()
36
+ if equipment_id is not None:
37
+ frontier = [equipment_id]
38
+ while frontier:
39
+ current = frontier.pop()
40
+ if current in excluded:
41
+ continue
42
+ excluded.add(current)
43
+ frontier.extend(fed_by.get(current, []))
44
+ return [
45
+ (f"#{item['ID']} {(item.get('Type') or {}).get('Name') or '?'}: {item.get('Name') or '-'}", item["ID"])
46
+ for item in items
47
+ if item.get("ID") is not None and item["ID"] not in excluded
48
+ ]
49
+
50
+
27
51
  class EquipmentFormScreen(ModalScreen[Optional[dict[str, Any]]]):
28
52
 
29
53
  def __init__(
@@ -32,10 +56,12 @@ class EquipmentFormScreen(ModalScreen[Optional[dict[str, Any]]]):
32
56
  controls_options: list[tuple[str, Any]],
33
57
  parent_equipment_id: Optional[int] = None,
34
58
  equipment: Optional[dict[str, Any]] = None,
59
+ electrical_parent_options: Optional[list[tuple[str, Any]]] = None,
35
60
  ) -> None:
36
61
  super().__init__()
37
62
  self.equipment_type_options = equipment_type_options
38
63
  self.controls_options = controls_options
64
+ self.electrical_parent_options = electrical_parent_options or []
39
65
  self.parent_equipment_id = parent_equipment_id
40
66
  self.equipment = equipment
41
67
  self.is_edit = equipment is not None
@@ -44,6 +70,9 @@ class EquipmentFormScreen(ModalScreen[Optional[dict[str, Any]]]):
44
70
  equipment = self.equipment or {}
45
71
  type_name = (equipment.get("Type") or {}).get("Name")
46
72
  current_controls = equipment.get("Controls_Equipment_ID")
73
+ current_electrical_parent = equipment.get("Electrical_Parent_ID")
74
+ if current_electrical_parent not in {value for _, value in self.electrical_parent_options}:
75
+ current_electrical_parent = None
47
76
  specs = equipment.get("Specs") or {}
48
77
 
49
78
  with VerticalScroll(id="equipment-form"):
@@ -63,6 +92,8 @@ class EquipmentFormScreen(ModalScreen[Optional[dict[str, Any]]]):
63
92
  yield Input(value=equipment.get("Serial_Number") or "", id="form-serial-number")
64
93
  yield Static("Besler/Sürer (Controls)")
65
94
  yield Select(self.controls_options, allow_blank=True, value=current_controls if current_controls is not None else Select.NULL, id="form-controls")
95
+ yield Static("Elektrik Beslemesi (Electrical Parent)")
96
+ yield Select(self.electrical_parent_options, allow_blank=True, value=current_electrical_parent if current_electrical_parent is not None else Select.NULL, id="form-electrical-parent")
66
97
  yield Static("Özellikler (Specs, JSON)")
67
98
  yield Input(value=json.dumps(specs, ensure_ascii=False) if specs else "", id="form-specs")
68
99
  yield Static("Not")
@@ -120,6 +151,10 @@ class EquipmentFormScreen(ModalScreen[Optional[dict[str, Any]]]):
120
151
  if controls_select.value is not Select.NULL:
121
152
  body["Controls_Equipment_ID"] = controls_select.value
122
153
 
154
+ electrical_parent_select = self.query_one("#form-electrical-parent", Select)
155
+ if electrical_parent_select.value is not Select.NULL:
156
+ body["Electrical_Parent_ID"] = electrical_parent_select.value
157
+
123
158
  self.dismiss(body)
124
159
 
125
160
  # End of File
@@ -37,7 +37,7 @@ from textual.widgets.tree import TreeNode
37
37
  from qapu_cli import main as cli_main
38
38
  from qapu_cli.tui import data as tui_data
39
39
  from qapu_cli.tui.screens.confirm import ConfirmScreen
40
- from qapu_cli.tui.screens.equipment_form import EquipmentFormScreen
40
+ from qapu_cli.tui.screens.equipment_form import EquipmentFormScreen, electrical_parent_candidates
41
41
 
42
42
  # A Unique Sentinel, Distinct From None - MeasurementPointScreen's "İptal"
43
43
  # (Cancel the Whole Assign Action) vs. Picking "Bilinmiyor"/Leaving it
@@ -217,6 +217,8 @@ class InfrastructureScreen(Screen):
217
217
  model_str = " ".join(part for part in (model.get("Manufacturer_Name"), model.get("Name")) if part) or "-"
218
218
  controls_id = item.get("Controls_Equipment_ID")
219
219
  controls_str = f"#{controls_id}" if controls_id is not None else "-"
220
+ electrical_parent_id = item.get("Electrical_Parent_ID")
221
+ enclosure_id = item.get("Enclosure_ID")
220
222
  return "\n".join([
221
223
  f"[bold]{(item.get('Type') or {}).get('Name') or '-'}[/bold] (#{item.get('ID')})",
222
224
  f"Ad: {item.get('Name') or '-'}",
@@ -225,6 +227,8 @@ class InfrastructureScreen(Screen):
225
227
  f"Seri No: {item.get('Serial_Number') or '-'}",
226
228
  f"Özellikler: {specs or '-'}",
227
229
  f"Besler/Sürer: {controls_str}",
230
+ f"Elektrik Beslemesi: {f'#{electrical_parent_id}' if electrical_parent_id is not None else '-'}",
231
+ f"Bulunduğu Pano: {f'#{enclosure_id}' if enclosure_id is not None else '-'}",
228
232
  f"Not: {item.get('Notes') or '-'}",
229
233
  ])
230
234
 
@@ -258,7 +262,7 @@ class InfrastructureScreen(Screen):
258
262
  return
259
263
  type_options = [(t.get("Name") or f"#{t.get('ID')}", t.get("ID")) for t in equipment_types if t.get("ID") is not None]
260
264
  parent_id = self._current_item.get("ID") if self._current_item else None
261
- body = await self.app.push_screen_wait(EquipmentFormScreen(type_options, self._controls_options(), parent_equipment_id=parent_id))
265
+ body = await self.app.push_screen_wait(EquipmentFormScreen(type_options, self._controls_options(), parent_equipment_id=parent_id, electrical_parent_options=electrical_parent_candidates(list(self._equipment_by_id.values()))))
262
266
  if body is None:
263
267
  return
264
268
  result, error = await self.run_worker(partial(tui_data.add_equipment, body), thread=True).wait()
@@ -276,7 +280,7 @@ class InfrastructureScreen(Screen):
276
280
  self.app.notify("Önce bir ekipman seçin.", severity="warning", title="Altyapı")
277
281
  return
278
282
  item = self._current_item
279
- body = await self.app.push_screen_wait(EquipmentFormScreen([], self._controls_options(exclude_id=item.get("ID")), equipment=item))
283
+ body = await self.app.push_screen_wait(EquipmentFormScreen([], self._controls_options(exclude_id=item.get("ID")), equipment=item, electrical_parent_options=electrical_parent_candidates(list(self._equipment_by_id.values()), item.get("ID"))))
280
284
  if body is None:
281
285
  return
282
286
  result, error = await self.run_worker(partial(tui_data.update_equipment, item["ID"], body), thread=True).wait()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qapu-cli
3
- Version: 0.76.0
3
+ Version: 0.77.0
4
4
  Summary: CLI client for the Qapu API - built for the Hermes agent, but usable by anyone talking to api.ovoo.com.tr from outside the Swarm.
5
5
  Author: OVOO Technology
6
6
  Classifier: Programming Language :: Python :: 3
@@ -730,7 +730,7 @@ Live-tested: a real 404 (`synthesis get 99999`) rendered as a red panel with the
730
730
 
731
731
  Getting set up: `qapu login` (Username+Password, the default - prompts for whatever's missing), then `qapu auth-token create <label> --packages catalog:read,telemetry:read,...` (must be a subset of your own account's granted packages - ask an admin if a `403` says you're missing one) - that command offers to save the freshly-minted token as this machine's active one right there, since the raw value is only ever shown once. `qapu auth-token use <token>` saves one you already have (e.g. minted on another machine, or printed by an earlier run you didn't save at the time) without minting a new one. The saved value lives at `~/.config/qapu/credentials.json`'s `Hermes_API_Token` key (mode 600) - a SEPARATE key from `Access_Token` (the `qapu login`-issued JWT, deliberately scope-limited to token management only and not itself a valid Hermes credential) - `qapu logout` clears the login session but deliberately leaves a saved Hermes token alone, since it has its own independent expiry/revocation lifecycle. `qapu whoami` shows whether each of the two is currently configured.
732
732
 
733
- This is the exact same token `services/mcp`'s full-access instance and `services/cinga_ai` now use to reach Hermes too (forwarded/minted per-caller rather than a shared secret) - see those services' own READMEs.
733
+ This is the exact same token `services/mcp`'s full-access instance and `Cinga_AI/runtime/service` now use to reach Hermes too (forwarded/minted per-caller rather than a shared secret) - see those services' own READMEs.
734
734
 
735
735
  ## Running locally
736
736
 
@@ -38,6 +38,7 @@ tests/test_logout.py
38
38
  tests/test_tui_authorization.py
39
39
  tests/test_tui_catalog.py
40
40
  tests/test_tui_device_edit.py
41
+ tests/test_tui_electrical_parent.py
41
42
  tests/test_tui_groups.py
42
43
  tests/test_tui_infrastructure.py
43
44
  tests/test_tui_smoke.py
@@ -0,0 +1,62 @@
1
+ # Electrical Parent in the Equipment Form (2026-10-21) - the candidate list
2
+ # never offers the item itself or anything it feeds, and the chosen value
3
+ # is sent as Electrical_Parent_ID. Same headless App.run_test() pattern as
4
+ # test_tui_infrastructure.py.
5
+
6
+ import pytest
7
+
8
+ from qapu_cli.tui.app import QapuTUIApp
9
+ from qapu_cli.tui.screens.equipment_form import EquipmentFormScreen, electrical_parent_candidates
10
+
11
+ BOX = {"ID": 17, "Type": {"Name": "Electric_Box"}, "Name": "Pano"}
12
+ MAIN = {"ID": 19, "Type": {"Name": "Main_Contactor"}, "Name": "Ana Kontaktör", "Electrical_Parent_ID": 17}
13
+ PUMP = {"ID": 18, "Type": {"Name": "Pump"}, "Name": "Pompa", "Electrical_Parent_ID": 19}
14
+ FUSE = {"ID": 28, "Type": {"Name": "Main_Fuse"}, "Name": "Sigorta", "Electrical_Parent_ID": 17}
15
+ ITEMS = [BOX, MAIN, PUMP, FUSE]
16
+
17
+
18
+ def _ids(options):
19
+ return [value for _, value in options]
20
+
21
+
22
+ def test_candidates_exclude_the_item_and_everything_it_feeds():
23
+ assert _ids(electrical_parent_candidates(ITEMS, 17)) == [] # the panel feeds everything here
24
+ assert _ids(electrical_parent_candidates(ITEMS, 19)) == [17, 28] # not itself, not the pump it feeds
25
+ assert _ids(electrical_parent_candidates(ITEMS, 18)) == [17, 19, 28] # a leaf can pick anything else
26
+
27
+
28
+ def test_candidates_for_a_new_item_offer_everything():
29
+ assert _ids(electrical_parent_candidates(ITEMS)) == [17, 19, 18, 28]
30
+
31
+
32
+ def test_candidates_survive_a_loop_already_in_the_data():
33
+ looped = [{"ID": 1, "Electrical_Parent_ID": 2}, {"ID": 2, "Electrical_Parent_ID": 1}, {"ID": 3}]
34
+ assert _ids(electrical_parent_candidates(looped, 1)) == [3]
35
+
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_form_sends_the_chosen_electrical_parent():
39
+ results = []
40
+ app = QapuTUIApp()
41
+ async with app.run_test() as pilot:
42
+ form = EquipmentFormScreen([], [], equipment=PUMP, electrical_parent_options=electrical_parent_candidates(ITEMS, 18))
43
+ await app.push_screen(form, results.append)
44
+ await pilot.pause(0.2)
45
+ from textual.widgets import Button, Select
46
+ select = app.screen.query_one("#form-electrical-parent", Select)
47
+ assert select.value == 19 # current value pre-selected
48
+ select.value = 17
49
+ app.screen.query_one("#form-save", Button).press()
50
+ await pilot.pause(0.2)
51
+ assert results and results[0]["Electrical_Parent_ID"] == 17
52
+
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_form_does_not_preselect_a_value_that_would_loop():
56
+ app = QapuTUIApp()
57
+ async with app.run_test() as pilot:
58
+ looped = dict(MAIN, Electrical_Parent_ID=18) # bad data: the contactor "fed" by its own pump
59
+ await app.push_screen(EquipmentFormScreen([], [], equipment=looped, electrical_parent_options=electrical_parent_candidates(ITEMS, 19)))
60
+ await pilot.pause(0.2)
61
+ from textual.widgets import Select
62
+ assert app.screen.query_one("#form-electrical-parent", Select).value is Select.NULL
File without changes
File without changes
File without changes
File without changes
File without changes