qapu-cli 0.76.0__tar.gz → 0.78.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.78.0}/PKG-INFO +2 -2
  2. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/README.md +1 -1
  3. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/pyproject.toml +1 -1
  4. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/command_packages.py +3 -0
  5. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/main.py +58 -4
  6. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/equipment_form.py +35 -0
  7. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/infrastructure.py +7 -3
  8. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli.egg-info/PKG-INFO +2 -2
  9. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli.egg-info/SOURCES.txt +1 -0
  10. qapu_cli-0.78.0/tests/test_tui_electrical_parent.py +62 -0
  11. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/__init__.py +0 -0
  12. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/client.py +0 -0
  13. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/__init__.py +0 -0
  14. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/app.py +0 -0
  15. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/app.tcss +0 -0
  16. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/data.py +0 -0
  17. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/rendering.py +0 -0
  18. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/__init__.py +0 -0
  19. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/catalog.py +0 -0
  20. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/catalog_form.py +0 -0
  21. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/catalog_picker.py +0 -0
  22. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/confirm.py +0 -0
  23. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/dashboard.py +0 -0
  24. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/device_authorization.py +0 -0
  25. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/device_edit.py +0 -0
  26. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/device_list.py +0 -0
  27. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/group_detail.py +0 -0
  28. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/group_form.py +0 -0
  29. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/screens/group_list.py +0 -0
  30. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/widgets/__init__.py +0 -0
  31. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/tui/widgets/device_table.py +0 -0
  32. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli/visibility.py +0 -0
  33. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli.egg-info/dependency_links.txt +0 -0
  34. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli.egg-info/entry_points.txt +0 -0
  35. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli.egg-info/requires.txt +0 -0
  36. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/qapu_cli.egg-info/top_level.txt +0 -0
  37. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/setup.cfg +0 -0
  38. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/tests/test_command_packages_complete.py +0 -0
  39. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/tests/test_login.py +0 -0
  40. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/tests/test_logout.py +0 -0
  41. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/tests/test_tui_authorization.py +0 -0
  42. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/tests/test_tui_catalog.py +0 -0
  43. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/tests/test_tui_device_edit.py +0 -0
  44. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/tests/test_tui_groups.py +0 -0
  45. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/tests/test_tui_infrastructure.py +0 -0
  46. {qapu_cli-0.76.0 → qapu_cli-0.78.0}/tests/test_tui_smoke.py +0 -0
  47. {qapu_cli-0.76.0 → qapu_cli-0.78.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.78.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.78.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"
@@ -200,8 +200,11 @@ COMMAND_REQUIRED_PACKAGES: dict[str, frozenset[str]] = {
200
200
  "score_watch": frozenset({"scores:read", "telemetry:read"}),
201
201
  "setting_get": frozenset({"settings:read"}),
202
202
  "setting_update": frozenset({"settings:write"}),
203
+ "sim_add": frozenset({"hardware-catalog:write"}),
204
+ "sim_delete": frozenset({"hardware-catalog:write"}),
203
205
  "sim_get": frozenset({"hardware-catalog:read"}),
204
206
  "sim_list": frozenset({"hardware-catalog:read"}),
207
+ "sim_update": frozenset({"hardware-catalog:write"}),
205
208
  "site_add": frozenset({"infrastructure:write"}),
206
209
  "site_context": frozenset({"infrastructure:read"}),
207
210
  "site_delete": frozenset({"infrastructure:write"}),
@@ -4277,7 +4277,6 @@ def sim_list(
4277
4277
  as_json: bool = typer.Option(False, "--json"),
4278
4278
  operator_id: Optional[int] = typer.Option(None, "--operator-id"),
4279
4279
  active: Optional[bool] = typer.Option(None, "--active/--inactive"),
4280
- online: Optional[bool] = typer.Option(None, "--online/--offline"),
4281
4280
  limit: Optional[int] = typer.Option(None, "--limit"),
4282
4281
  ):
4283
4282
  """List the SIM inventory (GET /hermes/sims)."""
@@ -4286,15 +4285,13 @@ def sim_list(
4286
4285
  params["operator_id"] = operator_id
4287
4286
  if active is not None:
4288
4287
  params["active"] = active
4289
- if online is not None:
4290
- params["online"] = online
4291
4288
  items: list[dict[str, Any]] = client.get("/hermes/sims", params=params)
4292
4289
  if limit is not None:
4293
4290
  items = items[:limit]
4294
4291
  if as_json:
4295
4292
  typer.echo(json.dumps(items, indent=2))
4296
4293
  return
4297
- _print_paginated(items, _simple_table_builder([("ICCID", "ICCID"), ("NUMBER", "Number"), ("OPERATOR", "Operator.MNC_Operator"), ("STATIC_IP", "Static_IP"), ("STATUS", "Status")]), limit=limit, noun="SIM")
4294
+ _print_paginated(items, _simple_table_builder([("ICCID", "ICCID"), ("NUMBER", "Number"), ("OPERATOR", "Operator.MNC_Brand"), ("APN", "APN"), ("INSTITUTIONAL", "Institutional"), ("STATIC_IP", "Static_IP"), ("STATUS", "Status")]), limit=limit, noun="SIM")
4298
4295
 
4299
4296
 
4300
4297
  # Get One SIM's Detail
@@ -4308,6 +4305,63 @@ def sim_get(iccid: str = typer.Argument(...), as_json: bool = typer.Option(False
4308
4305
  _print_detail(result)
4309
4306
 
4310
4307
 
4308
+ # SIM Body From CLI Options - Only What was Given
4309
+ def _sim_body(operator_id: Optional[int], number: Optional[str], static_ip: Optional[str], apn: Optional[str], institutional: Optional[bool], active: Optional[bool]) -> dict[str, Any]:
4310
+ body: dict[str, Any] = {}
4311
+ if operator_id is not None:
4312
+ body["Operator"] = {"ID": operator_id}
4313
+ for key, value in (("Number", number), ("Static_IP", static_ip), ("APN", apn), ("Institutional", institutional), ("Status", active)):
4314
+ if value is not None:
4315
+ body[key] = value
4316
+ return body
4317
+
4318
+
4319
+ # Add a SIM
4320
+ @sim_app.command("add")
4321
+ def sim_add(
4322
+ iccid: str = typer.Argument(..., help="ICCID (18-22 digits)."),
4323
+ operator_id: Optional[int] = typer.Option(None, "--operator-id", help="GSM operator ID (e.g. 28601 Turkcell); defaults to Unknown until the device reports it."),
4324
+ number: Optional[str] = typer.Option(None, "--number"),
4325
+ static_ip: Optional[str] = typer.Option(None, "--static-ip"),
4326
+ apn: Optional[str] = typer.Option(None, "--apn"),
4327
+ institutional: Optional[bool] = typer.Option(None, "--institutional/--not-institutional", help="SIM supplied by a customer institution."),
4328
+ active: Optional[bool] = typer.Option(None, "--active/--inactive"),
4329
+ ):
4330
+ """Add a SIM (POST /hermes/sims)."""
4331
+ result = client.post("/hermes/sims", json_body={"ICCID": iccid, **_sim_body(operator_id, number, static_ip, apn, institutional, active)})
4332
+ typer.echo(result.get("Message", result))
4333
+
4334
+
4335
+ # Update a SIM
4336
+ @sim_app.command("update")
4337
+ def sim_update(
4338
+ iccid: str = typer.Argument(...),
4339
+ operator_id: Optional[int] = typer.Option(None, "--operator-id"),
4340
+ number: Optional[str] = typer.Option(None, "--number"),
4341
+ static_ip: Optional[str] = typer.Option(None, "--static-ip"),
4342
+ apn: Optional[str] = typer.Option(None, "--apn"),
4343
+ institutional: Optional[bool] = typer.Option(None, "--institutional/--not-institutional"),
4344
+ active: Optional[bool] = typer.Option(None, "--active/--inactive"),
4345
+ ):
4346
+ """Update a SIM - only the options given change (PUT /hermes/sims/{iccid}). APN/operator are also refreshed from the device's own reports."""
4347
+ body = _sim_body(operator_id, number, static_ip, apn, institutional, active)
4348
+ if not body:
4349
+ typer.echo("Nothing to update - give at least one option.")
4350
+ raise typer.Exit(1)
4351
+ result = client.put(f"/hermes/sims/{iccid}", json_body=body)
4352
+ typer.echo(result.get("Message", result))
4353
+
4354
+
4355
+ # Delete a SIM - Refused by the API if a Device or any Stored Packet Uses it
4356
+ @sim_app.command("delete")
4357
+ def sim_delete(iccid: str = typer.Argument(...), yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt.")):
4358
+ """Delete an unused SIM (DELETE /hermes/sims/{iccid}). A SIM used by a device or any stored packet can't be deleted - deactivate it with `qapu sim update ICCID --inactive`."""
4359
+ if not yes and not typer.confirm(f"Delete SIM {iccid}?"):
4360
+ raise typer.Exit(0)
4361
+ result = client.delete(f"/hermes/sims/{iccid}")
4362
+ typer.echo(result.get("Message", result))
4363
+
4364
+
4311
4365
  # List Firmware Versions
4312
4366
  @firmware_app.command("list")
4313
4367
  def firmware_list(
@@ -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.78.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