From 087fc76d55e4e7e6dab7ad30d0e1c83fbabdc248 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 18 Jun 2026 04:16:39 +0200 Subject: [PATCH 01/15] drafts --- .vscode/settings.json | 2 +- README.md | 1 + docs/domain-architecture.md | 548 ++++++++++++++++++ docs/inspect-app/README.md | 56 ++ docs/inspect-app/concepts.md | 330 +++++++++++ .../decisions/0001-api-paradigm.md | 47 ++ .../decisions/0002-loading-and-state.md | 57 ++ .../decisions/0003-websocket-subscriptions.md | 45 ++ .../decisions/0004-async-strategy.md | 49 ++ .../inspect-app/decisions/0005-e2e-testing.md | 38 ++ .../decisions/0006-commit-write-model.md | 229 ++++++++ docs/inspect-app/decisions/README.md | 49 ++ 12 files changed, 1450 insertions(+), 1 deletion(-) create mode 100644 docs/domain-architecture.md create mode 100644 docs/inspect-app/README.md create mode 100644 docs/inspect-app/concepts.md create mode 100644 docs/inspect-app/decisions/0001-api-paradigm.md create mode 100644 docs/inspect-app/decisions/0002-loading-and-state.md create mode 100644 docs/inspect-app/decisions/0003-websocket-subscriptions.md create mode 100644 docs/inspect-app/decisions/0004-async-strategy.md create mode 100644 docs/inspect-app/decisions/0005-e2e-testing.md create mode 100644 docs/inspect-app/decisions/0006-commit-write-model.md create mode 100644 docs/inspect-app/decisions/README.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 4f8d0bf..5bf2cbe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,7 +21,7 @@ "python.analysis.extraPaths": [ "${workspaceFolder}/.venv/*" ], - "python.languageServer": "Pylance", + "python.languageServer": "None", "python.analysis.addImport.exactMatchOnly": true, "python.analysis.autoFormatStrings": true, "python.analysis.fixAll": [ diff --git a/README.md b/README.md index 591a6a9..1bcc230 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ except Exception as e: - [Python Module Architecture](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/python-module-architecture.md) - [Driver Versioning](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/driver-versioning.md) - [Development and Release](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/development-and-release.md) +- [Inspect App — Architecture & Concept Docs](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/inspect-app/README.md) (planning) ## Feedback & Contributions diff --git a/docs/domain-architecture.md b/docs/domain-architecture.md new file mode 100644 index 0000000..a228d99 --- /dev/null +++ b/docs/domain-architecture.md @@ -0,0 +1,548 @@ +# Unified Domain Architecture (proposed) + +> Status: **Draft / Vision** · Last updated: 2026-06-18 +> +> This document proposes a **package-wide** re-think: present **one unified +> domain model** in which the user simply **creates devices, configures them, +> and connects them** — without ever needing to know about the `inventory`, +> `topology`, or `inspect` apps, which VideoIPath (VIP) store or endpoint is +> involved, or how the work is split across planes. +> +> It stands in deliberate contrast to the current +> [`python-module-architecture.md`](./python-module-architecture.md), whose +> stated goal is to *"maintain a structure similar to VideoIPath."* That goal is +> exactly what this proposal revisits. The VIP wire format and per-app research +> this builds on lives in [`inspect-app/`](./inspect-app/README.md) +> (`concepts.md` and the ADRs); those documents remain the **faithful map of the +> server**, while this one is the **map we want to present to users**. +> +> Nothing here is implemented yet. This is the target picture and the decisions +> required to get there. + +## 1. The idea in one sentence + +> The user works with a **network of devices and connections**. *Where* that +> lives in VideoIPath — Inventory onboarding, Topology placement, Inspect +> monitoring, which REST/RPC endpoint, which store — is an **implementation +> detail** the package owns, not the user. + +Today the user must learn the VIP product structure and drive it step by step: + +```python +# Today: the user orchestrates VIP's app/plane split by hand +staged = app.inventory.create_device(driver="com.nevion.NMOS_multidevice-0.1.0") +staged.configuration.address = "10.100.100.1" +device = app.inventory.add_device(staged) # plane 1: Inventory (RPC) + +topo = app.topology.get_device(device.device_id) # plane 2: Topology (nGraphElements) +topo.configuration.position_x = 100 +app.topology.update_device(topo) + +edges = app.topology.create_edges(...) # plane 2/3: edges +# ... monitor via the collector aggregate ... # plane 3: Inspect +``` + +The proposed surface: + +```python +# Proposed: one domain, the package orchestrates the planes +dev = app.add_device(driver="...", address="10.100.100.1", label="Cam-1", position=Position(x=100, y=200)) +app.connect_devices(dev.port("Eth 1.10"), other.port("Eth 1.11"), bandwidth=Gbps(10)) +``` + +## 2. Why a unified domain + +The package today is a **transparent client**: its public surface speaks VIP's +own vocabulary — `BaseDevice`, `IpVertex`, `UnidirectionalEdge`, +`nGraphElements`, `_rev`, `fromId::toId` — and exposes VIP's **app split** +(`inventory` / `topology` / `inspect`). The user must know *which VIP app does +what*: onboard in Inventory → place/connect in Topology → monitor in Inspect, +while Inspect writes land back in `nGraphElements`. + +This is awkward for reasons confirmed against a real server (see +[`inspect-app/concepts.md`](./inspect-app/concepts.md) and the captured +`collector` payload): + +1. **The app split is VIP's internal structure, not the user's mental model.** + "Onboard, then place, then connect, then monitor" is a property of VIP's + architecture. Users think "I have devices and I want to connect them." +2. **VIP already hides its own stores behind facades.** The `collector` + aggregate composes the raw stores server-side. Mirroring the raw stores in + Python re-exposes a detail the vendor themselves chose to hide. +3. **The wire format is unstable and version-gated** (the package version-gates + endpoints and carries many `[VERIFY]` items). A transparent client passes + that instability straight through to users; one domain surface absorbs it in + a single place. +4. **One real device is fragmented across three models today** — + `InventoryDevice`, `TopologyDevice`, and the Inspect node — each a partial + view. A unified `Device` re-assembles the whole. +5. **The same entity has three+ wire representations** — the `collector` read + shape, the `updateTopology` delta, and the `nGraphElements` store. A + user-facing model that picks **one** domain representation and translates + internally is strictly simpler. + +This is a textbook case for an **anti-corruption layer (ACL)**: a stable domain +model with a hard boundary, behind which all VIP-specific translation lives. + +## 3. Verdict & guiding principle + +**Abstract VIP away — but as an explicit layered ACL, not a thin rename.** The +discipline: + +- The **domain layer never imports or returns a wire/app type.** Translation + lives only in the ACL. +- The domain model is **stable**; version churn is absorbed by ACL adapters, + selected by the existing version-check machinery — never by branching in user + code or in the domain types. +- We deliberately decide, per concept, **what to hide vs. what to expose** (§13). + Some semantics (commit, validation, conflict) are first-class domain + behaviour; others (edge pairing, key formats, namespaces, the app split) are + hidden. +- The existing apps remain the documented **escape hatch** for raw access — + keeping the change **additive and non-breaking**. + +> **What an ACL can and cannot hide.** Renaming fields (`unidirectionalEdge` → +> `Connection`) is trivial. Hiding *semantics* is the hard part and must be a +> conscious choice: a connection being two paired unidirectional edges (hide), +> commit validation that can fail after HTTP success (expose), optimistic +> concurrency via revisions (expose, but as an opaque token), driver-specific +> configuration schemas (cannot be hidden — see §12). A good ACL chooses; it +> does not pretend the semantics don't exist. + +## 4. Target architecture + +```text +┌────────────────────────────────────────────────────────────────┐ +│ Unified Domain API (NEW default surface) │ ← what users touch +│ app.network: Device, Port, Connection, Service, Status │ one model, one vocabulary +│ lifecycle: discover → onboard → place → connect → monitor │ stable, version-independent +├────────────────────────────────────────────────────────────────┤ +│ Orchestration + Anti-corruption layer │ ← sequences plane operations, +│ domain ⇄ {inventory RPC, topology PATCH, collector/updateTopo} │ translates & maps ids; no I/O state +├────────────────────────────────────────────────────────────────┤ +│ Existing apps (now the LOW-LEVEL layer / escape hatch) │ ← unchanged, still public +│ app.inventory · app.topology · app.inspect · app.profile · … │ faithful to VIP +├────────────────────────────────────────────────────────────────┤ +│ Connector (REST v2 + RPC) │ +└────────────────────────────────────────────────────────────────┘ +``` + +Key properties: + +- **`app.network` is the new default**; the per-app surfaces are demoted to a + documented **low-level layer / escape hatch**, not removed. +- **Orchestration is a core responsibility**: a single domain call may fan out + to **several planes in sequence** (e.g. `add_device` = Inventory RPC add → + optional Topology placement). +- The ACL is **pure translation** — no I/O, no transport state. It maps between + domain objects and wire DTOs, so the wire models can change shape without + touching the domain types. + +## 5. The device lifecycle + +The hardest part of a package-wide abstraction is owning the **onboarding +lifecycle**, which today is an explicit, ordered prerequisite chain (devices are +onboarded in Inventory first, only then placed/connected). + +Model the lifecycle explicitly on the domain `Device` so the user can reason +about *where* a device is without knowing *which app*: + +| Domain state | Meaning | VIP reality (hidden) | +| ------------ | ------- | -------------------- | +| `Discovered` | Auto-found, not yet managed | Inventory discovered devices | +| `Onboarded` | Known/credentialed, driver bound | Inventory `config/devman/devices` (RPC `/api/updateDevices`) | +| `Placed` | Positioned in the network graph | Topology `baseDevice` in `nGraphElements` | +| `Connected` | Has connections to other devices | `unidirectionalEdge` / `updateTopology` | +| `Monitored` | Live status/services available | `collector` aggregate + WebSocket | + +`add_device(...)` advances a device from nothing → `Onboarded` (and optionally +→ `Placed` in the same call). `connect(...)` advances to `Connected`. The user +sees one object moving through states; the package picks the endpoints. + +> **This is also where the abstraction is hardest** — see §12 (limits). Some +> lifecycle inputs (driver choice, credentials, per-driver custom settings) are +> genuinely device-specific and cannot be fully hidden. + +## 6. The unified domain model + +One `Device` re-assembles what is today three partial models. The whole +vertex/edge/element taxonomy collapses into a handful of nouns. These are +**proposals**, not final signatures: + +```python +# Domain model — intentionally NOT mirroring nGraphElements / collector DTOs. + +class Device: + id: DeviceId # opaque; internally "device34" / "virtual.3" + label: str + driver: DriverRef # required for onboarding (see §12) + connection: ConnectionInfo # address(es), credentials, driver custom settings + state: LifecycleState # §5 + position: Point | None # placement; hides maps[]/meta.coordinates/inspect_app_format + ports: list[Port] # capabilities (merges vertices + collector ports) + status: DeviceStatus # merges inventory status + collector node status + sync: SyncState # from nodeStatus.syncSeverity + tags: list[str] + +class Port: # hides ipVertex/codecVertex/genericVertex + vertexInfo single|double + id: PortId + label: str + direction: Direction # IN | OUT | BIDIRECTIONAL (hides .in/.out vertex split) + kind: PortKind # IP | CODEC | GENERIC + is_endpoint: bool + status: PortStatus + +class Connection: # ONE user-facing connection == the paired unidirectional edges + id: ConnectionId # "deviceA::deviceB" pair identity + a: PortRef + b: PortRef + forward: DirectionState # primary: bandwidth, status + reverse: DirectionState # secondary: bandwidth, status (may differ!) + redundancy: Redundancy + status: ConnectionStatus # rolled up from alarm/bandwidth/maintenance/ptp + +class Service: # a booking/path (collector inspect.paths) + id: ServiceId # "100001" + label: str # "Encoder 1.1 -> Decoder 1.5" + endpoints: tuple[Endpoint, Endpoint] + is_main: bool + status: ServiceStatus +``` + +Design principles: + +- **Opaque ids** (`DeviceId`, `PortId`, `ConnectionId`) are value objects, not + raw strings. They internally carry every wire form (§7). Users never construct + ids by string-mangling. +- **A `Connection` is singular** — the single most valuable abstraction. Hide + that an inter-device connection is two `unidirectionalEdge`s plus internal + fan-out. Carry **per-direction state** because the two directions are + genuinely asymmetric (the capture shows forward `bandwidth: 20.0`, reverse + `0.0`). +- **Status is a first-class, read-only projection**, separate from config. This + matches the config-plane vs. status-plane split + ([ADR-0001](./inspect-app/decisions/0001-api-paradigm.md)) and keeps the live + story clean. +- **Make illegal states unrepresentable** where cheap (enums for `Direction`, + `PortKind`, `Redundancy`, `LifecycleState`, `SyncState`). + +## 7. Identifiers — the "id zoo" and why ids must be opaque + +A single physical port appears under **four to five** id schemes, all visible in +one collector payload (and inventory adds its own device-id assignment on top). +For device0's "Eth 1.10": + +| Form | Example | Where it appears | +| ---- | ------- | ---------------- | +| Port pid | `device0.dev.1.10` | `nodeStatus` module/port keys, `context.portPid` (note the `.dev.` infix) | +| Vertex id (in/out) | `device0.1.10.in` / `device0.1.10.out` | `vertexInfo.in/out.id`, edge keys (**no** `.dev.`) | +| Resource id | `device:device0.dev.1.10` | `resourceId`, `security.*` | +| Topo endpoint ref | `topo:device0.1.1` | `serviceFields.from` / `.to` | +| Connection pair key | `device0::device1` | `externalEdgesByDeviceKey._id` | +| Edge key | `device0.1.10.out::device1.1.11.in` | edge ids, `replaceEdges` | +| Service id | `100001::main` | `inspect.paths`, `pathDescriptions` keys | + +Translating "the port a user names" → "the vertex id an edge key needs" is a +non-trivial, lossy-if-sloppy transform (`device0.dev.1.10` → +`device0.1.10.{in|out}`). **Therefore ids are value objects that hold all +forms.** String-mangling these in user code — especially the `.dev.` infix — +is the most likely source of subtle bugs. + +## 8. Reads — the collector snapshot as the aggregate root + +The `collector` aggregate (`GET …/data/status/collector/**`) returns the +**entire connected graph in one internally-consistent request**: devices, ports, +inter-device connections, services, security, profiles, tags. This eliminates +the N+1 / per-device fan-out and the torn data/rev split of the topology read +path. For "linked devices that have connections too," the server hands you the +whole component at once — **do not** rebuild the domain graph from N per-device +fetches. For onboarding-only attributes not in the collector (driver, +credentials, custom settings), the ACL supplements with an Inventory read. + +The cost is **heavy denormalization**. A single service (`100001::main`) +appears in at least six places in the snapshot, each with full status +sub-objects: + +- `inspect.paths._items[]` (canonical path + `serviceFields`) +- `externalEdgesByDeviceKey[].primary.data[…].pathDescriptions` +- `nodeStatus` source-endpoint port (`device0.dev.1.1`) +- `nodeStatus` egress port (`device0.dev.1.10`) +- `nodeStatus` ingress port (`device1.dev.1.11`) +- `nodeStatus` sink-endpoint port (`device1.dev.1.5`) + +**Rule:** pick **one canonical source per domain object**; treat the rest as +drill-down breadcrumbs. Building a domain object from each occurrence yields +divergent copies. + +| Domain object | Canonical source | +| ------------- | ---------------- | +| `Device` / `Port` | `inspect.nodeStatus._items[]` → `modules.*.ports.*` (+ inventory for onboarding fields) | +| `Connection` | `externalEdgesByDeviceKey._items[]` | +| `Service` | `inspect.paths._items[]` (+ `serviceFields`) | +| port ↔ service drill-down | `ports.*.pathDescriptions` (reference only — do not re-model) | + +### 8.1 Connections are pre-paired by the server + +`externalEdgesByDeviceKey._items[]` already groups the two unidirectional edges +under one bidirectional key: + +- `_id: "device0::device1"` — the connection identity +- `primary` → `device0.1.10.out::device1.1.11.in` (forward direction) +- `secondary` → `device1.1.11.out::device0.1.10.in` (reverse direction) + +So the `Connection` deduplication problem is solved on the read side: the `a::b` +key is the identity, and `primary` / `secondary` are the two `DirectionState`s. +The directions carry **independent** `bandwidth` / `fromStatus` / `toStatus` / +`status`, so the domain `Connection` must keep both, plus the top-level +aggregate `status` the collector provides. + +## 9. Operation → plane mapping (orchestration) + +Each domain operation expands to an ordered sequence of existing wire +operations. The ACL owns this table; the user never sees it. + +| Domain operation | Orchestrated VIP sequence | +| ---------------- | ------------------------- | +| `network.discover()` | Inventory discovered-devices read | +| `network.add_device(driver, connection, …)` | Inventory RPC `/api/updateDevices` (assigns `deviceN`) → optional Topology placement PATCH | +| `device.configure(...)` | Inventory custom-settings update (RPC) and/or Topology vertex/capability PATCH — by field | +| `device.place(x, y)` | Topology `baseDevice` `maps[]` PATCH (revisioned) | +| `network.connect(a, b, …)` | Resolve port→vertex ids → `updateTopology` / `nGraphElements` edge upsert (paired edges) | +| `network.get(...)` / `list_*()` | `collector` snapshot read (+ inventory status where needed) | +| `network.disconnect(...)` / `remove_device(...)` | `updateTopology` remove and/or Inventory RPC remove | +| `device.refresh()` / `network.refresh()` | Re-fetch `collector` snapshot (§11.3) | + +## 10. The configuration / write interface + +Make the **change set the unit of work** — this is genuinely how the server +behaves for topology/connection edits +([ADR-0006](./inspect-app/decisions/0006-commit-write-model.md)), so it is a +semantic to **expose**, not hide. Adopt ADR-0006 option 3 (explicit change set + +convenience auto-commit), with a context manager as the ergonomic default. +Proposed shape: + +```python +# Batched, atomic — the primary path +with app.network.change_set() as cs: + cs.place(device_id, at=Point(x, y)) + conn = cs.connect(port_a, port_b, bandwidth=Gbps(10), redundancy=Redundancy.ANY) + cs.remove(old_connection_id) + result = cs.validate() # maps data.validation.result; surfaces affected services + if result.ok: + cs.commit() # one updateTopology POST +# on exception → auto-discard; on clean exit without commit → configurable + +# Convenience — single change auto-commits +app.network.connect(port_a, port_b, bandwidth=Gbps(10)) +``` + +What the domain layer owns (and the ACL translates): + +- **Commit ≠ HTTP success.** A captured failed delete returned `header.ok: true` + but `data.res.ok: false` / `data.validation.result.ok: false` (ADR-0006). + This must surface as a typed `CommitResult` / `CommitFailed` carrying the + per-entity validation details (`status`, `rev`, `resolvable`, message) — + never a raw envelope. +- **Affected-services check** — already a precedent in + `TopologyApp.list_services_affected_by_device_update`. In the domain model it + becomes `change_set.validate()` returning structured impact. +- **Diffing stays internal.** Users describe *intent* (`connect`, `remove`); + the ACL computes the delta. The existing diff logic becomes an implementation + detail of staging. + +## 11. Writes & freshness across planes + +The Inspect-era findings hold and become **more pronounced** because three +planes are now in play. + +### 11.1 Writes span three concurrency models + +The three planes do **not** share a write/consistency model: + +| Plane | Write mechanism | Concurrency control | +| ----- | --------------- | ------------------- | +| Inventory | RPC `/api/updateDevices` | No revision/strict mode (RPC semantics) | +| Topology | `PATCH nGraphElements` | `_rev` optimistic locking, `mode: strict` | +| Inspect | `updateTopology` action | Commit-time validation; `_rev` behaviour **[VERIFY]** (ADR-0006) | + +So a single domain write that touches multiple planes has **no single +transaction and no uniform conflict story**. The ACL must define, per +orchestrated operation: ordering, what happens on partial failure midway through +the sequence, and how each plane's conflict surfaces as **one** domain error. +Lean on the change-set/commit model for the topology/inspect portion; onboarding +(Inventory RPC) is a separate step that must be sequenced and compensated +explicitly if a later step fails. + +### 11.2 The collector carries no `_rev` + +Every collector `_items[]` entry has `_id` / `_vid` and **no `_rev`** — it is a +pure status-plane projection. The revisioned source of truth lives only in the +config plane (`nGraphElements`). Consequences: + +1. **Status changes move no token.** Alarms, `ptp` severity, `bandwidth`, + `syncSeverity` are not backed by any revision. There is **no cheap "did + anything change?" probe** for status — the only ways to learn current status + are to re-fetch the collector (whole or subtree) or subscribe via WebSocket + ([ADR-0003](./inspect-app/decisions/0003-websocket-subscriptions.md)). +2. **Config-plane rev-polling detects config edits only.** Cheaply polling + `nGraphElements .../id,rev` catches "someone re-wired," but never "a + connection went into alarm." For monitoring, the latter is the majority of + interesting events. So rev-polling is necessary-but-insufficient. +3. **Read and write tokens live in different planes.** A safe optimistic write + needs the `nGraphElements` `_rev` (or the `updateTopology` per-entity `rev`), + which is **absent from the collector read**. A domain object built from the + collector **cannot, by itself, support an optimistic write** — the write path + must resolve revisions separately at commit time. + +### 11.3 Freshness strategy — re-snapshot, not rev-diff + +The collector deliberately trades per-entity revisioning for a single +globally-consistent snapshot — excellent for **read correctness** (no torn +graph), poor for **incremental freshness**. Therefore: + +- Treat the collector snapshot as an **immutable value object**, replaced + wholesale on `refresh()` — not mutated in place. +- `refresh()` means "fetch a new snapshot," not "diff revisions." Stamp each + snapshot with a **client-side fetch time** as the freshness marker, since the + server provides no token. +- If projection/filtering behind the `/**` wildcard turns out to be supported + (open `[VERIFY]`, not proven by the capture), a scoped re-snapshot of a + subtree is the optimisation — still a re-snapshot, not a diff. +- This is the strongest argument for making **WebSocket the real freshness + channel for status**, while config edits keep the rev-based path + ([ADR-0001](./inspect-app/decisions/0001-api-paradigm.md), + [ADR-0002](./inspect-app/decisions/0002-loading-and-state.md), + [ADR-0003](./inspect-app/decisions/0003-websocket-subscriptions.md)). + +### 11.4 Separate the read snapshot from the write handle + +Because the snapshot has no revisions, a domain mutation (`connect`, `remove`) +must, at commit time, resolve the affected entities' `nGraphElements` revisions +itself (or go through `updateTopology` and surface its validation result). Do +**not** let a read object pretend it can optimistically write. The clean split: + +- **Read snapshot** — immutable, rev-less, globally consistent, replace wholesale. +- **Write handle / change set** — resolves revisions at commit, owns the + commit/validate/discard/conflict lifecycle. + +## 12. Limits of the abstraction + +A package-wide abstraction hits boundaries that a thin rename cannot erase. Name +them explicitly so the abstraction stays trustworthy: + +- **Driver selection is irreducible.** A device *is* a specific driver with a + specific capability/custom-settings schema (NMOS port, "indices in IDs", SNMP + config, …). The package even **generates per-driver models**. The domain can + unify the *lifecycle, identity, connection, and status model*, but + `configure(...)` of driver-specific settings is inherently driver-shaped. + Proposed: a generic `Device.settings` entry point typed by driver, surfaced via + the existing per-driver model generation — abstracted *entry point*, not + abstracted *schema*. +- **Onboarding inputs are real.** Address(es), credentials, and driver choice + must be supplied at `add_device`; they cannot be inferred. +- **Virtual vs. physical devices** differ (virtual id allocation, no driver + contact). The lifecycle must accommodate both. +- **Capabilities come from the driver**, not the user. Ports/vertices are + largely driver-defined; the user configures and connects them but does not + invent them. +- **The escape hatch must stay first-class.** Power users will need raw + `nGraphElement` / `InventoryDevice` access; the low-level apps remain public + and documented for exactly this. + +The honest framing: **unify the lifecycle, identity, connection, and status +model; do not pretend driver-specific configuration is uniform.** + +## 13. Hide vs. expose — the key design ledger + +The single most important artifact of this approach. Proposed starting point +(to be ratified as an ADR): + +| Concept | Decision | Rationale | +| ------- | -------- | --------- | +| VIP app/namespace split (inventory/topology/collector planes) | **Hide** | The whole point of the abstraction | +| Edge pairing (two unidirectional edges → one `Connection`) | **Hide** | Server already groups via `externalEdgesByDeviceKey` | +| Id/key formats (`.dev.` infix, `a::b`, `topo:`/`device:` prefixes) | **Hide** behind opaque id value objects | Pure mechanical detail; error-prone in user code | +| Internal fan-out edges (`capacity: 1`) | **Hide** | Implementation detail of a device's internal wiring | +| Device lifecycle (onboard → place → connect → monitor) | **Expose** as `LifecycleState` | Users must reason about *where* a device is, not *which app* | +| Change set / commit | **Expose** (first-class) | Genuine server semantic; enables atomic multi-edits | +| Commit validation result & affected services | **Expose** (typed result) | `header.ok` lies; users must see real outcome | +| Optimistic concurrency / revisions | **Expose as opaque token** | Needed for safe writes; but never raw `_rev` strings | +| Multi-dimensional status (`alarm`/`bandwidth`/`maintenance`/`ptp`, `sa`/`severity`) | **Expose** (preserve dimensions + provide rollup) | Lossy to collapse; monitoring users need the detail | +| Snapshot freshness (no `_rev`) | **Expose** via explicit `refresh()` + fetch-time stamp | Honest about the lack of a server token | +| Driver-specific custom settings | **Cannot hide** — abstract the entry point only | A device *is* its driver schema (§12) | + +## 14. Migration & coexistence + +The change can be **additive and non-breaking**: + +1. **Build `app.network` on top of the existing apps.** No removal; the domain + facade calls the current `inventory` / `topology` / `inspect` APIs + internally. Reuse the existing wire models as the ACL's persisted form — e.g. + domain `Connection` → ACL → existing `UnidirectionalEdge` → `updateTopology` + (one wire model, not two). +2. **Ship read-first.** `app.network.get/list_*` over the `collector` snapshot + (+ inventory supplements), returning unified `Device` / `Connection` objects. +3. **Add lifecycle writes incrementally** — `add_device` (onboard [+ place]), + `place`, `connect`/`disconnect`, `configure` — each backed by the + orchestration table (§9) and the change-set commit model. +4. **Keep the low-level apps public** as the escape hatch; document them as such. +5. **Optionally** add async + WebSocket per the existing ADRs once the sync + surface settles. + +Replacing the existing apps outright (a breaking, single-surface package) is the +alternative to the additive approach — see §15. + +## 15. Open decisions + +| Decision | Options | Note | +| -------- | ------- | ---- | +| Anti-corruption layer vs. transparent client | ACL (this doc) **vs.** keep mirroring VIP | The overarching decision; ratify as an ADR | +| Migration style | **Additive `app.network` facade** vs. breaking replacement of the apps | Recommend additive; lowest risk | +| Entry-point name | `app.network` · `app.fabric` · `app` (top-level) | Neutral, non-vendor name preferred | +| Driver-config abstraction | Generic `settings` entry point with per-driver typed schema **vs.** explicit per-driver objects | §12: entry point can unify; schema cannot | +| Multi-plane write semantics | Best-effort sequence + compensation **vs.** topology/inspect change-set only, inventory separate | §11.1; define partial-failure behaviour | +| Lifecycle model surface | Explicit `LifecycleState` enum **vs.** implicit (methods just work) | §5; explicit aids reasoning & errors | +| Completeness bar for v1 | Full coverage **vs.** 80% happy path + escape hatch for the rest | Recommend the latter; lowest cost | +| Hide-vs-expose ledger (§13) | Ratify explicitly | The most consequential artifact | +| Snapshot vs. WS for status freshness | re-snapshot now, WS as the real channel | §11.3; ties into ADR-0002 / ADR-0003 | +| Read-snapshot ↔ write-handle revision bridge | How the change set resolves `_rev` at commit | §11.4; ties into ADR-0006 | +| Relationship to existing docs | Supersede `python-module-architecture.md` design goal **vs.** coexist as "current vs. target" | Currently coexists as the target picture | + +## 16. Field-mapping reference (wire → domain) + +Indicative mapping from the captured `collector` payload (and Inventory, for +onboarding fields) to the proposed domain types. To be completed during +discovery and turned into fixtures. + +| Domain field | Source | Notes | +| ------------ | ------ | ----- | +| `Device.id` | `nodeStatus._items[]._id` | e.g. `device0` | +| `Device.label` | `nodeStatus[].descriptor.label` | fallback `fDescriptor` if empty | +| `Device.driver` / `Device.connection` | Inventory `config/devman/devices` | onboarding fields, not in collector | +| `Device.state` | derived | from presence across inventory / topology / collector (§5) | +| `Device.position` | `nodeStatus[].meta.coordinates.{x,y}` | float; hides `maps[]`/`inspect_app_format` | +| `Device.sync` | `nodeStatus[].syncSeverity` | map severity → `SyncState` | +| `Device.status` | `nodeStatus[].status.{sa,severity}` + `ptpDeviceStatus` (+ inventory status) | multi-dimensional | +| `Port.id` | module/port key `device0.dev.1.10` | translate to vertex id for edges | +| `Port.label` | `ports.*.descriptor.label` | | +| `Port.direction` | `ports.*.vertexInfo.type/vertexType` | `single`+`In/Out` or `double` → `BIDIRECTIONAL` | +| `Port.is_endpoint` | `ports.*.vertexInfo.fields.isEndpoint` | | +| `Port.status` | `ports.*.status` + `ptpPortStatus` | | +| `Connection.id` | `externalEdgesByDeviceKey[]._id` | `device0::device1` | +| `Connection.forward` | `…primary.data[edgeKey].{bandwidth,status,fromStatus,toStatus}` | | +| `Connection.reverse` | `…secondary.data[edgeKey].{…}` | may differ from forward | +| `Connection.status` | `externalEdgesByDeviceKey[].status` | `{alarm,bandwidth,maintenance,ptp}` | +| `Service.id` | `inspect.paths._items[]._id` | `100001::main` | +| `Service.label` | `…serviceFields.generic.descriptor.label` | | +| `Service.endpoints` | `…serviceFields.{from,fromLabel}` / `{to,toLabel}` | `topo:` refs | +| `Service.status` | `…serviceFields.serviceStatus.{config,total}` | | +| `Service.is_main` | `…serviceFields.isMain` | | + +## 17. Relationship to existing documents + +- [`python-module-architecture.md`](./python-module-architecture.md) — describes + the **current** mirror-VIP architecture. This document proposes the **target**. +- [`inspect-app/`](./inspect-app/README.md) — `concepts.md` (the VIP wire-format + research, including the `collector` aggregate and `nGraphElements` store) and + the ADRs (API paradigm, loading, WebSocket, async, testing, commit model). The + decisions there apply package-wide and are referenced throughout this document. diff --git a/docs/inspect-app/README.md b/docs/inspect-app/README.md new file mode 100644 index 0000000..49c21c2 --- /dev/null +++ b/docs/inspect-app/README.md @@ -0,0 +1,56 @@ +# Inspect App — Architecture & Concept Docs + +Planning and architecture-decision documents for adding the VideoIPath **Inspect** +app to the package. In the VideoIPath product, Inspect is the newer app that +**replaces the Topology app** — building topologies and connecting devices — and +adds service monitoring with live (WebSocket-based) updates. It does **not** +replace **Inventory**: devices are still onboarded in Inventory first, then placed +and connected in Inspect. Inspect also uses a **commit-style** write model, where +create/edit/delete actions are gathered into a change set and committed together. + +In **this package**, `app.inspect` is **purely additive**: it is added alongside +the existing apps. `app.topology` and `app.inventory` keep working **unchanged** — +there is **no deprecation and no migration** planned. + +These docs are **living documents** — meant to be edited and refined as the +design firms up and as the real Inspect API is reverse-engineered. The official +[VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) +reference is now a primary source. Nothing here is implemented yet; this is the plan. + +## Reading order + +1. **[concepts.md](./concepts.md)** — what Inspect is, its domain model, how it + maps onto the existing package, and the endpoint/WebSocket discovery template + (the `[VERIFY]` items to confirm against a real server). Cross-references the + [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) + reference. +2. **[decisions/](./decisions/)** — the architecture decisions (ADRs), one per + topic. Start with [the index](./decisions/README.md). +3. **[implementation-plan.md](./implementation-plan.md)** — phased, non-breaking + rollout, target package layout, milestones, and risks. + +> **Wider context:** the package-wide re-think that grew out of this Inspect +> work — one unified `Device` / `Connection` domain model that hides the +> Inventory / Topology / Inspect split entirely — lives in +> [`../domain-architecture.md`](../domain-architecture.md). + +## Decision log + +| Question (from the brief) | Decision record | Status | +| -------------------------------------------------- | ------------------------------------------------------------ | -------- | +| Data-driven vs. event/action-driven API? | [ADR-0001](./decisions/0001-api-paradigm.md) | Open | +| Always sync/load vs. lazy load vs. cached state? | [ADR-0002](./decisions/0002-loading-and-state.md) | Open | +| Use WebSockets for event subscriptions? | [ADR-0003](./decisions/0003-websocket-subscriptions.md) | Open | +| Make the package async-ready? Support non-async? | [ADR-0004](./decisions/0004-async-strategy.md) | Open | +| How to test E2E (low-effort, stable, trustworthy)? | [ADR-0005](./decisions/0005-e2e-testing.md) | Open | +| How are config writes applied (immediate vs. commit)? | [ADR-0006](./decisions/0006-commit-write-model.md) | Open | + +## Status legend + +- **Draft** — concept/plan being shaped. +- **Proposed / Accepted / Superseded** — for ADRs, see + [the decisions index](./decisions/README.md). + +> ADRs currently capture **context and options only** — decisions are open. +> Promote an ADR to **Accepted** once agreed, and tick off `[VERIFY]` items in +> [concepts.md](./concepts.md) as they are confirmed. diff --git a/docs/inspect-app/concepts.md b/docs/inspect-app/concepts.md new file mode 100644 index 0000000..e368bfc --- /dev/null +++ b/docs/inspect-app/concepts.md @@ -0,0 +1,330 @@ +# Inspect App — Concepts & Technical Model + +> Status: **Draft** · Last updated: 2026-06-18 +> +> This document captures what the *Inspect* app is, how it maps onto the +> existing package, and which technical details still need to be verified +> against a real server. Anything not yet confirmed is marked **[VERIFY]**. +> The official +> [VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) +> reference is a primary source for the wire format. + +## 1. What Inspect is + +Nevion describes Inspect as an *"advanced monitoring application that allows +the operator to perform high-level monitoring of services combined with the +ability to drill-down and inspect details to pinpoint service-affecting +problems."* + +In recent VideoIPath releases the Inspect app **replaces the Topology app**: it +becomes the entry point for building the network connectivity model (vertices, +edges, device placement), connecting devices, and watching their live +operational status — including **WebSocket event subscriptions** for live +updates. Inspect applies configuration changes with a **commit-style** model: +create/edit/delete actions are gathered into a change set and committed together +(see [ADR-0006](./decisions/0006-commit-write-model.md)). + +Inspect does **not** replace the **Inventory** app. Devices are still onboarded +in Inventory first; only then can they be placed and connected in Inspect. So +Inventory remains a separate, required prerequisite. + +In **this package**, `app.inspect` is added **additively** — `app.topology` and +`app.inventory` keep working unchanged, with no deprecation planned. + +## 2. Architecture: the `collector` facade + +Inspect is built around a server-side **`collector` facade** — a distinct REST +v2 API surface under the `status` namespace. The server composes reads from +(and applies writes to) the underlying VideoIPath data store; the **API +contract is mostly net-new** relative to what `app.topology` and +`app.inventory` use today. + +**Reads** — one server-built aggregate: + +- `GET /rest/v2/data/status/collector/**` → `data.status.collector` (§3.1) +- Bundles topology nodes, inter-device edges, services/paths, live status, and + security context in a single tree with `_items[]` collections. + +**Writes** — one bulk action per commit: + +- `POST /rest/v2/actions/status/collector/updateTopology` + ([ADR-0006](./decisions/0006-commit-write-model.md)) +- Sends a client-assembled delta: `replaceDevices`, `replaceVertices`, + `replaceEdges`, `replaceResourceTransforms`, `addExternalEdges`, `remove`, + `force`. +- Validation runs at commit time; success/failure is determined by + `data.res.ok` / `data.validation.result.ok`, not `header.ok`. + +**Namespace** — the `collector` API entry points live under `status` +(`data/status/collector` for reads, `actions/status/collector` for writes), but +the **underlying store is the existing config plane**: `updateTopology` mutations +land in `config/network/nGraphElements`. Confirmed by capture — the edge +`device0.1.10.out::device1.1.11.in` set to `weight: 1` via `updateTopology` +appears in `GET …/data/config/network/nGraphElements/**` with `weight: 1` and a +bumped `_rev`. So the collector is a **facade**: a status-namespace read/action +surface in front of the revisioned `nGraphElements` config store (§3.3). + +**Shared with existing apps** — the underlying store and its models, not the +collector wire shape: + +- The config store `nGraphElements` is **the same one `app.topology` already + reads and models** (`BaseDevice`, `IpVertex`, `UnidirectionalEdge`, + `codecVertex`). Inspect edits land there, revisioned with `_rev` (§3.3). +- REST v2 envelope, session/XSRF auth, pid/id formats +- Vertex ids (`device0.1.10.out`), edge keys (`fromId::toId`) +- `descriptor` / `fDescriptor` objects, `sa` / `severity` status semantics +- Device positions as float coordinates — `meta.coordinates` in the collector + aggregate, `maps[].x/y` in `nGraphElements`, same *Inspect Topology* format as + `inspect_app_format` in the topology app + +**Net-new for `app.inspect`:** + +| Layer | Responsibility | +| ----- | -------------- | +| Collector read/parse | Model and parse `data.status.collector` | +| Change-set / commit write | Assemble `updateTopology` payloads, handle validation responses | +| Live / events | WebSocket subscriptions for status deltas | + +Inventory onboarding stays on the existing path (`config/devman/devices`, +`/api/updateDevices`). `app.topology` and `app.inventory` remain unchanged; +`app.inspect` is additive. + +## 3. Domain model + +How Inspect concepts map onto existing package concepts: + +| Inspect concept | Inspect API (collector) | Existing model / app | +| ---------------------- | ------------------------------------------------------ | --------------------------------------------- | +| Device (inventory) | Prerequisite — not part of collector; onboard via `config/devman/devices` | `InventoryDevice` (`apps/inventory`) | +| Device (topology node) | Read: `collector.inspect.nodeStatus`; stored as `baseDevice` in `nGraphElements` | `TopologyDevice` (`apps/topology`) — store reuses existing model | +| Vertices / Edges | Read: `nodeStatus` `vertexInfo` / `externalEdgesByDeviceKey`; stored as `ipVertex` / `codecVertex` / `unidirectionalEdge` in `nGraphElements` | `BaseDevice`, `IpVertex`, `UnidirectionalEdge` — store reuses existing models | +| Change set / commit | `POST …/actions/status/collector/updateTopology` → writes `nGraphElements` ([ADR-0006](./decisions/0006-commit-write-model.md)) | _commit flow net-new; target store is existing `nGraphElements`_ | +| Services / paths | `collector.inspect.paths`, `pathDescriptions` on nodes/edges | _none — net-new_ | +| Device / edge status | Embedded in collector (`status`, `sa`/`severity`, bandwidth, PTP) | `inventory.model.device_status`, `status/network/*` — partial overlap | +| Sync status | `syncSeverity` on nodeStatus items | `TopologySynchronize` via `status/network/nGraphSyncStatus` | +| Live events | WebSocket delta subscriptions | _none — net-new_ | +| Connections / Partial connections | **[VERIFY]** — may relate to `pathDescriptions` / bookings | _none yet_ | + +### 3.1 Collector aggregate — primary read surface + +Inspect reads a single server-built aggregate under the `collector` namespace +— the same namespace used for writes +(`actions/status/collector/updateTopology`, [ADR-0006](./decisions/0006-commit-write-model.md)): + +| Item | Value | +| ---- | ----- | +| Method / path | `GET /rest/v2/data/status/collector/**` | +| Root | `data.status.collector` | +| List shape | Collections use `_items[]` entries with `_id` / `_vid` | + +Top-level sections under `data.status.collector`: + +| Section | Purpose | +| ------- | ------- | +| `inspect.nodeStatus` | Topology nodes: devices → modules → ports, with live status, `meta.coordinates`, `vertexInfo`, and embedded `pathDescriptions` | +| `inspect.paths` | Service/path list: booking segments, endpoint labels, aggregated `serviceFields` | +| `externalEdgesByDeviceKey` | Inter-device link status grouped by device pair; `primary` / `secondary` each hold edges keyed by `"fromId::toId"` | +| `maintenanceBookings` | Maintenance bookings (empty in capture) | +| `security` | Security context for devices, profiles, matrices, … | +| `superProfiles` | Routing profiles | +| `tagInfo` | Tag → profile mappings | + +**ID conventions** (consistent across read and write): + +| Concept | Example | Notes | +| ------- | ------- | ----- | +| Device | `device0` | `deviceId`, `pid`, `_id` on nodeStatus items | +| Module pid | `device0.dev.1` | Nested under `modules` | +| Port pid | `device0.dev.1.10` | Nested under `ports` | +| Vertex id | `device0.1.10.out` | Shorter form in `vertexInfo`; used in edge keys | +| Edge id | `device0.1.10.out::device1.1.11.in` | Same key as `replaceEdges` in `updateTopology` | +| Device pair | `device0::device1` | Key for `externalEdgesByDeviceKey` items | +| Service / path | `100001::main` | `bookingId` + path role; appears in `pathDescriptions` and `inspect.paths` | +| Resource id | `device:device0.dev.1.1` | Prefixed resource references | +| Topo endpoint ref | `topo:device0.1.1` | Used in `serviceFields.from` / `.to` | + +**`vertexInfo`** on ports describes topology vertices: + +- `type: "single"` — one vertex (`id`, `vertexType`: `"In"` / `"Out"`, `fields`: + `isActive`, `isControlled`, `isEndpoint`) +- `type: "double"` — bidirectional port with separate `in` / `out` ids and labels + +**Drill-down / service linkage:** `pathDescriptions` on ports and edges embed +both `deviceLevel` (local hop: input → output within a device) and +`serviceLevel` (end-to-end service: `bookingId`, `serviceLabel`, `fromStatus` / +`toStatus`, `isMain`, `serviceStatus`). This is how the UI connects topology +elements to booked services — e.g. booking `100001` `"Encoder 1.1 -> Decoder 1.5"` +traverses `device0.1.10.out::device1.1.11.in`. + +**Edge live status** (`externalEdgesByDeviceKey`): each edge carries +`bandwidth`, `fromStatus` / `toStatus`, `pathDescriptions`, and aggregate +`status` (`alarm`, `bandwidth`, `maintenance`, `ptp` severities). + +**Node live status** (`inspect.nodeStatus`): hierarchical `status` with +`sa` / `severity` at device, module, port; plus `ptpDeviceStatus`, `syncSeverity`, +`hasEndpoints`, domains, tags. + +**Package implications:** + +- `app.inspect` uses `GET …/data/status/collector/**` as its canonical read + entry point. +- Edge keys and vertex ids from reads map directly onto `updateTopology` write + payloads. +- Commit validation references the same `bookingId`s visible in + `pathDescriptions` (e.g. failed delete for booking `100001` / main path edge). +- **[VERIFY]** exact sub-paths behind the `/**` wildcard, projection/filter + support, and pagination for large topologies. + +### 3.2 API planes — existing apps vs. Inspect + +The VideoIPath backend separates **config** (mutable, revisioned) and +**status** (read-only, subscription-friendly) +([Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro)). +Inspect's `collector` API entry points sit under `status`, but its topology +edits resolve to the **config** plane (`nGraphElements`): + +| Plane | Used by | Read | Write | +| ----- | ------- | ---- | ----- | +| Config | `app.topology`, `app.inventory`, **Inspect (effective store)** | `GET …/data/config/…` | `PATCH …/data/config/…` (revisioned) or RPC | +| Status | Inventory status reads | `GET …/data/status/…` | — | +| Collector (facade) | `app.inspect` | `GET …/data/status/collector/**` (composed aggregate) | `POST …/actions/status/collector/updateTopology` → `nGraphElements` | + +So Inspect's read aggregate is status-namespace and net-new in shape, but its +writes are commit-time-validated bulk actions that land in the revisioned +`config/network/nGraphElements` store (§3.3). The client gathers edits locally +until commit; server-side staging / change-set ids are **[VERIFY]** +([ADR-0006](./decisions/0006-commit-write-model.md)). + +Status-plane **WebSocket subscriptions** deliver event-based deltas — the live +update channel for Inspect ([ADR-0003](./decisions/0003-websocket-subscriptions.md)). + +This framing underpins the API-paradigm and loading decisions +([ADR-0001](./decisions/0001-api-paradigm.md), +[ADR-0002](./decisions/0002-loading-and-state.md)). + +### 3.3 Config store — `nGraphElements` (write target) + +`GET /rest/v2/data/config/network/nGraphElements/**` → +`data.config.network.nGraphElements._items[]`. This is the **revisioned source +of truth** for topology that Inspect's `updateTopology` writes into, and it is +already modelled by `app.topology`. + +| Field | Notes | +| ----- | ----- | +| `_id` / `_vid` | Element id; edges use the `fromId::toId` key (same as collector and `replaceEdges`) | +| `_rev` | CouchDB-style revision `N-` for optimistic concurrency | +| `type` | Element kind (see below) | +| `descriptor` / `fDescriptor` | User label/desc vs. fallback (device-reported) label/desc | + +Element `type` values seen in capture: + +| `type` | Represents | Key fields | +| ------ | ---------- | ---------- | +| `baseDevice` | Topology device node | `maps[]` (`cType: "Topology"`, integer `x`/`y`), `iconType`, `sdpStrategy`, `isVirtual` | +| `codecVertex` | Codec/SDI endpoint vertex | `vertexType` (`In`/`Out`), `codecFormat`, `useAsEndpoint`, `control`, SIPS/SDP fields | +| `ipVertex` | Ethernet/IP port vertex (`.in` / `.out`) | `vertexType`, `gpid.pointId`, `supports*Cfg` capability flags | +| `unidirectionalEdge` | Directed link/route between vertices | `fromId`, `toId`, `weight`, `capacity`, `bandwidth`, `redundancyMode`, `weightFactors`, `conflictPri` | + +**Write round-trip confirmed:** the edge `device0.1.10.out::device1.1.11.in` +edited via `updateTopology` (ADR-0006 example, `weight: 1`) is present here with +`weight: 1` and `_rev: "3-…"`, and inter-device links appear as paired +unidirectional edges (`device0.1.10.out::device1.1.11.in` and +`device1.1.11.out::device0.1.10.in`, each `capacity: 65535`). Internal +fan-out edges (vertex→vertex within a device) use `capacity: 1`. + +**Implication:** Inspect's underlying topology model is the existing +`nGraphElements`, so the package's `BaseDevice` / `IpVertex` / +`UnidirectionalEdge` / codec-vertex models are reusable for the **store**, even +though the **collector read aggregate** (§3.1) has its own status-oriented +shape. **[VERIFY]** whether `updateTopology` performs `_rev` checks server-side +or last-writer-wins. + +## 4. How the transport works today (recap) + +So the new layer fits the existing patterns rather than reinventing them: + +- `connector/` is a thin sync HTTP client built on `requests`, with two + sub-connectors: REST v2 (`GET`/`PATCH`/`POST`) and RPC (`POST /api/*`). Basic + auth, gzip, per-method timeouts. +- Each connector enforces an **allow-list of URL prefixes** (`ALLOWED_URLS` / + `ALLOWED_EXACT_MATCHES`). New Inspect endpoints must be added there. +- Responses are wrapped in a common envelope (`ResponseV2Get`/`…Patch`/`…Post`) + with a `header` (`code`, `auth`, …) and a `data`/`result` body, validated by + Pydantic. +- Apps are **lazy-loaded** off `VideoIPathApp` and are **stateless**: every call + re-fetches from the server (e.g. `topology.get_device` issues several `GET`s + and rebuilds the aggregate each time). + +## 5. Endpoint discovery — how to fill in the **[VERIFY]** gaps + +Two complementary sources: the **official reference** (authoritative for the +documented surface) and **browser capture** (authoritative for what the Inspect +GUI actually does, including undocumented calls and WebSocket frames). + +0. **Start from the official reference.** The + [VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) + collection documents `Connections`, `Partial Connections`, and + `Import and Export (preview)`, plus the `config` / `status` / `experimental` + top-level split. Use it as the primary map of the documented surface. + > Inspect uses `/rest/v2/` for collector read/write. Other resources may + > still use v1 paths documented in the public reference — **[VERIFY]** per + > resource during capture. +1. **Capture browser traffic.** Open the Inspect app in the VideoIPath web UI + with the browser DevTools → Network tab. Record a full session (HAR export): + open a device, connect two devices, **commit a change set**, drill into a + service, watch live updates. Note REST calls *and* the WebSocket (`WS` + filter) frames. +2. **Replay through the package logger.** The connector logs every response at + `DEBUG` (`_log_response`). Set `log_level="DEBUG"` and call candidate + endpoints to confirm payload shapes. +3. **Diff against known resources.** Compare captured paths to the prefixes the + package already allows. New prefixes ⇒ new Inspect resources. +4. **Save representative payloads as fixtures.** Store sanitised JSON under + `tests/fixtures/inspect//…`. These feed the offline tests in + [ADR-0005](./decisions/0005-e2e-testing.md) and double as living + documentation of the wire format. + +### 5.1 Discovery checklist (fill in as confirmed) + +REST: + +- [x] Base path: Inspect uses `/rest/v2/` for collector read/write (`data/status/collector/**`, `actions/status/collector/updateTopology`) +- [x] Service / monitoring aggregate: `GET /rest/v2/data/status/collector/**` → `data.status.collector` with `inspect.nodeStatus`, `inspect.paths`, `externalEdgesByDeviceKey`, `security`, `superProfiles`, `tagInfo` (§3.1) +- [x] Drill-down: services linked via `pathDescriptions` on ports/edges (`deviceLevel` + `serviceLevel`) and `inspect.paths._items[]` (`bookingId`, path segments, `serviceFields`) +- [ ] Collector sub-paths: exact URLs behind the `/**` wildcard; projection/filter/pagination support +- [ ] Connections / Partial Connections: resource paths, shapes, lifecycle +- [x] Change set / commit endpoint: `POST /rest/v2/actions/status/collector/updateTopology` — bulk delta with `replaceDevices`, `replaceVertices`, `replaceEdges` (key `"fromId::toId"`), `replaceResourceTransforms`, `addExternalEdges`, `remove`, `force` ([ADR-0006](./decisions/0006-commit-write-model.md)) +- [ ] Change set staging: server-side change-set id vs. client-only gather until commit +- [x] Commit failure response: check `data.res.ok` / `data.validation.result.ok` (not `header.ok`); `validation.details[id]` carries `status`, `rev`, `resolvable`, `type`; failed delete example: `"A required edge was not found. (main)"`, `items: []` ([ADR-0006](./decisions/0006-commit-write-model.md)) +- [ ] Commit success response: `data.items` shape when validation passes +- [ ] Commit semantics: partial apply after validation pass, discard/rollback of abandoned client-side change sets +- [ ] Import / Export (preview): scope and payload format +- [x] Write/action endpoint: `/rest/v2/actions/status/collector/updateTopology` (others under `…/actions/status/collector/*` still **[VERIFY]**) +- [x] Config store / write target: `updateTopology` lands in `GET /rest/v2/data/config/network/nGraphElements/**` (`_items[]`, `_rev`, `type` ∈ `baseDevice` / `codecVertex` / `ipVertex` / `unidirectionalEdge`); reuses existing topology models (§3.3) +- [ ] `_rev` handling on commit: does `updateTopology` enforce optimistic concurrency or last-writer-wins? +- [ ] Version gating: first VideoIPath version that exposes each endpoint + +WebSocket (see [ADR-0003](./decisions/0003-websocket-subscriptions.md)): + +- [ ] URL / scheme (`ws://` vs `wss://`, path, port) +- [ ] Auth handshake (Basic header, cookie/session, query token?) +- [ ] Subscribe / unsubscribe message format (which resources can be watched?) +- [ ] Event/patch frame format (full snapshot vs. delta/patch; ids & revisions) +- [ ] Heartbeat / keep-alive, server-side timeouts, reconnect & resync semantics +- [ ] Back-pressure / message ordering / dropped-update guarantees + +## 6. Open questions + +- **Collector sub-paths and scaling** — exact URLs behind `/**`; projection, + filtering, and pagination for large topologies + ([ADR-0002](./decisions/0002-loading-and-state.md)). +- **WebSocket scope** — generic subscription to any `status/...` path, or + Inspect-specific streams? ([ADR-0003](./decisions/0003-websocket-subscriptions.md)) +- **Commit model** ([ADR-0006](./decisions/0006-commit-write-model.md)) — + server-side change-set id vs. client-only gather; successful response shape + (`data.items`); all-or-nothing apply after validation; meaning of validation + `status` codes and `resolvable: true` cases. +- **Commit concurrency** — `updateTopology` writes bump `nGraphElements` `_rev` + (§3.3); does the action enforce `_rev` checks or last-writer-wins? +- **Connections / Partial Connections** — relationship to `pathDescriptions`, + bookings, and the Public API `Connections` resources. diff --git a/docs/inspect-app/decisions/0001-api-paradigm.md b/docs/inspect-app/decisions/0001-api-paradigm.md new file mode 100644 index 0000000..a7aa344 --- /dev/null +++ b/docs/inspect-app/decisions/0001-api-paradigm.md @@ -0,0 +1,47 @@ +# ADR-0001: API paradigm — data-driven core + event layer + +> Status: **Proposed** +> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl + +## Context + +The package today is **data-driven**: the user fetches a configuration +aggregate (`InventoryDevice`, `TopologyDevice`), mutates the object locally, and +calls `update_*`, which diffs against the server state and `PATCH`es only the +changed elements (revision-checked). It is stateless and request/response based. + +Inspect adds a **monitoring** dimension (services, live status, drill-down) and +a **WebSocket** channel. Monitoring is naturally **event/observation-driven**: +the client reacts to changes it did not initiate. So the question is whether to: + +- keep everything data-driven (poll for status), or +- move to an event/action-driven model, or +- combine the two. + +This maps cleanly onto the config-plane vs. status-plane split described in +[concepts.md](../concepts.md#3-domain-model). + +## Options + +1. **Data-driven only (status via polling).** + - Pros: zero conceptual change; consistent with existing apps; trivial to + test; no new dependencies. + - Cons: polling is chatty and laggy for live monitoring; ignores the new + WebSocket capability that motivated the work. + +2. **Event/action-driven only.** + - Pros: best fit for live monitoring; matches Inspect's UX. + - Cons: poor fit for configuration CRUD (which is inherently + request/response + revisioned); large departure from existing apps; high + migration cost; harder to reason about for simple scripts. + +3. **Hybrid: data-driven CRUD for the config plane, event-driven observation for the status plane.** + - Pros: each plane uses the model that fits it; preserves the existing CRUD + surface and tests; isolates the new event machinery; lets users opt into + live features only when needed. + - Cons: two interaction styles in one app; must document clearly which + methods are "read/modify/write" vs. "subscribe/observe". + +## Decision + +_To be decided._ diff --git a/docs/inspect-app/decisions/0002-loading-and-state.md b/docs/inspect-app/decisions/0002-loading-and-state.md new file mode 100644 index 0000000..163e5f5 --- /dev/null +++ b/docs/inspect-app/decisions/0002-loading-and-state.md @@ -0,0 +1,57 @@ +# ADR-0002: Loading & state model + +> Status: **Proposed** +> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl + +## Context + +This ADR is about **how data is fetched from the VideoIPath API** — eager vs. +lazy, how much is pulled per call, and whether anything is cached client-side. +It is **not** about app-object construction; the lazy `app.inspect` property +(built on first access, like the other apps) is a separate, settled detail and +out of scope here. + +Existing apps are **stateless** and **fetch-on-demand**: each call re-reads from +the server and rebuilds objects. A single read can be chatty — e.g. +`topology.get_device` issues several `GET`s and reassembles the aggregate every +time. + +**Usage context matters:** this package is used for **automations, pipelines and +bulk operations** — typically short-lived, scripted, do-and-exit runs — not +long-lived dashboards or applications. That favours **predictability and +freshness** over client-side caching, and makes **efficient bulk reads** the +real performance concern. + +Two sub-questions follow: + +1. **Per read** — fetch the full aggregate eagerly, or lazily/narrowly? +2. **Across reads** — stay stateless, or cache client-side? + +## Options + +1. **Stateless, eager per-entity (status quo).** Each call fetches the full + aggregate for the requested entity. + - Pros: predictable, always fresh, no cache-invalidation, easy to test, + matches existing apps; yields one coherent object. + - Cons: chatty; pulls more than a bulk job often needs; N+1 when iterating + many entities. + +2. **Stateless + field/section projection & bulk helpers.** Default to eager per + entity, but let callers request only the fields/sections they need and offer + list-with-projection helpers. (REST v2 already supports projections, e.g. + `/id,rev,vid`, `/maps/0/x,y`.) + - Pros: keeps statelessness & freshness; cuts payload and request count for + pipelines/bulk ops; still simple to test. + - Cons: more method surface (projection params); caller must know which + sections it needs. + +3. **Client-side cache / long-lived snapshot.** Hydrate once and serve + subsequent reads locally (optionally refreshed via WebSocket). + - Pros: cheap repeated reads for a long-running process. + - Cons: stateful; staleness & cache-invalidation risk; consistency issues vs. + concurrent writers; little benefit for short-lived automations; harder to + test. + +## Decision + +_To be decided._ diff --git a/docs/inspect-app/decisions/0003-websocket-subscriptions.md b/docs/inspect-app/decisions/0003-websocket-subscriptions.md new file mode 100644 index 0000000..db3ecd1 --- /dev/null +++ b/docs/inspect-app/decisions/0003-websocket-subscriptions.md @@ -0,0 +1,45 @@ +# ADR-0003: WebSocket event subscriptions + +> Status: **Proposed** +> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl + +## Context + +Inspect adds a **WebSocket channel** for live status/event updates. The package +has no WebSocket client today; the connector layer is sync `requests`. We want +live updates without destabilising the synchronous CRUD core, and without +forcing every user into `asyncio` (see [ADR-0004](./0004-async-strategy.md)). + +The [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) +reference **confirms** the high-level model: the `status` plane is "frequently +updated and a good candidate for subscriptions", and subscription handling uses +**event-based (delta) change reporting** from server to client. So the *shape* +(deltas over a persistent channel on the status plane) is settled; the **exact +protocol details** (URL, auth handshake, subscribe message, concrete frame +format, heartbeat, reconnect) remain **[VERIFY]** — capture them per +[concepts.md §5](../concepts.md#5-endpoint-discovery--how-to-fill-in-the-verify-gaps). + +## Options + +1. **Skip WebSocket; poll status endpoints.** + - Pros: no new dependency or concurrency; trivial. + - Cons: laggy, chatty, misses the feature's point; doesn't scale to many + watched entities. + +2. **Async-native WebSocket only** (e.g. `websockets`/`httpx-ws`), exposed via + `async`/`await` + async iterators. + - Pros: idiomatic, efficient, composes with other async I/O. + - Cons: forces asyncio on all consumers unless wrapped; bigger change now. + +3. **Sync-friendly WebSocket with a background thread** (e.g. + `websocket-client`) exposing callbacks / a blocking iterator / a `queue`. + - Pros: no asyncio required by users; drops into existing sync scripts. + - Cons: thread lifecycle management; not as clean for high-concurrency. + +4. **Both, behind one façade:** a transport-agnostic subscription API with a + sync default (thread-backed) and an async implementation, sharing typed event + models. + +## Decision + +_To be decided._ diff --git a/docs/inspect-app/decisions/0004-async-strategy.md b/docs/inspect-app/decisions/0004-async-strategy.md new file mode 100644 index 0000000..8ec46f7 --- /dev/null +++ b/docs/inspect-app/decisions/0004-async-strategy.md @@ -0,0 +1,49 @@ +# ADR-0004: Async readiness & migration + +> Status: **Proposed** +> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl + +## Context + +The package is **sync**, built on `requests`. Two issues forces push toward async: + +1. **WebSocket subscriptions** ([ADR-0003](./0003-websocket-subscriptions.md)) + are naturally async. +2. **Bulk/concurrent I/O** (Inspect's combined data set, large topologies) is + far more efficient with concurrency. + +But there is an **existing sync user base and sync test suite**, and a published +PyPI package (`0.8.x`). We must not break sync users, and we should avoid a +risky big-bang rewrite. Python is `>=3.11`, so modern async primitives +(`TaskGroup`, `asyncio.timeout`) are available. + +Key structural fact in our favour: the **Pydantic models and the diff/patch +logic are I/O-agnostic**. Only the connector and the api/app methods are +I/O-bound. So async-ification is mostly a transport + method-coloring problem, +not a domain-logic rewrite. + +## Options + +1. **Stay sync-only; thread the WebSocket.** + - Pros: lowest risk; no breaking changes; ships now. + - Cons: no concurrency for bulk ops; doesn't future-proof. + +2. **Async-first, sync as a thin shim** (`asyncio.run` / run-in-thread wrappers). + - Pros: one source of truth (async); future-proof. + - Cons: breaking for current sync users; sync-over-async shims have real + footguns (event-loop reentrancy, nested loops in notebooks, thread-pool + surprises); large immediate change. + +3. **Dual-stack on a shared core:** migrate the transport to + `httpx` (one library, sync **and** async clients with the same API), keep the + Pydantic models + diff logic shared, and provide both a sync and an async API + surface. Maintain the two surfaces from one source using a code transform + (e.g. [`unasync`](https://github.com/python-trio/unasync)) rather than by + hand. + - Pros: sync users keep working; async users get first-class support; + WebSocket fits the async side; minimal duplicated logic. + - Cons: build-time codegen/tooling to set up; both surfaces must be tested. + +## Decision + +_To be decided._ diff --git a/docs/inspect-app/decisions/0005-e2e-testing.md b/docs/inspect-app/decisions/0005-e2e-testing.md new file mode 100644 index 0000000..4a7a900 --- /dev/null +++ b/docs/inspect-app/decisions/0005-e2e-testing.md @@ -0,0 +1,38 @@ +# ADR-0005: E2E testing strategy + +> Status: **Proposed** +> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl + +## Context + +Inspect spans config CRUD, status reads, and a WebSocket channel against a +proprietary server whose payloads vary by version. Tests must be **low-effort, +stable, and trustworthy** — i.e. they should fail only for real regressions, and +when green they should genuinely mean "this works against VideoIPath". + +Today the suite is small (validators) and a live server is configured via +`tests/.env.test` + `pytest-dotenv`. There is precedent for "validate our +assumptions against the live server": `advanced_driver_schema_check` compares +local vs. server driver schemas. + +The tension: a **live-only** suite is the most trustworthy but is slow, flaky, +stateful, and needs infrastructure; a **mock-only** suite is fast and stable but +only tests our assumptions, not reality. + +## Options + +- **Live server only** — trustworthy, but flaky/stateful/slow; bad for CI on + every push. +- **Recorded HTTP interactions** (`respx` for `httpx` / `vcrpy` for `requests`) + — record real request/response pairs once, replay offline. Fast, stable, + realistic — as long as cassettes are periodically re-recorded. +- **Fake VideoIPath server** (small FastAPI app emulating endpoints + WS) — more + setup, but enables deterministic **end-to-end incl. WebSocket** flows that + cassettes can't easily replay. +- **Fixture/contract tests** — assert our Pydantic models parse real captured + payloads and our diff logic produces expected patches. Cheap, deterministic, + catches model drift. + +## Decision + +_To be decided._ diff --git a/docs/inspect-app/decisions/0006-commit-write-model.md b/docs/inspect-app/decisions/0006-commit-write-model.md new file mode 100644 index 0000000..b47e00d --- /dev/null +++ b/docs/inspect-app/decisions/0006-commit-write-model.md @@ -0,0 +1,229 @@ +# ADR-0006: Commit-style write model (change sets) + +> Status: **Proposed** +> Date: 2026-06-18 · Deciders: Paul Winterstein, Jonas Scholl + +## Context + +Inspect applies configuration changes with a **commit-style** model: when the +operator creates, edits, or deletes topology elements / connections, the actions +are first **gathered into a change set** and then **committed together**, rather +than each change hitting the server immediately. + +This differs from the existing apps. Today the write path is **immediate and +per-call**: + +- Topology: `get_device` → mutate locally → `update_device` diffs against the + server and applies the changes with a revisioned + `PATCH /rest/v2/data/config/network/nGraphElements` (mode `strict`, `_rev` + checked). +- Inventory: `add_device` / `update_device` go through the RPC + `/api/updateDevices` call. + +There is **no commit, transaction, or staging concept** in the package today +(the topology "staged device" is only an in-memory diff input, not a server-side +batch). So Inspect's commit behaviour is a **net-new write path**, not a reuse of +the existing one. + +### Verified wire format (browser capture, 2026-06-18) + +Inspect applies topology edits with a **single bulk action** — not the existing +revisioned `PATCH …/data/config/network/nGraphElements` path: + +| Item | Value | +| ---- | ----- | +| Method / path | `POST /rest/v2/actions/status/collector/updateTopology` | +| Auth | Session cookies (`VipathSession`, …) + `XSRF-TOKEN` cookie and matching `x-xsrf-token` header | +| Envelope | Standard v2 `{ "header": { "id": 0 }, "data": { … } }` | + +The `data` object carries the **entire delta** in one request. Empty maps / +arrays mean “no change” for that category: + +| Field | Shape | Purpose | +| ----- | ----- | ------- | +| `replaceDevices` | `Record` | Upsert devices | +| `replaceVertices` | `Record` | Upsert vertices | +| `replaceEdges` | `Record<"fromId::toId", edge>` | Upsert edges (composite key) | +| `replaceResourceTransforms` | `Record` | Upsert resource transforms | +| `addExternalEdges` | `edge[]` | Add external edges | +| `remove` | `string[]` | Remove entities by id | +| `force` | `boolean` | Force apply (example used `false`) | + +**Edge key:** `"::"` — e.g. +`device0.1.10.out::device1.1.11.in`. + +**Minimal example** (set edge `weight` to `1`; other categories empty): + +```json +{ + "header": { "id": 0 }, + "data": { + "replaceDevices": {}, + "replaceVertices": {}, + "replaceEdges": { + "device0.1.10.out::device1.1.11.in": { + "fromId": "device0.1.10.out", + "toId": "device1.1.11.in", + "descriptor": { "label": "Eth 1.10 (out) -> Eth 1.11 (in)", "desc": "" }, + "fDescriptor": { "label": "", "desc": "" }, + "tags": [], + "active": true, + "weight": 1, + "capacity": 65535, + "bandwidth": -1, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "weight": 0, "max": 100 } + }, + "redundancyMode": "Any", + "conflictPri": 0, + "includeFormats": [], + "excludeFormats": [] + } + }, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } +} +``` + +This confirms the **gather-then-commit** mental model at the API boundary: the +Inspect UI accumulates edits client-side, then sends one `updateTopology` payload +with populated `replace*` / `add*` / `remove` sections. Reads use the matching +**collector** namespace: `GET …/data/status/collector/**` returns the composed +topology + status + services tree ([concepts.md §3.1](../concepts.md#31-collector-aggregate--primary-inspect-read-surface)). +Edge keys (`fromId::toId`) and vertex ids are consistent between read and write. + +#### Response shape — failed commit (browser capture, 2026-06-18) + +**Important:** HTTP / envelope success is **not** commit success. On a failed +delete-device attempt the top-level `header` still reported success: + +| Field | Failed-commit value | Meaning | +| ----- | ------------------- | ------- | +| `header.ok` | `true` | Transport / auth succeeded | +| `header.code` | `"OK"` | Envelope accepted | +| `data.res.ok` | `false` | Commit rejected | +| `data.res.msg` | `["Validation failed"]` | High-level failure reason | +| `data.validation.result.ok` | `false` | Validation gate failed | +| `data.validation.result.msg` | e.g. `["A required edge was not found. (main)"]` | Actionable validation message | +| `data.items` | `[]` | No applied changes returned | + +Per-entity validation details live in `data.validation.details`, keyed by id +(e.g. `"100001"`): + +| Field | Example | Notes | +| ----- | ------- | ----- | +| `status` | `-22` | Server-specific error code | +| `rev` | `"2-2026-06-15T13:03:50.631612311Z[UTC]"` | Entity revision at validation time | +| `resolvable` | `false` | Whether the client can fix/retry in-place | +| `type` | `"generic"` | Error category | +| `isCancel` / `isProduct` | `false` | Flags on the validation item | + +```json +{ + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "ok": true, + "user": "sysadmin" + }, + "data": { + "items": [], + "res": { + "msg": ["Validation failed"], + "ok": false + }, + "validation": { + "createIds": [], + "details": { + "100001": { + "isCancel": false, + "isProduct": false, + "resolvable": false, + "rev": "2-2026-06-15T13:03:50.631612311Z[UTC]", + "status": -22, + "type": "generic" + } + }, + "result": { + "msg": ["A required edge was not found. (main)"], + "ok": false + } + } + } +} +``` + +The package must treat a commit as failed when `data.res.ok` or +`data.validation.result.ok` is `false`, even if `header.ok` is `true`. Revisions +appear on validation detail entries (`rev`), not as a top-level `_rev` conflict +like the existing `PATCH nGraphElements` path — **[VERIFY]** whether revision +mismatch surfaces the same way on other failure modes. + +### Write target — the `nGraphElements` config store (confirmed) + +`updateTopology` is a status-namespace **action**, but it persists to the +existing **`config/network/nGraphElements`** store. Confirmed by capture: the +edge `device0.1.10.out::device1.1.11.in` set to `weight: 1` via this action +appears in `GET /rest/v2/data/config/network/nGraphElements/**` with `weight: 1` +and a bumped `_rev` (`3-…`). Elements are revisioned (`_rev` = `N-`) +and typed (`baseDevice`, `codecVertex`, `ipVertex`, `unidirectionalEdge`) — the +same model `app.topology` already uses. Inter-device links persist as **paired +unidirectional edges** (`a::b` and `b::a`, `capacity: 65535`); the failed-delete +error *"A required edge was not found. (main)"* refers to this edge model. See +[concepts.md §3.3](../concepts.md#33-config-store--ngraphelements-write-target). + +This means the change-set write layer does **not** need new topology element +models — it can reuse the existing ones for the persisted form, while the +collector read aggregate keeps its own status-oriented shape. **[VERIFY]** +whether `updateTopology` enforces `_rev` optimistic concurrency or is +last-writer-wins. + +What remains **[VERIFY]**: + +- Whether the server exposes a **separate staging / change-set id** API, or + staging is purely client-side until this POST. +- **Successful** commit response shape (`data.items` contents when `ok: true`). +- Whether multi-category edits that pass validation can still **partially apply** + (the failed example returned empty `items`, suggesting reject-before-apply). +- Other `…/actions/status/collector/*` endpoints (discard, validate-only, …). +- Meaning of validation `status` codes (e.g. `-22`) and when `resolvable` is + `true`. +- Cross-check against the + [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) + (`Connections` / `Partial Connections` / `Import and Export`). + +## Options + +1. **Auto-commit per call (hide the change set).** Each `add/update/delete` + transparently opens a change set, applies one action, and commits — mimicking + the existing immediate-write UX. + - Pros: familiar; matches the current topology/inventory mental model; simple + for one-off scripts. + - Cons: defeats the point of batching (atomic multi-element changes, fewer + round-trips, single validation/commit); doesn't expose commit-time conflict + handling; may not even match how the server expects connections to be built. + +2. **Explicit change set only.** Force callers to open a change set, stage + actions, then commit (or discard). + - Pros: faithful to the server model; enables atomic multi-step edits and a + single pre-commit validation; explicit conflict/rollback handling. + - Cons: more ceremony for the trivial single-change case; easy to leak an + un-committed change set. + +3. **Hybrid: explicit change set with a convenience auto-commit default.** + Provide a first-class change-set object (ideally a context manager) for + batched, atomic edits, *and* a convenience path where a single + `add/update/delete` auto-commits when no change set is open. + - Pros: ergonomic for both one-off scripts and batched pipeline edits; + exposes commit/validate/discard when needed; degrades to simple usage. + - Cons: two usage styles to document; must define what happens to an open + change set on error / context exit. + +## Decision + +_To be decided._ diff --git a/docs/inspect-app/decisions/README.md b/docs/inspect-app/decisions/README.md new file mode 100644 index 0000000..987859e --- /dev/null +++ b/docs/inspect-app/decisions/README.md @@ -0,0 +1,49 @@ +# Architecture Decision Records — Inspect App + +Each ADR captures the **context, research, and options** for one architectural +question. Once a choice is made, the **Decision** and **Consequences** sections +are filled in and the status moves to **Accepted**. ADRs are immutable once +accepted — to change a decision, add a new ADR that supersedes the old one +(update the `Status` line of both). + +## Status legend + +- **Proposed** — context and options documented; decision not yet made. +- **Accepted** — agreed; implement accordingly. +- **Superseded by ADR-XXXX** — replaced; kept for history. +- **Deprecated** — no longer relevant. + +## Index + +| ADR | Title | Status | +| ----------------------------------------------------- | ------------------------------------ | -------- | +| [0001](./0001-api-paradigm.md) | API paradigm: data-driven + events | Proposed | +| [0002](./0002-loading-and-state.md) | Loading & state model | Proposed | +| [0003](./0003-websocket-subscriptions.md) | WebSocket event subscriptions | Proposed | +| [0004](./0004-async-strategy.md) | Async readiness & migration | Proposed | +| [0005](./0005-e2e-testing.md) | E2E testing strategy | Proposed | +| [0006](./0006-commit-write-model.md) | Commit-style write model (change sets) | Proposed | + +## Template + +```markdown +# ADR-XXXX: + +> Status: Proposed | Accepted | Superseded by ADR-YYYY +> Date: YYYY-MM-DD · Deciders: + +## Context +What problem are we solving? What constraints apply (existing code, users, +versions)? + +## Options +1. Option A — pros / cons +2. Option B — pros / cons +3. ... + +## Decision +_To be decided._ (fill in once a choice is made) + +## Consequences +_Add once a decision is made._ +``` From 602879e0be1dd607d17660949c7c9c2bb1368262 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 25 Jun 2026 07:12:24 +0200 Subject: [PATCH 02/15] model draft --- docs/{ => future}/domain-architecture.md | 0 docs/inspect-app/README.md | 8 +- docs/inspect-app/concepts.md | 162 +-- .../decisions/0001-api-paradigm.md | 40 +- .../decisions/0002-loading-and-state.md | 21 +- .../decisions/0003-websocket-subscriptions.md | 31 +- .../decisions/0004-async-strategy.md | 41 +- .../inspect-app/decisions/0005-e2e-testing.md | 34 +- .../decisions/0006-commit-write-model.md | 40 +- docs/inspect-app/decisions/README.md | 16 +- docs/inspect-app/endpoints.md | 974 ++++++++++++++++++ docs/inspect-app/models.md | 602 +++++++++++ .../apps/__init__.py | 1 + .../apps/inspect/__init__.py | 3 + .../apps/inspect/domain/__init__.py | 11 + .../apps/inspect/domain/device.py | 71 ++ .../apps/inspect/domain/edge.py | 77 ++ .../apps/inspect/domain/port.py | 76 ++ .../apps/inspect/domain/service.py | 113 ++ .../apps/inspect/model/__init__.py | 5 + .../apps/inspect/model/actions.py | 102 ++ .../apps/inspect/model/collector.py | 264 +++++ .../apps/inspect/model/common.py | 87 ++ .../apps/inspect/model/ngraph.py | 147 +++ .../apps/inspect/model/update_topology.py | 88 ++ .../apps/inspect/snapshot.py | 408 ++++++++ 26 files changed, 3285 insertions(+), 137 deletions(-) rename docs/{ => future}/domain-architecture.md (100%) create mode 100644 docs/inspect-app/endpoints.md create mode 100644 docs/inspect-app/models.md create mode 100644 src/videoipath_automation_tool/apps/inspect/__init__.py create mode 100644 src/videoipath_automation_tool/apps/inspect/domain/__init__.py create mode 100644 src/videoipath_automation_tool/apps/inspect/domain/device.py create mode 100644 src/videoipath_automation_tool/apps/inspect/domain/edge.py create mode 100644 src/videoipath_automation_tool/apps/inspect/domain/port.py create mode 100644 src/videoipath_automation_tool/apps/inspect/domain/service.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/__init__.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/actions.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/collector.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/common.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/ngraph.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/update_topology.py create mode 100644 src/videoipath_automation_tool/apps/inspect/snapshot.py diff --git a/docs/domain-architecture.md b/docs/future/domain-architecture.md similarity index 100% rename from docs/domain-architecture.md rename to docs/future/domain-architecture.md diff --git a/docs/inspect-app/README.md b/docs/inspect-app/README.md index 49c21c2..44c7bbb 100644 --- a/docs/inspect-app/README.md +++ b/docs/inspect-app/README.md @@ -24,9 +24,13 @@ reference is now a primary source. Nothing here is implemented yet; this is the (the `[VERIFY]` items to confirm against a real server). Cross-references the [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) reference. -2. **[decisions/](./decisions/)** — the architecture decisions (ADRs), one per +2. **[models.md](./models.md)** — practical model guide for transport `InspectApi*` + DTOs, `InspectSnapshot`, and user-facing domain objects such as `InspectDevice`. +3. **[endpoints.md](./endpoints.md)** — anonymized endpoint reference with + concrete request and response shapes captured from a local test instance. +4. **[decisions/](./decisions/)** — the architecture decisions (ADRs), one per topic. Start with [the index](./decisions/README.md). -3. **[implementation-plan.md](./implementation-plan.md)** — phased, non-breaking +5. **[implementation-plan.md](./implementation-plan.md)** — phased, non-breaking rollout, target package layout, milestones, and risks. > **Wider context:** the package-wide re-think that grew out of this Inspect diff --git a/docs/inspect-app/concepts.md b/docs/inspect-app/concepts.md index e368bfc..8c076c0 100644 --- a/docs/inspect-app/concepts.md +++ b/docs/inspect-app/concepts.md @@ -1,6 +1,6 @@ # Inspect App — Concepts & Technical Model -> Status: **Draft** · Last updated: 2026-06-18 +> Status: **Draft** · Last updated: 2026-06-25 > > This document captures what the *Inspect* app is, how it maps onto the > existing package, and which technical details still need to be verified @@ -16,13 +16,16 @@ the operator to perform high-level monitoring of services combined with the ability to drill-down and inspect details to pinpoint service-affecting problems."* -In recent VideoIPath releases the Inspect app **replaces the Topology app**: it -becomes the entry point for building the network connectivity model (vertices, -edges, device placement), connecting devices, and watching their live -operational status — including **WebSocket event subscriptions** for live -updates. Inspect applies configuration changes with a **commit-style** model: -create/edit/delete actions are gathered into a change set and committed together -(see [ADR-0006](./decisions/0006-commit-write-model.md)). +In recent VideoIPath releases the Inspect app **replaces the Topology app** in +the product UI: it becomes the entry point for building the network connectivity +model (vertices, edges, device placement), connecting devices, and watching +operational status. The server exposes live update capabilities, but this +package deliberately stays request/response only (see +[ADR-0001](./decisions/0001-api-paradigm.md) and +[ADR-0003](./decisions/0003-websocket-subscriptions.md)). Inspect applies +configuration changes with a **commit-style** model: create/edit/delete actions +are gathered into a client-side change set and committed together (see +[ADR-0006](./decisions/0006-commit-write-model.md)). Inspect does **not** replace the **Inventory** app. Devices are still onboarded in Inventory first; only then can they be placed and connected in Inspect. So @@ -42,7 +45,7 @@ contract is mostly net-new** relative to what `app.topology` and **Reads** — one server-built aggregate: - `GET /rest/v2/data/status/collector/**` → `data.status.collector` (§3.1) -- Bundles topology nodes, inter-device edges, services/paths, live status, and +- Bundles topology nodes, inter-device edges, services/paths, status, and security context in a single tree with `_items[]` collections. **Writes** — one bulk action per commit: @@ -58,20 +61,21 @@ contract is mostly net-new** relative to what `app.topology` and **Namespace** — the `collector` API entry points live under `status` (`data/status/collector` for reads, `actions/status/collector` for writes), but the **underlying store is the existing config plane**: `updateTopology` mutations -land in `config/network/nGraphElements`. Confirmed by capture — the edge -`device0.1.10.out::device1.1.11.in` set to `weight: 1` via `updateTopology` -appears in `GET …/data/config/network/nGraphElements/**` with `weight: 1` and a +land in `config/network/nGraphElements`. Confirmed by capture — an anonymized +edge updated via `updateTopology` appeared in +`GET …/data/config/network/nGraphElements/**` with the changed value and a bumped `_rev`. So the collector is a **facade**: a status-namespace read/action surface in front of the revisioned `nGraphElements` config store (§3.3). -**Shared with existing apps** — the underlying store and its models, not the -collector wire shape: +**Shared with existing apps** — the underlying store and wire conventions, not +model classes: - The config store `nGraphElements` is **the same one `app.topology` already - reads and models** (`BaseDevice`, `IpVertex`, `UnidirectionalEdge`, - `codecVertex`). Inspect edits land there, revisioned with `_rev` (§3.3). + reads and models**. Inspect edits land there, revisioned with `_rev` (§3.3), + but the Inspect package keeps its own `InspectApi*` DTOs and does **not** import + or subclass topology/inventory model classes. - REST v2 envelope, session/XSRF auth, pid/id formats -- Vertex ids (`device0.1.10.out`), edge keys (`fromId::toId`) +- Vertex ids (`device-a.module-1.port-out-1.out`), edge keys (`fromId::toId`) - `descriptor` / `fDescriptor` objects, `sa` / `severity` status semantics - Device positions as float coordinates — `meta.coordinates` in the collector aggregate, `maps[].x/y` in `nGraphElements`, same *Inspect Topology* format as @@ -81,9 +85,9 @@ collector wire shape: | Layer | Responsibility | | ----- | -------------- | -| Collector read/parse | Model and parse `data.status.collector` | -| Change-set / commit write | Assemble `updateTopology` payloads, handle validation responses | -| Live / events | WebSocket subscriptions for status deltas | +| Collector read/parse | Parse `data.status.collector` with `InspectApi*` transport DTOs, then expose user-facing `InspectDevice` / `InspectService` objects via `InspectSnapshot` | +| Change-set / commit write | Assemble `updateTopology` payloads with `InspectApi*` DTOs, handle validation responses | +| Lookup / network actions | Model captured lookup, add-device, and sync-device action envelopes with `InspectApi*` DTOs | Inventory onboarding stays on the existing path (`config/devman/devices`, `/api/updateDevices`). `app.topology` and `app.inventory` remain unchanged; @@ -96,13 +100,13 @@ How Inspect concepts map onto existing package concepts: | Inspect concept | Inspect API (collector) | Existing model / app | | ---------------------- | ------------------------------------------------------ | --------------------------------------------- | | Device (inventory) | Prerequisite — not part of collector; onboard via `config/devman/devices` | `InventoryDevice` (`apps/inventory`) | -| Device (topology node) | Read: `collector.inspect.nodeStatus`; stored as `baseDevice` in `nGraphElements` | `TopologyDevice` (`apps/topology`) — store reuses existing model | -| Vertices / Edges | Read: `nodeStatus` `vertexInfo` / `externalEdgesByDeviceKey`; stored as `ipVertex` / `codecVertex` / `unidirectionalEdge` in `nGraphElements` | `BaseDevice`, `IpVertex`, `UnidirectionalEdge` — store reuses existing models | +| Device (topology node) | Read: `collector.inspect.nodeStatus`; stored as `baseDevice` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApiBaseDevice` | +| Vertices / Edges | Read: `nodeStatus` `vertexInfo` / `externalEdgesByDeviceKey`; stored as `ipVertex` / `codecVertex` / `unidirectionalEdge` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApi*` nGraph DTOs | | Change set / commit | `POST …/actions/status/collector/updateTopology` → writes `nGraphElements` ([ADR-0006](./decisions/0006-commit-write-model.md)) | _commit flow net-new; target store is existing `nGraphElements`_ | | Services / paths | `collector.inspect.paths`, `pathDescriptions` on nodes/edges | _none — net-new_ | | Device / edge status | Embedded in collector (`status`, `sa`/`severity`, bandwidth, PTP) | `inventory.model.device_status`, `status/network/*` — partial overlap | | Sync status | `syncSeverity` on nodeStatus items | `TopologySynchronize` via `status/network/nGraphSyncStatus` | -| Live events | WebSocket delta subscriptions | _none — net-new_ | +| Lookup / network actions | `lookupInspectDevice`, `lookupSyncInfo`, `addDevices`, `syncDevices` | request/response envelopes captured and modelled | | Connections / Partial connections | **[VERIFY]** — may relate to `pathDescriptions` / bookings | _none yet_ | ### 3.1 Collector aggregate — primary read surface @@ -133,15 +137,15 @@ Top-level sections under `data.status.collector`: | Concept | Example | Notes | | ------- | ------- | ----- | -| Device | `device0` | `deviceId`, `pid`, `_id` on nodeStatus items | -| Module pid | `device0.dev.1` | Nested under `modules` | -| Port pid | `device0.dev.1.10` | Nested under `ports` | -| Vertex id | `device0.1.10.out` | Shorter form in `vertexInfo`; used in edge keys | -| Edge id | `device0.1.10.out::device1.1.11.in` | Same key as `replaceEdges` in `updateTopology` | -| Device pair | `device0::device1` | Key for `externalEdgesByDeviceKey` items | -| Service / path | `100001::main` | `bookingId` + path role; appears in `pathDescriptions` and `inspect.paths` | -| Resource id | `device:device0.dev.1.1` | Prefixed resource references | -| Topo endpoint ref | `topo:device0.1.1` | Used in `serviceFields.from` / `.to` | +| Device | `device-a` | `deviceId`, `pid`, `_id` on nodeStatus items | +| Module pid | `device-a.dev.module-1` | Nested under `modules` | +| Port pid | `device-a.dev.module-1.port-out-1` | Nested under `ports` | +| Vertex id | `device-a.module-1.port-out-1.out` | Shorter form in `vertexInfo`; used in edge keys | +| Edge id | `device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in` | Same key as `replaceEdges` in `updateTopology` | +| Device pair | `device-a::device-b` | Key for `externalEdgesByDeviceKey` items | +| Service / path | `booking-1001::main` | `bookingId` + path role; appears in `pathDescriptions` and `inspect.paths` | +| Resource id | `device:device-a.dev.module-1.port-out-1` | Prefixed resource references | +| Topo endpoint ref | `topo:device-a.module-1.port-out-1` | Used in `serviceFields.from` / `.to` | **`vertexInfo`** on ports describes topology vertices: @@ -153,8 +157,7 @@ Top-level sections under `data.status.collector`: both `deviceLevel` (local hop: input → output within a device) and `serviceLevel` (end-to-end service: `bookingId`, `serviceLabel`, `fromStatus` / `toStatus`, `isMain`, `serviceStatus`). This is how the UI connects topology -elements to booked services — e.g. booking `100001` `"Encoder 1.1 -> Decoder 1.5"` -traverses `device0.1.10.out::device1.1.11.in`. +elements to booked services. **Edge live status** (`externalEdgesByDeviceKey`): each edge carries `bandwidth`, `fromStatus` / `toStatus`, `pathDescriptions`, and aggregate @@ -171,7 +174,8 @@ traverses `device0.1.10.out::device1.1.11.in`. - Edge keys and vertex ids from reads map directly onto `updateTopology` write payloads. - Commit validation references the same `bookingId`s visible in - `pathDescriptions` (e.g. failed delete for booking `100001` / main path edge). + `pathDescriptions` (e.g. failed delete for an anonymized booking / main path + edge). - **[VERIFY]** exact sub-paths behind the `/**` wildcard, projection/filter support, and pagination for large topologies. @@ -181,22 +185,27 @@ The VideoIPath backend separates **config** (mutable, revisioned) and **status** (read-only, subscription-friendly) ([Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro)). Inspect's `collector` API entry points sit under `status`, but its topology -edits resolve to the **config** plane (`nGraphElements`): +edits resolve to the **config** plane (`nGraphElements`). Network action +endpoints under `actions/status/network/*` are also relevant for device add/sync +workflows: | Plane | Used by | Read | Write | | ----- | ------- | ---- | ----- | | Config | `app.topology`, `app.inventory`, **Inspect (effective store)** | `GET …/data/config/…` | `PATCH …/data/config/…` (revisioned) or RPC | -| Status | Inventory status reads | `GET …/data/status/…` | — | +| Status | Inventory status reads, Inspect status reads | `GET …/data/status/…` | — | | Collector (facade) | `app.inspect` | `GET …/data/status/collector/**` (composed aggregate) | `POST …/actions/status/collector/updateTopology` → `nGraphElements` | +| Network actions | `app.inspect` device topology workflows | — | `POST …/actions/status/network/addDevices`, `POST …/actions/status/network/syncDevices` | So Inspect's read aggregate is status-namespace and net-new in shape, but its writes are commit-time-validated bulk actions that land in the revisioned `config/network/nGraphElements` store (§3.3). The client gathers edits locally -until commit; server-side staging / change-set ids are **[VERIFY]** -([ADR-0006](./decisions/0006-commit-write-model.md)). +until commit; ADR-0006 accepts that there is no separate server-side change-set +id for the verified `updateTopology` flow. -Status-plane **WebSocket subscriptions** deliver event-based deltas — the live -update channel for Inspect ([ADR-0003](./decisions/0003-websocket-subscriptions.md)). +The server can expose status-plane subscriptions, but WebSockets are out of +scope for this package. Fresh status is obtained by explicit re-fetches +([ADR-0001](./decisions/0001-api-paradigm.md), +[ADR-0003](./decisions/0003-websocket-subscriptions.md)). This framing underpins the API-paradigm and loading decisions ([ADR-0001](./decisions/0001-api-paradigm.md), @@ -206,8 +215,9 @@ This framing underpins the API-paradigm and loading decisions `GET /rest/v2/data/config/network/nGraphElements/**` → `data.config.network.nGraphElements._items[]`. This is the **revisioned source -of truth** for topology that Inspect's `updateTopology` writes into, and it is -already modelled by `app.topology`. +of truth** for topology that Inspect's `updateTopology` writes into. Its wire +shape overlaps with the Topology app, but the Inspect package models it with +standalone `InspectApi*` DTOs. | Field | Notes | | ----- | ----- | @@ -225,19 +235,17 @@ Element `type` values seen in capture: | `ipVertex` | Ethernet/IP port vertex (`.in` / `.out`) | `vertexType`, `gpid.pointId`, `supports*Cfg` capability flags | | `unidirectionalEdge` | Directed link/route between vertices | `fromId`, `toId`, `weight`, `capacity`, `bandwidth`, `redundancyMode`, `weightFactors`, `conflictPri` | -**Write round-trip confirmed:** the edge `device0.1.10.out::device1.1.11.in` -edited via `updateTopology` (ADR-0006 example, `weight: 1`) is present here with -`weight: 1` and `_rev: "3-…"`, and inter-device links appear as paired -unidirectional edges (`device0.1.10.out::device1.1.11.in` and -`device1.1.11.out::device0.1.10.in`, each `capacity: 65535`). Internal -fan-out edges (vertex→vertex within a device) use `capacity: 1`. +**Write round-trip confirmed:** an anonymized edge edited via `updateTopology` +is present here with the changed value and a bumped `_rev`, and inter-device +links appear as paired unidirectional edges (one in each direction, typically +with `capacity: 65535`). Internal fan-out edges (vertex→vertex within a device) +use `capacity: 1`. -**Implication:** Inspect's underlying topology model is the existing -`nGraphElements`, so the package's `BaseDevice` / `IpVertex` / -`UnidirectionalEdge` / codec-vertex models are reusable for the **store**, even -though the **collector read aggregate** (§3.1) has its own status-oriented -shape. **[VERIFY]** whether `updateTopology` performs `_rev` checks server-side -or last-writer-wins. +**Implication:** Inspect's underlying topology store is `nGraphElements`, but +the package keeps a separate Inspect model namespace (`InspectApiBaseDevice`, +`InspectApiIpVertex`, `InspectApiUnidirectionalEdge`, …). Do not reuse topology app +model classes in Inspect DTOs. **[VERIFY]** whether `updateTopology` performs +`_rev` checks server-side or last-writer-wins. ## 4. How the transport works today (recap) @@ -253,13 +261,19 @@ So the new layer fits the existing patterns rather than reinventing them: Pydantic. - Apps are **lazy-loaded** off `VideoIPathApp` and are **stateless**: every call re-fetches from the server (e.g. `topology.get_device` issues several `GET`s - and rebuilds the aggregate each time). + and rebuilds the aggregate each time). Inspect should follow that pattern. +- Inspect models live in two layers: + - `apps/inspect/model` — `InspectApi*` transport DTOs for HTTP payloads + - `apps/inspect/domain` and `apps/inspect/snapshot.py` — user-facing read + models backed by a collector snapshot and internal indexes +- App/API methods should own fetching, staging, committing, and error handling. ## 5. Endpoint discovery — how to fill in the **[VERIFY]** gaps Two complementary sources: the **official reference** (authoritative for the documented surface) and **browser capture** (authoritative for what the Inspect -GUI actually does, including undocumented calls and WebSocket frames). +GUI actually does, including undocumented calls). WebSocket frames can be useful +product context, but they are out of scope for the package per ADR-0003. 0. **Start from the official reference.** The [VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) @@ -271,9 +285,8 @@ GUI actually does, including undocumented calls and WebSocket frames). > resource during capture. 1. **Capture browser traffic.** Open the Inspect app in the VideoIPath web UI with the browser DevTools → Network tab. Record a full session (HAR export): - open a device, connect two devices, **commit a change set**, drill into a - service, watch live updates. Note REST calls *and* the WebSocket (`WS` - filter) frames. + open a device, add/sync devices, connect two devices, **commit a change set**, + and drill into a service. Note REST calls and payloads. 2. **Replay through the package logger.** The connector logs every response at `DEBUG` (`_log_response`). Set `log_level="DEBUG"` and call candidate endpoints to confirm payload shapes. @@ -294,36 +307,29 @@ REST: - [ ] Collector sub-paths: exact URLs behind the `/**` wildcard; projection/filter/pagination support - [ ] Connections / Partial Connections: resource paths, shapes, lifecycle - [x] Change set / commit endpoint: `POST /rest/v2/actions/status/collector/updateTopology` — bulk delta with `replaceDevices`, `replaceVertices`, `replaceEdges` (key `"fromId::toId"`), `replaceResourceTransforms`, `addExternalEdges`, `remove`, `force` ([ADR-0006](./decisions/0006-commit-write-model.md)) -- [ ] Change set staging: server-side change-set id vs. client-only gather until commit +- [x] Change set staging: client-side gather until `updateTopology`; no separate server-side change-set id for the verified flow (ADR-0006) - [x] Commit failure response: check `data.res.ok` / `data.validation.result.ok` (not `header.ok`); `validation.details[id]` carries `status`, `rev`, `resolvable`, `type`; failed delete example: `"A required edge was not found. (main)"`, `items: []` ([ADR-0006](./decisions/0006-commit-write-model.md)) -- [ ] Commit success response: `data.items` shape when validation passes +- [x] Commit success response for no-op: `data.items: []`, `data.res.ok: true`, `data.validation.result.ok: true` ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) +- [ ] Commit success response with real applied changes: `data.items` contents when validation passes and changes are applied - [ ] Commit semantics: partial apply after validation pass, discard/rollback of abandoned client-side change sets - [ ] Import / Export (preview): scope and payload format -- [x] Write/action endpoint: `/rest/v2/actions/status/collector/updateTopology` (others under `…/actions/status/collector/*` still **[VERIFY]**) -- [x] Config store / write target: `updateTopology` lands in `GET /rest/v2/data/config/network/nGraphElements/**` (`_items[]`, `_rev`, `type` ∈ `baseDevice` / `codecVertex` / `ipVertex` / `unidirectionalEdge`); reuses existing topology models (§3.3) +- [x] Write/action endpoint: `/rest/v2/actions/status/collector/updateTopology` +- [x] Collector action payloads captured and modelled: `/rest/v2/actions/status/collector/lookupInspectDevice`, `/rest/v2/actions/status/collector/lookupSyncInfo` +- [x] Network action request shapes, normal action responses, and validation-error responses captured: `/rest/v2/actions/status/network/addDevices`, `/rest/v2/actions/status/network/syncDevices` +- [x] Config store / write target: `updateTopology` lands in `GET /rest/v2/data/config/network/nGraphElements/**` (`_items[]`, `_rev`, `type` ∈ `baseDevice` / `codecVertex` / `ipVertex` / `unidirectionalEdge`); model with standalone `InspectApi*` DTOs (§3.3) - [ ] `_rev` handling on commit: does `updateTopology` enforce optimistic concurrency or last-writer-wins? - [ ] Version gating: first VideoIPath version that exposes each endpoint - -WebSocket (see [ADR-0003](./decisions/0003-websocket-subscriptions.md)): - -- [ ] URL / scheme (`ws://` vs `wss://`, path, port) -- [ ] Auth handshake (Basic header, cookie/session, query token?) -- [ ] Subscribe / unsubscribe message format (which resources can be watched?) -- [ ] Event/patch frame format (full snapshot vs. delta/patch; ids & revisions) -- [ ] Heartbeat / keep-alive, server-side timeouts, reconnect & resync semantics -- [ ] Back-pressure / message ordering / dropped-update guarantees +- [x] DTO coverage: add typed request/response models for captured lookup, action result, and validation-error endpoint payloads in [endpoints.md](./endpoints.md) ## 6. Open questions - **Collector sub-paths and scaling** — exact URLs behind `/**`; projection, filtering, and pagination for large topologies ([ADR-0002](./decisions/0002-loading-and-state.md)). -- **WebSocket scope** — generic subscription to any `status/...` path, or - Inspect-specific streams? ([ADR-0003](./decisions/0003-websocket-subscriptions.md)) - **Commit model** ([ADR-0006](./decisions/0006-commit-write-model.md)) — - server-side change-set id vs. client-only gather; successful response shape - (`data.items`); all-or-nothing apply after validation; meaning of validation - `status` codes and `resolvable: true` cases. + successful applied-change response shape (`data.items`); all-or-nothing apply + after validation; meaning of validation `status` codes and `resolvable: true` + cases. - **Commit concurrency** — `updateTopology` writes bump `nGraphElements` `_rev` (§3.3); does the action enforce `_rev` checks or last-writer-wins? - **Connections / Partial Connections** — relationship to `pathDescriptions`, diff --git a/docs/inspect-app/decisions/0001-api-paradigm.md b/docs/inspect-app/decisions/0001-api-paradigm.md index a7aa344..c123cdb 100644 --- a/docs/inspect-app/decisions/0001-api-paradigm.md +++ b/docs/inspect-app/decisions/0001-api-paradigm.md @@ -1,6 +1,6 @@ -# ADR-0001: API paradigm — data-driven core + event layer +# ADR-0001: API paradigm — data-driven, request/response -> Status: **Proposed** +> Status: **Accepted** > Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl ## Context @@ -10,15 +10,16 @@ aggregate (`InventoryDevice`, `TopologyDevice`), mutates the object locally, and calls `update_*`, which diffs against the server state and `PATCH`es only the changed elements (revision-checked). It is stateless and request/response based. -Inspect adds a **monitoring** dimension (services, live status, drill-down) and -a **WebSocket** channel. Monitoring is naturally **event/observation-driven**: -the client reacts to changes it did not initiate. So the question is whether to: +Inspect adds a **monitoring** dimension (services, status, drill-down). The +primary consumers are **deterministic pipeline automations** — short-lived, +scripted runs that load state, apply changes, and exit. For that usage model, +live event streams and WebSocket subscriptions add complexity without clear +benefit: each run should start from an explicit server read and produce a known +outcome. -- keep everything data-driven (poll for status), or -- move to an event/action-driven model, or -- combine the two. - -This maps cleanly onto the config-plane vs. status-plane split described in +The question was whether to keep everything data-driven, move to an +event/action-driven model, or combine the two. This maps onto the config-plane +vs. status-plane split described in [concepts.md](../concepts.md#3-domain-model). ## Options @@ -44,4 +45,21 @@ This maps cleanly onto the config-plane vs. status-plane split described in ## Decision -_To be decided._ +**Option 1: data-driven only (request/response).** + +The package stays fully data-driven. Reads fetch aggregates from the server; +writes apply changes via explicit API calls. No WebSocket subscriptions, no live +update layer, no event-driven observation API. + +Status reads use the same request/response model as configuration CRUD. If +freshness is needed, the caller re-fetches explicitly. + +## Consequences + +- Consistent with existing apps and the automation/pipeline usage model. +- No WebSocket client, subscription machinery, or dual interaction styles to + build or document. +- Live monitoring UX (as in the Inspect UI) is out of scope for the package; + automations get predictable, reproducible runs instead. +- See [ADR-0003](./0003-websocket-subscriptions.md) (deprecated) and + [ADR-0004](./0004-async-strategy.md) for related decisions. diff --git a/docs/inspect-app/decisions/0002-loading-and-state.md b/docs/inspect-app/decisions/0002-loading-and-state.md index 163e5f5..adf9260 100644 --- a/docs/inspect-app/decisions/0002-loading-and-state.md +++ b/docs/inspect-app/decisions/0002-loading-and-state.md @@ -1,6 +1,6 @@ # ADR-0002: Loading & state model -> Status: **Proposed** +> Status: **Accepted** > Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl ## Context @@ -54,4 +54,21 @@ Two sub-questions follow: ## Decision -_To be decided._ +**Option 1: stateless, eager per-entity — always load the full aggregate.** + +Each read fetches the **complete device aggregate** for the requested entity, +including edges, connections, vertices, and related sections. No lazy partial +objects, no field/section projection as a default path, and no client-side cache +or long-lived snapshots. + +Bulk helpers may be added later to reduce N+1 when iterating many entities, but +each entity returned is still a full aggregate. + +## Consequences + +- Predictable object shape: callers always receive a coherent, complete device. +- Always fresh on each read; no cache-invalidation logic. +- Chatty per-entity reads remain a known cost; internal parallelism for + multi-request reads is handled separately ([ADR-0004](./0004-async-strategy.md)). +- Projection/narrow reads and client-side caching are deferred unless a concrete + pipeline need emerges. diff --git a/docs/inspect-app/decisions/0003-websocket-subscriptions.md b/docs/inspect-app/decisions/0003-websocket-subscriptions.md index db3ecd1..598cd9c 100644 --- a/docs/inspect-app/decisions/0003-websocket-subscriptions.md +++ b/docs/inspect-app/decisions/0003-websocket-subscriptions.md @@ -1,14 +1,18 @@ # ADR-0003: WebSocket event subscriptions -> Status: **Proposed** +> Status: **Deprecated** > Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl ## Context -Inspect adds a **WebSocket channel** for live status/event updates. The package -has no WebSocket client today; the connector layer is sync `requests`. We want -live updates without destabilising the synchronous CRUD core, and without -forcing every user into `asyncio` (see [ADR-0004](./0004-async-strategy.md)). +The VideoIPath server exposes a **WebSocket channel** for live status/event +updates. This ADR originally explored how the Python package might consume +those subscriptions. + +The package has no WebSocket client today; the connector layer is sync +`requests`. [ADR-0001](./0001-api-paradigm.md) now accepts a fully +data-driven, request/response model for deterministic pipeline automations, +which makes WebSocket support unnecessary for the package's scope. The [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) reference **confirms** the high-level model: the `status` plane is "frequently @@ -42,4 +46,19 @@ format, heartbeat, reconnect) remain **[VERIFY]** — capture them per ## Decision -_To be decided._ +**Option 1: skip WebSocket; no live subscriptions in the package.** + +WebSocket event subscriptions are **out of scope**. The package does not +implement a subscription API, background listener thread, or async event stream. +Callers that need updated status re-fetch via the normal read methods. + +This ADR is **deprecated** — kept for historical context on the server +capability, not as an active design direction. + +## Consequences + +- No new WebSocket dependency or concurrency machinery. +- Removes the primary driver for an async public API surface. +- Server-side subscription capability remains documented in + [concepts.md](../concepts.md) for reference; a future ADR would be needed to + revisit this if long-lived monitoring clients become a package goal. diff --git a/docs/inspect-app/decisions/0004-async-strategy.md b/docs/inspect-app/decisions/0004-async-strategy.md index 8ec46f7..fcb69c4 100644 --- a/docs/inspect-app/decisions/0004-async-strategy.md +++ b/docs/inspect-app/decisions/0004-async-strategy.md @@ -1,26 +1,21 @@ # ADR-0004: Async readiness & migration -> Status: **Proposed** +> Status: **Accepted** > Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl ## Context -The package is **sync**, built on `requests`. Two issues forces push toward async: +The package is **sync**, built on `requests`. Bulk reads (e.g. assembling a full +device aggregate) can benefit from **concurrent I/O** when a single high-level +action requires multiple API requests. -1. **WebSocket subscriptions** ([ADR-0003](./0003-websocket-subscriptions.md)) - are naturally async. -2. **Bulk/concurrent I/O** (Inspect's combined data set, large topologies) is - far more efficient with concurrency. - -But there is an **existing sync user base and sync test suite**, and a published +There is an **existing sync user base and sync test suite**, and a published PyPI package (`0.8.x`). We must not break sync users, and we should avoid a -risky big-bang rewrite. Python is `>=3.11`, so modern async primitives -(`TaskGroup`, `asyncio.timeout`) are available. +risky big-bang rewrite. WebSocket subscriptions ([ADR-0003](./0003-websocket-subscriptions.md)) +are out of scope, removing the main reason for an async public API. -Key structural fact in our favour: the **Pydantic models and the diff/patch -logic are I/O-agnostic**. Only the connector and the api/app methods are -I/O-bound. So async-ification is mostly a transport + method-coloring problem, -not a domain-logic rewrite. +Key structural fact: the **Pydantic models and diff/patch logic are +I/O-agnostic**. Only the connector and api/app methods are I/O-bound. ## Options @@ -46,4 +41,20 @@ not a domain-logic rewrite. ## Decision -_To be decided._ +**Stay sync at the package boundary; use internal parallelism only where a +single action needs multiple API requests.** + +The public API remains synchronous — no `async`/`await` surface, no dual-stack +codegen, no migration to `httpx` async clients. When one high-level operation +(e.g. loading a full device aggregate) requires several independent `GET`s, +those requests may be issued **in parallel internally** (e.g. thread pool) to +reduce latency. This is an implementation detail of the connector/read path, not +a new interaction model for callers. + +## Consequences + +- Zero breaking change for existing sync users and scripts. +- No async test matrix, no `unasync` tooling, no sync-over-async footguns. +- Performance gains are limited to multi-request reads/writes inside the + library; callers do not manage concurrency themselves. +- A future async public API would require a new ADR; it is not planned now. diff --git a/docs/inspect-app/decisions/0005-e2e-testing.md b/docs/inspect-app/decisions/0005-e2e-testing.md index 4a7a900..d86b6c3 100644 --- a/docs/inspect-app/decisions/0005-e2e-testing.md +++ b/docs/inspect-app/decisions/0005-e2e-testing.md @@ -1,23 +1,22 @@ # ADR-0005: E2E testing strategy -> Status: **Proposed** +> Status: **Accepted** > Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl ## Context -Inspect spans config CRUD, status reads, and a WebSocket channel against a -proprietary server whose payloads vary by version. Tests must be **low-effort, -stable, and trustworthy** — i.e. they should fail only for real regressions, and -when green they should genuinely mean "this works against VideoIPath". +Inspect spans config CRUD and status reads against a proprietary server whose +payloads vary by version. Tests must be **low-effort and trustworthy** — when +green they should genuinely mean "this works against VideoIPath". Today the suite is small (validators) and a live server is configured via `tests/.env.test` + `pytest-dotenv`. There is precedent for "validate our assumptions against the live server": `advanced_driver_schema_check` compares local vs. server driver schemas. -The tension: a **live-only** suite is the most trustworthy but is slow, flaky, -stateful, and needs infrastructure; a **mock-only** suite is fast and stable but -only tests our assumptions, not reality. +We want to keep testing **simple** for now: one layer, run locally by the +developer against a real instance — not a multi-tier strategy with mocks, +cassettes, and fake servers. ## Options @@ -35,4 +34,21 @@ only tests our assumptions, not reality. ## Decision -_To be decided._ +**Live server E2E only — developer-run, locally, against a real VideoIPath instance.** + +E2E tests use the Python package to execute full test scenarios against a live +server. The developer provides credentials and connection details (via +`tests/.env.test` or equivalent). No recorded HTTP cassettes, no fake VideoIPath +server, and no separate contract/fixture test layer for now. + +These tests are **not** required on every CI push; they are run locally when a +developer has an instance available. + +## Consequences + +- Highest confidence: tests exercise the real API, auth, and payload shapes. +- Simple setup: one test style, one configuration path, no cassette maintenance. +- Tests are stateful, environment-dependent, and slower — acceptable trade-off + for the current team size and Inspect scope. +- Mock/cassette/fake-server layers can be revisited via a new ADR if CI + automation or faster feedback loops become a priority. diff --git a/docs/inspect-app/decisions/0006-commit-write-model.md b/docs/inspect-app/decisions/0006-commit-write-model.md index b47e00d..902b997 100644 --- a/docs/inspect-app/decisions/0006-commit-write-model.md +++ b/docs/inspect-app/decisions/0006-commit-write-model.md @@ -1,6 +1,6 @@ # ADR-0006: Commit-style write model (change sets) -> Status: **Proposed** +> Status: **Accepted** > Date: 2026-06-18 · Deciders: Paul Winterstein, Jonas Scholl ## Context @@ -25,6 +25,11 @@ There is **no commit, transaction, or staging concept** in the package today batch). So Inspect's commit behaviour is a **net-new write path**, not a reuse of the existing one. +**Data vs. operations:** Pydantic models / DTOs hold **data only** — fields, +nested structures, validation. They do not expose `add`, `update`, `delete`, or +`commit` methods. All mutations go through the **app instance** or an optional +**transaction** object that stages changes and commits them to the server. + ### Verified wire format (browser capture, 2026-06-18) Inspect applies topology edits with a **single bulk action** — not the existing @@ -112,7 +117,7 @@ delete-device attempt the top-level `header` still reported success: | `data.items` | `[]` | No applied changes returned | Per-entity validation details live in `data.validation.details`, keyed by id -(e.g. `"100001"`): +(e.g. `"booking-1001"`): | Field | Example | Notes | | ----- | ------- | ----- | @@ -129,7 +134,7 @@ Per-entity validation details live in `data.validation.details`, keyed by id "caption": "Operation Successful", "code": "OK", "ok": true, - "user": "sysadmin" + "user": "api-user" }, "data": { "items": [], @@ -140,11 +145,11 @@ Per-entity validation details live in `data.validation.details`, keyed by id "validation": { "createIds": [], "details": { - "100001": { + "booking-1001": { "isCancel": false, "isProduct": false, "resolvable": false, - "rev": "2-2026-06-15T13:03:50.631612311Z[UTC]", + "rev": "2-2026-06-15T13:03:50.000000000Z[UTC]", "status": -22, "type": "generic" } @@ -226,4 +231,27 @@ What remains **[VERIFY]**: ## Decision -_To be decided._ +**Option 3 (hybrid): explicit transaction via context manager, plus direct write +operations on the app.** + +- **Data-only DTOs** — models represent server payloads; no behaviour methods. +- **App-level direct writes** — `add` / `update` / `delete` on the inspect app + apply and commit immediately (one change set, one `updateTopology` call), for + simple one-off scripts. +- **Optional transaction** — a context manager (e.g. `with app.inspect.transaction()` + or similar) stages multiple actions and commits them atomically on exit; discard + on error or explicit cancel. + +Staging is **client-side** until the `POST …/updateTopology` commit; there is no +separate server-side change-set id (per verified wire format below). + +## Consequences + +- Two documented usage styles, but both map to the same underlying commit + payload shape. +- Context manager must define exit behaviour: commit on success, discard/raise + on failure; document what happens to an uncommitted transaction. +- DTOs stay portable and serializable; business logic lives in the app/transaction + layer, consistent with existing topology/inventory patterns. +- Single-change scripts stay ergonomic via direct writes; pipeline edits that + touch multiple elements use the transaction path for atomicity. diff --git a/docs/inspect-app/decisions/README.md b/docs/inspect-app/decisions/README.md index 987859e..39746b2 100644 --- a/docs/inspect-app/decisions/README.md +++ b/docs/inspect-app/decisions/README.md @@ -15,14 +15,14 @@ accepted — to change a decision, add a new ADR that supersedes the old one ## Index -| ADR | Title | Status | -| ----------------------------------------------------- | ------------------------------------ | -------- | -| [0001](./0001-api-paradigm.md) | API paradigm: data-driven + events | Proposed | -| [0002](./0002-loading-and-state.md) | Loading & state model | Proposed | -| [0003](./0003-websocket-subscriptions.md) | WebSocket event subscriptions | Proposed | -| [0004](./0004-async-strategy.md) | Async readiness & migration | Proposed | -| [0005](./0005-e2e-testing.md) | E2E testing strategy | Proposed | -| [0006](./0006-commit-write-model.md) | Commit-style write model (change sets) | Proposed | +| ADR | Title | Status | +| ----------------------------------------------------- | ------------------------------------ | ---------- | +| [0001](./0001-api-paradigm.md) | API paradigm: data-driven | Accepted | +| [0002](./0002-loading-and-state.md) | Loading & state model | Accepted | +| [0003](./0003-websocket-subscriptions.md) | WebSocket event subscriptions | Deprecated | +| [0004](./0004-async-strategy.md) | Async readiness & migration | Accepted | +| [0005](./0005-e2e-testing.md) | E2E testing strategy | Accepted | +| [0006](./0006-commit-write-model.md) | Commit-style write model (change sets) | Accepted | ## Template diff --git a/docs/inspect-app/endpoints.md b/docs/inspect-app/endpoints.md new file mode 100644 index 0000000..94f408b --- /dev/null +++ b/docs/inspect-app/endpoints.md @@ -0,0 +1,974 @@ +# Inspect App Endpoint Reference + +Captured against a local VideoIPath test instance. All hostnames, usernames, +device IDs, booking IDs, labels, endpoint IDs, IP addresses, multicast +addresses, UUIDs, and revisions in this document are anonymized examples. Only +read-only requests and one empty no-op `updateTopology` POST were executed. + +The Inspect package scope follows the accepted ADRs: + +- Request/response only; no WebSocket subscription API. +- Stateless reads; callers re-fetch when they need fresh status. +- Data-only DTOs; write/commit behaviour belongs in the future app/transaction + layer, not in the model classes. + +## Common Envelope + +All observed REST v2 responses use the standard envelope: + +```json +{ + "data": {}, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +`header.ok` only describes transport/envelope success. For `updateTopology`, +commit success must also check `data.res.ok` and +`data.validation.result.ok`. + +## `GET /rest/v2/data/status/system/about/version` + +Purpose: identify the server version used for endpoint and payload capture. + +Request: + +```http +GET /rest/v2/data/status/system/about/version +Authorization: Basic +Accept: application/json +``` + +Response example: + +```json +{ + "data": { + "status": { + "system": { + "about": { + "version": "2025.4.x" + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +## `GET /rest/v2/data/status/collector/**` + +Purpose: canonical Inspect read aggregate. This is the primary read surface for +services, path drill-down, external edge status, and optional topology node +status. + +Observed top-level sections: + +- `inspect.nodeStatus` +- `inspect.paths` +- `externalEdgesByDeviceKey` +- `maintenanceBookings` +- `superProfiles` +- `tagInfo` + +`security/**` returned an empty `collector` object on this instance. + +Request: + +```http +GET /rest/v2/data/status/collector/** +Authorization: Basic +Accept: application/json +``` + +Response shape example: + +```json +{ + "data": { + "status": { + "collector": { + "externalEdgesByDeviceKey": { "_items": [] }, + "inspect": { + "nodeStatus": { "_items": [] }, + "paths": { "_items": [] } + }, + "maintenanceBookings": { "_items": [] }, + "superProfiles": { "_items": [] }, + "tagInfo": { "_items": [] } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +## `GET /rest/v2/data/status/collector/inspect/paths/**` + +Purpose: list service/path records with endpoint labels, service state, and +per-hop path structures. + +Request: + +```http +GET /rest/v2/data/status/collector/inspect/paths/** +Authorization: Basic +Accept: application/json +``` + +Response item example: + +```json +{ + "_id": "booking-1001::main", + "_vid": "_:booking-1001::main", + "serviceFields": { + "bid": "booking-1001", + "ctype": 2, + "formatSubState": 0, + "from": "topo:device-a.module-1.port-out-1", + "fromLabel": "Source Endpoint A", + "fromPid": "device-a.dev.module-1.port-out-1", + "fromStatus": { "sa": 0, "severity": 1 }, + "generic": { + "allocationState": 0, + "cancelTime": null, + "descriptor": { + "desc": "", + "label": "Source Endpoint A -> Destination Endpoint B" + }, + "locked": false, + "state": 1, + "tags": [] + }, + "isMain": true, + "serviceStatus": { + "config": { "sa": 0, "severity": 1 }, + "total": { "sa": 0, "severity": 1 } + }, + "to": "topo:device-b.module-2.port-in-1", + "toLabel": "Destination Endpoint B", + "toPid": "device-b.dev.module-2.port-in-1", + "toStatus": { "sa": 0, "severity": 1 } + }, + "path": [ + { + "bid": "booking-1001", + "ipDesc": ":", + "structure": { + "deviceId": "device-a", + "deviceLabel": "Example Source Device", + "devicePid": "device-a", + "expectConfig": true, + "inputStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "label": "Source Endpoint A", + "pid": "device-a.dev.module-1.port-out-1", + "status": { "sa": 0, "severity": 1 } + }, + "moduleAndDeviceStatus": { "sa": 0, "severity": 1 }, + "outputStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.network-module", + "portPid": "device-a.dev.network-module.port-1" + }, + "label": "Network Port A", + "pid": "device-a.dev.network-module.port-1", + "status": { "sa": 0, "severity": 1 } + } + } + } + ] +} +``` + +## `GET /rest/v2/data/status/collector/externalEdgesByDeviceKey/**` + +Purpose: live inter-device link status grouped by device pair. + +Request: + +```http +GET /rest/v2/data/status/collector/externalEdgesByDeviceKey/** +Authorization: Basic +Accept: application/json +``` + +Response item example: + +```json +{ + "_id": "device-a::device-b", + "_vid": "device-a::device-b", + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + }, + "primary": { + "devicePid": "device-a", + "label": "Example Source Device", + "data": { + "edge-uuid-0001": { + "bandwidth": 0.0, + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "label": "Port A (out)", + "pid": "device-a.dev.module-1.port-out-1", + "status": { "sa": 0, "severity": 1 } + }, + "id": "edge-uuid-0001", + "maxBandwidth": null, + "pathDescriptions": {}, + "ratio": 0.0, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + }, + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.module-1", + "portPid": "device-b.dev.module-1.port-in-1" + }, + "label": "Port B (in)", + "pid": "device-b.dev.module-1.port-in-1", + "status": { "sa": 0, "severity": 1 } + } + } + } + }, + "secondary": { + "devicePid": "device-b", + "label": "Example Destination Device", + "data": {} + } +} +``` + +## `GET /rest/v2/data/status/collector/inspect/nodeStatus/**` + +Purpose: topology node/device status, including modules, ports, +`vertexInfo`, coordinates, statuses, and embedded path descriptions when the +server/user exposes them. + +In the sanitized capture, the endpoint was valid but returned no items: + +```json +{ + "data": { + "status": { + "collector": { + "inspect": { + "nodeStatus": { + "_items": [] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +The model still includes `InspectApiNodeStatusItem` based on the accepted concept +document and the known Inspect UI payload shape. + +## `GET /rest/v2/data/status/collector/maintenanceBookings/**` + +Purpose: maintenance booking status relevant to Inspect service/link display. + +Observed response on this instance: + +```json +{ + "data": { + "status": { + "collector": { + "maintenanceBookings": { + "_items": [] + } + } + } + } +} +``` + +## `GET /rest/v2/data/status/collector/superProfiles/**` + +Purpose: routing/super-profile status data used by the Inspect aggregate. + +Observed response on this instance: + +```json +{ + "data": { + "status": { + "collector": { + "superProfiles": { + "_items": [] + } + } + } + } +} +``` + +## `GET /rest/v2/data/status/collector/tagInfo/**` + +Purpose: tag/profile metadata used by the Inspect aggregate. + +Observed response on this instance: + +```json +{ + "data": { + "status": { + "collector": { + "tagInfo": { + "_items": [] + } + } + } + } +} +``` + +## `GET /rest/v2/data/status/network/edgesByDevice/**` + +Purpose: existing status-plane edge view. This is not the collector facade, but +it is useful for cross-checking edge payload fields when +`config/network/nGraphElements` is unavailable or permission-filtered. + +Request: + +```http +GET /rest/v2/data/status/network/edgesByDevice/** +Authorization: Basic +Accept: application/json +``` + +Response fragment: + +```json +{ + "data": { + "status": { + "network": { + "edgesByDevice": { + "_items": [ + { + "_id": "device-a", + "_vid": "device-a", + "edge-uuid-0001": { + "active": true, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": { "desc": "", "label": "" }, + "excludeFormats": [], + "fromId": "device-a.module-1.port-out-1", + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": "device-b.module-1.port-in-1", + "type": "unidirectionalEdge", + "weight": 1, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "max": 100, "weight": 0 } + } + } + } + ] + } + } + } + } +} +``` + +## `GET /rest/v2/data/config/network/nGraphElements/**` + +Purpose: revisioned config store that `updateTopology` persists into. Inspect +models include independent `InspectApi*` nGraph DTOs for this persisted shape, but +they do not import or subclass topology app models. + +In the sanitized capture, the endpoint was valid but returned no items: + +```json +{ + "data": { + "config": { + "network": { + "nGraphElements": { + "_items": [] + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Relevant type-filtered forms: + +```http +GET /rest/v2/data/config/network/nGraphElements/* where type='baseDevice' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='ipVertex' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='codecVertex' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='genericVertex' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='unidirectionalEdge' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='nGraphResourceTransform' /** +``` + +## Known Action Endpoints Needing Payload Capture + +Manual endpoint discovery also identified the following Inspect-related action +endpoints. The examples below are anonymized and were captured with safe lookup +or no-op requests. + +### `POST /rest/v2/actions/status/collector/lookupInspectDevice` + +Purpose: collector lookup for one Inspect device/topology node. + +Request: + +```http +POST /rest/v2/actions/status/collector/lookupInspectDevice +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": "device-a" +} +``` + +Response example: + +```json +{ + "data": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "fields": { + "coordinates": { + "x": 500, + "y": 8150 + }, + "descriptor": { + "desc": "Example device description", + "label": "Example Source Device" + }, + "iconSize": "medium", + "iconType": "gateway", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": ["#example-tag-a", "#example-tag-b"], + "virtualDeviceFields": null + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Model coverage: + +- `InspectApiLookupInspectDeviceRequest` +- `InspectApiLookupInspectDeviceResponse` +- `InspectApiAssignedTags` +- `InspectApiLookupInspectDeviceFields` + +Invalid object-style request example: + +```json +{ + "header": { + "auth": true, + "caption": "Invalid Request", + "code": "INVALID_REQUEST", + "errorDetails": [ + { + "msg": "Can't convert { object } to String", + "path": [], + "type": "conversionError" + } + ], + "id": "0", + "msg": ["Can't convert { object } to String"], + "ok": false, + "user": "api-user" + } +} +``` + +### `POST /rest/v2/actions/status/collector/lookupSyncInfo` + +Purpose: collector lookup for synchronization information. + +Request: + +```http +POST /rest/v2/actions/status/collector/lookupSyncInfo +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": ["device-a"] +} +``` + +Response example: + +```json +{ + "data": { + "device-a": { + "add": {}, + "label": "Example Source Device", + "remove": {}, + "severity": 0, + "update": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Model coverage: + +- `InspectApiLookupSyncInfoRequest` +- `InspectApiLookupSyncInfoResponse` +- `InspectApiLookupSyncInfoItem` + +The device list must be non-empty. An empty list produced: + +```json +{ + "header": { + "auth": true, + "caption": "Invalid Request", + "code": "INVALID_REQUEST", + "errorDetails": [ + { + "msg": "Cannot create NonEmptyList from empty list", + "path": [], + "type": "conversionError" + } + ], + "id": "0", + "msg": ["Cannot create NonEmptyList from empty list"], + "ok": false, + "user": "api-user" + } +} +``` + +### `POST /rest/v2/actions/status/network/addDevices` + +Purpose: network action used by Inspect topology workflows to add devices. + +Request shape: + +```http +POST /rest/v2/actions/status/network/addDevices +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": [ + { + "id": "device-a", + "x": 500, + "y": 8150 + } + ] +} +``` + +No-op request used for safe capture: + +```json +{ + "header": { "id": 0 }, + "data": [] +} +``` + +No-op response: + +```json +{ + "data": { + "msg": [], + "ok": true + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Model coverage: + +- `InspectApiAddDevicesRequest` +- `InspectApiAddDevicesItem` +- `InspectApiSimpleActionResponse` +- `InspectApiActionValidationErrorResponse` + +Non-empty request against a device outside the caller's writable domains returned +an HTTP 422 validation envelope and did not create any `nGraphElements`: + +```json +{ + "header": { + "auth": true, + "caption": "The request could not be processed due to e.g. validation errors", + "code": "VALIDATION_ERROR", + "errorCodes": [], + "errorDetails": [ + { + "cause": "general", + "msg": "Operation 'update' not allowed for resource type 'device' in domain 'GroupDomainId(example-domain)'", + "path": ["device-a"], + "type": "validationError" + } + ], + "id": "", + "msg": [ + "Operation 'update' not allowed for resource type 'device' in domain 'GroupDomainId(example-domain)'" + ], + "ok": false, + "user": "api-user" + } +} +``` + +Non-empty request against a disposable, non-driver device ID returned the normal +action response envelope with `data.ok: false` and did not create any +`nGraphElements`: + +```json +{ + "data": { + "msg": ["No topology reported by the device"], + "ok": false + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +### `POST /rest/v2/actions/status/network/syncDevices` + +Purpose: network action used by Inspect topology workflows to synchronize +devices. + +Request: + +```http +POST /rest/v2/actions/status/network/syncDevices +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": { + "ids": ["device-a"], + "addOnly": true, + "conflictStrategy": 0 + } +} +``` + +`conflictStrategy` values observed in the frontend bundle: + +| Value | Meaning | +| --- | --- | +| `0` | Strict | +| `1` | Invalidate services | +| `2` | Cancel services | + +No-op request used for safe capture: + +```json +{ + "header": { "id": 0 }, + "data": { + "ids": [], + "addOnly": true, + "conflictStrategy": 0 + } +} +``` + +No-op response: + +```json +{ + "data": { + "msg": [], + "ok": true + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Model coverage: + +- `InspectApiSyncDevicesRequest` +- `InspectApiSyncDevicesRequestData` +- `InspectApiSimpleActionResponse` + +Non-empty request against a disposable, non-driver device ID returned: + +```json +{ + "data": { + "msg": [ + "A device sync requires the device to be present both in the graph and in the driver" + ], + "ok": false + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +## `POST /rest/v2/actions/status/collector/updateTopology` + +Purpose: Inspect commit endpoint. The client sends a full change set; empty +maps/lists mean no changes in that category. Staging is client-side until this +POST. + +No-op request used for this capture: + +```http +POST /rest/v2/actions/status/collector/updateTopology +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": { + "replaceDevices": {}, + "replaceVertices": {}, + "replaceEdges": {}, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } +} +``` + +No-op response: + +```json +{ + "data": { + "items": [], + "res": { + "msg": [], + "ok": true + }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": [], + "ok": true + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Successful commit detection: + +```python +committed = response.header.ok and response.data.res.ok and response.data.validation.result.ok +``` + +Known failure shape from the accepted ADR: + +```json +{ + "data": { + "items": [], + "res": { + "msg": ["Validation failed"], + "ok": false + }, + "validation": { + "createIds": [], + "details": { + "booking-1001": { + "isCancel": false, + "isProduct": false, + "resolvable": false, + "rev": "2-2026-01-01T00:00:00.000000000Z[UTC]", + "status": -22, + "type": "generic" + } + }, + "result": { + "msg": ["A required edge was not found. (main)"], + "ok": false + } + } + } +} +``` diff --git a/docs/inspect-app/models.md b/docs/inspect-app/models.md new file mode 100644 index 0000000..c6400a6 --- /dev/null +++ b/docs/inspect-app/models.md @@ -0,0 +1,602 @@ +# Inspect App Data Model + +This document describes Inspect data as package users should think about it: +services, devices, ports, edges, status, and topology changes. + +The implementation uses two layers: + +- **Transport DTOs** in `src/videoipath_automation_tool/apps/inspect/model/` mirror + HTTP request and response payloads. These classes are prefixed with `InspectApi` + and are intended for direct API communication and parsing. +- **Domain models** in `src/videoipath_automation_tool/apps/inspect/domain/` are + the objects package users work with: `InspectDevice`, `InspectPort`, + `InspectEdge`, and `InspectService`. They are built from an `InspectSnapshot` + and resolve relations from internal indexes instead of making extra HTTP calls. + +Wire-shape examples and endpoint references live in +[endpoints.md](./endpoints.md). All examples below are anonymized. + +## Two Layers + +```mermaid +flowchart LR + Http[HTTP collector response] --> ApiDto[InspectApiCollectorResponse] + ApiDto --> Snapshot[InspectSnapshot] + Snapshot --> Device[InspectDevice] + Device --> Ports[InspectPort] + Device --> Edges[InspectEdge] + Device --> Services[InspectService] +``` + +Typical read flow: + +```python +response = InspectApiCollectorResponse.model_validate(payload) +snapshot = InspectSnapshot.from_response(response) +device = snapshot.get_device_by_id("device-a") +ports = device.ports +edges = device.edges +services = device.services +linked = device.linked_devices + +port = ports[0] +if port.edge is not None: + peer = port.edge.to_device + peer_port = port.edge.to_port +``` + +Relation getters resolve full domain objects from snapshot indexes. Repeated +access returns the same cached instance for a given device, port, edge, or +service. + +Refresh data by fetching a new collector response and building a new snapshot. +Relation getters never perform hidden HTTP requests. + +## Mental Model + +Inspect is built from two data surfaces: + +- A **collector snapshot** describes what the Inspect UI can show right now: + services, path hops, devices, ports, external edges, sync hints, and status. +- A **topology change set** describes what the client wants to commit to the + persisted graph store. + +```mermaid +flowchart LR + Service[Service booking] --> Path[Path hops] + Path --> DeviceA[Device A] + Path --> DeviceB[Device B] + DeviceA --> PortA[Input/output ports] + DeviceB --> PortB[Input/output ports] + PortA --> Edge[External edge status] + Edge --> PortB + DeviceA --> Sync[Sync information] + ChangeSet[Topology change set] --> Graph[nGraphElements store] +``` + +## Collector Snapshot + +The collector response is a REST v2 envelope with a `header` and a `data` object. +The useful Inspect content is under `data.status.collector`. + +```json +{ + "data": { + "status": { + "collector": { + "inspect": { + "paths": { "_items": [] }, + "nodeStatus": { "_items": [] } + }, + "externalEdgesByDeviceKey": { "_items": [] }, + "maintenanceBookings": { "_items": [] }, + "superProfiles": { "_items": [] }, + "tagInfo": { "_items": [] } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +VideoIPath collections use `_items`. Transport DTOs preserve that wire shape. +`InspectSnapshot` indexes the parsed collector data once and exposes user-facing +objects from those indexes. + +## User-Facing Domain Objects + +These are the classes package users should prefer for read-side workflows. + +### `InspectDevice` + +Represents one topology device/node with status, sync hints, and relations. + +| Field / property | Meaning | +| --- | --- | +| `id` | Device identifier, for example `device-a` | +| `label` | Display label | +| `status` | Device status summary | +| `sync_severity` | Sync severity from node status | +| `tags` | Assigned tags | +| `coordinates` | Topology map position when available | +| `ports` | Ports on this device | +| `edges` | External edges touching this device | +| `services` | Services whose path includes this device | +| `linked_devices` | Neighbour devices via edges or shared service paths | + +Lookup: + +```python +device = snapshot.get_device_by_id("device-a") +matches = snapshot.find_devices_by_name("Example Device A") +``` + +### `InspectPort` + +Represents one module port with status, optional vertex linkage, and an optional +external edge to another device. + +| Field / property | Meaning | +| --- | --- | +| `id` | Port pid | +| `label` | Display label | +| `device` | Owning `InspectDevice` | +| `module_id` | Owning module | +| `status` | Port status summary | +| `vertex_id` | Linked topology vertex when available | +| `edge` | External edge when this port connects to another device; otherwise `None` | + +### `InspectEdge` + +Represents one external edge status entry between two endpoint devices/ports. + +| Field / property | Meaning | +| --- | --- | +| `id` | Edge identifier | +| `from_device` / `to_device` | Endpoint devices | +| `from_port` / `to_port` | Endpoint ports | +| `bandwidth` / `max_bandwidth` | Bandwidth values | +| `status` | Alarm, bandwidth, maintenance, and PTP summary | +| `services` | Services touching either endpoint device | + +### `InspectService` + +Represents one service/booking path across devices and ports. + +| Field / property | Meaning | +| --- | --- | +| `booking_id` | Booking identifier | +| `label` | Service label when available | +| `source` / `destination` | Endpoint labels | +| `source_device` / `destination_device` | Endpoint devices | +| `source_port` / `destination_port` | Endpoint ports | +| `status` | Service status summary | +| `path_devices` | Ordered devices in the path | +| `path_ports` | Ports encountered in the path | + +Lookup: + +```python +service = snapshot.get_service_by_booking_id("booking-1001") +all_services = snapshot.get_services() +``` + +## Transport DTO Examples + +A path item links one service or booking to the devices and ports used to carry +it. This is the best starting point when a caller wants to answer: "Which devices +does this service traverse?" + +```json +{ + "_id": "booking-1001::main", + "_vid": "_:booking-1001::main", + "serviceFields": { + "bid": "booking-1001", + "from": "endpoint-a", + "fromLabel": "Example Source", + "fromPid": "endpoint-a-pid", + "to": "endpoint-b", + "toLabel": "Example Destination", + "toPid": "endpoint-b-pid", + "isMain": true, + "serviceStatus": { + "config": { "sa": 0, "severity": 0 }, + "total": { "sa": 0, "severity": 0 } + } + }, + "path": [ + { + "bid": "booking-1001", + "ipDesc": ":", + "structure": { + "deviceId": "device-a", + "devicePid": "device-a", + "deviceLabel": "Example Device A", + "expectConfig": true, + "inputStatus": { + "pid": "port-a-in", + "label": "Input Port", + "context": { + "devicePid": "device-a", + "modulePid": "module-a", + "portPid": "port-a-in" + }, + "status": { "sa": 0, "severity": 0 } + }, + "outputStatus": { + "pid": "port-a-out", + "label": "Output Port", + "context": { + "devicePid": "device-a", + "modulePid": "module-a", + "portPid": "port-a-out" + }, + "status": { "sa": 0, "severity": 0 } + }, + "moduleAndDeviceStatus": { "sa": 0, "severity": 0 } + } + } + ] +} +``` + +In Python this maps to `InspectApiPathItem`, `InspectApiPathServiceFields`, +`InspectApiPathSegment`, and `InspectApiPathStructure`. + +## Devices, Modules, And Ports + +Node status describes a device-like node as the Inspect UI sees it. It can +include coordinates, modules, ports, status, tags, sync severity, and embedded +references back to service paths. + +```json +{ + "_id": "node-device-a", + "_vid": "_:node-device-a", + "deviceId": "device-a", + "pid": "device-a", + "label": "Example Device A", + "meta": { + "coordinates": { "x": 500, "y": 8150 } + }, + "status": { "sa": 0, "severity": 0 }, + "syncSeverity": 0, + "tags": ["#example-tag"], + "modules": { + "module-a": { + "_id": "module-a", + "pid": "module-a", + "label": "Example Module", + "status": { "sa": 0, "severity": 0 }, + "ports": { + "port-a-out": { + "_id": "port-a-out", + "pid": "port-a-out", + "label": "Output Port", + "status": { "sa": 0, "severity": 0 }, + "vertexInfo": { + "type": "single", + "id": "vertex-a-out", + "label": "Output Vertex", + "vertexType": "ip", + "fields": { + "isActive": true, + "isControlled": true, + "isEndpoint": false + } + } + } + } + } + } +} +``` + +In Python this maps to `InspectApiNodeStatusItem`, `InspectApiModuleStatus`, +`InspectApiPortStatus`, `InspectApiSingleVertexInfo`, and `InspectApiDoubleVertexInfo`. + +## External Edges + +External edge status groups live connectivity between two devices. Each group has +two sides, and each side can contain one or more edge entries keyed by edge ID. + +```json +{ + "_id": "device-a::device-b", + "_vid": "device-a::device-b", + "primary": { + "devicePid": "device-a", + "label": "Example Device A", + "data": { + "edge-a-b-0001": { + "id": "edge-a-b-0001", + "bandwidth": 1000, + "maxBandwidth": 10000, + "ratio": 0.1, + "fromStatus": { + "pid": "port-a-out", + "label": "Output Port", + "status": { "sa": 0, "severity": 0 } + }, + "toStatus": { + "pid": "port-b-in", + "label": "Input Port", + "status": { "sa": 0, "severity": 0 } + }, + "status": { + "alarm": 0, + "bandwidth": 0, + "maintenance": 0, + "ptp": 0 + } + } + } + }, + "secondary": { + "devicePid": "device-b", + "label": "Example Device B", + "data": {} + }, + "status": { + "alarm": 0, + "bandwidth": 0, + "maintenance": 0, + "ptp": 0 + } +} +``` + +In Python this maps to `InspectApiExternalEdgesByDeviceKeyItem`, +`InspectApiExternalEdgeSide`, `InspectApiExternalEdgeStatus`, and +`InspectApiExternalEdgeLiveStatus`. + +## Lookup And Sync Actions + +Action endpoints return focused views for UI workflows. + +`lookupInspectDevice` returns editable/display fields for one device: + +```json +{ + "data": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "fields": { + "coordinates": { "x": 500, "y": 8150 }, + "descriptor": { + "desc": "Example device description", + "label": "Example Device A" + }, + "iconSize": "medium", + "iconType": "gateway", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": ["#example-tag"], + "virtualDeviceFields": null + } + } +} +``` + +`lookupSyncInfo` returns per-device sync differences: + +```json +{ + "data": { + "device-a": { + "add": {}, + "label": "Example Device A", + "remove": {}, + "severity": 0, + "update": {} + } + } +} +``` + +`addDevices` and `syncDevices` use the same normal action-result shape when the +action runs: + +```json +{ + "data": { + "msg": [], + "ok": true + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Some invalid `addDevices` requests can be rejected before an action result is +returned. Those responses contain only the REST header with validation details. + +## Persisted Topology Graph + +The collector snapshot is a status view. Committed Inspect topology data is stored +in `config.network.nGraphElements._items[]`. Each item has an ID, optional +revision, display descriptors, tags, and a `type` value that tells callers which +shape to parse. + +```mermaid +flowchart LR + BaseDevice[baseDevice: device node] + IpVertex[ipVertex: IP port/vertex] + CodecVertex[codecVertex: media endpoint vertex] + GenericVertex[genericVertex: generic vertex] + Edge[unidirectionalEdge: connection] + Transform[nGraphResourceTransform: resource mapping] + + BaseDevice --> IpVertex + BaseDevice --> CodecVertex + BaseDevice --> GenericVertex + IpVertex --> Edge + Edge --> IpVertex + Transform --> Edge +``` + +Example persisted graph elements: + +```json +[ + { + "_id": "device-a", + "_vid": "device-a", + "_rev": "1-example-revision", + "type": "baseDevice", + "descriptor": { + "label": "Example Device A", + "desc": "Example device description" + }, + "fDescriptor": { + "label": "Example Device A", + "desc": "Example device description" + }, + "iconSize": "medium", + "iconType": "gateway", + "isVirtual": false, + "maps": [], + "sdpStrategy": "always", + "siteId": null, + "tags": ["#example-tag"] + }, + { + "_id": "vertex-a-out", + "_vid": "vertex-a-out", + "type": "ipVertex", + "deviceId": "device-a", + "descriptor": { "label": "Output Vertex", "desc": "" }, + "fDescriptor": { "label": "Output Vertex", "desc": "" }, + "gpid": { + "component": 1, + "pointId": ["device-a", "module-a", "port-a-out"] + }, + "ipAddress": "198.51.100.10", + "ipNetmask": "255.255.255.0", + "supportsIgmpCfg": true, + "tags": [] + }, + { + "_id": "edge-a-b-0001", + "_vid": "edge-a-b-0001", + "type": "unidirectionalEdge", + "fromId": "vertex-a-out", + "toId": "vertex-b-in", + "active": true, + "bandwidth": -1, + "capacity": 65535, + "redundancyMode": "Any", + "weight": 0, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "max": 100, "weight": 0 } + }, + "tags": [] + } +] +``` + +In Python these shapes live in `ngraph.py`. + +## Committing Changes + +`updateTopology` sends the whole change set in one request. Empty maps/lists are a +valid no-op. Non-empty maps upsert graph elements by ID, `addExternalEdges` adds +edge objects, and `remove` deletes graph elements by ID. + +```json +{ + "header": { "id": 0 }, + "data": { + "replaceDevices": { + "device-a": { + "_id": "device-a", + "_vid": "device-a", + "type": "baseDevice", + "descriptor": { "label": "Example Device A", "desc": "" }, + "fDescriptor": { "label": "Example Device A", "desc": "" }, + "tags": [] + } + }, + "replaceVertices": { + "vertex-a-out": { + "_id": "vertex-a-out", + "_vid": "vertex-a-out", + "type": "ipVertex", + "deviceId": "device-a", + "descriptor": { "label": "Output Vertex", "desc": "" }, + "fDescriptor": { "label": "Output Vertex", "desc": "" }, + "tags": [] + } + }, + "replaceEdges": { + "vertex-a-out::vertex-b-in": { + "_id": "edge-a-b-0001", + "_vid": "edge-a-b-0001", + "type": "unidirectionalEdge", + "fromId": "vertex-a-out", + "toId": "vertex-b-in", + "descriptor": { "label": "", "desc": "" }, + "fDescriptor": { "label": "", "desc": "" }, + "tags": [] + } + }, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } +} +``` + +A commit is successful only when all three success flags are true: + +```python +response.header.ok and response.data.res.ok and response.data.validation.result.ok +``` + +Validation failures can still arrive with `header.ok == true`, so callers must +inspect `data.res`, `data.validation.result`, and `data.validation.details`. + +## Python Module Layout + +Transport DTOs are split by payload area under `apps/inspect/model/`: + +- `common.py` — shared envelopes, descriptors, status summaries, action wrappers +- `collector.py` — collector snapshot wire models +- `ngraph.py` — persisted `nGraphElements` wire models +- `actions.py` — lookup/add/sync action request and response DTOs +- `update_topology.py` — `updateTopology` change-set and commit response DTOs + +User-facing read models live alongside the snapshot: + +- `snapshot.py` — `InspectSnapshot` and internal indexes +- `domain/device.py` — `InspectDevice` +- `domain/port.py` — `InspectPort` +- `domain/edge.py` — `InspectEdge` +- `domain/service.py` — `InspectService` + +When adding transport fields, prefer extending the nearest existing `InspectApi*` +DTO. When adding user-facing behaviour, extend the domain layer and snapshot +indexes instead of exposing raw HTTP nesting to callers. diff --git a/src/videoipath_automation_tool/apps/__init__.py b/src/videoipath_automation_tool/apps/__init__.py index 624c414..dd26ecb 100644 --- a/src/videoipath_automation_tool/apps/__init__.py +++ b/src/videoipath_automation_tool/apps/__init__.py @@ -1,4 +1,5 @@ from videoipath_automation_tool.apps.inventory import * +from videoipath_automation_tool.apps.inspect import * from videoipath_automation_tool.apps.preferences import * from videoipath_automation_tool.apps.profile import * from videoipath_automation_tool.apps.topology import * diff --git a/src/videoipath_automation_tool/apps/inspect/__init__.py b/src/videoipath_automation_tool/apps/inspect/__init__.py new file mode 100644 index 0000000..b4bc85c --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/__init__.py @@ -0,0 +1,3 @@ +from videoipath_automation_tool.apps.inspect.domain import * +from videoipath_automation_tool.apps.inspect.model import * +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot as InspectSnapshot diff --git a/src/videoipath_automation_tool/apps/inspect/domain/__init__.py b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py new file mode 100644 index 0000000..b65cbc5 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py @@ -0,0 +1,11 @@ +from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice +from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge +from videoipath_automation_tool.apps.inspect.domain.port import InspectPort +from videoipath_automation_tool.apps.inspect.domain.service import InspectService + +__all__ = [ + "InspectDevice", + "InspectEdge", + "InspectPort", + "InspectService", +] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py new file mode 100644 index 0000000..3b49ce2 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary +from videoipath_automation_tool.apps.inspect.snapshot import _DeviceRecord + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +@dataclass(frozen=True, slots=True) +class InspectDevice: + snapshot: InspectSnapshot + record: _DeviceRecord + + @property + def id(self) -> str: + return self.record.device_id + + @property + def label(self) -> str | None: + return self.record.label + + @property + def pid(self) -> str | None: + return self.record.pid + + @property + def status(self) -> InspectApiStatusSummary | None: + if self.record.node is None: + return None + return self.record.node.status + + @property + def sync_severity(self) -> int | str | None: + if self.record.node is None: + return None + return self.record.node.syncSeverity + + @property + def tags(self) -> tuple[str, ...]: + if self.record.node is None: + return () + return tuple(self.record.node.tags) + + @property + def coordinates(self) -> dict[str, float | int | str | None] | None: + if self.record.node is None or self.record.node.meta is None: + return None + return self.record.node.meta.coordinates + + @property + def ports(self) -> list[InspectPort]: + return self.snapshot.get_ports_for_device(self.id) + + @property + def edges(self) -> list[InspectEdge]: + return self.snapshot.get_edges_for_device(self.id) + + @property + def services(self) -> list[InspectService]: + return self.snapshot.get_services_for_device(self.id) + + @property + def linked_devices(self) -> list[InspectDevice]: + return self.snapshot.get_linked_devices(self.id) diff --git a/src/videoipath_automation_tool/apps/inspect/domain/edge.py b/src/videoipath_automation_tool/apps/inspect/domain/edge.py new file mode 100644 index 0000000..60d94cb --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/edge.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from videoipath_automation_tool.apps.inspect.model.collector import InspectApiExternalEdgeLiveStatus +from videoipath_automation_tool.apps.inspect.snapshot import _IndexedEdge + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +@dataclass(frozen=True, slots=True) +class InspectEdge: + snapshot: InspectSnapshot + indexed: _IndexedEdge + + @property + def id(self) -> str: + return self.indexed.edge_id + + @property + def pair_id(self) -> str: + return self.indexed.pair_id + + @property + def from_device(self) -> InspectDevice | None: + if self.indexed.from_device_id is None: + return None + return self.snapshot.get_device_by_id(self.indexed.from_device_id) + + @property + def from_port(self) -> InspectPort | None: + if self.indexed.from_device_id is None or self.indexed.from_port_id is None: + return None + return self.snapshot.get_port(self.indexed.from_device_id, self.indexed.from_port_id) + + @property + def to_device(self) -> InspectDevice | None: + if self.indexed.to_device_id is None: + return None + return self.snapshot.get_device_by_id(self.indexed.to_device_id) + + @property + def to_port(self) -> InspectPort | None: + if self.indexed.to_device_id is None or self.indexed.to_port_id is None: + return None + return self.snapshot.get_port(self.indexed.to_device_id, self.indexed.to_port_id) + + @property + def bandwidth(self) -> float | int | None: + return self.indexed.edge.bandwidth + + @property + def max_bandwidth(self) -> float | int | None: + return self.indexed.edge.maxBandwidth + + @property + def status(self) -> InspectApiExternalEdgeLiveStatus | None: + return self.indexed.edge.status + + @property + def services(self) -> list[InspectService]: + services: list[InspectService] = [] + seen_booking_ids: set[str] = set() + for device in (self.from_device, self.to_device): + if device is None: + continue + for service in self.snapshot.get_services_for_device(device.id): + if service.booking_id in seen_booking_ids: + continue + seen_booking_ids.add(service.booking_id) + services.append(service) + return services diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py new file mode 100644 index 0000000..5120e6a --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiSingleVertexInfo, + InspectPortStatus, +) +from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary +from videoipath_automation_tool.apps.inspect.snapshot import _IndexedPort, _port_id_from_status + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +@dataclass(frozen=True, slots=True) +class InspectPort: + snapshot: InspectSnapshot + indexed: _IndexedPort + + @property + def id(self) -> str | None: + return _port_id_from_status(self.indexed.port) + + @property + def label(self) -> str | None: + return self.indexed.port.label + + @property + def device(self) -> InspectDevice | None: + return self.snapshot.get_device_by_id(self.indexed.device_id) + + @property + def module_id(self) -> str | None: + return self.indexed.module_id + + @property + def status(self) -> InspectApiStatusSummary | None: + return self.indexed.port.status + + @property + def vertex_id(self) -> str | None: + return self._vertex_id_from(self.indexed.port) + + @property + def edge(self) -> InspectEdge | None: + port_id = self.id + if not port_id: + return None + return self.snapshot.get_edge_for_port(self.indexed.device_id, port_id) + + @staticmethod + def _vertex_id_from(port: InspectPortStatus) -> str | None: + vertex_info = port.vertexInfo + if vertex_info is None: + return None + if isinstance(vertex_info, InspectApiSingleVertexInfo): + return vertex_info.id + if isinstance(vertex_info, dict): + vertex_type = vertex_info.get("type") + if vertex_type == "single": + single = vertex_info.get("id") + return single if isinstance(single, str) else None + if vertex_type == "double": + for key in ("in", "out"): + side = vertex_info.get(key) + if isinstance(side, dict) and isinstance(side.get("id"), str): + return side["id"] + else: + for candidate in (getattr(vertex_info, "out", None), getattr(vertex_info, "in_", None)): + if candidate is not None and candidate.id: + return candidate.id + return None diff --git a/src/videoipath_automation_tool/apps/inspect/domain/service.py b/src/videoipath_automation_tool/apps/inspect/domain/service.py new file mode 100644 index 0000000..3263a17 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/service.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from videoipath_automation_tool.apps.inspect.model.collector import InspectApiPathItem, InspectServiceStatus +from videoipath_automation_tool.apps.inspect.snapshot import _port_id_from_endpoint + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +@dataclass(frozen=True, slots=True) +class InspectService: + snapshot: InspectSnapshot + path_item: InspectApiPathItem + + @property + def booking_id(self) -> str: + return self.path_item.serviceFields.bid + + @property + def label(self) -> str | None: + generic = self.path_item.serviceFields.generic + if generic is not None and generic.descriptor is not None: + return generic.descriptor.label or None + return self.path_item.serviceFields.fromLabel or self.path_item.serviceFields.toLabel + + @property + def source(self) -> str | None: + return self.path_item.serviceFields.fromLabel + + @property + def destination(self) -> str | None: + return self.path_item.serviceFields.toLabel + + @property + def source_port(self) -> InspectPort | None: + return self._resolve_endpoint_port(self.path_item.serviceFields.fromPid) + + @property + def destination_port(self) -> InspectPort | None: + return self._resolve_endpoint_port(self.path_item.serviceFields.toPid) + + @property + def source_device(self) -> InspectDevice | None: + port = self.source_port + if port is not None: + return port.device + devices = self.path_devices + return devices[0] if devices else None + + @property + def destination_device(self) -> InspectDevice | None: + port = self.destination_port + if port is not None: + return port.device + devices = self.path_devices + return devices[-1] if devices else None + + @property + def is_main(self) -> bool | None: + return self.path_item.serviceFields.isMain + + @property + def status(self) -> InspectServiceStatus | None: + return self.path_item.serviceFields.serviceStatus + + @property + def path_devices(self) -> list[InspectDevice]: + devices: list[InspectDevice] = [] + seen: set[str] = set() + for segment in self.path_item.path: + structure = segment.structure + if structure is None or not structure.deviceId or structure.deviceId in seen: + continue + device = self.snapshot.get_device_by_id(structure.deviceId) + if device is not None: + devices.append(device) + seen.add(structure.deviceId) + return devices + + @property + def path_ports(self) -> list[InspectPort]: + ports: list[InspectPort] = [] + seen: set[tuple[str, str]] = set() + for segment in self.path_item.path: + structure = segment.structure + if structure is None or not structure.deviceId: + continue + for endpoint in (structure.inputStatus, structure.outputStatus): + port_id = _port_id_from_endpoint(endpoint) + if not port_id: + continue + key = (structure.deviceId, port_id) + if key in seen: + continue + port = self.snapshot.get_port(structure.deviceId, port_id) + if port is not None: + ports.append(port) + seen.add(key) + return ports + + def _resolve_endpoint_port(self, port_id: str | None) -> InspectPort | None: + if not port_id: + return None + for device in self.path_devices: + port = self.snapshot.get_port(device.id, port_id) + if port is not None: + return port + return self.snapshot.find_port_by_id(port_id) diff --git a/src/videoipath_automation_tool/apps/inspect/model/__init__.py b/src/videoipath_automation_tool/apps/inspect/model/__init__.py new file mode 100644 index 0000000..8b773e0 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/__init__.py @@ -0,0 +1,5 @@ +from videoipath_automation_tool.apps.inspect.model.actions import * +from videoipath_automation_tool.apps.inspect.model.collector import * +from videoipath_automation_tool.apps.inspect.model.common import * +from videoipath_automation_tool.apps.inspect.model.ngraph import * +from videoipath_automation_tool.apps.inspect.model.update_topology import * diff --git a/src/videoipath_automation_tool/apps/inspect/model/actions.py b/src/videoipath_automation_tool/apps/inspect/model/actions.py new file mode 100644 index 0000000..e0f329a --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/actions.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiDescriptor, + InspectApiPostRequestHeader, + InspectApiRestV2Header, +) + + +class InspectApiLookupInspectDeviceRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: str + + +class InspectApiAssignedTags(InspectApiBaseModel): + all: list[str] = Field(default_factory=list) + inherited: dict[str, Any] = Field(default_factory=dict) + inheritedConflict: bool = False + local: dict[str, Any] = Field(default_factory=dict) + + +class InspectApiLookupInspectDeviceFields(InspectApiBaseModel): + coordinates: dict[str, float | int] | None = None + descriptor: InspectApiDescriptor + iconSize: str | None = None + iconType: str | None = None + localAssignedTags: list[str] = Field(default_factory=list) + sdpStrategy: str | None = None + siteId: str | None = None + tags: list[str] = Field(default_factory=list) + virtualDeviceFields: dict[str, Any] | None = None + + +class InspectApiLookupInspectDeviceResponseData(InspectApiBaseModel): + assignedTags: InspectApiAssignedTags + fields: InspectApiLookupInspectDeviceFields + + +class InspectApiLookupInspectDeviceResponse(InspectApiBaseModel): + data: InspectApiLookupInspectDeviceResponseData + header: InspectApiRestV2Header + + +class InspectApiLookupSyncInfoRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: list[str] + + +class InspectApiLookupSyncInfoItem(InspectApiBaseModel): + add: dict[str, Any] = Field(default_factory=dict) + label: str + remove: dict[str, Any] = Field(default_factory=dict) + severity: int | str | None = None + update: dict[str, Any] = Field(default_factory=dict) + + +class InspectApiLookupSyncInfoResponse(InspectApiBaseModel): + data: dict[str, InspectApiLookupSyncInfoItem] + header: InspectApiRestV2Header + + +class InspectApiAddDevicesItem(InspectApiBaseModel): + id: str + x: float | int + y: float | int + + +class InspectApiAddDevicesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: list[InspectApiAddDevicesItem] + + +class InspectApiSyncDevicesRequestData(InspectApiBaseModel): + ids: list[str] = Field(default_factory=list) + addOnly: bool + conflictStrategy: Literal[0, 1, 2] + + +class InspectApiSyncDevicesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiSyncDevicesRequestData + + +__all__ = [ + "InspectApiAddDevicesItem", + "InspectApiAddDevicesRequest", + "InspectApiAssignedTags", + "InspectApiLookupInspectDeviceFields", + "InspectApiLookupInspectDeviceRequest", + "InspectApiLookupInspectDeviceResponse", + "InspectApiLookupInspectDeviceResponseData", + "InspectApiLookupSyncInfoItem", + "InspectApiLookupSyncInfoRequest", + "InspectApiLookupSyncInfoResponse", + "InspectApiSyncDevicesRequest", + "InspectApiSyncDevicesRequestData", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py new file mode 100644 index 0000000..1939fa7 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiCollection, + InspectApiDescriptor, + InspectApiEndpointStatus, + InspectApiRestV2Header, + InspectServiceStatus, + InspectApiStatusSummary, +) + + +class InspectApiGenericServiceFields(InspectApiBaseModel): + allocationState: int | None = None + cancelTime: str | None = None + descriptor: InspectApiDescriptor | None = None + locked: bool | None = None + state: int | None = None + tags: list[str] = Field(default_factory=list) + + +class InspectApiPathServiceFields(InspectApiBaseModel): + bid: str + ctype: int | None = None + formatSubState: int | None = None + from_: str | None = Field(default=None, alias="from") + fromLabel: str | None = None + fromPid: str | None = None + fromStatus: InspectApiStatusSummary | None = None + generic: InspectApiGenericServiceFields | None = None + isMain: bool | None = None + serviceStatus: InspectServiceStatus | None = None + to: str | None = None + toLabel: str | None = None + toPid: str | None = None + toStatus: InspectApiStatusSummary | None = None + + +class InspectApiPathStructure(InspectApiBaseModel): + deviceId: str | None = None + deviceLabel: str | None = None + devicePid: str | None = None + expectConfig: bool | None = None + inputStatus: InspectApiEndpointStatus | None = None + moduleAndDeviceStatus: InspectApiStatusSummary | None = None + outputStatus: InspectApiEndpointStatus | None = None + + +class InspectApiPathSegment(InspectApiBaseModel): + bid: str + ipDesc: str | None = None + structure: InspectApiPathStructure | None = None + + +class InspectApiPathItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str = Field(alias="_vid") + path: list[InspectApiPathSegment] = Field(default_factory=list) + serviceFields: InspectApiPathServiceFields + + +class InspectApiNodeMeta(InspectApiBaseModel): + coordinates: dict[str, float | int | str | None] | None = None + + +class InspectApiVertexInfoFields(InspectApiBaseModel): + isActive: bool | None = None + isControlled: bool | None = None + isEndpoint: bool | None = None + + +class InspectApiSingleVertexInfo(InspectApiBaseModel): + type: Literal["single"] = "single" + id: str | None = None + label: str | None = None + vertexType: str | None = None + fields: InspectApiVertexInfoFields | None = None + + +class InspectApiDoubleVertexInfo(InspectApiBaseModel): + type: Literal["double"] = "double" + in_: InspectApiSingleVertexInfo | None = Field(default=None, alias="in") + out: InspectApiSingleVertexInfo | None = None + + +class InspectApiPathDescriptionItem(InspectApiBaseModel): + bookingId: str | None = None + deviceLevel: dict[str, Any] | None = None + fromStatus: InspectApiStatusSummary | None = None + isMain: bool | None = None + serviceLabel: str | None = None + serviceLevel: dict[str, Any] | None = None + serviceStatus: InspectServiceStatus | InspectApiStatusSummary | None = None + toStatus: InspectApiStatusSummary | None = None + + +class InspectPortStatus(InspectApiBaseModel): + id: str | None = Field(default=None, alias="_id") + vid: str | None = Field(default=None, alias="_vid") + label: str | None = None + pid: str | None = None + status: InspectApiStatusSummary | None = None + vertexInfo: InspectApiSingleVertexInfo | InspectApiDoubleVertexInfo | dict[str, Any] | None = None + pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) + + +class InspectApiModuleStatus(InspectApiBaseModel): + id: str | None = Field(default=None, alias="_id") + vid: str | None = Field(default=None, alias="_vid") + label: str | None = None + pid: str | None = None + ports: dict[str, InspectPortStatus] | list[InspectPortStatus] = Field(default_factory=dict) + status: InspectApiStatusSummary | None = None + + +class InspectApiNodeStatusItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + deviceId: str | None = None + hasEndpoints: bool | None = None + label: str | None = None + meta: InspectApiNodeMeta | None = None + modules: dict[str, InspectApiModuleStatus] | list[InspectApiModuleStatus] = Field(default_factory=dict) + pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) + pid: str | None = None + ptpDeviceStatus: dict[str, Any] | None = None + status: InspectApiStatusSummary | None = None + syncSeverity: int | str | None = None + tags: list[str] = Field(default_factory=list) + + +class InspectApiExternalEdgeLiveStatus(InspectApiBaseModel): + alarm: int | str | None = None + bandwidth: int | float | str | None = None + maintenance: int | str | None = None + ptp: int | str | None = None + + +class InspectApiExternalEdgeStatus(InspectApiBaseModel): + bandwidth: float | int | None = None + fromStatus: InspectApiEndpointStatus | None = None + id: str + maxBandwidth: float | int | None = None + pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) + ratio: float | int | None = None + status: InspectApiExternalEdgeLiveStatus | None = None + toStatus: InspectApiEndpointStatus | None = None + + +class InspectApiExternalEdgeSide(InspectApiBaseModel): + data: dict[str, InspectApiExternalEdgeStatus] = Field(default_factory=dict) + devicePid: str | None = None + label: str | None = None + + +class InspectApiExternalEdgesByDeviceKeyItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str = Field(alias="_vid") + primary: InspectApiExternalEdgeSide + secondary: InspectApiExternalEdgeSide + status: InspectApiExternalEdgeLiveStatus | None = None + + +class InspectApiMaintenanceBookingItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + + +class InspectApiSuperProfileItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + + +class InspectApiTagInfoItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + + +class InspectApiCollectorInspect(InspectApiBaseModel): + nodeStatus: InspectApiCollection = Field(default_factory=InspectApiCollection) + paths: InspectApiCollection = Field(default_factory=InspectApiCollection) + + @property + def node_status_items(self) -> list[InspectApiNodeStatusItem]: + return [InspectApiNodeStatusItem.model_validate(item) for item in self.nodeStatus.items] + + @property + def path_items(self) -> list[InspectApiPathItem]: + return [InspectApiPathItem.model_validate(item) for item in self.paths.items] + + +class InspectApiCollector(InspectApiBaseModel): + inspect: InspectApiCollectorInspect = Field(default_factory=InspectApiCollectorInspect) + externalEdgesByDeviceKey: InspectApiCollection = Field(default_factory=InspectApiCollection) + maintenanceBookings: InspectApiCollection = Field(default_factory=InspectApiCollection) + security: dict[str, Any] = Field(default_factory=dict) + superProfiles: InspectApiCollection = Field(default_factory=InspectApiCollection) + tagInfo: InspectApiCollection = Field(default_factory=InspectApiCollection) + + @property + def external_edges_by_device_key_items(self) -> list[InspectApiExternalEdgesByDeviceKeyItem]: + return [ + InspectApiExternalEdgesByDeviceKeyItem.model_validate(item) + for item in self.externalEdgesByDeviceKey.items + ] + + @property + def maintenance_booking_items(self) -> list[InspectApiMaintenanceBookingItem]: + return [InspectApiMaintenanceBookingItem.model_validate(item) for item in self.maintenanceBookings.items] + + @property + def super_profile_items(self) -> list[InspectApiSuperProfileItem]: + return [InspectApiSuperProfileItem.model_validate(item) for item in self.superProfiles.items] + + @property + def tag_info_items(self) -> list[InspectApiTagInfoItem]: + return [InspectApiTagInfoItem.model_validate(item) for item in self.tagInfo.items] + + +class InspectApiCollectorStatus(InspectApiBaseModel): + collector: InspectApiCollector = Field(default_factory=InspectApiCollector) + + +class InspectApiCollectorResponseData(InspectApiBaseModel): + status: InspectApiCollectorStatus + + +class InspectApiCollectorResponse(InspectApiBaseModel): + data: InspectApiCollectorResponseData + header: InspectApiRestV2Header + + +__all__ = [ + "InspectApiCollector", + "InspectApiCollectorInspect", + "InspectApiCollectorResponse", + "InspectApiCollectorResponseData", + "InspectApiCollectorStatus", + "InspectApiDoubleVertexInfo", + "InspectApiExternalEdgeLiveStatus", + "InspectApiExternalEdgeSide", + "InspectApiExternalEdgeStatus", + "InspectApiExternalEdgesByDeviceKeyItem", + "InspectApiGenericServiceFields", + "InspectApiMaintenanceBookingItem", + "InspectApiModuleStatus", + "InspectApiNodeMeta", + "InspectApiNodeStatusItem", + "InspectApiPathDescriptionItem", + "InspectApiPathItem", + "InspectApiPathSegment", + "InspectApiPathServiceFields", + "InspectApiPathStructure", + "InspectPortStatus", + "InspectApiSingleVertexInfo", + "InspectApiSuperProfileItem", + "InspectApiTagInfoItem", + "InspectApiVertexInfoFields", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/common.py b/src/videoipath_automation_tool/apps/inspect/model/common.py new file mode 100644 index 0000000..68e9539 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/common.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class InspectApiBaseModel(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_assignment=True, extra="allow") + + +class InspectApiDescriptor(InspectApiBaseModel): + desc: str = "" + label: str = "" + + +class InspectApiCollection(InspectApiBaseModel): + items: list[dict[str, Any]] = Field(default_factory=list, alias="_items") + + +class InspectApiStatusSummary(InspectApiBaseModel): + sa: int | str | None = None + severity: int | str | None = None + + +class InspectApiStatusContext(InspectApiBaseModel): + devicePid: str | None = None + modulePid: str | None = None + portPid: str | None = None + + +class InspectApiEndpointStatus(InspectApiBaseModel): + context: InspectApiStatusContext | None = None + label: str | None = None + pid: str | None = None + status: InspectApiStatusSummary | None = None + + +class InspectServiceStatus(InspectApiBaseModel): + config: InspectApiStatusSummary | None = None + total: InspectApiStatusSummary | None = None + + +class InspectApiRestV2Header(InspectApiBaseModel): + auth: bool + caption: str + code: str + errorCodes: list[Any] = Field(default_factory=list) + errorDetails: list[Any] = Field(default_factory=list) + id: str + msg: list[str] = Field(default_factory=list) + ok: bool + user: str + + +class InspectApiPostRequestHeader(InspectApiBaseModel): + id: int = 0 + + +class InspectApiSimpleActionResult(InspectApiBaseModel): + msg: list[str] = Field(default_factory=list) + ok: bool + + +class InspectApiSimpleActionResponse(InspectApiBaseModel): + data: InspectApiSimpleActionResult + header: InspectApiRestV2Header + + +class InspectApiActionValidationErrorResponse(InspectApiBaseModel): + header: InspectApiRestV2Header + + +__all__ = [ + "InspectApiActionValidationErrorResponse", + "InspectApiBaseModel", + "InspectApiCollection", + "InspectApiDescriptor", + "InspectApiEndpointStatus", + "InspectApiPostRequestHeader", + "InspectApiRestV2Header", + "InspectServiceStatus", + "InspectApiSimpleActionResponse", + "InspectApiSimpleActionResult", + "InspectApiStatusContext", + "InspectApiStatusSummary", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/ngraph.py b/src/videoipath_automation_tool/apps/inspect/model/ngraph.py new file mode 100644 index 0000000..80fb466 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/ngraph.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiDescriptor, +) + + +InspectApiNGraphElementType = Literal[ + "baseDevice", + "codecVertex", + "genericVertex", + "ipVertex", + "nGraphResourceTransform", + "unidirectionalEdge", +] + + +class InspectApiGpid(InspectApiBaseModel): + component: int | None = None + pointId: list[str] = Field(default_factory=list) + + +class InspectApiMapElement(InspectApiBaseModel): + cType: str = "Topology" + id: str = "" + name: str = "" + visible: bool = True + x: float = 0.0 + y: float = 0.0 + + +class InspectApiNGraphElement(InspectApiBaseModel): + id: str = Field(alias="_id") + rev: str | None = Field(default=None, alias="_rev") + vid: str | None = Field(default=None, alias="_vid") + descriptor: InspectApiDescriptor = Field(default_factory=InspectApiDescriptor) + fDescriptor: InspectApiDescriptor = Field(default_factory=InspectApiDescriptor) + tags: list[str] = Field(default_factory=list) + type: InspectApiNGraphElementType + + +class InspectApiBaseDevice(InspectApiNGraphElement): + type: Literal["baseDevice"] = "baseDevice" + iconSize: str = "medium" + iconType: str = "default" + isVirtual: bool = False + maps: list[InspectApiMapElement] = Field(default_factory=list) + sdpStrategy: str = "always" + siteId: str | None = None + + +class InspectApiVertex(InspectApiNGraphElement): + deviceId: str + gpid: InspectApiGpid | None = None + configPriority: str | int | None = None + control: str | int | None = None + custom: dict[str, Any] = Field(default_factory=dict) + extraAlertFilters: list[Any] = Field(default_factory=list) + imgUrl: str | None = None + isVirtual: bool = False + maps: list[InspectApiMapElement] = Field(default_factory=list) + sipsMode: str | None = None + useAsEndpoint: bool | None = None + vertexType: str | None = None + + +class InspectApiIpVertex(InspectApiVertex): + type: Literal["ipVertex"] = "ipVertex" + ipAddress: str | None = None + ipNetmask: str | None = None + public: bool | None = None + supportsCpipeCfg: bool | None = None + supportsIgmpCfg: bool | None = None + supportsMacForwardingCfg: bool | None = None + supportsNsoCfg: bool | None = None + supportsOpenflowCfg: bool | None = None + supportsStaticIgmpCfg: bool | None = None + supportsVlanCfg: bool | None = None + supportsVplsCfg: bool | None = None + vlanId: str | None = None + vrfId: str | None = None + + +class InspectApiCodecVertex(InspectApiVertex): + type: Literal["codecVertex"] = "codecVertex" + codecFormat: str | None = None + codecType: str | None = None + + +class InspectApiGenericVertex(InspectApiVertex): + type: Literal["genericVertex"] = "genericVertex" + + +class InspectApiWeightFactorBandwidth(InspectApiBaseModel): + weight: int = 0 + + +class InspectApiWeightFactorService(InspectApiBaseModel): + max: int = 100 + weight: int = 0 + + +class InspectApiWeightFactors(InspectApiBaseModel): + bandwidth: InspectApiWeightFactorBandwidth = Field(default_factory=InspectApiWeightFactorBandwidth) + service: InspectApiWeightFactorService = Field(default_factory=InspectApiWeightFactorService) + + +class InspectApiUnidirectionalEdge(InspectApiNGraphElement): + type: Literal["unidirectionalEdge"] = "unidirectionalEdge" + active: bool = True + bandwidth: float | int = -1.0 + capacity: int = 65535 + conflictPri: int | str = 0 + excludeFormats: list[str] = Field(default_factory=list) + fromId: str + includeFormats: list[str] = Field(default_factory=list) + redundancyMode: str = "Any" + toId: str + weight: int = 0 + weightFactors: InspectApiWeightFactors = Field(default_factory=InspectApiWeightFactors) + + +class InspectApiNGraphResourceTransform(InspectApiNGraphElement): + type: Literal["nGraphResourceTransform"] = "nGraphResourceTransform" + + +__all__ = [ + "InspectApiBaseDevice", + "InspectApiCodecVertex", + "InspectApiGenericVertex", + "InspectApiGpid", + "InspectApiIpVertex", + "InspectApiMapElement", + "InspectApiNGraphElement", + "InspectApiNGraphElementType", + "InspectApiNGraphResourceTransform", + "InspectApiUnidirectionalEdge", + "InspectApiVertex", + "InspectApiWeightFactorBandwidth", + "InspectApiWeightFactorService", + "InspectApiWeightFactors", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/update_topology.py b/src/videoipath_automation_tool/apps/inspect/model/update_topology.py new file mode 100644 index 0000000..f15af97 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/update_topology.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiPostRequestHeader, + InspectApiRestV2Header, +) +from videoipath_automation_tool.apps.inspect.model.ngraph import ( + InspectApiBaseDevice, + InspectApiCodecVertex, + InspectApiGenericVertex, + InspectApiIpVertex, + InspectApiNGraphResourceTransform, + InspectApiUnidirectionalEdge, +) + + +InspectApiReplaceVertex = InspectApiIpVertex | InspectApiCodecVertex | InspectApiGenericVertex | dict[str, Any] +InspectApiReplaceDevice = InspectApiBaseDevice | dict[str, Any] +InspectApiReplaceResourceTransform = InspectApiNGraphResourceTransform | dict[str, Any] + + +class InspectApiUpdateTopologyData(InspectApiBaseModel): + replaceDevices: dict[str, InspectApiReplaceDevice] = Field(default_factory=dict) + replaceVertices: dict[str, InspectApiReplaceVertex] = Field(default_factory=dict) + replaceEdges: dict[str, InspectApiUnidirectionalEdge] = Field(default_factory=dict) + replaceResourceTransforms: dict[str, InspectApiReplaceResourceTransform] = Field(default_factory=dict) + addExternalEdges: list[InspectApiUnidirectionalEdge] = Field(default_factory=list) + remove: list[str] = Field(default_factory=list) + force: bool = False + + +class InspectApiUpdateTopologyRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiUpdateTopologyData = Field(default_factory=InspectApiUpdateTopologyData) + + +class InspectApiUpdateTopologyResult(InspectApiBaseModel): + msg: list[str] = Field(default_factory=list) + ok: bool + + +class InspectApiUpdateTopologyValidationDetail(InspectApiBaseModel): + isCancel: bool | None = None + isProduct: bool | None = None + resolvable: bool | None = None + rev: str | None = None + status: int | str | None = None + type: str | None = None + + +class InspectApiUpdateTopologyValidation(InspectApiBaseModel): + createIds: list[str] = Field(default_factory=list) + details: dict[str, InspectApiUpdateTopologyValidationDetail] = Field(default_factory=dict) + result: InspectApiUpdateTopologyResult + + +class InspectApiUpdateTopologyResponseData(InspectApiBaseModel): + items: list[dict[str, Any]] = Field(default_factory=list) + res: InspectApiUpdateTopologyResult + validation: InspectApiUpdateTopologyValidation + + +class InspectApiUpdateTopologyResponse(InspectApiBaseModel): + data: InspectApiUpdateTopologyResponseData + header: InspectApiRestV2Header + + @property + def committed(self) -> bool: + return self.header.ok and self.data.res.ok and self.data.validation.result.ok + + +__all__ = [ + "InspectApiReplaceDevice", + "InspectApiReplaceResourceTransform", + "InspectApiReplaceVertex", + "InspectApiUpdateTopologyData", + "InspectApiUpdateTopologyRequest", + "InspectApiUpdateTopologyResponse", + "InspectApiUpdateTopologyResponseData", + "InspectApiUpdateTopologyResult", + "InspectApiUpdateTopologyValidation", + "InspectApiUpdateTopologyValidationDetail", +] diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py new file mode 100644 index 0000000..b685d5c --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Iterator + +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiCollectorResponse, + InspectApiExternalEdgeStatus, + InspectApiModuleStatus, + InspectApiNodeStatusItem, + InspectApiPathItem, + InspectPortStatus, +) + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + + +@dataclass(frozen=True, slots=True) +class _DeviceRecord: + device_id: str + label: str | None + pid: str | None + node: InspectApiNodeStatusItem | None + + +@dataclass(frozen=True, slots=True) +class _IndexedPort: + device_id: str + module_id: str | None + port: InspectPortStatus + + +@dataclass(frozen=True, slots=True) +class _IndexedEdge: + edge_id: str + pair_id: str + edge: InspectApiExternalEdgeStatus + primary_device_id: str | None + secondary_device_id: str | None + from_device_id: str | None + from_port_id: str | None + to_device_id: str | None + to_port_id: str | None + + +class InspectSnapshot: + def __init__(self, response: InspectApiCollectorResponse) -> None: + self._response = response + collector = response.data.status.collector + + self._node_status_items = collector.inspect.node_status_items + self._path_items = collector.inspect.path_items + self._external_edge_items = collector.external_edges_by_device_key_items + + self._devices_by_id: dict[str, _DeviceRecord] = {} + self._devices_by_label: dict[str, list[str]] = {} + self._paths_by_booking_id: dict[str, InspectApiPathItem] = {} + self._services_by_device_id: dict[str, list[str]] = {} + self._ports_by_device_id: dict[str, list[_IndexedPort]] = {} + self._port_by_key: dict[tuple[str, str], _IndexedPort] = {} + self._ports_by_pid: dict[str, list[_IndexedPort]] = {} + self._edges_by_device_id: dict[str, list[_IndexedEdge]] = {} + self._edge_by_port_key: dict[tuple[str, str], _IndexedEdge] = {} + + self._device_cache: dict[str, InspectDevice] = {} + self._port_cache: dict[tuple[str, str], InspectPort] = {} + self._edge_cache: dict[str, InspectEdge] = {} + self._service_cache: dict[str, InspectService] = {} + self._ports_for_device_cache: dict[str, list[InspectPort]] = {} + self._edges_for_device_cache: dict[str, list[InspectEdge]] = {} + self._services_for_device_cache: dict[str, list[InspectService]] = {} + + self._build_device_indexes() + self._build_path_indexes() + self._build_port_indexes() + self._build_edge_indexes() + + @property + def raw_response(self) -> InspectApiCollectorResponse: + return self._response + + @classmethod + def from_response(cls, response: InspectApiCollectorResponse) -> InspectSnapshot: + return cls(response) + + def get_device_by_id(self, device_id: str) -> InspectDevice | None: + record = self._devices_by_id.get(device_id) + if record is None: + return None + return self._wrap_device(record) + + def find_devices_by_name(self, label: str) -> list[InspectDevice]: + devices: list[InspectDevice] = [] + for device_id in self._devices_by_label.get(label, []): + device = self.get_device_by_id(device_id) + if device is not None: + devices.append(device) + return devices + + def get_devices(self) -> list[InspectDevice]: + return [self._wrap_device(record) for record in self._devices_by_id.values()] + + def get_service_by_booking_id(self, booking_id: str) -> InspectService | None: + path_item = self._paths_by_booking_id.get(booking_id) + if path_item is None: + return None + return self._wrap_service(path_item) + + def get_services(self) -> list[InspectService]: + return [self._wrap_service(item) for item in self._path_items] + + def get_port(self, device_id: str, port_id: str) -> InspectPort | None: + indexed = self._port_by_key.get((device_id, port_id)) + if indexed is None: + return None + return self._wrap_port(indexed) + + def find_port_by_id(self, port_id: str) -> InspectPort | None: + indexed_ports = self._ports_by_pid.get(port_id) + if not indexed_ports: + return None + return self._wrap_port(indexed_ports[0]) + + def get_ports_for_device(self, device_id: str) -> list[InspectPort]: + cached = self._ports_for_device_cache.get(device_id) + if cached is not None: + return cached + ports = [self._wrap_port(indexed) for indexed in self._ports_by_device_id.get(device_id, [])] + self._ports_for_device_cache[device_id] = ports + return ports + + def get_edge_for_port(self, device_id: str, port_id: str) -> InspectEdge | None: + indexed = self._edge_by_port_key.get((device_id, port_id)) + if indexed is None: + return None + return self._wrap_edge(indexed) + + def get_edges(self) -> list[InspectEdge]: + seen: set[str] = set() + edges: list[InspectEdge] = [] + for indexed_edges in self._edges_by_device_id.values(): + for indexed in indexed_edges: + if indexed.edge_id in seen: + continue + seen.add(indexed.edge_id) + edges.append(self._wrap_edge(indexed)) + return edges + + def get_edges_for_device(self, device_id: str) -> list[InspectEdge]: + cached = self._edges_for_device_cache.get(device_id) + if cached is not None: + return cached + seen: set[str] = set() + edges: list[InspectEdge] = [] + for indexed in self._edges_by_device_id.get(device_id, []): + if indexed.edge_id in seen: + continue + seen.add(indexed.edge_id) + edges.append(self._wrap_edge(indexed)) + self._edges_for_device_cache[device_id] = edges + return edges + + def get_services_for_device(self, device_id: str) -> list[InspectService]: + cached = self._services_for_device_cache.get(device_id) + if cached is not None: + return cached + services: list[InspectService] = [] + for booking_id in self._services_by_device_id.get(device_id, []): + service = self.get_service_by_booking_id(booking_id) + if service is not None: + services.append(service) + self._services_for_device_cache[device_id] = services + return services + + def get_linked_devices(self, device_id: str) -> list[InspectDevice]: + linked: set[str] = set() + for indexed in self._edges_by_device_id.get(device_id, []): + for candidate in (indexed.from_device_id, indexed.to_device_id): + if candidate and candidate != device_id: + linked.add(candidate) + for booking_id in self._services_by_device_id.get(device_id, []): + path_item = self._paths_by_booking_id.get(booking_id) + if path_item is None: + continue + for segment in path_item.path: + structure = segment.structure + if structure and structure.deviceId and structure.deviceId != device_id: + linked.add(structure.deviceId) + return [device for linked_id in sorted(linked) if (device := self.get_device_by_id(linked_id)) is not None] + + def _wrap_device(self, record: _DeviceRecord) -> InspectDevice: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + + cached = self._device_cache.get(record.device_id) + if cached is not None: + return cached + device = InspectDevice(snapshot=self, record=record) + self._device_cache[record.device_id] = device + return device + + def _wrap_port(self, indexed: _IndexedPort) -> InspectPort: + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + + port_id = _port_id_from_status(indexed.port) + if port_id is not None: + key = (indexed.device_id, port_id) + cached = self._port_cache.get(key) + if cached is not None: + return cached + port = InspectPort(snapshot=self, indexed=indexed) + self._port_cache[key] = port + return port + return InspectPort(snapshot=self, indexed=indexed) + + def _wrap_edge(self, indexed: _IndexedEdge) -> InspectEdge: + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + + cached = self._edge_cache.get(indexed.edge_id) + if cached is not None: + return cached + edge = InspectEdge(snapshot=self, indexed=indexed) + self._edge_cache[indexed.edge_id] = edge + return edge + + def _wrap_service(self, path_item: InspectApiPathItem) -> InspectService: + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + + booking_id = path_item.serviceFields.bid + cached = self._service_cache.get(booking_id) + if cached is not None: + return cached + service = InspectService(snapshot=self, path_item=path_item) + self._service_cache[booking_id] = service + return service + + def _build_device_indexes(self) -> None: + for node in self._node_status_items: + device_id = node.deviceId or node.pid or node.id + if not device_id: + continue + self._upsert_device_record( + _DeviceRecord( + device_id=device_id, + label=node.label, + pid=node.pid, + node=node, + ) + ) + + for path_item in self._path_items: + for segment in path_item.path: + structure = segment.structure + if structure is None or not structure.deviceId: + continue + existing = self._devices_by_id.get(structure.deviceId) + if existing is not None and existing.node is not None: + continue + self._upsert_device_record( + _DeviceRecord( + device_id=structure.deviceId, + label=structure.deviceLabel, + pid=structure.devicePid, + node=existing.node if existing else None, + ) + ) + + def _upsert_device_record(self, record: _DeviceRecord) -> None: + existing = self._devices_by_id.get(record.device_id) + if existing is not None and existing.node is not None and record.node is None: + merged = existing + elif existing is not None and record.node is not None: + merged = _DeviceRecord( + device_id=record.device_id, + label=record.label or existing.label, + pid=record.pid or existing.pid, + node=record.node, + ) + else: + merged = record + + self._devices_by_id[merged.device_id] = merged + if merged.label: + labels = self._devices_by_label.setdefault(merged.label, []) + if merged.device_id not in labels: + labels.append(merged.device_id) + + def _build_path_indexes(self) -> None: + for path_item in self._path_items: + booking_id = path_item.serviceFields.bid + self._paths_by_booking_id[booking_id] = path_item + device_ids: set[str] = set() + for segment in path_item.path: + structure = segment.structure + if structure and structure.deviceId: + device_ids.add(structure.deviceId) + for device_id in device_ids: + booking_ids = self._services_by_device_id.setdefault(device_id, []) + if booking_id not in booking_ids: + booking_ids.append(booking_id) + + def _build_port_indexes(self) -> None: + for node in self._node_status_items: + device_id = node.deviceId or node.pid or node.id + if not device_id: + continue + for module in _iter_modules(node.modules): + module_id = module.pid or module.id + for port in _iter_ports(module.ports): + indexed = _IndexedPort(device_id=device_id, module_id=module_id, port=port) + self._ports_by_device_id.setdefault(device_id, []).append(indexed) + port_id = _port_id_from_status(port) + if port_id is None: + continue + key = (device_id, port_id) + self._port_by_key[key] = indexed + self._ports_by_pid.setdefault(port_id, []).append(indexed) + + def _build_edge_indexes(self) -> None: + for pair_item in self._external_edge_items: + primary_device_id = pair_item.primary.devicePid + secondary_device_id = pair_item.secondary.devicePid + for side, device_id in ( + (pair_item.primary, primary_device_id), + (pair_item.secondary, secondary_device_id), + ): + if not device_id: + continue + for edge in side.data.values(): + from_device_id = ( + _device_id_from_context(edge.fromStatus.context if edge.fromStatus else None) + or primary_device_id + ) + from_port_id = _port_id_from_endpoint(edge.fromStatus) + to_device_id = ( + _device_id_from_context(edge.toStatus.context if edge.toStatus else None) + or secondary_device_id + ) + to_port_id = _port_id_from_endpoint(edge.toStatus) + indexed = _IndexedEdge( + edge_id=edge.id, + pair_id=pair_item.id, + edge=edge, + primary_device_id=primary_device_id, + secondary_device_id=secondary_device_id, + from_device_id=from_device_id, + from_port_id=from_port_id, + to_device_id=to_device_id, + to_port_id=to_port_id, + ) + self._edges_by_device_id.setdefault(device_id, []).append(indexed) + for endpoint_device_id, port_id in ( + (from_device_id, from_port_id), + (to_device_id, to_port_id), + ): + if endpoint_device_id and port_id: + self._edge_by_port_key[(endpoint_device_id, port_id)] = indexed + + +def _device_id_from_context(context: Any) -> str | None: + if context is None: + return None + device_pid = getattr(context, "devicePid", None) + if device_pid: + return device_pid + if isinstance(context, dict): + value = context.get("devicePid") + return value if isinstance(value, str) else None + return None + + +def _port_id_from_endpoint(endpoint: Any) -> str | None: + if endpoint is None: + return None + pid = getattr(endpoint, "pid", None) + if isinstance(pid, str) and pid: + return pid + context = getattr(endpoint, "context", None) + if context is not None: + if isinstance(context, dict): + port_pid = context.get("portPid") + else: + port_pid = getattr(context, "portPid", None) + if isinstance(port_pid, str) and port_pid: + return port_pid + return None + + +def _port_id_from_status(port: InspectPortStatus) -> str | None: + port_id = port.pid or port.id + return port_id if port_id else None + + +def _iter_modules(modules: dict[str, InspectApiModuleStatus] | list[InspectApiModuleStatus]) -> Iterator[InspectApiModuleStatus]: + if isinstance(modules, dict): + yield from modules.values() + return + yield from modules + + +def _iter_ports(ports: dict[str, InspectPortStatus] | list[InspectPortStatus]) -> Iterator[InspectPortStatus]: + if isinstance(ports, dict): + yield from ports.values() + return + yield from ports From d59c663c3b1cf055c59069e9aa263b7d0eda69c6 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:02:00 +0200 Subject: [PATCH 03/15] research & concept refinement progress --- AGENTS.md | 128 + docs/future/domain-architecture.md | 134 +- docs/inspect-app/README.md | 19 +- docs/inspect-app/concepts.md | 180 +- .../decisions/0002-loading-and-state.md | 9 +- .../decisions/0003-websocket-subscriptions.md | 16 +- .../decisions/0006-commit-write-model.md | 77 +- .../decisions/0007-lazy-snapshot-loading.md | 126 + .../0008-collector-only-endpoints.md | 100 + .../decisions/0009-write-consistency.md | 125 + .../0010-post-commit-snapshot-refresh.md | 100 + docs/inspect-app/decisions/README.md | 6 +- docs/inspect-app/endpoints.md | 584 ++++- docs/inspect-app/models.md | 160 +- .../2025.4.9/action_schema_collector.json | 97 + .../device_hydration_modules_ports.json | 73 + .../inspect/2025.4.9/edge_skeleton.json | 2259 +++++++++++++++++ .../2025.4.9/inspect_paths_limit5.json | 75 + .../2025.4.9/lookup_inspect_edges_by_ids.json | 49 + .../2025.4.9/lookup_inspect_vertex_by_id.json | 60 + .../2025.4.9/nodestatus_noid_collection.json | 265 ++ .../2025.4.9/skeleton_nodestatus_short.json | 268 ++ .../update_topology_fail_booking.json | 49 + .../2025.4.9/update_topology_fail_remove.json | 30 + .../2025.4.9/update_topology_success.json | 40 + 25 files changed, 4884 insertions(+), 145 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/inspect-app/decisions/0007-lazy-snapshot-loading.md create mode 100644 docs/inspect-app/decisions/0008-collector-only-endpoints.md create mode 100644 docs/inspect-app/decisions/0009-write-consistency.md create mode 100644 docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md create mode 100644 tests/fixtures/inspect/2025.4.9/action_schema_collector.json create mode 100644 tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json create mode 100644 tests/fixtures/inspect/2025.4.9/edge_skeleton.json create mode 100644 tests/fixtures/inspect/2025.4.9/inspect_paths_limit5.json create mode 100644 tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json create mode 100644 tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json create mode 100644 tests/fixtures/inspect/2025.4.9/nodestatus_noid_collection.json create mode 100644 tests/fixtures/inspect/2025.4.9/skeleton_nodestatus_short.json create mode 100644 tests/fixtures/inspect/2025.4.9/update_topology_fail_booking.json create mode 100644 tests/fixtures/inspect/2025.4.9/update_topology_fail_remove.json create mode 100644 tests/fixtures/inspect/2025.4.9/update_topology_success.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4556da3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,128 @@ +# AGENTS.md + +This file provides guidance for AI coding agents working in this repository. + +## Data anonymization (mandatory) + +**All concrete data committed to this repository must be anonymized.** This applies everywhere: test fixtures, documentation examples, scripts, comments, and any other artifacts that contain VideoIPath or customer-specific information. + +Before adding or modifying data, replace real identifiers with generic placeholders: + +| Category | Do not commit | Use instead | +|---|---|---| +| Hostnames / FQDNs | `vip-prod.example.customer.com` | `vip-server.example` or `device-host-a` | +| IP addresses | Real network addresses | `10.0.0.1`, `192.0.2.1` (RFC 5737 documentation ranges) | +| Usernames / passwords | Real credentials | `test-user`, `test-password` | +| Device / module / port names | Customer site labels | `device-a`, `module-1`, `port-out-1` | +| Service / path / edge labels | Production naming | `service-a`, `path-1`, `edge-a` | +| Organization / site names | Customer or internal names | `example-org`, `site-a` | +| MAC addresses, serial numbers | Real hardware IDs | Synthetic values with no production mapping | + +Rules: + +1. **Never commit live API responses as-is.** Capture from a real server only in a local, untracked workflow; anonymize before staging. +2. **Apply the same rules to docs and inline examples** — a markdown code block with a real hostname is as sensitive as a JSON fixture. +3. **Preserve structure, not identity.** Keep IDs, relationships, and field shapes realistic so tests and docs remain meaningful; only replace identifying values. +4. **Review diffs for leaks.** Scan new files for hostnames, email addresses, customer abbreviations, and internal project codenames. +5. **When in doubt, generalize.** Prefer `device-a` / `port-out-1` style names already used under `tests/fixtures/`. + +Inspect fixtures can be anonymized with `scripts/anonymize_inspect_fixtures.py` when available. + +## Commands + +```bash +# Install all dependencies (including dev and test groups) +poetry install --with dev,test + +# Run all tests +poetry run pytest + +# Run a single test file +poetry run pytest tests/validators/test_device_id.py + +# Lint with auto-fix +poetry run ruff check --fix src/ tests/ + +# Format +poetry run ruff format src/ tests/ + +# Install pre-commit hooks (runs ruff lint+format on commit) +pre-commit install + +# Driver schema CLI tools (after package install) +set-videoipath-version # e.g. 2024.3.3 +get-videoipath-version +list-videoipath-versions +``` + +## Architecture + +### Three-layer design + +``` +VideoIPathApp (public entry point — src/videoipath_automation_tool/apps/videoipath_app.py) + ├── inventory → InventoryApp + ├── topology → TopologyApp + ├── preferences → PreferencesApp + ├── profile → ProfileApp + └── security → SecurityApp + +Each App: + App class (user-facing methods, business logic) + └── *API class (raw API calls, response parsing) + └── VideoIPathConnector (src/videoipath_automation_tool/connector/) + ├── VideoIPathRestConnector (REST v2 GET/PATCH) + └── VideoIPathRPCConnector (RPC POST) +``` + +`VideoIPathApp` lazily initializes each sub-app on first property access. When `VIPAT_ENVIRONMENT=DEV`, the internal `*_api` objects are also exposed directly on the `VideoIPathApp` instance for easier exploration. + +### Connector layer + +`VideoIPathConnector` (`connector/vip_connector.py`) wraps two low-level connectors for the two VideoIPath API styles: +- **REST connector**: `/rest/v2/data/…` endpoints (GET, PATCH) +- **RPC connector**: RPC POST calls + +Response models live in `connector/models/` as Pydantic models. + +### Inventory app structure + +`InventoryApp` (`apps/inventory/`) uses Python mixins to split its methods across files: +- `app/app.py` — composes `InventoryCreateDeviceMixin`, `InventoryGetDeviceMixin`, etc. +- `inventory_api.py` — raw API methods +- `model/drivers.py` — auto-generated driver schemas; `SELECTED_SCHEMA_VERSION` and `AVAILABLE_SCHEMA_VERSIONS` control which VideoIPath server version is targeted + +### Inspect app (in-progress) + +`apps/inspect/` follows a different, read-only pattern built around `InspectSnapshot`: +- `snapshot.py` — builds in-memory indexes from a bulk API response; domain objects (`InspectDevice`, `InspectPort`, `InspectEdge`, `InspectService`) are created lazily and cached on the snapshot +- `domain/` — thin view objects that hold a back-reference to the snapshot for cross-entity lookups +- `model/` — raw Pydantic models for the API response (`collector.py`, etc.) + +### Driver versioning + +Driver schemas (Pydantic models for device `custom_settings`) are auto-generated from the VideoIPath API's JSON schema and live under `apps/inventory/model/drivers.py`. Run `set-videoipath-version ` to regenerate them for a different server version. The CLI scripts are in `src/vipat_cli_scripts/`. + +### Settings and environment variables + +All configuration is loaded via `Settings` (`settings.py`, backed by `pydantic-settings`). Variables are prefixed `VIPAT_`. Copy `.env.example` to `.env` for local development. Tests use `tests/.env.test`. + +Key variables: +| Variable | Default | Notes | +|---|---|---| +| `VIPAT_ENVIRONMENT` | `PROD` | `DEV` exposes internal APIs on `VideoIPathApp` | +| `VIPAT_VIDEOIPATH_SERVER_ADDRESS` | — | Required | +| `VIPAT_VIDEOIPATH_USERNAME` | — | Required | +| `VIPAT_VIDEOIPATH_PASSWORD` | — | Required | +| `VIPAT_USE_HTTPS` | `true` | | +| `VIPAT_VERIFY_SSL_CERT` | `true` | | +| `VIPAT_LOG_LEVEL` | _(root logger)_ | `DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL` | +| `VIPAT_ADVANCED_DRIVER_SCHEMA_CHECK` | `true` | Compares local vs. server driver schema on init | + +## Release process + +1. Update `version` in `pyproject.toml` +2. Create a GitHub Release with a `v*` tag +3. CI builds and publishes to PyPI automatically + +Hotfix branches must use the `hotfix/` prefix. Development builds are published automatically for every commit on `main` or open PRs with a `.dev` suffix. diff --git a/docs/future/domain-architecture.md b/docs/future/domain-architecture.md index a228d99..a37b489 100644 --- a/docs/future/domain-architecture.md +++ b/docs/future/domain-architecture.md @@ -9,10 +9,10 @@ > involved, or how the work is split across planes. > > It stands in deliberate contrast to the current -> [`python-module-architecture.md`](./python-module-architecture.md), whose +> [`python-module-architecture.md`](../python-module-architecture.md), whose > stated goal is to *"maintain a structure similar to VideoIPath."* That goal is > exactly what this proposal revisits. The VIP wire format and per-app research -> this builds on lives in [`inspect-app/`](./inspect-app/README.md) +> this builds on lives in [`inspect-app/`](../inspect-app/README.md) > (`concepts.md` and the ADRs); those documents remain the **faithful map of the > server**, while this one is the **map we want to present to users**. > @@ -60,7 +60,7 @@ what*: onboard in Inventory → place/connect in Topology → monitor in Inspect while Inspect writes land back in `nGraphElements`. This is awkward for reasons confirmed against a real server (see -[`inspect-app/concepts.md`](./inspect-app/concepts.md) and the captured +[`inspect-app/concepts.md`](../inspect-app/concepts.md) and the captured `collector` payload): 1. **The app split is VIP's internal structure, not the user's mental model.** @@ -221,7 +221,7 @@ Design principles: `0.0`). - **Status is a first-class, read-only projection**, separate from config. This matches the config-plane vs. status-plane split - ([ADR-0001](./inspect-app/decisions/0001-api-paradigm.md)) and keeps the live + ([ADR-0001](../inspect-app/decisions/0001-api-paradigm.md)) and keeps the live story clean. - **Make illegal states unrepresentable** where cheap (enums for `Direction`, `PortKind`, `Redundancy`, `LifecycleState`, `SyncState`). @@ -250,14 +250,17 @@ is the most likely source of subtle bugs. ## 8. Reads — the collector snapshot as the aggregate root -The `collector` aggregate (`GET …/data/status/collector/**`) returns the -**entire connected graph in one internally-consistent request**: devices, ports, -inter-device connections, services, security, profiles, tags. This eliminates -the N+1 / per-device fan-out and the torn data/rev split of the topology read -path. For "linked devices that have connections too," the server hands you the -whole component at once — **do not** rebuild the domain graph from N per-device -fetches. For onboarding-only attributes not in the collector (driver, -credentials, custom settings), the ACL supplements with an Inventory read. +The `collector` tree is the aggregate root for reads. Loading follows +[ADR-0007](../inspect-app/decisions/0007-lazy-snapshot-loading.md): +a **skeleton** query pair fetches the whole graph structure (all devices +without module/port detail + all edges with a lean projection) in one +consistent round, and per-device subtrees are **lazily hydrated** on demand. +The rule: never rebuild the *graph structure* from N per-device fetches — the +skeleton delivers it at once; per-device fetches are only for drill-down +detail. The full `GET …/data/status/collector/**` aggregate remains the eager +mode when a point-in-time view of everything is needed. For onboarding-only +attributes not in the collector (driver, credentials, custom settings), the +ACL supplements with an Inventory read. The cost is **heavy denormalization**. A single service (`100001::main`) appears in at least six places in the snapshot, each with full status @@ -304,19 +307,26 @@ operations. The ACL owns this table; the user never sees it. | Domain operation | Orchestrated VIP sequence | | ---------------- | ------------------------- | | `network.discover()` | Inventory discovered-devices read | -| `network.add_device(driver, connection, …)` | Inventory RPC `/api/updateDevices` (assigns `deviceN`) → optional Topology placement PATCH | -| `device.configure(...)` | Inventory custom-settings update (RPC) and/or Topology vertex/capability PATCH — by field | -| `device.place(x, y)` | Topology `baseDevice` `maps[]` PATCH (revisioned) | -| `network.connect(a, b, …)` | Resolve port→vertex ids → `updateTopology` / `nGraphElements` edge upsert (paired edges) | +| `network.add_device(driver, connection, …)` | Inventory RPC `/api/updateDevices` (assigns `deviceN`) → optional placement via `addDevices` network action / `updateTopology` `replaceDevices` | +| `device.configure(...)` | Inventory custom-settings update (RPC) and/or `updateTopology` `replaceVertices` — by field | +| `device.place(x, y)` | `updateTopology` `replaceDevices` (coordinates) | +| `network.connect(a, b, …)` | Resolve port→vertex ids → `updateTopology` `replaceEdges` / `addExternalEdges` (paired edges) | | `network.get(...)` / `list_*()` | `collector` snapshot read (+ inventory status where needed) | -| `network.disconnect(...)` / `remove_device(...)` | `updateTopology` remove and/or Inventory RPC remove | -| `device.refresh()` / `network.refresh()` | Re-fetch `collector` snapshot (§11.3) | +| `network.disconnect(...)` / `remove_device(...)` | `updateTopology` `remove` and/or Inventory RPC remove | +| `device.refresh()` / `network.refresh()` | Re-fetch `collector` snapshot (§11.3); after own commits: targeted refresh ([ADR-0010](../inspect-app/decisions/0010-post-commit-snapshot-refresh.md)) | + +All topology-plane operations go through the **Inspect surface only** +(`updateTopology`, collector reads, `addDevices`/`syncDevices`) — never through +`PATCH nGraphElements` +([ADR-0008](../inspect-app/decisions/0008-collector-only-endpoints.md)). The +legacy Topology path remains available solely via the `app.topology` escape +hatch. ## 10. The configuration / write interface Make the **change set the unit of work** — this is genuinely how the server behaves for topology/connection edits -([ADR-0006](./inspect-app/decisions/0006-commit-write-model.md)), so it is a +([ADR-0006](../inspect-app/decisions/0006-commit-write-model.md)), so it is a semantic to **expose**, not hide. Adopt ADR-0006 option 3 (explicit change set + convenience auto-commit), with a context manager as the ergonomic default. Proposed shape: @@ -327,9 +337,9 @@ with app.network.change_set() as cs: cs.place(device_id, at=Point(x, y)) conn = cs.connect(port_a, port_b, bandwidth=Gbps(10), redundancy=Redundancy.ANY) cs.remove(old_connection_id) - result = cs.validate() # maps data.validation.result; surfaces affected services + result = cs.validate() # client-side conflict check (ADR-0009); server validation runs at commit if result.ok: - cs.commit() # one updateTopology POST + cs.commit() # conflict re-check → one updateTopology POST → targeted refresh (ADR-0010) # on exception → auto-discard; on clean exit without commit → configurable # Convenience — single change auto-commits @@ -362,8 +372,8 @@ The three planes do **not** share a write/consistency model: | Plane | Write mechanism | Concurrency control | | ----- | --------------- | ------------------- | | Inventory | RPC `/api/updateDevices` | No revision/strict mode (RPC semantics) | -| Topology | `PATCH nGraphElements` | `_rev` optimistic locking, `mode: strict` | -| Inspect | `updateTopology` action | Commit-time validation; `_rev` behaviour **[VERIFY]** (ADR-0006) | +| Topology (escape hatch only, [ADR-0008](../inspect-app/decisions/0008-collector-only-endpoints.md)) | `PATCH nGraphElements` | `_rev` optimistic locking, `mode: strict` | +| Inspect | `updateTopology` action | Commit-time validation; **last-writer-wins** (verified 2025.4.9 — `_rev` ignored); client-side compare-and-commit ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)) | So a single domain write that touches multiple planes has **no single transaction and no uniform conflict story**. The ACL must define, per @@ -383,16 +393,19 @@ config plane (`nGraphElements`). Consequences: `syncSeverity` are not backed by any revision. There is **no cheap "did anything change?" probe** for status — the only ways to learn current status are to re-fetch the collector (whole or subtree) or subscribe via WebSocket - ([ADR-0003](./inspect-app/decisions/0003-websocket-subscriptions.md)). -2. **Config-plane rev-polling detects config edits only.** Cheaply polling - `nGraphElements .../id,rev` catches "someone re-wired," but never "a - connection went into alarm." For monitoring, the latter is the majority of - interesting events. So rev-polling is necessary-but-insufficient. -3. **Read and write tokens live in different planes.** A safe optimistic write - needs the `nGraphElements` `_rev` (or the `updateTopology` per-entity `rev`), - which is **absent from the collector read**. A domain object built from the - collector **cannot, by itself, support an optimistic write** — the write path - must resolve revisions separately at commit time. + ([ADR-0003](../inspect-app/decisions/0003-websocket-subscriptions.md)). +2. **Config-plane rev-polling is off the table anyway.** Polling + `nGraphElements .../id,rev` could catch "someone re-wired" (never "a + connection went into alarm"), but the package does not call that surface + ([ADR-0008](../inspect-app/decisions/0008-collector-only-endpoints.md)) — + and it would still miss the status changes that matter for monitoring. +3. **There is no write token at all on the Inspect surface.** The collector + read is rev-less and `updateTopology` ignores `_rev` (last-writer-wins, + verified 2025.4.9) — so revision-based optimistic writes are impossible, + not merely inconvenient. A domain object built from the collector **cannot + support an optimistic write**; concurrent-edit detection is the change + set's job via stage-time baselines + pre-commit compare + ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)). ### 11.3 Freshness strategy — re-snapshot, not rev-diff @@ -400,30 +413,42 @@ The collector deliberately trades per-entity revisioning for a single globally-consistent snapshot — excellent for **read correctness** (no torn graph), poor for **incremental freshness**. Therefore: -- Treat the collector snapshot as an **immutable value object**, replaced - wholesale on `refresh()` — not mutated in place. +- The collector snapshot is **replaced wholesale on `refresh()`** — never + patched by diffing. Within its lifetime it *accretes*: skeleton-first, with + lazily hydrated subtrees merged in + ([ADR-0007](../inspect-app/decisions/0007-lazy-snapshot-loading.md)). - `refresh()` means "fetch a new snapshot," not "diff revisions." Stamp each - snapshot with a **client-side fetch time** as the freshness marker, since the - server provides no token. -- If projection/filtering behind the `/**` wildcard turns out to be supported - (open `[VERIFY]`, not proven by the capture), a scoped re-snapshot of a - subtree is the optimisation — still a re-snapshot, not a diff. + entity/section with a **client-side fetch time** as the freshness marker, + since the server provides no token. +- Projection/filtering on collector sub-paths is **proven** (Inspect UI + WebSocket capture — see + [endpoints.md](../inspect-app/endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)): + scoped re-snapshots of a subtree are the freshness optimisation — still a + re-snapshot, not a diff. +- **After the package's own commits**, freshness is cheaper: the change set + knows what it touched, so the snapshot is updated by targeted invalidation + + scoped re-fetch instead of a full re-snapshot + ([ADR-0010](../inspect-app/decisions/0010-post-commit-snapshot-refresh.md)). - This is the strongest argument for making **WebSocket the real freshness channel for status**, while config edits keep the rev-based path - ([ADR-0001](./inspect-app/decisions/0001-api-paradigm.md), - [ADR-0002](./inspect-app/decisions/0002-loading-and-state.md), - [ADR-0003](./inspect-app/decisions/0003-websocket-subscriptions.md)). + ([ADR-0001](../inspect-app/decisions/0001-api-paradigm.md), + [ADR-0002](../inspect-app/decisions/0002-loading-and-state.md), + [ADR-0003](../inspect-app/decisions/0003-websocket-subscriptions.md)). ### 11.4 Separate the read snapshot from the write handle -Because the snapshot has no revisions, a domain mutation (`connect`, `remove`) -must, at commit time, resolve the affected entities' `nGraphElements` revisions -itself (or go through `updateTopology` and surface its validation result). Do -**not** let a read object pretend it can optimistically write. The clean split: +Because the snapshot has no revisions (and the write path enforces none), a +domain mutation (`connect`, `remove`) must not pretend a read object can +optimistically write. The clean split: -- **Read snapshot** — immutable, rev-less, globally consistent, replace wholesale. -- **Write handle / change set** — resolves revisions at commit, owns the - commit/validate/discard/conflict lifecycle. +- **Read snapshot** — rev-less, replace wholesale on refresh; accretes lazily + hydrated detail within its lifetime (ADR-0007); after own commits it is + updated by targeted scoped re-reads + ([ADR-0010](../inspect-app/decisions/0010-post-commit-snapshot-refresh.md)). +- **Write handle / change set** — fetches stage-time baselines via + Inspect-surface lookups, re-checks them immediately before the + `updateTopology` POST, and owns the commit/validate/discard/conflict + lifecycle ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)). ## 12. Limits of the abstraction @@ -466,9 +491,10 @@ The single most important artifact of this approach. Proposed starting point | Device lifecycle (onboard → place → connect → monitor) | **Expose** as `LifecycleState` | Users must reason about *where* a device is, not *which app* | | Change set / commit | **Expose** (first-class) | Genuine server semantic; enables atomic multi-edits | | Commit validation result & affected services | **Expose** (typed result) | `header.ok` lies; users must see real outcome | -| Optimistic concurrency / revisions | **Expose as opaque token** | Needed for safe writes; but never raw `_rev` strings | +| Concurrent-write conflicts | **Expose** as explicit conflict check + typed conflict error ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)) | No rev token exists on the Inspect surface (`updateTopology` is last-writer-wins); pretending otherwise would fake a guarantee | | Multi-dimensional status (`alarm`/`bandwidth`/`maintenance`/`ptp`, `sa`/`severity`) | **Expose** (preserve dimensions + provide rollup) | Lossy to collapse; monitoring users need the detail | | Snapshot freshness (no `_rev`) | **Expose** via explicit `refresh()` + fetch-time stamp | Honest about the lack of a server token | +| Lazy hydration on property access ([ADR-0007](../inspect-app/decisions/0007-lazy-snapshot-loading.md)) | **Expose** (documented behaviour) | Getters may perform one fetch and raise connector errors; hiding it would misrepresent cost and failure modes | | Driver-specific custom settings | **Cannot hide** — abstract the entry point only | A device *is* its driver schema (§12) | ## 14. Migration & coexistence @@ -505,7 +531,7 @@ alternative to the additive approach — see §15. | Completeness bar for v1 | Full coverage **vs.** 80% happy path + escape hatch for the rest | Recommend the latter; lowest cost | | Hide-vs-expose ledger (§13) | Ratify explicitly | The most consequential artifact | | Snapshot vs. WS for status freshness | re-snapshot now, WS as the real channel | §11.3; ties into ADR-0002 / ADR-0003 | -| Read-snapshot ↔ write-handle revision bridge | How the change set resolves `_rev` at commit | §11.4; ties into ADR-0006 | +| Read-snapshot ↔ write-handle bridge | ~~How the change set resolves `_rev` at commit~~ **Decided**: stage-time baselines + pre-commit compare ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)); post-commit targeted refresh ([ADR-0010](../inspect-app/decisions/0010-post-commit-snapshot-refresh.md)) | §11.4 | | Relationship to existing docs | Supersede `python-module-architecture.md` design goal **vs.** coexist as "current vs. target" | Currently coexists as the target picture | ## 16. Field-mapping reference (wire → domain) @@ -540,9 +566,9 @@ discovery and turned into fixtures. ## 17. Relationship to existing documents -- [`python-module-architecture.md`](./python-module-architecture.md) — describes +- [`python-module-architecture.md`](../python-module-architecture.md) — describes the **current** mirror-VIP architecture. This document proposes the **target**. -- [`inspect-app/`](./inspect-app/README.md) — `concepts.md` (the VIP wire-format +- [`inspect-app/`](../inspect-app/README.md) — `concepts.md` (the VIP wire-format research, including the `collector` aggregate and `nGraphElements` store) and the ADRs (API paradigm, loading, WebSocket, async, testing, commit model). The decisions there apply package-wide and are referenced throughout this document. diff --git a/docs/inspect-app/README.md b/docs/inspect-app/README.md index 44c7bbb..f34d474 100644 --- a/docs/inspect-app/README.md +++ b/docs/inspect-app/README.md @@ -19,15 +19,19 @@ reference is now a primary source. Nothing here is implemented yet; this is the ## Reading order -1. **[concepts.md](./concepts.md)** — what Inspect is, its domain model, how it - maps onto the existing package, and the endpoint/WebSocket discovery template - (the `[VERIFY]` items to confirm against a real server). Cross-references the +1. **[concepts.md](./concepts.md)** — what Inspect is, its domain model (including + the Inspect-vs-Topology tagging split: vertex tags in `device_tags`, not + `nGraphElements`), how it maps onto the existing package, and the + endpoint/WebSocket discovery template (the `[VERIFY]` items to confirm against + a real server). Cross-references the [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) reference. 2. **[models.md](./models.md)** — practical model guide for transport `InspectApi*` DTOs, `InspectSnapshot`, and user-facing domain objects such as `InspectDevice`. 3. **[endpoints.md](./endpoints.md)** — anonymized endpoint reference with - concrete request and response shapes captured from a local test instance. + concrete request and response shapes captured from a local test instance, + including the live-server verification results (VideoIPath 2025.4.9, + 2026-07-08). 4. **[decisions/](./decisions/)** — the architecture decisions (ADRs), one per topic. Start with [the index](./decisions/README.md). 5. **[implementation-plan.md](./implementation-plan.md)** — phased, non-breaking @@ -36,18 +40,21 @@ reference is now a primary source. Nothing here is implemented yet; this is the > **Wider context:** the package-wide re-think that grew out of this Inspect > work — one unified `Device` / `Connection` domain model that hides the > Inventory / Topology / Inspect split entirely — lives in -> [`../domain-architecture.md`](../domain-architecture.md). +> [`../future/domain-architecture.md`](../future/domain-architecture.md). ## Decision log | Question (from the brief) | Decision record | Status | | -------------------------------------------------- | ------------------------------------------------------------ | -------- | | Data-driven vs. event/action-driven API? | [ADR-0001](./decisions/0001-api-paradigm.md) | Open | -| Always sync/load vs. lazy load vs. cached state? | [ADR-0002](./decisions/0002-loading-and-state.md) | Open | +| Always sync/load vs. lazy load vs. cached state? | [ADR-0002](./decisions/0002-loading-and-state.md) → [ADR-0007](./decisions/0007-lazy-snapshot-loading.md) | Decided (ADR-0007) | | Use WebSockets for event subscriptions? | [ADR-0003](./decisions/0003-websocket-subscriptions.md) | Open | | Make the package async-ready? Support non-async? | [ADR-0004](./decisions/0004-async-strategy.md) | Open | | How to test E2E (low-effort, stable, trustworthy)? | [ADR-0005](./decisions/0005-e2e-testing.md) | Open | | How are config writes applied (immediate vs. commit)? | [ADR-0006](./decisions/0006-commit-write-model.md) | Open | +| Which API surface may the package call? | [ADR-0008](./decisions/0008-collector-only-endpoints.md) | Decided (collector-only) | +| How are concurrent writes detected (no server `_rev` check)? | [ADR-0009](./decisions/0009-write-consistency.md) | Decided (compare-and-commit) | +| How does the snapshot catch up after a commit? | [ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md) | Decided (targeted refresh) | ## Status legend diff --git a/docs/inspect-app/concepts.md b/docs/inspect-app/concepts.md index 8c076c0..f69dd5c 100644 --- a/docs/inspect-app/concepts.md +++ b/docs/inspect-app/concepts.md @@ -1,6 +1,6 @@ # Inspect App — Concepts & Technical Model -> Status: **Draft** · Last updated: 2026-06-25 +> Status: **Draft** · Last updated: 2026-07-08 > > This document captures what the *Inspect* app is, how it maps onto the > existing package, and which technical details still need to be verified @@ -42,11 +42,20 @@ v2 API surface under the `status` namespace. The server composes reads from contract is mostly net-new** relative to what `app.topology` and `app.inventory` use today. -**Reads** — one server-built aggregate: +**Reads** — scoped queries against the collector tree +([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)): +- The collector sub-paths accept `* where ` filters, `limit N`, and deep + field projections — confirmed by a WebSocket capture of the Inspect UI, which + never loads the full tree (see + [endpoints.md — Collector Scoped Queries](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). +- Default loading model: a **skeleton** read (all devices without modules/ports + + all edges with a lean projection) followed by **lazy per-device hydration** + and section-level loads for services. - `GET /rest/v2/data/status/collector/**` → `data.status.collector` (§3.1) -- Bundles topology nodes, inter-device edges, services/paths, status, and - security context in a single tree with `_items[]` collections. + remains the eager/fallback mode: the whole tree — topology nodes, + inter-device edges, services/paths, status, and security context — in a + single response with `_items[]` collections. **Writes** — one bulk action per commit: @@ -102,12 +111,13 @@ How Inspect concepts map onto existing package concepts: | Device (inventory) | Prerequisite — not part of collector; onboard via `config/devman/devices` | `InventoryDevice` (`apps/inventory`) | | Device (topology node) | Read: `collector.inspect.nodeStatus`; stored as `baseDevice` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApiBaseDevice` | | Vertices / Edges | Read: `nodeStatus` `vertexInfo` / `externalEdgesByDeviceKey`; stored as `ipVertex` / `codecVertex` / `unidirectionalEdge` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApi*` nGraph DTOs | +| Vertex tags | Read: per-port `tagsInfo` on hydrated `nodeStatus`; editable form via `lookupInspectVertexByIds` (`assignedTags`, `fields.tags`, `fields.localAssignedTags`) | **Not in `nGraphElements`** — `app.topology` has no vertex-tag concept; server-side bindings live in `videoipath_docs.device_tags`, not the `ngraph` table (§3.4) | | Change set / commit | `POST …/actions/status/collector/updateTopology` → writes `nGraphElements` ([ADR-0006](./decisions/0006-commit-write-model.md)) | _commit flow net-new; target store is existing `nGraphElements`_ | | Services / paths | `collector.inspect.paths`, `pathDescriptions` on nodes/edges | _none — net-new_ | | Device / edge status | Embedded in collector (`status`, `sa`/`severity`, bandwidth, PTP) | `inventory.model.device_status`, `status/network/*` — partial overlap | | Sync status | `syncSeverity` on nodeStatus items | `TopologySynchronize` via `status/network/nGraphSyncStatus` | -| Lookup / network actions | `lookupInspectDevice`, `lookupSyncInfo`, `addDevices`, `syncDevices` | request/response envelopes captured and modelled | -| Connections / Partial connections | **[VERIFY]** — may relate to `pathDescriptions` / bookings | _none yet_ | +| Lookup / network actions | `lookupInspectDevice`, `lookupInspectEdgesByIds`, `lookupInspectVertexByIds`, `lookupSyncInfo`, `addDevices`, `syncDevices` | request/response envelopes captured and modelled | +| Connections / Partial connections | `collector.inspect.paths` + `conman.services`; linked via `serviceFields.bid` / `bookingId` in `pathDescriptions` ([endpoints.md](./endpoints.md#get-restv2datastatuscollectorinspectpaths)) | _read via collector + conman; no separate Connections REST on this instance_ | ### 3.1 Collector aggregate — primary read surface @@ -165,19 +175,36 @@ elements to booked services. **Node live status** (`inspect.nodeStatus`): hierarchical `status` with `sa` / `severity` at device, module, port; plus `ptpDeviceStatus`, `syncSeverity`, -`hasEndpoints`, domains, tags. +`hasEndpoints`, domains, and tags. Device-level tags appear on the node itself +(`tags`, `meta.tags`, `tagsInfo`); **vertex-level tag bindings** appear on +hydrated ports (`tagsInfo` with `assigned` / `inherited` / `local` / `custom` +subtrees — see §3.4). The skeleton projection only carries device-level tagging; +port `tagsInfo` requires per-device hydration (`modules/*`). **Package implications:** -- `app.inspect` uses `GET …/data/status/collector/**` as its canonical read - entry point. +- `app.inspect` reads the collector through **scoped queries** + ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)): skeleton first + (`inspect/nodeStatus` with `modules/"_noId"`, `externalEdgesByDeviceKey` + with a lean projection), then per-device hydration (`modules/*` detail + projection) and section-level loads (`inspect/paths`). The full + `GET …/data/status/collector/**` fetch is the eager/fallback mode. +- The collector query language is confirmed via UI capture: `* where ` + (with `and`/`or`, `contains()`, `lower()`), `limit N`, field projections + with `/.../` up-navigation, `**` subtrees, and the `"_noId"` + expansion-suppressor (see + [endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). - Edge keys and vertex ids from reads map directly onto `updateTopology` write payloads. - Commit validation references the same `bookingId`s visible in `pathDescriptions` (e.g. failed delete for an anonymized booking / main path edge). -- **[VERIFY]** exact sub-paths behind the `/**` wildcard, projection/filter - support, and pagination for large topologies. +- Scoped collector queries work as REST GETs when the URL fits within the server + URI limit ([endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). The full UI + projection hits **HTTP 414**; use a trimmed skeleton projection or `/**` + fallback. Both `nodeStatus//…` and + `* where deviceId='…' limit 1/…` work for single-device hydration. Omitting + `where` does **not** require `limit`. ### 3.2 API planes — existing apps vs. Inspect @@ -193,7 +220,7 @@ workflows: | ----- | ------- | ---- | ----- | | Config | `app.topology`, `app.inventory`, **Inspect (effective store)** | `GET …/data/config/…` | `PATCH …/data/config/…` (revisioned) or RPC | | Status | Inventory status reads, Inspect status reads | `GET …/data/status/…` | — | -| Collector (facade) | `app.inspect` | `GET …/data/status/collector/**` (composed aggregate) | `POST …/actions/status/collector/updateTopology` → `nGraphElements` | +| Collector (facade) | `app.inspect` | Scoped queries on `…/data/status/collector/…` (skeleton + hydration, ADR-0007); `GET …/**` as eager/fallback | `POST …/actions/status/collector/updateTopology` → `nGraphElements` | | Network actions | `app.inspect` device topology workflows | — | `POST …/actions/status/network/addDevices`, `POST …/actions/status/network/syncDevices` | So Inspect's read aggregate is status-namespace and net-new in shape, but its @@ -202,6 +229,18 @@ writes are commit-time-validated bulk actions that land in the revisioned until commit; ADR-0006 accepts that there is no separate server-side change-set id for the verified `updateTopology` flow. +**Endpoint policy** ([ADR-0008](./decisions/0008-collector-only-endpoints.md)): +the Inspect package calls **only** the Inspect surface — collector data reads, +collector actions, and the `addDevices`/`syncDevices` network actions. The +config-plane row above is context, not a call path: the package never issues +`GET`/`PATCH …/config/network/nGraphElements` (that stays `app.topology`'s +surface). Consequence: no `_rev` is available to Inspect, and since +`updateTopology` ignores revisions anyway (last-writer-wins, verified), +concurrent-write detection is client-side compare-and-commit +([ADR-0009](./decisions/0009-write-consistency.md)); after a commit the +snapshot catches up via targeted scoped re-reads +([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)). + The server can expose status-plane subscriptions, but WebSockets are out of scope for this package. Fresh status is obtained by explicit re-fetches ([ADR-0001](./decisions/0001-api-paradigm.md), @@ -219,6 +258,11 @@ of truth** for topology that Inspect's `updateTopology` writes into. Its wire shape overlaps with the Topology app, but the Inspect package models it with standalone `InspectApi*` DTOs. +> Documented here as store/background knowledge only — the package does **not** +> read or write this endpoint at runtime +> ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). The persisted +> element *shape* still matters: `updateTopology` `replace*` payloads carry it. + | Field | Notes | | ----- | ----- | | `_id` / `_vid` | Element id; edges use the `fromId::toId` key (same as collector and `replaceEdges`) | @@ -230,9 +274,9 @@ Element `type` values seen in capture: | `type` | Represents | Key fields | | ------ | ---------- | ---------- | -| `baseDevice` | Topology device node | `maps[]` (`cType: "Topology"`, integer `x`/`y`), `iconType`, `sdpStrategy`, `isVirtual` | -| `codecVertex` | Codec/SDI endpoint vertex | `vertexType` (`In`/`Out`), `codecFormat`, `useAsEndpoint`, `control`, SIPS/SDP fields | -| `ipVertex` | Ethernet/IP port vertex (`.in` / `.out`) | `vertexType`, `gpid.pointId`, `supports*Cfg` capability flags | +| `baseDevice` | Topology device node | `maps[]` (`cType: "Topology"`, integer `x`/`y`), `iconType`, `sdpStrategy`, `isVirtual`, `tags` (device-level only) | +| `ipVertex` | Ethernet/IP port vertex (`.in` / `.out`) | `vertexType`, `gpid.pointId`, `supports*Cfg` capability flags — **not** vertex tag bindings (§3.4) | +| `codecVertex` | Codec/SDI endpoint vertex | `vertexType` (`In`/`Out`), `codecFormat`, `useAsEndpoint`, `control`, SIPS/SDP fields — **not** vertex tag bindings (§3.4) | | `unidirectionalEdge` | Directed link/route between vertices | `fromId`, `toId`, `weight`, `capacity`, `bandwidth`, `redundancyMode`, `weightFactors`, `conflictPri` | **Write round-trip confirmed:** an anonymized edge edited via `updateTopology` @@ -244,8 +288,35 @@ use `capacity: 1`. **Implication:** Inspect's underlying topology store is `nGraphElements`, but the package keeps a separate Inspect model namespace (`InspectApiBaseDevice`, `InspectApiIpVertex`, `InspectApiUnidirectionalEdge`, …). Do not reuse topology app -model classes in Inspect DTOs. **[VERIFY]** whether `updateTopology` performs -`_rev` checks server-side or last-writer-wins. +model classes in Inspect DTOs. `updateTopology` is **last-writer-wins** — a +stale `_rev` in the payload is ignored ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)). + +### 3.4 Tagging — device vs. vertex (Inspect vs. Topology) + +Inspect distinguishes two tag scopes. This is a **key difference from +`app.topology`**, which only models tags on topology **devices** (`baseDevice` +entries in `nGraphElements`). + +| Scope | What is tagged | Topology / `nGraphElements` | Inspect read surface | Server storage | +| ----- | -------------- | --------------------------- | -------------------- | -------------- | +| **Device** | Topology node (`baseDevice`) | `tags` on the `baseDevice` item | `nodeStatus` `tags` / `meta.tags` / `tagsInfo`; `lookupInspectDevice` | `ngraph` / `nGraphElements` | +| **Vertex** | Individual port vertex (`ipVertex`, `codecVertex`, …) | **Not present** — no vertex-tag field on persisted graph elements | Hydrated port `tagsInfo`; editable form in `lookupInspectVertexByIds` | `videoipath_docs.device_tags` (separate from `ngraph`) | + +**Implications for the package:** + +- `app.topology` reads/writes device tags via `nGraphElements` only. It has no + API for binding tags to a vertex id such as + `device-a.module-1.port-out-1.out`. +- `app.inspect` must treat vertex tags as a **separate concern** from the + persisted graph element shape. Do not assume a vertex's `tags` array in an + `nGraphElements` `ipVertex` / `codecVertex` item (if present at all) is the + source of truth for tag bindings — confirmed empty in captures while + `lookupInspectVertexByIds` carries `assignedTags` and `fields.tags`. +- Stage-time baselines and compare-and-commit for vertex edits must use + `lookupInspectVertexByIds` for tag fields ([ADR-0009](./decisions/0009-write-consistency.md)), + not `nGraphElements` or the collector skeleton. +- Collector `tagInfo` provides tag → profile metadata for the aggregate; it does + not replace per-vertex `assignedTags` on the lookup response. ## 4. How the transport works today (recap) @@ -255,13 +326,19 @@ So the new layer fits the existing patterns rather than reinventing them: sub-connectors: REST v2 (`GET`/`PATCH`/`POST`) and RPC (`POST /api/*`). Basic auth, gzip, per-method timeouts. - Each connector enforces an **allow-list of URL prefixes** (`ALLOWED_URLS` / - `ALLOWED_EXACT_MATCHES`). New Inspect endpoints must be added there. + `ALLOWED_EXACT_MATCHES`). New Inspect endpoints must be added there — but + only Inspect-surface prefixes; `config/network/nGraphElements` is not added + for the Inspect app ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). - Responses are wrapped in a common envelope (`ResponseV2Get`/`…Patch`/`…Post`) with a `header` (`code`, `auth`, …) and a `data`/`result` body, validated by Pydantic. - Apps are **lazy-loaded** off `VideoIPathApp` and are **stateless**: every call re-fetches from the server (e.g. `topology.get_device` issues several `GET`s - and rebuilds the aggregate each time). Inspect should follow that pattern. + and rebuilds the aggregate each time). Inspect deviates deliberately: state + is **snapshot-scoped** — a skeleton is loaded up front, detail is lazily + hydrated into the same snapshot, and freshness means building a new snapshot + ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)). There is still no + cache across snapshots. - Inspect models live in two layers: - `apps/inspect/model` — `InspectApi*` transport DTOs for HTTP payloads - `apps/inspect/domain` and `apps/inspect/snapshot.py` — user-facing read @@ -272,8 +349,13 @@ So the new layer fits the existing patterns rather than reinventing them: Two complementary sources: the **official reference** (authoritative for the documented surface) and **browser capture** (authoritative for what the Inspect -GUI actually does, including undocumented calls). WebSocket frames can be useful -product context, but they are out of scope for the package per ADR-0003. +GUI actually does, including undocumented calls). WebSocket *subscriptions* +stay out of scope for the package per ADR-0003, but captured WS frames are a +first-class **discovery source**: the subscription `path`s address the same +data tree as REST v2 and revealed the collector's query language and the UI's +skeleton-first load strategy (see +[endpoints.md — Collector Scoped Queries](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui), +[ADR-0007](./decisions/0007-lazy-snapshot-loading.md)). 0. **Start from the official reference.** The [VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) @@ -304,33 +386,53 @@ REST: - [x] Base path: Inspect uses `/rest/v2/` for collector read/write (`data/status/collector/**`, `actions/status/collector/updateTopology`) - [x] Service / monitoring aggregate: `GET /rest/v2/data/status/collector/**` → `data.status.collector` with `inspect.nodeStatus`, `inspect.paths`, `externalEdgesByDeviceKey`, `security`, `superProfiles`, `tagInfo` (§3.1) - [x] Drill-down: services linked via `pathDescriptions` on ports/edges (`deviceLevel` + `serviceLevel`) and `inspect.paths._items[]` (`bookingId`, path segments, `serviceFields`) -- [ ] Collector sub-paths: exact URLs behind the `/**` wildcard; projection/filter/pagination support -- [ ] Connections / Partial Connections: resource paths, shapes, lifecycle +- [x] Collector sub-paths & query language: confirmed via Inspect UI WebSocket capture — `* where `, `limit N`, deep projections with `/.../` up-navigation, `**`, `"_noId"` expansion-suppressor; UI loads skeleton-first (`modules/"_noId"`) — decoded queries in [endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui) ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)) +- [x] Scoped-query REST GET equivalence: confirmed for practical query lengths; full UI projection → HTTP 414 ([endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)) +- [x] Single-device hydration form: both `nodeStatus//…` and `* where deviceId='…' limit 1/…` work; prefer direct id +- [x] Exact `"_noId"` semantics: subtree expansion suppressor → `modules: {}` at collection level, key omitted on single-device queries +- [x] Populated `modules/*` detail payload: confirmed on synced devices; `syncSeverity=2` devices return empty modules until synced +- [x] Skeleton vs. full aggregate: ~92 KB skeleton (27 devices + 40 edges) vs ~19 MB `/**` on this instance; `limit` not required when `where` is omitted +- [x] Connections / Partial Connections: services via `collector.inspect.paths` + `conman.services`; `bid` ↔ `connection.id` - [x] Change set / commit endpoint: `POST /rest/v2/actions/status/collector/updateTopology` — bulk delta with `replaceDevices`, `replaceVertices`, `replaceEdges` (key `"fromId::toId"`), `replaceResourceTransforms`, `addExternalEdges`, `remove`, `force` ([ADR-0006](./decisions/0006-commit-write-model.md)) - [x] Change set staging: client-side gather until `updateTopology`; no separate server-side change-set id for the verified flow (ADR-0006) - [x] Commit failure response: check `data.res.ok` / `data.validation.result.ok` (not `header.ok`); `validation.details[id]` carries `status`, `rev`, `resolvable`, `type`; failed delete example: `"A required edge was not found. (main)"`, `items: []` ([ADR-0006](./decisions/0006-commit-write-model.md)) - [x] Commit success response for no-op: `data.items: []`, `data.res.ok: true`, `data.validation.result.ok: true` ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) -- [ ] Commit success response with real applied changes: `data.items` contents when validation passes and changes are applied -- [ ] Commit semantics: partial apply after validation pass, discard/rollback of abandoned client-side change sets -- [ ] Import / Export (preview): scope and payload format +- [x] Commit success response with real applied changes: `data.items[]` with `{id, idx, res.ok}` per applied entity ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) +- [x] Commit semantics: reject-before-apply; invalid ops return `items: []`; client-side staging only (no server change-set id) +- [x] Import / Export (preview): **unregistered** on 2025.4.9 — empty data namespaces; GET action schema empty; POST → `No action node in request` - [x] Write/action endpoint: `/rest/v2/actions/status/collector/updateTopology` - [x] Collector action payloads captured and modelled: `/rest/v2/actions/status/collector/lookupInspectDevice`, `/rest/v2/actions/status/collector/lookupSyncInfo` - [x] Network action request shapes, normal action responses, and validation-error responses captured: `/rest/v2/actions/status/network/addDevices`, `/rest/v2/actions/status/network/syncDevices` - [x] Config store / write target: `updateTopology` lands in `GET /rest/v2/data/config/network/nGraphElements/**` (`_items[]`, `_rev`, `type` ∈ `baseDevice` / `codecVertex` / `ipVertex` / `unidirectionalEdge`); model with standalone `InspectApi*` DTOs (§3.3) -- [ ] `_rev` handling on commit: does `updateTopology` enforce optimistic concurrency or last-writer-wins? -- [ ] Version gating: first VideoIPath version that exposes each endpoint +- [x] `_rev` handling on commit: last-writer-wins; `_rev` in `replaceEdges` payload is ignored +- [x] Version gating: collector read/write and `updateTopology` verified on **2025.4.9** - [x] DTO coverage: add typed request/response models for captured lookup, action result, and validation-error endpoint payloads in [endpoints.md](./endpoints.md) +Write consistency & post-commit refresh ([ADR-0009](./decisions/0009-write-consistency.md), [ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)) — verified live on 2025.4.9 (2026-07-08): + +- [x] Persisted-element lookups: `lookupInspectEdgesByIds` returns the **full persisted edge form** (batched); `lookupInspectVertexById`/`…ByIds` and `lookupInspectDevice` return editable forms; **no `_rev` in any lookup response**; `lookupGraphElement` does **not** exist; `lookupNodeInfo`/`lookupEdgeInfo`/`lookupDeviceVertices` are display-oriented ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorlookupinspectedgesbyids)) +- [x] Vertex tag bindings: separate from `nGraphElements` — stored server-side in `videoipath_docs.device_tags`; surfaced on `lookupInspectVertexByIds` (`assignedTags`, `fields.tags`, `fields.localAssignedTags`) and hydrated port `tagsInfo` in `nodeStatus`; not modelled by `app.topology` (§3.4) +- [x] UI edit/commit flows avoid `nGraphElements`: the Inspect UI bundle contains zero references; the edge edit flow calls `lookupInspectEdgesByIds` with both pair directions ([ADR-0008](./decisions/0008-collector-only-endpoints.md)) +- [x] Batched lookups for compare-and-commit baselines: `…ByIds` actions take id lists natively +- [x] Direct edge-pair addressing for targeted refresh: `externalEdgesByDeviceKey//` returns the single pair item +- [ ] Collector propagation delay: time between `updateTopology` OK and the change being visible in collector reads (drives the ADR-0010 retry window) — needs a live write test (coordinate nudge + revert) +- [ ] Re-check on every server upgrade: does `updateTopology` gain `_rev` enforcement (would allow replacing client-side compare-and-commit) + ## 6. Open questions -- **Collector sub-paths and scaling** — exact URLs behind `/**`; projection, - filtering, and pagination for large topologies - ([ADR-0002](./decisions/0002-loading-and-state.md)). -- **Commit model** ([ADR-0006](./decisions/0006-commit-write-model.md)) — - successful applied-change response shape (`data.items`); all-or-nothing apply - after validation; meaning of validation `status` codes and `resolvable: true` - cases. -- **Commit concurrency** — `updateTopology` writes bump `nGraphElements` `_rev` - (§3.3); does the action enforce `_rev` checks or last-writer-wins? -- **Connections / Partial Connections** — relationship to `pathDescriptions`, - bookings, and the Public API `Connections` resources. +_All VERIFY items are resolved (results merged into +[endpoints.md](./endpoints.md) and the §5.1 checklist) or documented as +confirmed limitations / unregistered stubs — with one exception:_ + +- **Collector propagation delay after a commit** + ([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)) — requires a + live write test (coordinate nudge + revert) to size the post-commit retry + window. + +Remaining follow-up (only if a future server version registers new actions): + +- Re-run `GET /rest/v2/actions/status/collector/` schema check after + upgrade; probe POST only for newly registered actions — and re-check whether + `updateTopology` gains `_rev` enforcement (ADR-0009 would be revisited). +- Capture a `resolvable: true` validation case if one appears in the UI during + normal operator workflows. diff --git a/docs/inspect-app/decisions/0002-loading-and-state.md b/docs/inspect-app/decisions/0002-loading-and-state.md index adf9260..90a79f6 100644 --- a/docs/inspect-app/decisions/0002-loading-and-state.md +++ b/docs/inspect-app/decisions/0002-loading-and-state.md @@ -1,7 +1,14 @@ # ADR-0002: Loading & state model -> Status: **Accepted** +> Status: **Superseded by [ADR-0007](./0007-lazy-snapshot-loading.md)** > Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl +> +> The per-read decision below ("always load the full aggregate") did not hold +> up for large environments; a WebSocket capture of the Inspect UI later +> confirmed collector projection/filter support and showed the vendor UI +> loading skeleton-first. ADR-0007 replaces the per-read model with +> skeleton-first loading + lazy hydration. The "no client-side cache across +> reads/snapshots" part of this ADR still stands. ## Context diff --git a/docs/inspect-app/decisions/0003-websocket-subscriptions.md b/docs/inspect-app/decisions/0003-websocket-subscriptions.md index 598cd9c..c24fcaa 100644 --- a/docs/inspect-app/decisions/0003-websocket-subscriptions.md +++ b/docs/inspect-app/decisions/0003-websocket-subscriptions.md @@ -17,10 +17,18 @@ which makes WebSocket support unnecessary for the package's scope. The [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) reference **confirms** the high-level model: the `status` plane is "frequently updated and a good candidate for subscriptions", and subscription handling uses -**event-based (delta) change reporting** from server to client. So the *shape* -(deltas over a persistent channel on the status plane) is settled; the **exact -protocol details** (URL, auth handshake, subscribe message, concrete frame -format, heartbeat, reconnect) remain **[VERIFY]** — capture them per +**event-based (delta) change reporting** from server to client. Protocol details +verified on 2025.4.9 ([endpoints.md — subscription transport](../endpoints.md#observed-subscription-transport-reference-only--out-of-package-scope)): + +| Item | Value | +| ---- | ----- | +| URL | `{ws\|wss}:///rest/v2/sessions/me/ws?exclusive=false` | +| Auth | Session cookies on handshake | +| Subscribe | `{"channel":"subsc","messageId":N,"path":"…","id":""}` | +| Reply | `{"channel":"subsc","id":"…","messageId":N,"payload":{"_id":"…","_ver":[1,1],"data":{…}}}` | +| Unsubscribe | `{"channel":"unsubsc","messageId":N,"id":""}` | + +Captured frames also in `websocket-messages.txt`. See [concepts.md §5](../concepts.md#5-endpoint-discovery--how-to-fill-in-the-verify-gaps). ## Options diff --git a/docs/inspect-app/decisions/0006-commit-write-model.md b/docs/inspect-app/decisions/0006-commit-write-model.md index 902b997..12edd17 100644 --- a/docs/inspect-app/decisions/0006-commit-write-model.md +++ b/docs/inspect-app/decisions/0006-commit-write-model.md @@ -2,6 +2,11 @@ > Status: **Accepted** > Date: 2026-06-18 · Deciders: Paul Winterstein, Jonas Scholl +> +> Concurrency handling for this write model is decided in +> [ADR-0009](./0009-write-consistency.md) (compare-and-commit; the server is +> last-writer-wins). Post-commit snapshot refresh is decided in +> [ADR-0010](./0010-post-commit-snapshot-refresh.md). ## Context @@ -98,9 +103,35 @@ This confirms the **gather-then-commit** mental model at the API boundary: the Inspect UI accumulates edits client-side, then sends one `updateTopology` payload with populated `replace*` / `add*` / `remove` sections. Reads use the matching **collector** namespace: `GET …/data/status/collector/**` returns the composed -topology + status + services tree ([concepts.md §3.1](../concepts.md#31-collector-aggregate--primary-inspect-read-surface)). +topology + status + services tree ([concepts.md §3.1](../concepts.md#31-collector-aggregate--primary-read-surface)). Edge keys (`fromId::toId`) and vertex ids are consistent between read and write. +#### Response shape — successful commit (verified 2026-07-08, VideoIPath 2025.4.9) + +Edge `weight` change applied via `replaceEdges`: + +```json +{ + "data": { + "items": [ + { + "external": null, + "id": "device-a.dev.module-1.port-out-1.out::device-b.dev.module-1.port-in-1.in", + "idx": 0, + "res": { "msg": [""], "ok": true } + } + ], + "res": { "msg": [], "ok": true }, + "validation": { + "createIds": [], + "details": {}, + "result": { "msg": [], "ok": true } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + #### Response shape — failed commit (browser capture, 2026-06-18) **Important:** HTTP / envelope success is **not** commit success. On a failed @@ -166,8 +197,9 @@ Per-entity validation details live in `data.validation.details`, keyed by id The package must treat a commit as failed when `data.res.ok` or `data.validation.result.ok` is `false`, even if `header.ok` is `true`. Revisions appear on validation detail entries (`rev`), not as a top-level `_rev` conflict -like the existing `PATCH nGraphElements` path — **[VERIFY]** whether revision -mismatch surfaces the same way on other failure modes. +like the existing `PATCH nGraphElements` path. On 2025.4.9, `updateTopology` is +**last-writer-wins** — a stale `_rev` in the payload is ignored +([endpoints.md](../endpoints.md#post-restv2actionsstatuscollectorupdatetopology)). ### Write target — the `nGraphElements` config store (confirmed) @@ -184,23 +216,28 @@ error *"A required edge was not found. (main)"* refers to this edge model. See This means the change-set write layer does **not** need new topology element models — it can reuse the existing ones for the persisted form, while the -collector read aggregate keeps its own status-oriented shape. **[VERIFY]** -whether `updateTopology` enforces `_rev` optimistic concurrency or is -last-writer-wins. - -What remains **[VERIFY]**: - -- Whether the server exposes a **separate staging / change-set id** API, or - staging is purely client-side until this POST. -- **Successful** commit response shape (`data.items` contents when `ok: true`). -- Whether multi-category edits that pass validation can still **partially apply** - (the failed example returned empty `items`, suggesting reject-before-apply). -- Other `…/actions/status/collector/*` endpoints (discard, validate-only, …). -- Meaning of validation `status` codes (e.g. `-22`) and when `resolvable` is - `true`. -- Cross-check against the - [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) - (`Connections` / `Partial Connections` / `Import and Export`). +collector read aggregate keeps its own status-oriented shape. **Confirmed +last-writer-wins** on 2025.4.9 — `updateTopology` does not enforce `_rev` +checks; stale `_rev` in `replaceEdges` is ignored +([endpoints.md](../endpoints.md#post-restv2actionsstatuscollectorupdatetopology)). + +What remains unverified: + +- ~~Whether the server exposes a **separate staging / change-set id** API~~ + **Confirmed client-side only** on 2025.4.9. +- ~~**Successful** commit response shape~~ **Confirmed** (see above). +- ~~**Partial apply** after validation~~ **Confirmed reject-before-apply**. +- ~~Validation `status` codes and `resolvable`~~ **Confirmed** for booking-blocked + delete (`status: -22`, `resolvable: false`). `resolvable: true` not observed. +- ~~`validateTopology` / `discardTopology` / `importTopology` / `exportTopology`~~ + **Confirmed unregistered** on 2025.4.9 via + `GET /rest/v2/actions/status/collector/` schema (empty `collector: {}`); + 27+ POST payload variants tried. See + [endpoints.md — Action registration discovery](../endpoints.md#action-registration-discovery). +- ~~Import / Export (preview)~~ **Confirmed unregistered** — empty data + namespaces and empty GET action schema. +- Other `…/actions/status/collector/*` lookup endpoints — captured in + [endpoints.md](../endpoints.md). ## Options diff --git a/docs/inspect-app/decisions/0007-lazy-snapshot-loading.md b/docs/inspect-app/decisions/0007-lazy-snapshot-loading.md new file mode 100644 index 0000000..670ee20 --- /dev/null +++ b/docs/inspect-app/decisions/0007-lazy-snapshot-loading.md @@ -0,0 +1,126 @@ +# ADR-0007: Skeleton-first snapshot loading with lazy hydration + +> Status: **Accepted** — supersedes the per-read decision of +> [ADR-0002](./0002-loading-and-state.md) +> Date: 2026-07-08 · Deciders: Jonas Scholl + +## Context + +[ADR-0002](./0002-loading-and-state.md) decided "stateless, eager per-entity — +always load the full aggregate." For the Inspect snapshot this means +`GET /rest/v2/data/status/collector/**`: one request whose payload is +O(entire network) and dominated by module/port subtrees and the heavily +denormalized `pathDescriptions`. On large environments (thousands of nodes) +this is too slow for the common automation case, which typically touches the +graph structure of all devices but the *detail* of only a few. + +Two facts changed since ADR-0002 was accepted (see +[endpoints.md — Collector Scoped Queries](../endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)): + +1. A WebSocket capture of the Inspect UI proves the collector supports + **scoped queries** on its sub-paths: `* where ` filters, `limit N`, + `order by`, and deep field projections — the `[VERIFY]` item from + ADR-0002/concepts §6 is resolved. +2. The **vendor UI itself loads skeleton-first**: its main topology query + suppresses the module subtree (`modules/"_noId"` → `modules: {}`) and its + edge query projects only ids, labels, and status severities. Full module/ + port detail is fetched by separate, narrower queries. + +The usage context from ADR-0002 still holds: short-lived automation runs that +favour predictability and freshness — but the "efficient bulk reads" concern +now outweighs "one request returns everything." + +## Options + +1. **Eager full aggregate (status quo, ADR-0002).** One `/**` fetch per + snapshot. + - Pros: single request; point-in-time consistent; simplest. + - Cons: O(network) payload; seconds–minutes on large plants; most of the + data is never touched. + +2. **Skeleton + transparent per-entity lazy hydration into one accreting + snapshot state.** + - Pros: initial load ~proportional to device/edge count, not port count; + detail cost paid only for entities actually inspected; mirrors the vendor + UI's own strategy; domain surface unchanged (property access just works). + - Cons: hidden HTTP on property access; snapshot no longer single-point-in- + time; N+1 risk when iterating details; more bookkeeping. + +3. **Skeleton + explicit `load_details()` calls.** + - Pros: no hidden I/O; explicit cost model. + - Cons: leaks the loading mechanics into every user workflow; unloaded + property access needs error or `None` semantics — worse ergonomics. + +4. **Per-field projections on every access.** + - Pros: minimal bytes per read. + - Cons: extremely chatty; complex merge bookkeeping; no coherent entity + objects. + +## Decision + +**Option 2: skeleton-first snapshot with transparent per-entity lazy +hydration.** The internal-state concept of `InspectSnapshot` is unchanged — +one snapshot, one set of indexes, domain objects resolve through it — but the +state is allowed to be **partially populated** and accretes as details are +fetched. + +- **Skeleton load.** A snapshot is built from two parallel scoped GETs, + using the queries captured from the UI (endpoints.md): + - *Device skeleton*: `inspect/nodeStatus` with the UI's projection and + `modules/"_noId"` — identity, descriptor, `meta`/coordinates, `status`, + `syncSeverity`, tags, `ptpDeviceStatus`; no modules/ports. + - *Edge skeleton*: `externalEdgesByDeviceKey` with the lean projection — + device pair, edge ids, endpoint port `context`/labels, status severities; + no `pathDescriptions`, no bandwidth values. + + The graph structure (all devices, all edges) is therefore complete from the + start; module/port detail and services are not. + +- **Per-entity hydration.** First access to an unloaded device property + (`ports`, port status, path drill-down, …) fetches that one device's + `nodeStatus` subtree using the UI's detail projection (`modules/*`) scoped + to the device. The parsed item replaces the skeleton record; port indexes + for that device are built incrementally. Hydration is idempotent and cached + — repeated access is local. + +- **Section-level lazy loads.** Services are cross-device, so `inspect/paths` + loads as a whole section (UI service-list projection) on first touch of + `get_services()` / `device.services`. `maintenanceBookings`, + `superProfiles`, and `tagInfo` load the same way if and when exposed. + +- **Snapshot construction.** The snapshot holds a reference to the inspect + API layer to perform hydration fetches. Fixture-built snapshots + ([ADR-0005](./0005-e2e-testing.md)) are constructed from a full collector + response with lazy loading inert. An eager mode + (e.g. `get_snapshot(load="full")` → `/**` fetch) is retained for small + environments and for callers needing point-in-time consistency. Scoped + queries are verified to work as REST GETs on 2025.4.9; only the full + UI-length projection hits HTTP 414 — use a trimmed skeleton projection + ([endpoints.md](../endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). + +What ADR-0002 got right still stands: **no client-side cache across +snapshots**. All accreted state lives inside one snapshot's lifetime; fresh +data means building a new snapshot. + +## Consequences + +- **Hidden HTTP on property access.** Domain getters may perform one + hydration request and can therefore raise connector errors and add latency. + This is documented, deliberate behaviour (models.md). +- **No single point in time.** Skeleton and hydrated subtrees are fetched at + different moments. The snapshot records a fetch timestamp per entity/ + section; `refresh()` still means "build a new snapshot," never + re-fetch-and-diff. +- **N+1 returns for detail iteration.** Touching detail on every device in a + loop degenerates to one request per device. Mitigation: bulk preload + helpers (e.g. `snapshot.preload_devices([...])`, `get_devices(detail=True)`) + that batch or parallelize hydration + ([ADR-0004](./0004-async-strategy.md)); the skeleton alone answers most + bulk questions (inventory of nodes, connectivity, sync/status severities). +- **Verification status**: all core loading items are confirmed on 2025.4.9 — + GET equivalence, `"_noId"` semantics, single-device hydration forms, + populated `modules/*`, payload sizes (~92 KB skeleton vs ~19 MB full). The + only accepted limitation is HTTP 414 for the untrimmed UI projection. See + [endpoints.md](../endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui) + and the checklist in + [concepts.md §5.1](../concepts.md#51-discovery-checklist-fill-in-as-confirmed). diff --git a/docs/inspect-app/decisions/0008-collector-only-endpoints.md b/docs/inspect-app/decisions/0008-collector-only-endpoints.md new file mode 100644 index 0000000..b93348b --- /dev/null +++ b/docs/inspect-app/decisions/0008-collector-only-endpoints.md @@ -0,0 +1,100 @@ +# ADR-0008: Collector-only endpoint policy (no legacy topology API) + +> Status: **Accepted** +> Date: 2026-07-08 · Deciders: Jonas Scholl + +## Context + +The Inspect package sits on top of two API generations that can both read and +write the same topology data: + +- **Legacy topology surface** (used by `app.topology` today): + `GET/PATCH /rest/v2/data/config/network/nGraphElements/**` — revisioned + (`_rev`, `mode: strict`) — plus older status views such as + `GET /rest/v2/data/status/network/edgesByDevice/**`. +- **Inspect surface**: collector data reads + (`GET /rest/v2/data/status/collector/…`, scoped or `/**`), collector actions + (`POST /rest/v2/actions/status/collector/*` — `updateTopology`, + `lookupInspectDevice`, `lookupSyncInfo`, …), and the network actions used by + Inspect workflows (`POST /rest/v2/actions/status/network/{addDevices,syncDevices}`). + +In the product, Inspect **replaces** the Topology app; `nGraphElements` remains +the underlying store (`updateTopology` writes land there, ADR-0006), but it is +an implementation detail behind the Inspect facade. Mixing the two surfaces in +one package layer would mean two write models (revisioned strict PATCH vs. +commit-time-validated bulk action), two read shapes for the same entities, and +a version-gating story spanning both. + +The Inspect UI's captured traffic (initial load) uses **only** the Inspect +surface, and the UI bundle (`/assets/index-*.js`, 2025.4.9) contains **zero +references to `nGraphElements`** — the edit and commit flows are built on the +collector lookups (`lookupInspectEdgesByIds`, `lookupInspectVertexByIds`, …) +and `updateTopology` +([endpoints.md](../endpoints.md#action-registration-discovery)). The vendor's +own client never touches the legacy surface. + +## Options + +1. **Collector-only (Inspect surface only).** The package never calls + `nGraphElements` or other legacy topology endpoints at runtime. + - Pros: one API generation; one write model; matches what the vendor UI + does; simple version gating; no accidental coupling to the app Inspect + replaces. + - Cons: gives up the only **server-enforced** optimistic locking + (`PATCH nGraphElements` strict mode) and the only revision-bearing read — + consistency must be solved client-side (ADR-0009). + +2. **Hybrid: Inspect surface + `nGraphElements` reads for `_rev`.** + - Pros: real revision tokens for conflict detection. + - Cons: reads and writes disagree (`_rev` from config plane is **ignored** + by `updateTopology` — verified last-writer-wins on 2025.4.9, so the token + buys detection only, not enforcement); couples the package to the legacy + surface anyway. + +3. **Hybrid: `updateTopology` for bulk, strict `PATCH nGraphElements` for + single-entity writes needing hard concurrency guarantees.** + - Pros: server-enforced locking where it matters. + - Cons: two write paths with different validation semantics (`updateTopology` + runs booking/service validation; a raw PATCH bypasses it) — dangerous, not + just inconsistent. + +## Decision + +**Option 1: the Inspect package uses only the Inspect surface.** Allowed at +runtime: + +| Kind | Endpoints | +| ---- | --------- | +| Data reads | `GET /rest/v2/data/status/collector/…` (scoped queries and `/**`) | +| Collector actions | `POST /rest/v2/actions/status/collector/*` (`updateTopology`, `lookupInspectDevice`, `lookupSyncInfo`, and further registered lookups) | +| Network actions | `POST /rest/v2/actions/status/network/addDevices`, `…/syncDevices` | +| System probes | `GET /rest/v2/data/status/system/about/…` (version gating) | + +Explicitly **not called** by the package: + +- `GET`/`PATCH /rest/v2/data/config/network/nGraphElements/**` +- `GET /rest/v2/data/status/network/edgesByDevice/**` +- RPC topology calls + +These stay documented in [endpoints.md](../endpoints.md) as **store +documentation and discovery/cross-check references** only. `app.topology` +remains the public escape hatch for raw, revisioned `nGraphElements` access — +unchanged and out of scope here. + +## Consequences + +- **No `_rev` is available to the Inspect package** (collector items and all + verified lookup responses carry none), and the write path enforces none + (last-writer-wins, verified 2025.4.9). Write consistency is therefore + solved client-side — [ADR-0009](./0009-write-consistency.md). +- The persisted form of an element (all config fields needed to build full + `replace*` payloads) comes from collector-namespace lookups — verified on + 2025.4.9: `lookupInspectDevice` (devices), `lookupInspectVertexById`/`…ByIds` + (vertices), `lookupInspectEdgesByIds` (edges, full persisted form, batched; + see [endpoints.md](../endpoints.md#post-restv2actionsstatuscollectorlookupinspectedgesbyids)) + — not from `nGraphElements` reads. (`lookupGraphElement` does not exist.) +- The connector URL allow-list for Inspect gains only Inspect-surface prefixes; + `config/network/nGraphElements` is not added for the Inspect app. +- If a future server version changes the Inspect surface (e.g. registers + `validateTopology`, adds rev enforcement to `updateTopology`), the policy is + re-evaluated per version — never by silently reaching for the legacy API. diff --git a/docs/inspect-app/decisions/0009-write-consistency.md b/docs/inspect-app/decisions/0009-write-consistency.md new file mode 100644 index 0000000..072cf56 --- /dev/null +++ b/docs/inspect-app/decisions/0009-write-consistency.md @@ -0,0 +1,125 @@ +# ADR-0009: Write consistency — client-side compare-and-commit + +> Status: **Accepted** — complements [ADR-0006](./0006-commit-write-model.md) +> (commit model) under the [ADR-0008](./0008-collector-only-endpoints.md) +> endpoint policy +> Date: 2026-07-08 · Deciders: Jonas Scholl + +## Context + +Requirement: a write must not silently overwrite changes somebody else made +between our read and our commit (lost update). What the server gives us +(verified 2025.4.9, see +[endpoints.md — `updateTopology`](../endpoints.md#post-restv2actionsstatuscollectorupdatetopology)): + +- **`updateTopology` is last-writer-wins.** A stale `_rev` in `replace*` + payloads is **ignored** — there is no server-enforced optimistic locking on + the Inspect write path. Commit-time validation protects *service integrity* + (booking-blocked deletes, dangling edge refs), **not** concurrent edits. +- **Collector reads carry no `_rev`.** The only revisioned surface is + `nGraphElements`, which the package does not call + ([ADR-0008](./0008-collector-only-endpoints.md)). +- **`replace*` entries are full-object upserts** (ADR-0006): committing an edge + weight change means sending the *complete* edge. A payload built from stale + state clobbers every other field — so a write is exposed to lost updates even + when the caller only intends to touch one field. +- **No server-side staging**: `validateTopology`/`discardTopology` are + unregistered stubs; the change set exists only client-side until the one + `updateTopology` POST (atomic, reject-before-apply). + +## Options + +1. **Accept last-writer-wins.** Document the risk, do nothing. + - Pros: zero cost. + - Cons: violates the requirement; full-object upserts make silent clobbering + likely, not just possible. + +2. **Client-side compare-and-commit.** Record a baseline of every touched + entity at staging time; immediately before the commit POST, re-read the + entities and compare against the baseline; abort on any drift. + - Pros: detects concurrent modification with Inspect-surface endpoints only; + the stage-time read is needed anyway to build full `replace*` payloads; + conflicts surface as one typed error before anything is written. + - Cons: a race window remains between the pre-commit re-read and the POST + (TOCTOU) — detection, not enforcement; costs one extra scoped read per + touched entity at commit time. + +3. **Rev tokens from `nGraphElements`.** + - Cons: rejected by ADR-0008; and pointless as enforcement — `updateTopology` + ignores `_rev`, so the token would still only enable client-side detection + (same guarantee as option 2, plus a legacy-surface dependency). + +4. **Strict `PATCH nGraphElements` for writes.** + - Cons: rejected by ADR-0008; bypasses `updateTopology`'s booking/service + validation — trades one integrity mechanism for another. + +## Decision + +**Option 2: compare-and-commit, built into the change-set lifecycle +(ADR-0006's transaction / direct-write paths both get it).** + +1. **Baseline at staging time.** When an entity is first staged (replace or + remove), the change set fetches and stores its current form via + Inspect-surface lookups (all verified 2025.4.9, payloads in + [endpoints.md](../endpoints.md#post-restv2actionsstatuscollectorlookupinspectedgesbyids); + none of them exposes a `_rev`): + - edges: `lookupInspectEdgesByIds` — the **full persisted edge form** + (every `replaceEdges` field), batched; the UI's own edit flow calls it + with both directions of a connection. + - vertices: `lookupInspectVertexByIds` (batched) — editable vertex form + (`fields` incl. label, tags, `typeFields` capability flags, + `useAsEndpoint`). Vertex tag bindings come from this lookup (and hydrated + port `tagsInfo`), **not** from `nGraphElements` — they are stored in + `videoipath_docs.device_tags` server-side + ([concepts.md §3.4](../concepts.md#34-tagging--device-vs-vertex-inspect-vs-topology)). + - devices: `lookupInspectDevice` — editable device form (coordinates, + descriptor, iconType, sdpStrategy, tags). + + Edges come back in the persisted `replaceEdges` shape directly; for + devices and vertices the lookup returns the *edit form*, and the ACL maps + it to the persisted `replace*` shape (e.g. `coordinates` ↔ `maps[]`) — the + same mapping the UI performs. The baseline serves double duty: it is the + basis for building the full `replace*` payload (caller mutations applied + on top) *and* the reference for conflict detection, compared on the + editable fields. For `remove` entries the baseline is the element's + existence + form. + +2. **Pre-commit conflict check.** `commit()` re-fetches the same entities and + deep-compares against the baselines over the fields the package models + (volatile status fields excluded). Any mismatch aborts the whole commit — + consistent with the server's all-or-nothing apply — and raises a typed + conflict error carrying the entity ids and per-field diffs. The caller + decides: re-stage on top of fresh state, or override. + +3. **Override is explicit.** A caller can skip the check + (`commit(check_conflicts=False)` or equivalent) — that is deliberate + last-writer-wins, stated in the call. The server's `force` flag is + unrelated (it does not bypass apply-gate errors; verified) and stays an + independent, documented option. + +4. **Commit result still rules.** Compare-and-commit runs *before* the POST; + `data.res.ok` / `data.validation.result.ok` evaluation after the POST is + unchanged (ADR-0006). + +## Consequences + +- **Honest guarantee: detection, not enforcement.** The re-read→POST window + cannot be closed with the current server. This is the strongest guarantee + available on the Inspect surface; state it plainly in user docs. Re-check + per server version whether `updateTopology` gains rev enforcement + (**[VERIFY]** on upgrades; concepts.md §5.1). +- One extra lookup round per stage and per commit — bounded by change-set + size, not network size, and batchable: `lookupInspectEdgesByIds` and + `lookupInspectVertexByIds` take id lists natively (verified). +- The conflict check needs a stable field-level equality over the persisted + form — DTO comparison must exclude server-managed/volatile fields; document + the excluded set alongside the DTOs. +- Snapshot data (rev-less, possibly stale) is explicitly **not** used as the + baseline; baselines always come from fresh lookups at stage time. The + snapshot is a read surface, not a write token + ([ADR-0007](./0007-lazy-snapshot-loading.md), + ADR-0010 for post-commit refresh). +- The device/vertex lookups return *edit forms*, not raw persisted elements — + the ACL's edit-form ↔ `replace*`-shape mapping must be exact, or replaces + clobber unmapped fields. Fixture-test the round-trip per element kind + (edges need none; devices/vertices do). diff --git a/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md b/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md new file mode 100644 index 0000000..c06f04d --- /dev/null +++ b/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md @@ -0,0 +1,100 @@ +# ADR-0010: Post-commit snapshot maintenance — targeted invalidation + scoped re-fetch + +> Status: **Accepted** — extends [ADR-0007](./0007-lazy-snapshot-loading.md) +> to the write path ([ADR-0006](./0006-commit-write-model.md) commit model) +> Date: 2026-07-08 · Deciders: Jonas Scholl + +## Context + +After a successful `updateTopology` commit, the caller's `InspectSnapshot` is +stale exactly where the commit touched it. Automation flows typically continue +working with the snapshot right after a write ("connect, then assert the edge +exists"), so the state must catch up — efficiently: + +- A full re-snapshot costs ~19 MB (`/**`) on the 30-device test instance + (measured sizes in + [endpoints.md](../endpoints.md#observed-subscription-transport-reference-only--out-of-package-scope)) + and scales with network size — unacceptable per write. +- Scoped reads are cheap: one device subtree ≈ 21 KB, the complete edge + skeleton ≈ 84 KB. +- The change set knows precisely which entities it touched, and the commit + response confirms them (`data.items[]` with per-item `res.ok`). +- The collector is a **status-plane projection** of the config store the + commit writes to; the change may become visible only after a propagation + delay (**[VERIFY]** below). + +## Options + +1. **Full re-snapshot after every commit.** Correct but O(network) per write — + defeats ADR-0007. + +2. **Optimistic local apply.** Translate the committed write DTOs into the + snapshot's read shapes and patch the indexes locally, no re-read. + - Pros: zero extra requests. + - Cons: write shapes (`nGraphElements` element form) ≠ collector read shapes + (status projection with `vertexInfo`, live status, `pathDescriptions`); + the translation re-implements server logic and cannot produce the + server-derived fields — the snapshot would hold fabricated entries. + +3. **Targeted invalidation + scoped re-fetch of affected entities.** Reuse the + ADR-0007 hydration machinery: mark what the commit touched as stale, re-read + only that. + +4. **Invalidate-only (fully lazy).** Like 3, but never re-fetch eagerly; next + property access re-hydrates. + - Pros: cheapest when the caller never looks again. + - Cons: "commit, then assert" — the common automation pattern — hits the + propagation delay unmanaged, on an access that looks local. + +## Decision + +**Option 3, with lazy fallback (option 4) for sections.** After a commit whose +result is success (`res.ok` and `validation.result.ok`): + +1. **Compute the affected set** from the change set and the commit response + `items[]`: + - *devices*: keys of `replaceDevices`, removed device ids, plus the owning + device of every replaced/removed vertex and edge endpoint (derivable from + the id conventions — vertex `device-a.module-1.port-out-1.out` → device + `device-a`). + - *edge pairs*: `replaceEdges` / `addExternalEdges` keys and removed edge + ids, mapped to their `deviceA::deviceB` pair keys (both directions belong + to one pair item). +2. **Apply removes locally**: deleted entities leave the indexes and caches + immediately (no read needed to know they are gone). +3. **Invalidate and eagerly re-fetch** the remaining affected entities with the + existing scoped queries: `nodeStatus//…` per affected device and + the edge-pair item per affected pair — direct pair addressing + `externalEdgesByDeviceKey//` is **verified** + on 2025.4.9 (returns the single pair item). Cached domain objects for these + ids are dropped or updated so subsequent property access sees the new + state; per-entity fetch timestamps are updated (ADR-0007). +4. **Sections go stale, not eager**: if the services section (`inspect/paths`) + or other section-level data was loaded, mark it stale and let the next + access re-load it (ADR-0007 lazy path). Commits change services only + indirectly; eager re-reads here are usually wasted. +5. **Propagation handling**: the re-fetch verifies the committed change is + visible (e.g. a replaced field has the committed value) and retries briefly + if the projection lags; give up after a small bounded window and surface + the staleness (log + fetch timestamp), never hang. + **[VERIFY]** the actual delay between `updateTopology` OK and collector + visibility on a real instance — if it is consistently ~0, drop the retry. + +A failed commit changes nothing server-side (reject-before-apply, verified) — +the snapshot is left untouched. + +## Consequences + +- Post-commit cost is proportional to the **change set size**, not the network: + N affected devices ⇒ ~N × 21 KB + one edge query. +- The snapshot stays a pure read model built from collector responses — no + fabricated entries (option 2 rejected), no divergence between "what we wrote" + and "what the server derived". +- The refresh step needs the affected-set derivation to be exact; the id + conventions it relies on are already documented (concepts.md §3.1) and + fixture-tested. +- Remaining `[VERIFY]` (tracked in concepts.md §5.1): the collector + propagation delay after a commit — requires a live write test (coordinate + nudge + revert) to size the retry window. +- Together with ADR-0009: `commit()` = pre-commit conflict check → POST → + result evaluation → targeted refresh. One documented lifecycle. diff --git a/docs/inspect-app/decisions/README.md b/docs/inspect-app/decisions/README.md index 39746b2..cd8e8f2 100644 --- a/docs/inspect-app/decisions/README.md +++ b/docs/inspect-app/decisions/README.md @@ -18,11 +18,15 @@ accepted — to change a decision, add a new ADR that supersedes the old one | ADR | Title | Status | | ----------------------------------------------------- | ------------------------------------ | ---------- | | [0001](./0001-api-paradigm.md) | API paradigm: data-driven | Accepted | -| [0002](./0002-loading-and-state.md) | Loading & state model | Accepted | +| [0002](./0002-loading-and-state.md) | Loading & state model | Superseded by ADR-0007 | | [0003](./0003-websocket-subscriptions.md) | WebSocket event subscriptions | Deprecated | | [0004](./0004-async-strategy.md) | Async readiness & migration | Accepted | | [0005](./0005-e2e-testing.md) | E2E testing strategy | Accepted | | [0006](./0006-commit-write-model.md) | Commit-style write model (change sets) | Accepted | +| [0007](./0007-lazy-snapshot-loading.md) | Skeleton-first loading & lazy hydration | Accepted | +| [0008](./0008-collector-only-endpoints.md) | Collector-only endpoint policy | Accepted | +| [0009](./0009-write-consistency.md) | Write consistency (compare-and-commit) | Accepted | +| [0010](./0010-post-commit-snapshot-refresh.md) | Post-commit snapshot refresh | Accepted | ## Template diff --git a/docs/inspect-app/endpoints.md b/docs/inspect-app/endpoints.md index 94f408b..492ec9c 100644 --- a/docs/inspect-app/endpoints.md +++ b/docs/inspect-app/endpoints.md @@ -7,8 +7,12 @@ read-only requests and one empty no-op `updateTopology` POST were executed. The Inspect package scope follows the accepted ADRs: -- Request/response only; no WebSocket subscription API. -- Stateless reads; callers re-fetch when they need fresh status. +- Request/response only; no WebSocket subscription API. Subscription *captures* + are still used as an endpoint-discovery source — see + [Collector Scoped Queries](#collector-scoped-queries-captured-from-the-inspect-ui). +- Snapshot-scoped reads: a snapshot loads a skeleton first and lazily hydrates + detail; fresh status means building a new snapshot + ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)). - Data-only DTOs; write/commit behaviour belongs in the future app/transaction layer, not in the model classes. @@ -78,9 +82,14 @@ Response example: ## `GET /rest/v2/data/status/collector/**` -Purpose: canonical Inspect read aggregate. This is the primary read surface for -services, path drill-down, external edge status, and optional topology node -status. +Purpose: full Inspect read aggregate — services, path drill-down, external edge +status, and topology node status in one response. + +> Since [ADR-0007](./decisions/0007-lazy-snapshot-loading.md) this full fetch is +> the **eager/fallback mode** only. The default read path uses the scoped +> queries documented in +> [Collector Scoped Queries](#collector-scoped-queries-captured-from-the-inspect-ui), +> which is also how the vendor's Inspect UI loads its data. Observed top-level sections: @@ -133,6 +142,276 @@ Response shape example: } ``` +## Collector Scoped Queries (captured from the Inspect UI) + +Source: a browser WebSocket capture of the Inspect app's **initial load** +(2026-07-08, VideoIPath 2025.4.x test instance, 27 devices / 40 edge pairs). +The UI never fetches `/status/collector/**`. It opens ~8 **parallel scoped +subscriptions**, each addressing a collector sub-path with a filter and a deep +field projection. These queries are the concrete basis for the skeleton + +lazy-hydration loading model ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)). + +All ids, labels, and addresses below are anonymized. Decoded paths are shown +with URL encoding removed (`%20` → space, `%22` → `"`, `%2C` → `,`, +`%3D` → `=`, `%3C` → `<`). + +### Observed subscription transport (reference only — out of package scope) + +Each subscription is one WebSocket message; the `path` addresses the same data +tree as REST v2: + +```json +{"channel": "subsc", "messageId": 14, "path": "/status/collector/externalEdgesByDeviceKey/", "id": ""} +``` + +The initial reply carries the full query result; the `data` object mirrors the +REST v2 `data` tree exactly (`_items[]`, `_id`, `_vid`): + +```json +{"channel": "subsc", "id": "", "messageId": 14, "payload": {"_id": "", "_ver": [1, 1], "data": {"status": {"collector": {"externalEdgesByDeviceKey": {"_items": ["..."]}}}}}} +``` + +Protocol details (verified by live connection and the UI bundle on 2025.4.9): + +| Item | Value | +| ---- | ----- | +| URL | `{ws\|wss}:///rest/v2/sessions/me/ws?exclusive=false` | +| Auth | Session cookies on the WebSocket handshake (same as REST) | +| Subscribe | `{"channel":"subsc","messageId":N,"path":"","id":""}` | +| Initial reply | `{"channel":"subsc","id":"…","messageId":N,"payload":{"_id":"…","_ver":[1,1],"data":{…}}}` | +| Unsubscribe | `{"channel":"unsubsc","messageId":N,"id":""}` | +| Delta frames | `payload.data._e` (observed in the UI decoder) | +| Reconnect | UI re-opens when the socket is gone and the tab is visible; no dedicated heartbeat channel | + +The package stays request/response ([ADR-0001](./decisions/0001-api-paradigm.md), +[ADR-0003](./decisions/0003-websocket-subscriptions.md)): the same query paths +work as `GET /rest/v2/data`. **Confirmed** on VideoIPath 2025.4.9 +for practical query lengths; URL encoding of spaces (`%20`), quotes (`%22`), +parentheses (`%28`/`%29`), and `=` (`%3D`) works as captured. The **full** UI +projection below returns **HTTP 414** (URI Too Long) as a REST GET — a +proxy/URL-length constraint, not a server bug — so the package uses a trimmed +skeleton projection or the `/**` fallback. + +Measured payload sizes (2025.4.9 instance, 30 devices / 40 edge pairs): + +| Query | Size | +| ----- | ---- | +| `GET …/status/collector/**` (eager fallback) | **19.3 MB** | +| Trimmed device skeleton + edge skeleton | **~92 KB** | +| Minimal `nodeStatus` projection with `"_noId"` (all 30 devices) | **8 KB** | +| Single device `/**` (hydration) | **21 KB** | + +### Query language observed on collector paths + +| Construct | Example | Meaning | +| --------- | ------- | ------- | +| `*` | `nodeStatus/*` | Select all items of a collection | +| `""` | `fromStatus/generic/alias/"a0"` | Select one child by literal key | +| `"_noId"` | `modules/"_noId"` | Subtree expansion suppressor — collection queries return `"modules": {}`; single-device queries omit the `modules` key. Also used on `inspect/paths/"_noId"/…` | +| `* where ` | `* where syncSeverity=2` | Filter items; operators seen: `=`, `<=`, `and`, `or`, parentheses, `contains(,'')`, `lower()` | +| `limit N` | `* where … limit 1000000` | Cap the number of returned items (UI uses `1000` for side lists, `1000000` for the full topology) | +| `order by asc(en) alphanum` | `panelConfigs/* order by label asc(en) alphanum` | Server-side sort (seen outside the collector) | +| `/field1,field2/` | `/deviceId,resourceId/` | Project only the listed fields at the current level | +| `/.../` | `…/coordinates/x,y/.../...` | Pop one level back up in the projection tree | +| `/**` | `status,syncSeverity/**` | Include the full subtree below the selected fields | +| *(no projection)* | `externalEdgesByDeviceKey` | **No expansion**: the bare collection root returns no items (observed, msg 15) | + +### Device skeleton — `nodeStatus` without modules (UI main topology load) + +The UI's primary topology query. Note `modules/"_noId"`: **the vendor UI itself +loads devices without module/port detail**. All 27 captured items came back +with `modules: {}`. + +Decoded subscription path (one line, msg 13): + +```text +/status/collector/inspect/nodeStatus/* where ((syncSeverity=0) or (syncSeverity=1)) or (syncSeverity=3) limit 1000000/deviceId,resourceId/.../context/devicePid,modulePid,portPid/.../.../descriptor/desc,label/.../.../meta/hwPanelType,isCore,isVirtual,siteId/.../coordinates/x,y/.../.../iconSize,iconType,sdpStrategy/**/.../.../tags/*/.../.../.../relatedNodeTags/*/.../.../status,syncSeverity/**/.../.../tags/*/.../.../modules/"_noId"/resourceId/.../context/devicePid,modulePid,portPid/.../.../descriptor/desc,label/.../.../ptpStatus,status/**/.../.../relatedNodeTags/*/.../.../tags/*/.../.../ports/*/pid,resourceId/.../context/devicePid,modulePid,portPid/.../.../descriptor/desc,label/.../.../relatedNodeTags/*/.../.../status/**/.../.../tags/*/.../.../vertexInfo/id,type,vertexType/.../fields/isActive,isControlled,isEndpoint/.../.../in,out/id,label/.../.../.../ptpPortStatus/info/clockType,domain,identity/.../.../status/**/.../.../.../tagsInfo/assigned/inheritedConflict/.../all/*/.../.../inherited/*/label/.../ancestors/*/.../.../path/*/.../.../.../.../local/*/label/.../path/*/.../.../.../.../.../custom/*/.../.../.../pathDescriptions/*/deviceLevel/deviceId,deviceLabel,devicePid,expectConfig/.../inputStatus,outputStatus/label,pid/.../context/devicePid,modulePid,portPid/.../.../status/**/.../.../.../moduleAndDeviceStatus/**/.../.../.../serviceLevel/bookingId,isMain,serviceLabel/.../fromStatus,toStatus/label,pid/.../context/devicePid,modulePid,portPid/.../.../status/**/.../.../.../serviceStatus/config,total/**/.../.../.../.../.../.../.../.../tagsInfo/assigned/inheritedConflict/.../all/*/.../.../inherited/*/label/.../ancestors/*/.../.../path/*/.../.../.../.../local/*/label/.../path/*/.../.../.../.../.../custom/*/.../.../.../.../.../ptpDeviceStatus/info/clockType,domain,identity/.../.../status/**/.../.../.../tagsInfo/assigned/inheritedConflict/.../all/*/.../.../inherited/*/label/.../ancestors/*/.../.../path/*/.../.../.../.../local/*/label/.../path/*/.../.../.../.../.../custom/* +``` + +Effective skeleton selection per device (the module subtree is projected in +full but suppressed by `"_noId"`): + +- identity: `deviceId`, `resourceId`, `context{devicePid,modulePid,portPid}` +- display: `descriptor{desc,label}`, + `meta{hwPanelType,isCore,isVirtual,siteId,coordinates{x,y},iconSize,iconType,sdpStrategy,tags}` +- state: `status/**`, `syncSeverity`, `ptpDeviceStatus{info,status}` +- tagging: `tags`, `relatedNodeTags`, `tagsInfo{assigned,custom}` + +The UI splits `nodeStatus` by sync state into three subscriptions with the same +projection: this one (`syncSeverity` 0/1/3, topology map), a sync-pending list +(msg 9, below), and a label-search variant +(`* where (syncSeverity=3) and (contains(lower(descriptor.label),'')) limit 1000`, +msg 10). A package skeleton load can drop the `where` clause and fetch all +devices in one query. **Confirmed:** omitting `where` does **not** require +`limit` (30 devices returned with or without `limit 1000000` on 2025.4.9). + +Anonymized response item (skeleton shape — note `modules: {}`): + +```json +{ + "_id": "device-a", + "_vid": "device-a", + "context": { "devicePid": "device-a", "modulePid": null, "portPid": null }, + "descriptor": { + "desc": "Type: example_multidevice\nIP: ", + "label": "Example Device A" + }, + "deviceId": "device-a", + "meta": { + "coordinates": { "x": 1600.0, "y": 9050.0 }, + "hwPanelType": null, + "iconSize": "medium", + "iconType": "default", + "isCore": false, + "isVirtual": false, + "sdpStrategy": "always", + "siteId": null, + "tags": [] + }, + "modules": {}, + "ptpDeviceStatus": { + "info": null, + "status": { "sa": 2, "severity": 6 } + }, + "relatedNodeTags": ["Format~~example"], + "resourceId": "device:device-a", + "status": { "sa": 2, "severity": 6 }, + "syncSeverity": 0, + "tags": [], + "tagsInfo": { + "assigned": { "all": [], "inherited": {}, "inheritedConflict": false, "local": {} }, + "custom": [] + } +} +``` + +### Device detail — `nodeStatus` with `modules/*` (hydration template) + +The sync-pending subscription (msg 9) is **byte-identical** to the skeleton +query except for two segments: + +```text +- * where ((syncSeverity=0) or (syncSeverity=1)) or (syncSeverity=3) limit 1000000 ++ * where syncSeverity=2 limit 1000 +- modules/"_noId" ++ modules/* +``` + +With `modules/*` the projection expands modules → ports → `vertexInfo`, +`ptpPortStatus`, per-port `status/**`, `tagsInfo`, and `pathDescriptions` +(`deviceLevel` + `serviceLevel`) — the full drill-down detail. + +This is the template for **per-device lazy hydration** +([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)): reuse the detail +projection but scope it to one device. **Confirmed** on 2025.4.9 — both work; +prefer the shorter direct-id form: + +```http +GET /rest/v2/data/status/collector/inspect/nodeStatus//modules/*/… +GET /rest/v2/data/status/collector/inspect/nodeStatus/* where deviceId='' limit 1/modules/*/… +``` + +**Confirmed** populated `modules/*` on synced devices (`syncSeverity` 0/1/3): +module keys map to child objects whose expansion follows the projection depth +(e.g. `…/modules/*/ports/*/pid,descriptor/label,status` yields ports with +`pid`, `descriptor.label`, and `status`). Devices with `syncSeverity=2` +(sync-pending) return `"modules": {}` even with `modules/*` — modules populate +only after sync completes; the earlier capture was not a projection bug. +`GET …/nodeStatus//**` returns the full subtree (~21 KB for one +device) — fixture: +`tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json`. + +### Edge skeleton — `externalEdgesByDeviceKey` lean projection + +The UI loads **all** edge pairs with a lean projection (msg 14): endpoint +device pids/labels, edge ids, endpoint port labels + `context`, and the +pair-level status severities — **no `pathDescriptions`, no bandwidth numbers** +(only the `bandwidth` *severity* inside `status`). + +Decoded subscription path (one line, msg 14): + +```text +/status/collector/externalEdgesByDeviceKey/* limit 1000000/primary,secondary/devicePid,label/.../.../status/alarm,bandwidth,maintenance,ptp/.../.../primary/data/*/id/.../fromStatus,toStatus/label/.../context/devicePid,modulePid,portPid/.../.../.../.../.../.../secondary/data/*/id/.../fromStatus,toStatus/label/.../context/devicePid,modulePid,portPid +``` + +Anonymized response item: + +```json +{ + "_id": "device-a::device-b", + "_vid": "device-a::device-b", + "primary": { + "data": { + "edge-uuid-0001": { + "id": "edge-uuid-0001", + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "label": "Port A (out)" + }, + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.module-1", + "portPid": "device-b.dev.module-1.port-in-1" + }, + "label": "Port B (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device A" + }, + "secondary": { + "data": {}, + "devicePid": "device-b", + "label": "Example Device B" + }, + "status": { "alarm": 1, "bandwidth": null, "maintenance": null, "ptp": 1 } +} +``` + +A parallel subscription to the **bare collection root** +(`/status/collector/externalEdgesByDeviceKey`, msg 15) returned `_items: []` +while the projected query returned 40 items — without a projection or `/**` +there is no expansion. Edge detail (bandwidth values, `pathDescriptions`) stays +in the full per-pair shape documented under +[`GET …/externalEdgesByDeviceKey/**`](#get-restv2datastatuscollectorexternaledgesbydevicekey). + +### Service list — `inspect/paths` projection + +The UI subscribes to `inspect/paths` with a projection covering `serviceFields` +and the per-hop `path` structure (msg 12; the capture returned no items — no +active services at capture time; the item shape is documented under +[`GET …/inspect/paths/**`](#get-restv2datastatuscollectorinspectpaths)): + +```text +/status/collector/inspect/paths/"_noId"/serviceFields/bid,from,fromLabel,isMain,to,toLabel/.../fromStatus,toStatus/**/.../.../generic/descriptor/desc,label/.../.../.../serviceStatus/config,total/**/.../.../.../.../path/*/bid,ipDesc/.../structure/deviceId,deviceLabel,devicePid,expectConfig/.../inputStatus,outputStatus/label,pid/.../context/devicePid,modulePid,portPid/.../.../status/**/.../.../.../moduleAndDeviceStatus/** +``` + +Selected: `serviceFields{bid,from,fromLabel,isMain,to,toLabel,fromStatus, +toStatus,generic.descriptor,serviceStatus{config,total}}` plus +`path[]{bid,ipDesc,structure{deviceId,deviceLabel,devicePid,expectConfig, +inputStatus,outputStatus,moduleAndDeviceStatus}}` — everything the snapshot's +service index needs, without the full raw records. + +### Auxiliary section queries + +Also part of the UI's initial load (initial replies at capture time in +parentheses): + +| Sub-path (decoded) | Purpose | msg | +| ------------------ | ------- | --- | +| `/status/collector/maintenanceBookings/* where ((contains(lower(generic.descriptor.label),'')) or (contains(tags,''))) and (generic.state<=1)/` | Active maintenance bookings (empty) | 16 | +| `/status/collector/superProfiles` *(projection not captured; reply held profile records)* | Routing profiles (populated) | 3 | +| `/status/collector/tagInfo` *(projection not captured; reply held `profileTags._items[]`)* | Tag → profile mappings (populated) | 4 | +| `/status/conman/services/"_noId" where connection.generic.state<=1/` | Rich service list for the Services panel — **not** a collector path; reference only (empty) | 11 | +| `/status/system/about/copyright,gitHead,version` | Version probe | 17 | +| `/status/system/status/serverState/broadcastAddress,hostname,mode,role` | Server role/state | 19 | + ## `GET /rest/v2/data/status/collector/inspect/paths/**` Purpose: list service/path records with endpoint labels, service state, and @@ -388,8 +667,11 @@ Observed response on this instance: ## `GET /rest/v2/data/status/network/edgesByDevice/**` +> **Reference only — not called by the package** +> ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). + Purpose: existing status-plane edge view. This is not the collector facade, but -it is useful for cross-checking edge payload fields when +it is useful for cross-checking edge payload fields during discovery when `config/network/nGraphElements` is unavailable or permission-filtered. Request: @@ -442,10 +724,27 @@ Response fragment: ## `GET /rest/v2/data/config/network/nGraphElements/**` +> **Reference only — not called by the package** +> ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). This is +> `app.topology`'s surface; it is documented here because `updateTopology` +> persists into it and the `replace*` payloads carry the persisted element +> shape. Its `_rev` is irrelevant to Inspect writes — `updateTopology` ignores +> revisions (last-writer-wins; see +> [ADR-0009](./decisions/0009-write-consistency.md)). + Purpose: revisioned config store that `updateTopology` persists into. Inspect models include independent `InspectApi*` nGraph DTOs for this persisted shape, but they do not import or subclass topology app models. +> **Vertex tags are not stored here.** Device-level tags appear on `baseDevice` +> items, but tag bindings on individual vertices live in +> `videoipath_docs.device_tags` (separate from the `ngraph` table). Inspect +> surfaces vertex tags via `lookupInspectVertexByIds` and hydrated port +> `tagsInfo` in `nodeStatus` — see +> [concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-inspect-vs-topology). +> `app.topology` only knows the `nGraphElements` shape and therefore has no +> vertex-tag API. + In the sanitized capture, the endpoint was valid but returned no items: ```json @@ -490,6 +789,145 @@ Manual endpoint discovery also identified the following Inspect-related action endpoints. The examples below are anonymized and were captured with safe lookup or no-op requests. +### `POST /rest/v2/actions/status/collector/lookupInspectEdgesByIds` + +**Verified 2025.4.9.** The Inspect-surface source of an edge's **full persisted +form** — every field a `replaceEdges` payload needs (`weight`, `capacity`, +`bandwidth`, `redundancyMode`, `weightFactors`, `descriptor`, `fDescriptor`, +`tags`, `conflictPri`, `includeFormats`, `excludeFormats`). Batched by design. +This is the stage-time baseline read for compare-and-commit +([ADR-0009](./decisions/0009-write-consistency.md)). The UI bundle's edge edit +flow calls it with **both directions of a connection** +(`[edgeId, pairedEdgeId]`) before opening the dialog. **No `_rev` anywhere in +the response.** + +> A `lookupGraphElement` action does **not** exist on 2025.4.9 (POST → +> `No action node in request`); earlier references to it were wrong. + +Request: + +```json +{ + "header": { "id": 0 }, + "data": ["device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in"] +} +``` + +Response (keyed by requested edge id): + +```json +{ + "data": { + "device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in": { + "edge": { + "active": true, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": { "desc": "", "label": "" }, + "excludeFormats": [], + "fDescriptor": { "desc": "", "label": "" }, + "fromId": "device-a.module-1.port-out-1.out", + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": "device-b.module-1.port-in-1.in", + "weight": 1, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "max": 100, "weight": 0 } + } + }, + "fromDevice": "device-a", + "toDevice": "device-b" + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Fixture: `tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json`. + +### `POST /rest/v2/actions/status/collector/lookupInspectVertexById` / `…ByIds` + +**Verified 2025.4.9.** Editable vertex form for one id (`data: ""`) +or batched (`…ByIds`, `data: ["", …]`, response keyed by id). The UI bundle +uses only the batched form. **No `_rev`.** Together with +`lookupInspectDevice` (devices, above) and `lookupInspectEdgesByIds` this +covers all three `replace*` element kinds for stage-time baselines +(ADR-0009). + +> **Vertex tags.** Tag bindings on a vertex are **not** part of the topology +> `nGraphElements` store (`app.topology` has no equivalent). Server-side they +> live in `videoipath_docs.device_tags`, separate from the `ngraph` table. +> This lookup is the Inspect-surface source for vertex tag state: +> `assignedTags` (with `all`, `inherited`, `local`, `inheritedConflict`) and +> `fields.tags` / `fields.localAssignedTags`. Hydrated `nodeStatus` ports carry +> the same bindings under `tagsInfo` for read-side display +> ([concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-inspect-vs-topology)). + +Response (single form; `…ByIds` nests this per id): + +```json +{ + "data": { + "assignedTags": { "all": [], "inherited": {}, "inheritedConflict": false, "local": {} }, + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "customSchemas": {}, + "fields": { + "active": true, + "controlProps": null, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "Port A (out)", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "ipAddress": null, + "ipNetmask": null, + "public": false, + "supportsCpipeCfg": false, + "supportsIgmpCfg": false, + "supportsMacForwardingCfg": false, + "supportsNsoCfg": false, + "supportsOpenflowCfg": false, + "supportsStaticIgmpCfg": false, + "supportsVlanCfg": false, + "supportsVplsCfg": false, + "type": "ip", + "vlanId": null, + "vrfId": null + }, + "useAsEndpoint": false + }, + "id": "device-a.module-1.port-out-1.out", + "isVirtual": false, + "vertexType": "Out" + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Fixture: `tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json`. + +### `POST /rest/v2/actions/status/collector/lookupNodeInfo` / `…/lookupEdgeInfo` / `…/lookupDeviceVertices` + +**Verified 2025.4.9 — display-oriented**, not baselines. `lookupNodeInfo` +(`data: ""`) and `lookupEdgeInfo` (`data: ""`) return +info-panel section lists (`[{header, content: [{label, field: {type, value}, +meta}]}]`) — the drill-down side panels, with port `context` pids in `meta`. +`lookupDeviceVertices` takes `{"primary": "", "secondary": +""}` and returns the connectable vertices per side in the same +label/value style (the connect dialog's port lists). + ### `POST /rest/v2/actions/status/collector/lookupInspectDevice` Purpose: collector lookup for one Inspect device/topology node. @@ -942,7 +1380,85 @@ Successful commit detection: committed = response.header.ok and response.data.res.ok and response.data.validation.result.ok ``` -Known failure shape from the accepted ADR: +Concurrency: the action performs **no revision check** — a stale `_rev` in +`replace*` payloads is ignored (last-writer-wins, verified 2025.4.9). Conflict +detection is client-side compare-and-commit +([ADR-0009](./decisions/0009-write-consistency.md)); after a successful commit +the snapshot is refreshed with targeted scoped reads +([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)). + +Failure modes (verified 2025.4.9): + +| Failure mode | `data.res.ok` | `validation.result.ok` | Example | +| ------------ | ------------- | ---------------------- | ------- | +| Validation gate | `false` | `false` | Booking-blocked device remove (`status: -22`, `resolvable: false`) | +| Apply gate | `false` | `true` | `remove: ["unknown-id"]` → *Cannot remove non-existent object* | +| Bad edge reference | `false` | `true` | `replaceEdges` with non-existent `fromId` | + +- **No partial apply**: mixing a valid `replaceEdges` with an invalid `remove` + in one commit fails entirely (`items: []`) — reject-before-apply. +- **`force: true` does not bypass apply-gate errors** (`res.ok` stays `false` + for non-existent remove keys). +- Writes appear in `nGraphElements` under the composite `fromId::toId` key with + a bumped `_rev`; a parallel UUID-keyed document may also exist for the same + edge. +- `resolvable: true` has not been observed on 2025.4.9. +- Fixtures: `tests/fixtures/inspect/2025.4.9/update_topology_success.json`, + `…/update_topology_fail_remove.json`, `…/update_topology_fail_booking.json`. + +Applied-change response (verified on 2025.4.9 — edge `weight` change): + +```json +{ + "data": { + "items": [ + { + "external": null, + "id": "device-a.dev.module-1.port-out-1.out::device-b.dev.module-1.port-in-1.in", + "idx": 0, + "res": { "msg": [""], "ok": true } + } + ], + "res": { "msg": [], "ok": true }, + "validation": { + "createIds": [], + "details": {}, + "result": { "msg": [], "ok": true } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Known failure shape from the accepted ADR (booking-blocked device delete, +verified 2025.4.9): + +```json +{ + "data": { + "items": [], + "res": { "msg": ["Validation failed"], "ok": false }, + "validation": { + "details": { + "booking-1001": { + "isCancel": false, + "isProduct": false, + "resolvable": false, + "rev": "2-2026-06-10T19:54:01.297948842Z[UTC]", + "status": -22, + "type": "generic" + } + }, + "result": { + "msg": ["A required edge was not found. (main); A required edge was not found. (redundant)"], + "ok": false + } + } + } +} +``` + +Earlier anonymized failure shape (browser capture, 2026-06-18): ```json { @@ -972,3 +1488,57 @@ Known failure shape from the accepted ADR: } } ``` + +## Action registration discovery + +`GET /rest/v2/actions/status/collector/` returns whether an action +is registered on the server (verified 2025.4.9): + +```http +GET /rest/v2/actions/status/collector/updateTopology +``` + +```json +{ + "actions": { + "status": { + "collector": { + "updateTopology": { "desc": "", "label": "UpdateTopology" } + } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +The complete registered set (`GET /rest/v2/actions/status/collector/**`, +enumerated live on 2025.4.9): + +```text +findContext lookupInspectDevice lookupPathHistoryTimes +lookupConfigDesc lookupInspectEdgesByIds lookupPathNodeAlarms +lookupDeviceAlarms lookupInspectVertexById lookupResourceSummary +lookupDeviceVertices lookupInspectVertexByIds lookupResourceTransformInfo +lookupEdgeInfo lookupInstancesStatus lookupServiceInfo + lookupNodeInfo lookupSyncInfo + lookupPathHistory lookupVertexAlarms + restoreHistoricalPath + updateTopology +``` + +Unregistered actions return an empty `collector` object; POST to those URLs +responds with `No action node in request` regardless of payload. On 2025.4.9 +`lookupGraphElement`, `validateTopology`, `discardTopology`, `importTopology`, +`exportTopology`, and `importExport/{import,export}` are **not registered** +(27+ POST payload variants tried for `validateTopology`; the UI bundle +references none of them; `importExport` data namespaces are empty under +`status`/`config`/`experimental`). These are unregistered server stubs — no +further payload probing is warranted unless a future version registers them +(re-check the GET schema after upgrades). Fixture: +`tests/fixtures/inspect/2025.4.9/action_schema_collector.json`. + +Cross-check against the UI bundle (`/assets/index-*.js`, 2025.4.9): it +references `updateTopology`, `addDevices`, `syncDevices`, `lookupSyncInfo`, +`lookupInspectEdgesByIds`, `lookupInspectVertexByIds`, `lookupEdgeInfo`, +`lookupNodeInfo`, and `lookupConfigDesc` — and contains **zero references to +`nGraphElements`** ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). diff --git a/docs/inspect-app/models.md b/docs/inspect-app/models.md index c6400a6..0ac0e20 100644 --- a/docs/inspect-app/models.md +++ b/docs/inspect-app/models.md @@ -20,23 +20,32 @@ Wire-shape examples and endpoint references live in ```mermaid flowchart LR - Http[HTTP collector response] --> ApiDto[InspectApiCollectorResponse] - ApiDto --> Snapshot[InspectSnapshot] + Skeleton[Skeleton fetch: devices + edges] --> Snapshot[InspectSnapshot] Snapshot --> Device[InspectDevice] + Device -- unloaded property --> Hydrate[Per-device detail fetch] + Hydrate -- merged into state --> Snapshot Device --> Ports[InspectPort] Device --> Edges[InspectEdge] Device --> Services[InspectService] ``` +A snapshot is built **skeleton-first** +([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)): two parallel scoped +collector queries load all devices (without modules/ports) and all external +edges (lean projection). Detail is **lazily hydrated** — accessing an unloaded +property fetches that one device's full `nodeStatus` subtree (or, for +services, the `inspect/paths` section once) and merges it into the snapshot's +internal state. The concrete queries are documented in +[endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui). + Typical read flow: ```python -response = InspectApiCollectorResponse.model_validate(payload) -snapshot = InspectSnapshot.from_response(response) -device = snapshot.get_device_by_id("device-a") -ports = device.ports -edges = device.edges -services = device.services +snapshot = app.inspect.get_snapshot() # skeleton fetch (devices + edges) +device = snapshot.get_device_by_id("device-a") # local: skeleton-backed +ports = device.ports # first access: hydrates device-a, then local +edges = device.edges # local: edge skeleton +services = device.services # first access: loads the paths section, then local linked = device.linked_devices port = ports[0] @@ -49,8 +58,21 @@ Relation getters resolve full domain objects from snapshot indexes. Repeated access returns the same cached instance for a given device, port, edge, or service. -Refresh data by fetching a new collector response and building a new snapshot. -Relation getters never perform hidden HTTP requests. +The loading contract: + +- A getter performs **at most one hydration fetch** per entity (device + subtree) or section (services, maintenance bookings, …); after that, access + is local. Hydrated data is merged into the same snapshot state and indexes. +- Because hydration is HTTP, touching an unloaded property can add latency and + raise connector errors — this is deliberate, documented behaviour. +- Iterating detail over many devices hydrates one device per iteration (N+1); + use bulk preload helpers (e.g. `snapshot.preload_devices(...)` / + `get_devices(detail=True)`) for that pattern. +- The snapshot is **not** a single point in time: skeleton and hydrated + subtrees carry their own fetch timestamps. + +Refresh data by building a new snapshot; the state accretes within one +snapshot's lifetime but is never reused across snapshots. ## Mental Model @@ -108,8 +130,29 @@ The useful Inspect content is under `data.status.collector`. ``` VideoIPath collections use `_items`. Transport DTOs preserve that wire shape. -`InspectSnapshot` indexes the parsed collector data once and exposes user-facing -objects from those indexes. +`InspectSnapshot` indexes the skeleton data at construction and extends its +indexes incrementally as entities and sections are hydrated. Scoped queries +return the same wire shapes as the full aggregate, just filtered/projected — +one set of DTOs covers both (skeleton items simply have `modules: {}` and +omitted fields). + +## Loaded vs. Unloaded State + +The snapshot tracks, per device and per section, whether detail has been +hydrated and when it was fetched. + +| Data | Backing | Loaded when | +| --- | --- | --- | +| Device identity, `label`, `pid`, `coordinates`, icon/meta, `status`, `sync_severity`, `tags` | Device skeleton query | Snapshot construction | +| Edge connectivity, endpoint ports/labels, status severities | Edge skeleton query | Snapshot construction | +| Device `ports` (modules, port status, `vertexInfo`, port `tagsInfo`, PTP), port-level path drill-down | Per-device `nodeStatus` subtree (`modules/*` projection) | First access on that device | +| `services`, service path structures | `inspect/paths` section query | First access to any service data | +| Maintenance bookings, super profiles, tag info | Section queries | First access, if exposed | +| Edge bandwidth values, edge `pathDescriptions` | Full per-pair edge shape | Not in the skeleton; loaded with the owning section/entity detail | + +An eager snapshot (`load="full"`, one `GET …/collector/**`) starts fully +hydrated; fixture-built snapshots used in offline tests behave the same, with +lazy loading inert. ## User-Facing Domain Objects @@ -152,6 +195,7 @@ external edge to another device. | `module_id` | Owning module | | `status` | Port status summary | | `vertex_id` | Linked topology vertex when available | +| `tags` | Vertex tag bindings when hydrated (`tagsInfo` from `nodeStatus`) | | `edge` | External edge when this port connects to another device; otherwise `None` | ### `InspectEdge` @@ -392,6 +436,40 @@ Action endpoints return focused views for UI workflows. } ``` +`lookupInspectVertexByIds` returns the editable vertex form, including **vertex +tag bindings** (not available from `nGraphElements` or `app.topology`): + +```json +{ + "data": { + "device-a.module-1.port-out-1.out": { + "assignedTags": { + "all": ["#example-tag"], + "inherited": {}, + "inheritedConflict": false, + "local": { "#example-tag": { "label": "#example-tag", "path": [] } } + }, + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "fields": { + "label": "Port A (out)", + "localAssignedTags": ["#example-tag"], + "tags": ["#example-tag"], + "typeFields": { "type": "ip" } + }, + "id": "device-a.module-1.port-out-1.out", + "vertexType": "Out" + } + } +} +``` + +This is the stage-time baseline for vertex tag fields in compare-and-commit +([ADR-0009](./decisions/0009-write-consistency.md)). + `lookupSyncInfo` returns per-device sync differences: ```json @@ -436,9 +514,20 @@ returned. Those responses contain only the REST header with validation details. The collector snapshot is a status view. Committed Inspect topology data is stored in `config.network.nGraphElements._items[]`. Each item has an ID, optional -revision, display descriptors, tags, and a `type` value that tells callers which +revision, display descriptors, and a `type` value that tells callers which shape to parse. +> **Vertex tags are not persisted here.** Device-level tags live on `baseDevice` +> items, but bindings of tags to individual vertices (`ipVertex`, `codecVertex`, +> …) are stored server-side in `videoipath_docs.device_tags`, not in the +> `ngraph` / `nGraphElements` store. `app.topology` therefore has no vertex-tag +> concept. Inspect reads vertex tags from hydrated port `tagsInfo` in +> `nodeStatus` and from `lookupInspectVertexByIds` (`assignedTags`, +> `fields.tags`, `fields.localAssignedTags`) — see +> [concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-inspect-vs-topology). +> A `tags` field may appear on vertex elements in `nGraphElements` wire examples +> but is not the authoritative store for vertex tag bindings. + ```mermaid flowchart LR BaseDevice[baseDevice: device node] @@ -579,6 +668,51 @@ response.header.ok and response.data.res.ok and response.data.validation.result. Validation failures can still arrive with `header.ok == true`, so callers must inspect `data.res`, `data.validation.result`, and `data.validation.details`. +### Consistency Against Concurrent Writers + +`updateTopology` enforces no revisions (last-writer-wins), `replace*` entries +are full-object upserts, and collector reads carry no `_rev` — so the change +set protects callers itself +([ADR-0009](./decisions/0009-write-consistency.md)): + +- **Staging an entity fetches its baseline**: the current form, read via + Inspect-surface lookups (`lookupInspectDevice` for devices, + `lookupInspectVertexByIds` for vertices, `lookupInspectEdgesByIds` for edges + — the latter returns the full persisted edge form, batched; see + [endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorlookupinspectedgesbyids)). + The caller's mutations are applied on top of the baseline, so the committed + payload never clobbers fields built from stale state. +- **`commit()` re-checks before posting**: the touched entities are re-read + and compared against their baselines; any third-party change aborts the whole + commit with a typed conflict error (entity ids + field diffs). Skipping the + check is an explicit opt-in (deliberate last-writer-wins). +- The check is **detection, not enforcement** — a small race window between + re-read and POST remains; the server offers nothing stronger on the Inspect + surface. + +Snapshot data is never used as a baseline — it is a read projection without +revisions, possibly stale by design (lazy hydration). + +### Snapshot Refresh After a Commit + +A successful commit leaves the caller's `InspectSnapshot` stale exactly where +the change set touched it. Instead of a full re-snapshot (~MBs), the snapshot +catches up with **targeted scoped re-reads** +([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)): + +- removed entities are dropped from the indexes locally; +- affected devices and edge pairs (derived from the change-set keys and the + commit response `items[]`) are re-fetched with the same per-device / + per-pair queries the lazy-hydration path uses, replacing their records and + fetch timestamps; +- loaded sections (e.g. services) are marked stale and re-load lazily on next + access; +- the refresh verifies the committed values are visible and retries briefly if + the collector projection lags the config store. + +A failed commit changes nothing server-side (reject-before-apply), so the +snapshot is left untouched. + ## Python Module Layout Transport DTOs are split by payload area under `apps/inspect/model/`: diff --git a/tests/fixtures/inspect/2025.4.9/action_schema_collector.json b/tests/fixtures/inspect/2025.4.9/action_schema_collector.json new file mode 100644 index 0000000..335c4ee --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/action_schema_collector.json @@ -0,0 +1,97 @@ +{ + "updateTopology": { + "actions": { + "status": { + "collector": { + "updateTopology": { + "desc": "", + "label": "UpdateTopology" + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + }, + "validateTopology": { + "actions": { + "status": { + "collector": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + }, + "exportTopology": { + "actions": { + "status": { + "collector": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + }, + "importTopology": { + "actions": { + "status": { + "collector": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + }, + "discardTopology": { + "actions": { + "status": { + "collector": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + } +} diff --git a/tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json b/tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json new file mode 100644 index 0000000..2aa9e50 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json @@ -0,0 +1,73 @@ +{ + "data": { + "status": { + "collector": { + "inspect": { + "nodeStatus": { + "_items": [ + { + "_id": "device-a", + "_vid": "device-a", + "modules": { + "device-a.dev.0": { + "ports": { + "device-a.dev.module-1.port-out-1": { + "descriptor": { + "label": "port-out-1 (out)" + }, + "pid": "device-a.dev.module-1.port-out-1" + } + } + }, + "device-a.dev.uuid-0001": { + "ports": { + "device-a.dev.uuid-0001.S00000001-0000-4000-8000-000000000001": { + "descriptor": { + "label": "Example Stream" + }, + "pid": "device-a.dev.uuid-0001.S00000001-0000-4000-8000-000000000001" + }, + "device-a.dev.uuid-0001.S00000002-0000-4000-8000-000000000002": { + "descriptor": { + "label": "Example Stream" + }, + "pid": "device-a.dev.uuid-0001.S00000002-0000-4000-8000-000000000002" + } + } + }, + "device-a.dev.uuid-0002": { + "ports": { + "device-a.dev.uuid-0002.S00000003-0000-4000-8000-000000000003": { + "descriptor": { + "label": "Example Pipeline" + }, + "pid": "device-a.dev.uuid-0002.S00000003-0000-4000-8000-000000000003" + }, + "device-a.dev.uuid-0002.S00000004-0000-4000-8000-000000000004": { + "descriptor": { + "label": "Example Pipeline" + }, + "pid": "device-a.dev.uuid-0002.S00000004-0000-4000-8000-000000000004" + } + } + } + } + } + ] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/edge_skeleton.json b/tests/fixtures/inspect/2025.4.9/edge_skeleton.json new file mode 100644 index 0000000..1f360d6 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/edge_skeleton.json @@ -0,0 +1,2259 @@ +{ + "data": { + "status": { + "collector": { + "externalEdgesByDeviceKey": { + "_items": [ + { + "_id": "device-h::device-a", + "_vid": "device-h::device-a", + "primary": { + "data": { + "uuid-0003": { + "fromStatus": { + "context": { + "devicePid": "device-h", + "modulePid": "device-h.dev.0", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "label": "port-out-1 (out)" + }, + "id": "uuid-0003", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-h", + "label": "Example Device A" + }, + "secondary": { + "data": {}, + "devicePid": "device-a", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-i::device-m", + "_vid": "device-i::device-m", + "primary": { + "data": { + "device-i.1.3.out::device-m.1.Ethernet4_42.in": { + "fromStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.3" + }, + "label": "eth-0-3" + }, + "id": "device-i.1.3.out::device-m.1.Ethernet4_42.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_42" + }, + "label": "port-in-1/42" + } + } + }, + "devicePid": "device-i", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-m.1.Ethernet4_42.out::device-i.1.3.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_42" + }, + "label": "port-in-1/42" + }, + "id": "device-m.1.Ethernet4_42.out::device-i.1.3.in", + "toStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.3" + }, + "label": "eth-0-3" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-i::device-p", + "_vid": "device-i::device-p", + "primary": { + "data": { + "uuid-0002": { + "fromStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.51" + }, + "label": "eth-0-51" + }, + "id": "uuid-0002", + "toStatus": { + "context": { + "devicePid": "device-p", + "modulePid": "device-p.dev.0", + "portPid": "device-p.dev.0.NMS-RED" + }, + "label": "NMS-RED" + } + } + }, + "devicePid": "device-i", + "label": "Example Device" + }, + "secondary": { + "data": { + "uuid-0005": { + "fromStatus": { + "context": { + "devicePid": "device-p", + "modulePid": "device-p.dev.0", + "portPid": "device-p.dev.0.NMS-RED" + }, + "label": "NMS-RED" + }, + "id": "uuid-0005", + "toStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.51" + }, + "label": "eth-0-51" + } + } + }, + "devicePid": "device-p", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-i::virtual.0", + "_vid": "device-i::virtual.0", + "primary": { + "data": { + "uuid-0010": { + "fromStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.51" + }, + "label": "eth-0-51" + }, + "id": "uuid-0010", + "toStatus": { + "context": { + "devicePid": "virtual-0", + "modulePid": "virtual-0.virt.0", + "portPid": "virtual-0.virt.0.1" + }, + "label": "Ethernet 1" + } + } + }, + "devicePid": "device-i", + "label": "Example Device" + }, + "secondary": { + "data": { + "uuid-0006": { + "fromStatus": { + "context": { + "devicePid": "virtual-0", + "modulePid": "virtual-0.virt.0", + "portPid": "virtual-0.virt.0.3" + }, + "label": "Ethernet 1" + }, + "id": "uuid-0006", + "toStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.51" + }, + "label": "eth-0-51" + } + } + }, + "devicePid": "virtual-0", + "label": "Virtual Device 1" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-j::device-d", + "_vid": "device-j::device-d", + "primary": { + "data": { + "device-j.0.P1.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-j", + "modulePid": "device-j.dev.0", + "portPid": "device-j.dev.0.P1" + }, + "label": "P1 (out)" + }, + "id": "device-j.0.P1.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-j", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-j", + "_vid": "device-a::device-j", + "primary": { + "data": {}, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-j.0.P2.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-j", + "modulePid": "device-j.dev.0", + "portPid": "device-j.dev.0.P2" + }, + "label": "P2 (out)" + }, + "id": "device-j.0.P2.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-j", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-k", + "_vid": "device-a::device-k", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-k.0.P2.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-k.0.P2.in", + "toStatus": { + "context": { + "devicePid": "device-k", + "modulePid": "device-k.dev.0", + "portPid": "device-k.dev.0.P2" + }, + "label": "P2 (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-k", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-l", + "_vid": "device-a::device-l", + "primary": { + "data": { + "device-a.1.Ethernet54_1.out::device-l.4.Ethernet3_37_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.Ethernet54_1" + }, + "label": "port-in-1/1" + }, + "id": "device-a.1.Ethernet54_1.out::device-l.4.Ethernet3_37_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_37_1" + }, + "label": "port-in-1/37/1" + } + }, + "device-a.1.Ethernet56_1.out::device-l.4.Ethernet3_39_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.Ethernet56_1" + }, + "label": "port-in-1/1" + }, + "id": "device-a.1.Ethernet56_1.out::device-l.4.Ethernet3_39_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_39_1" + }, + "label": "port-in-1/39/1" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-l.4.Ethernet3_37_1.out::device-a.1.Ethernet54_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_37_1" + }, + "label": "port-in-1/37/1" + }, + "id": "device-l.4.Ethernet3_37_1.out::device-a.1.Ethernet54_1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.Ethernet54_1" + }, + "label": "port-in-1/1" + } + }, + "device-l.4.Ethernet3_39_1.out::device-a.1.Ethernet56_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_39_1" + }, + "label": "port-in-1/39/1" + }, + "id": "device-l.4.Ethernet3_39_1.out::device-a.1.Ethernet56_1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.Ethernet56_1" + }, + "label": "port-in-1/1" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-r", + "_vid": "device-a::device-r", + "primary": { + "data": { + "uuid-0004": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "uuid-0004", + "toStatus": { + "context": { + "devicePid": "device-r", + "modulePid": "device-r.dev.0", + "portPid": "device-r.dev.0.P2" + }, + "label": "P2 (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-r", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-s", + "_vid": "device-a::device-s", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-s.0.P2.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-s.0.P2.in", + "toStatus": { + "context": { + "devicePid": "device-s", + "modulePid": "device-s.dev.0", + "portPid": "device-s.dev.0.P2" + }, + "label": "P2 (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-s", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-t", + "_vid": "device-a::device-t", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-t.0.mmc1.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-t.0.mmc1.in", + "toStatus": { + "context": { + "devicePid": "device-t", + "modulePid": "device-t.dev.0", + "portPid": "device-t.dev.0.mmc1" + }, + "label": "mmc1 (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-t", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-u", + "_vid": "device-a::device-u", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-u.3.10006.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-u.3.10006.in", + "toStatus": { + "context": { + "devicePid": "device-u", + "modulePid": "device-u.dev.3", + "portPid": "device-u.dev.3.10006" + }, + "label": "Ethernet 3.4" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-u.3.10006.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-u", + "modulePid": "device-u.dev.3", + "portPid": "device-u.dev.3.10006" + }, + "label": "Ethernet 3.4" + }, + "id": "device-u.3.10006.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-u", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-f", + "_vid": "device-a::device-f", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-f.1.10006.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-f.1.10006.in", + "toStatus": { + "context": { + "devicePid": "device-f", + "modulePid": "device-f.dev.1", + "portPid": "device-f.dev.1.10006" + }, + "label": "Ethernet 1.4" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-f.1.10006.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-f", + "modulePid": "device-f.dev.1", + "portPid": "device-f.dev.1.10006" + }, + "label": "Ethernet 1.4" + }, + "id": "device-f.1.10006.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-f", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-g", + "_vid": "device-a::device-g", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-g.6.10006.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-g.6.10006.in", + "toStatus": { + "context": { + "devicePid": "device-g", + "modulePid": "device-g.dev.6", + "portPid": "device-g.dev.6.10006" + }, + "label": "Ethernet 6.4" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-g.6.10006.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-g", + "modulePid": "device-g.dev.6", + "portPid": "device-g.dev.6.10006" + }, + "label": "Ethernet 6.4" + }, + "id": "device-g.6.10006.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-g", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-k::device-d", + "_vid": "device-k::device-d", + "primary": { + "data": {}, + "devicePid": "device-k", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-k.0.P1.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-k.0.P1.in", + "toStatus": { + "context": { + "devicePid": "device-k", + "modulePid": "device-k.dev.0", + "portPid": "device-k.dev.0.P1" + }, + "label": "P1 (in)" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-n", + "_vid": "device-l::device-n", + "primary": { + "data": { + "device-l.4.Ethernet3_47_1.out::device-n.1.49.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_47_1" + }, + "label": "port-in-1/47/1" + }, + "id": "device-l.4.Ethernet3_47_1.out::device-n.1.49.in", + "toStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.49" + }, + "label": "eth-0-49" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-n.1.49.out::device-l.4.Ethernet3_47_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.49" + }, + "label": "eth-0-49" + }, + "id": "device-n.1.49.out::device-l.4.Ethernet3_47_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_47_1" + }, + "label": "port-in-1/47/1" + } + } + }, + "devicePid": "device-n", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-o", + "_vid": "device-l::device-o", + "primary": { + "data": { + "device-l.4.Ethernet3_1_1.out::device-o.0.p9p1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_1_1" + }, + "label": "port-in-1/1/1" + }, + "id": "device-l.4.Ethernet3_1_1.out::device-o.0.p9p1.in", + "toStatus": { + "context": { + "devicePid": "device-o", + "modulePid": "device-o.dev.0", + "portPid": "device-o.dev.0.p9p1" + }, + "label": "p9p1" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-o.0.p9p1.out::device-l.4.Ethernet3_1_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-o", + "modulePid": "device-o.dev.0", + "portPid": "device-o.dev.0.p9p1" + }, + "label": "p9p1" + }, + "id": "device-o.0.p9p1.out::device-l.4.Ethernet3_1_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_1_1" + }, + "label": "port-in-1/1/1" + } + } + }, + "devicePid": "device-o", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-v", + "_vid": "device-l::device-v", + "primary": { + "data": { + "device-l.4.Ethernet3_7_1.out::device-v.0.data1B.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_7_1" + }, + "label": "port-in-1/7/1" + }, + "id": "device-l.4.Ethernet3_7_1.out::device-v.0.data1B.in", + "toStatus": { + "context": { + "devicePid": "device-v", + "modulePid": "device-v.dev.0", + "portPid": "device-v.dev.0.data1B" + }, + "label": "data1B" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-v.0.data1B.out::device-l.4.Ethernet3_7_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-v", + "modulePid": "device-v.dev.0", + "portPid": "device-v.dev.0.data1B" + }, + "label": "data1B" + }, + "id": "device-v.0.data1B.out::device-l.4.Ethernet3_7_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_7_1" + }, + "label": "port-in-1/7/1" + } + } + }, + "devicePid": "device-v", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-y", + "_vid": "device-l::device-y", + "primary": { + "data": { + "device-l.1.Ethernet4_1.out::device-y.0.port-102.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.1", + "portPid": "device-l.dev.1.Ethernet4_1" + }, + "label": "port-in-1/1" + }, + "id": "device-l.1.Ethernet4_1.out::device-y.0.port-102.in", + "toStatus": { + "context": { + "devicePid": "device-y", + "modulePid": "device-y.dev.0", + "portPid": "device-y.dev.0.port-102" + }, + "label": "port-102" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-y.0.port-102.out::device-l.1.Ethernet4_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-y", + "modulePid": "device-y.dev.0", + "portPid": "device-y.dev.0.port-102" + }, + "label": "port-102" + }, + "id": "device-y.0.port-102.out::device-l.1.Ethernet4_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.1", + "portPid": "device-l.dev.1.Ethernet4_1" + }, + "label": "port-in-1/1" + } + } + }, + "devicePid": "device-y", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-e", + "_vid": "device-l::device-e", + "primary": { + "data": { + "device-l.4.Ethernet3_2_1.out::device-e.1000.1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_2_1" + }, + "label": "port-in-1/2/1" + }, + "id": "device-l.4.Ethernet3_2_1.out::device-e.1000.1.in", + "toStatus": { + "context": { + "devicePid": "device-e", + "modulePid": "device-e.dev.1000", + "portPid": "device-e.dev.1000.1" + }, + "label": "IP WAN 1" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-e.1000.1.out::device-l.4.Ethernet3_2_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-e", + "modulePid": "device-e.dev.1000", + "portPid": "device-e.dev.1000.1" + }, + "label": "IP WAN 1" + }, + "id": "device-e.1000.1.out::device-l.4.Ethernet3_2_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_2_1" + }, + "label": "port-in-1/2/1" + } + } + }, + "devicePid": "device-e", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-o", + "_vid": "device-m::device-o", + "primary": { + "data": { + "device-m.4.Ethernet3_1_1.out::device-o.0.p9p2.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_1_1" + }, + "label": "port-in-1/1/1" + }, + "id": "device-m.4.Ethernet3_1_1.out::device-o.0.p9p2.in", + "toStatus": { + "context": { + "devicePid": "device-o", + "modulePid": "device-o.dev.0", + "portPid": "device-o.dev.0.p9p2" + }, + "label": "p9p2" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-o.0.p9p2.out::device-m.4.Ethernet3_1_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-o", + "modulePid": "device-o.dev.0", + "portPid": "device-o.dev.0.p9p2" + }, + "label": "p9p2" + }, + "id": "device-o.0.p9p2.out::device-m.4.Ethernet3_1_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_1_1" + }, + "label": "port-in-1/1/1" + } + } + }, + "devicePid": "device-o", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-c", + "_vid": "device-m::device-c", + "primary": { + "data": { + "device-m.1.Ethernet4_43.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_43" + }, + "label": "port-in-1/43" + }, + "id": "device-m.1.Ethernet4_43.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-m.1.Ethernet4_43.in": { + "fromStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-m.1.Ethernet4_43.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_43" + }, + "label": "port-in-1/43" + } + } + }, + "devicePid": "device-c", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-v", + "_vid": "device-m::device-v", + "primary": { + "data": { + "device-m.4.Ethernet3_7_1.out::device-v.0.data1A.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_7_1" + }, + "label": "port-in-1/7/1" + }, + "id": "device-m.4.Ethernet3_7_1.out::device-v.0.data1A.in", + "toStatus": { + "context": { + "devicePid": "device-v", + "modulePid": "device-v.dev.0", + "portPid": "device-v.dev.0.data1A" + }, + "label": "data1A" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-v.0.data1A.out::device-m.4.Ethernet3_7_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-v", + "modulePid": "device-v.dev.0", + "portPid": "device-v.dev.0.data1A" + }, + "label": "data1A" + }, + "id": "device-v.0.data1A.out::device-m.4.Ethernet3_7_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_7_1" + }, + "label": "port-in-1/7/1" + } + } + }, + "devicePid": "device-v", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-y", + "_vid": "device-m::device-y", + "primary": { + "data": { + "device-m.1.Ethernet4_1.out::device-y.0.port-110.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_1" + }, + "label": "port-in-1/1" + }, + "id": "device-m.1.Ethernet4_1.out::device-y.0.port-110.in", + "toStatus": { + "context": { + "devicePid": "device-y", + "modulePid": "device-y.dev.0", + "portPid": "device-y.dev.0.port-110" + }, + "label": "port-110" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-y.0.port-110.out::device-m.1.Ethernet4_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-y", + "modulePid": "device-y.dev.0", + "portPid": "device-y.dev.0.port-110" + }, + "label": "port-110" + }, + "id": "device-y.0.port-110.out::device-m.1.Ethernet4_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_1" + }, + "label": "port-in-1/1" + } + } + }, + "devicePid": "device-y", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-d", + "_vid": "device-m::device-d", + "primary": { + "data": { + "device-m.4.Ethernet3_37_1.out::device-d.1.Ethernet53_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_37_1" + }, + "label": "port-in-1/37/1" + }, + "id": "device-m.4.Ethernet3_37_1.out::device-d.1.Ethernet53_1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.Ethernet53_1" + }, + "label": "port-in-1/1" + } + }, + "device-m.4.Ethernet3_39_1.out::device-d.1.Ethernet55_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_39_1" + }, + "label": "port-in-1/39/1" + }, + "id": "device-m.4.Ethernet3_39_1.out::device-d.1.Ethernet55_1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.Ethernet55_1" + }, + "label": "port-in-1/1" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-d.1.Ethernet53_1.out::device-m.4.Ethernet3_37_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.Ethernet53_1" + }, + "label": "port-in-1/1" + }, + "id": "device-d.1.Ethernet53_1.out::device-m.4.Ethernet3_37_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_37_1" + }, + "label": "port-in-1/37/1" + } + }, + "device-d.1.Ethernet55_1.out::device-m.4.Ethernet3_39_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.Ethernet55_1" + }, + "label": "port-in-1/1" + }, + "id": "device-d.1.Ethernet55_1.out::device-m.4.Ethernet3_39_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_39_1" + }, + "label": "port-in-1/39/1" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-e", + "_vid": "device-m::device-e", + "primary": { + "data": { + "device-m.4.Ethernet3_2_1.out::device-e.1000.1.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_2_1" + }, + "label": "port-in-1/2/1" + }, + "id": "device-m.4.Ethernet3_2_1.out::device-e.1000.1.in", + "toStatus": { + "context": { + "devicePid": "device-e", + "modulePid": "device-e.dev.1000", + "portPid": "device-e.dev.1000.1" + }, + "label": "IP WAN 1" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-e.1000.1.out::device-m.4.Ethernet3_2_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-e", + "modulePid": "device-e.dev.1000", + "portPid": "device-e.dev.1000.1" + }, + "label": "IP WAN 1" + }, + "id": "device-e.1000.1.out::device-m.4.Ethernet3_2_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_2_1" + }, + "label": "port-in-1/2/1" + } + } + }, + "devicePid": "device-e", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-n::device-p", + "_vid": "device-n::device-p", + "primary": { + "data": { + "uuid-0001": { + "fromStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.51" + }, + "label": "eth-0-51" + }, + "id": "uuid-0001", + "toStatus": { + "context": { + "devicePid": "device-p", + "modulePid": "device-p.dev.0", + "portPid": "device-p.dev.0.NMS-BLUE" + }, + "label": "NMS-BLUE" + } + } + }, + "devicePid": "device-n", + "label": "Example Device" + }, + "secondary": { + "data": { + "uuid-0008": { + "fromStatus": { + "context": { + "devicePid": "device-p", + "modulePid": "device-p.dev.0", + "portPid": "device-p.dev.0.NMS-BLUE" + }, + "label": "NMS-BLUE" + }, + "id": "uuid-0008", + "toStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.51" + }, + "label": "eth-0-51" + } + } + }, + "devicePid": "device-p", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-n::virtual.0", + "_vid": "device-n::virtual.0", + "primary": { + "data": { + "uuid-0007": { + "fromStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.51" + }, + "label": "eth-0-51" + }, + "id": "uuid-0007", + "toStatus": { + "context": { + "devicePid": "virtual-0", + "modulePid": "virtual-0.virt.0", + "portPid": "virtual-0.virt.0.2" + }, + "label": "Ethernet 2" + } + } + }, + "devicePid": "device-n", + "label": "Example Device" + }, + "secondary": { + "data": { + "uuid-0009": { + "fromStatus": { + "context": { + "devicePid": "virtual-0", + "modulePid": "virtual-0.virt.0", + "portPid": "virtual-0.virt.0.4" + }, + "label": "Ethernet 2" + }, + "id": "uuid-0009", + "toStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.51" + }, + "label": "eth-0-51" + } + } + }, + "devicePid": "virtual-0", + "label": "Virtual Device 1" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-q::device-c", + "_vid": "device-q::device-c", + "primary": { + "data": { + "device-q.0.Intel(R) Ethernet Controller (3) I225-LM #2.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-q", + "modulePid": "device-q.dev.0", + "portPid": "device-q.dev.0.Intel(R)-Ethernet-Controller-(3)-I225-LM-#2" + }, + "label": "Intel(R) Ethernet Controller (3) I225-LM #2 (out)" + }, + "id": "device-q.0.Intel(R) Ethernet Controller (3) I225-LM #2.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-q", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-c", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-b::device-l", + "_vid": "device-b::device-l", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-l.1.Ethernet4_43.in": { + "fromStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-l.1.Ethernet4_43.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.1", + "portPid": "device-l.dev.1.Ethernet4_43" + }, + "label": "port-in-1/43" + } + } + }, + "devicePid": "device-b", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-l.1.Ethernet4_43.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.1", + "portPid": "device-l.dev.1.Ethernet4_43" + }, + "label": "port-in-1/43" + }, + "id": "device-l.1.Ethernet4_43.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-b::device-q", + "_vid": "device-b::device-q", + "primary": { + "data": {}, + "devicePid": "device-b", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-q.0.Intel(R) Ethernet Controller (3) I225-LM.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-q", + "modulePid": "device-q.dev.0", + "portPid": "device-q.dev.0.Intel(R)-Ethernet-Controller-(3)-I225-LM" + }, + "label": "Intel(R) Ethernet Controller (3) I225-LM (out)" + }, + "id": "device-q.0.Intel(R) Ethernet Controller (3) I225-LM.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-q", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-b::device-w", + "_vid": "device-b::device-w", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-w.0.eth1.in": { + "fromStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-w.0.eth1.in", + "toStatus": { + "context": { + "devicePid": "device-w", + "modulePid": "device-w.dev.0", + "portPid": "device-w.dev.0.eth1" + }, + "label": "eth1" + } + } + }, + "devicePid": "device-b", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-w.0.eth1.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-w", + "modulePid": "device-w.dev.0", + "portPid": "device-w.dev.0.eth1" + }, + "label": "eth1" + }, + "id": "device-w.0.eth1.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-w", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-b::device-x", + "_vid": "device-b::device-x", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-x.0.eth1.in": { + "fromStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-x.0.eth1.in", + "toStatus": { + "context": { + "devicePid": "device-x", + "modulePid": "device-x.dev.0", + "portPid": "device-x.dev.0.eth1" + }, + "label": "eth1" + } + } + }, + "devicePid": "device-b", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-x.0.eth1.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-x", + "modulePid": "device-x.dev.0", + "portPid": "device-x.dev.0.eth1" + }, + "label": "eth1" + }, + "id": "device-x.0.eth1.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-x", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-s::device-d", + "_vid": "device-s::device-d", + "primary": { + "data": {}, + "devicePid": "device-s", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-s.0.P1.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-s.0.P1.in", + "toStatus": { + "context": { + "devicePid": "device-s", + "modulePid": "device-s.dev.0", + "portPid": "device-s.dev.0.P1" + }, + "label": "P1 (in)" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-t::device-d", + "_vid": "device-t::device-d", + "primary": { + "data": {}, + "devicePid": "device-t", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-t.0.mmc0.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-t.0.mmc0.in", + "toStatus": { + "context": { + "devicePid": "device-t", + "modulePid": "device-t.dev.0", + "portPid": "device-t.dev.0.mmc0" + }, + "label": "mmc0 (in)" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-u::device-d", + "_vid": "device-u::device-d", + "primary": { + "data": { + "device-u.3.10004.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-u", + "modulePid": "device-u.dev.3", + "portPid": "device-u.dev.3.10004" + }, + "label": "Ethernet 3.3" + }, + "id": "device-u.3.10004.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-u", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-u.3.10004.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-u.3.10004.in", + "toStatus": { + "context": { + "devicePid": "device-u", + "modulePid": "device-u.dev.3", + "portPid": "device-u.dev.3.10004" + }, + "label": "Ethernet 3.3" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-c::device-w", + "_vid": "device-c::device-w", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-w.0.eth0.in": { + "fromStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-w.0.eth0.in", + "toStatus": { + "context": { + "devicePid": "device-w", + "modulePid": "device-w.dev.0", + "portPid": "device-w.dev.0.eth0" + }, + "label": "eth0" + } + } + }, + "devicePid": "device-c", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-w.0.eth0.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-w", + "modulePid": "device-w.dev.0", + "portPid": "device-w.dev.0.eth0" + }, + "label": "eth0" + }, + "id": "device-w.0.eth0.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-w", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-c::device-x", + "_vid": "device-c::device-x", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-x.0.eth0.in": { + "fromStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-x.0.eth0.in", + "toStatus": { + "context": { + "devicePid": "device-x", + "modulePid": "device-x.dev.0", + "portPid": "device-x.dev.0.eth0" + }, + "label": "eth0" + } + } + }, + "devicePid": "device-c", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-x.0.eth0.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-x", + "modulePid": "device-x.dev.0", + "portPid": "device-x.dev.0.eth0" + }, + "label": "eth0" + }, + "id": "device-x.0.eth0.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-x", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-d::device-f", + "_vid": "device-d::device-f", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-f.1.10004.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-f.1.10004.in", + "toStatus": { + "context": { + "devicePid": "device-f", + "modulePid": "device-f.dev.1", + "portPid": "device-f.dev.1.10004" + }, + "label": "Ethernet 1.3" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-f.1.10004.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-f", + "modulePid": "device-f.dev.1", + "portPid": "device-f.dev.1.10004" + }, + "label": "Ethernet 1.3" + }, + "id": "device-f.1.10004.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-f", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-d::device-g", + "_vid": "device-d::device-g", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-g.6.10004.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-g.6.10004.in", + "toStatus": { + "context": { + "devicePid": "device-g", + "modulePid": "device-g.dev.6", + "portPid": "device-g.dev.6.10004" + }, + "label": "Ethernet 6.3" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-g.6.10004.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-g", + "modulePid": "device-g.dev.6", + "portPid": "device-g.dev.6.10004" + }, + "label": "Ethernet 6.3" + }, + "id": "device-g.6.10004.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-g", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + } + ] + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/inspect_paths_limit5.json b/tests/fixtures/inspect/2025.4.9/inspect_paths_limit5.json new file mode 100644 index 0000000..038b879 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/inspect_paths_limit5.json @@ -0,0 +1,75 @@ +{ + "data": { + "status": { + "collector": { + "inspect": { + "paths": { + "_items": [ + { + "_id": "booking-1001::main", + "_vid": "_:booking-1001::main", + "serviceFields": { + "bid": "booking-1001", + "from": "topo:device-a.1.101S", + "isMain": true, + "to": "topo:device-b.uuid-0001.R00000001-0000-4000-8000-000000000001" + } + }, + { + "_id": "booking-1001::spare", + "_vid": "_:booking-1001::spare", + "serviceFields": { + "bid": "booking-1001", + "from": "topo:device-a.1.101S", + "isMain": false, + "to": "topo:device-b.uuid-0001.R00000001-0000-4000-8000-000000000001" + } + }, + { + "_id": "booking-1002::main", + "_vid": "_:booking-1002::main", + "serviceFields": { + "bid": "booking-1002", + "from": "topo:device-b.uuid-0001.S00000005-0000-4000-8000-000000000005", + "isMain": true, + "to": "topo:device-c.3.3000000" + } + }, + { + "_id": "booking-1002::spare", + "_vid": "_:booking-1002::spare", + "serviceFields": { + "bid": "booking-1002", + "from": "topo:device-b.uuid-0001.S00000005-0000-4000-8000-000000000005", + "isMain": false, + "to": "topo:device-c.3.3000000" + } + }, + { + "_id": "booking-1003::main", + "_vid": "_:booking-1003::main", + "serviceFields": { + "bid": "booking-1003", + "from": "topo:device-b.uuid-0001.S00000006-0000-4000-8000-000000000006", + "isMain": true, + "to": "topo:device-c.3.5000000" + } + } + ] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json b/tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json new file mode 100644 index 0000000..a536022 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json @@ -0,0 +1,49 @@ +{ + "data": { + "device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in": { + "edge": { + "active": true, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": { + "desc": "", + "label": "" + }, + "excludeFormats": [], + "fDescriptor": { + "desc": "", + "label": "" + }, + "fromId": "device-a.module-1.port-out-1.out", + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": "device-b.module-1.port-in-1.in", + "weight": 1, + "weightFactors": { + "bandwidth": { + "weight": 0 + }, + "service": { + "max": 100, + "weight": 0 + } + } + }, + "fromDevice": "device-a", + "toDevice": "device-b" + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} \ No newline at end of file diff --git a/tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json b/tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json new file mode 100644 index 0000000..60aa756 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json @@ -0,0 +1,60 @@ +{ + "data": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "customSchemas": {}, + "fields": { + "active": true, + "controlProps": null, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "Port A (out)", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "ipAddress": null, + "ipNetmask": null, + "public": false, + "supportsCpipeCfg": false, + "supportsIgmpCfg": false, + "supportsMacForwardingCfg": false, + "supportsNsoCfg": false, + "supportsOpenflowCfg": false, + "supportsStaticIgmpCfg": false, + "supportsVlanCfg": false, + "supportsVplsCfg": false, + "type": "ip", + "vlanId": null, + "vrfId": null + }, + "useAsEndpoint": false + }, + "id": "device-a.module-1.port-out-1.out", + "isVirtual": false, + "vertexType": "Out" + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} \ No newline at end of file diff --git a/tests/fixtures/inspect/2025.4.9/nodestatus_noid_collection.json b/tests/fixtures/inspect/2025.4.9/nodestatus_noid_collection.json new file mode 100644 index 0000000..5ad0c1a --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/nodestatus_noid_collection.json @@ -0,0 +1,265 @@ +{ + "data": { + "status": { + "collector": { + "inspect": { + "nodeStatus": { + "_items": [ + { + "_id": "device-h", + "_vid": "device-h", + "descriptor": { + "label": "Example Device A" + }, + "deviceId": "device-h" + }, + { + "_id": "device-i", + "_vid": "device-i", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-i" + }, + { + "_id": "device-j", + "_vid": "device-j", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-j" + }, + { + "_id": "device-a", + "_vid": "device-a", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-a" + }, + { + "_id": "device-k", + "_vid": "device-k", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-k" + }, + { + "_id": "device-b", + "_vid": "device-b", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-b" + }, + { + "_id": "device-l", + "_vid": "device-l", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-l" + }, + { + "_id": "device-m", + "_vid": "device-m", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-m" + }, + { + "_id": "device-n", + "_vid": "device-n", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-n" + }, + { + "_id": "device-o", + "_vid": "device-o", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-o" + }, + { + "_id": "device-p", + "_vid": "device-p", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-p" + }, + { + "_id": "device-q", + "_vid": "device-q", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-q" + }, + { + "_id": "device-r", + "_vid": "device-r", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-r" + }, + { + "_id": "device-s", + "_vid": "device-s", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-s" + }, + { + "_id": "device-c", + "_vid": "device-c", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-c" + }, + { + "_id": "device-t", + "_vid": "device-t", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-t" + }, + { + "_id": "device-u", + "_vid": "device-u", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-u" + }, + { + "_id": "device-v", + "_vid": "device-v", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-v" + }, + { + "_id": "device-w", + "_vid": "device-w", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-w" + }, + { + "_id": "device-x", + "_vid": "device-x", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-x" + }, + { + "_id": "device-y", + "_vid": "device-y", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-y" + }, + { + "_id": "device-z", + "_vid": "device-z", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-z" + }, + { + "_id": "device-1", + "_vid": "device-1", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-1" + }, + { + "_id": "device-2", + "_vid": "device-2", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-2" + }, + { + "_id": "device-d", + "_vid": "device-d", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-d" + }, + { + "_id": "device-e", + "_vid": "device-e", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-e" + }, + { + "_id": "device-f", + "_vid": "device-f", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-f" + }, + { + "_id": "device-g", + "_vid": "device-g", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-g" + }, + { + "_id": "virtual-0", + "_vid": "virtual-0", + "descriptor": { + "label": "Virtual Device 1" + }, + "deviceId": "virtual.0" + }, + { + "_id": "virtual-1", + "_vid": "virtual-1", + "descriptor": { + "label": "Virtual Device 1" + }, + "deviceId": "virtual.1" + } + ] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/skeleton_nodestatus_short.json b/tests/fixtures/inspect/2025.4.9/skeleton_nodestatus_short.json new file mode 100644 index 0000000..323db04 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/skeleton_nodestatus_short.json @@ -0,0 +1,268 @@ +{ + "data": { + "status": { + "collector": { + "inspect": { + "nodeStatus": { + "_items": [ + { + "_id": "device-h", + "_vid": "device-h", + "descriptor": { + "label": "Example Device A" + }, + "deviceId": "device-h", + "resourceId": "device:device-h" + }, + { + "_id": "device-i", + "_vid": "device-i", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-i", + "resourceId": "device:device-i" + }, + { + "_id": "device-a", + "_vid": "device-a", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-a", + "resourceId": "device:device-a" + }, + { + "_id": "device-j", + "_vid": "device-j", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-j", + "resourceId": "device:device-j" + }, + { + "_id": "device-b", + "_vid": "device-b", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-b", + "resourceId": "device:device-b" + }, + { + "_id": "device-k", + "_vid": "device-k", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-k", + "resourceId": "device:device-k" + }, + { + "_id": "device-l", + "_vid": "device-l", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-l", + "resourceId": "device:device-l" + }, + { + "_id": "device-m", + "_vid": "device-m", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-m", + "resourceId": "device:device-m" + }, + { + "_id": "device-n", + "_vid": "device-n", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-n", + "resourceId": "device:device-n" + }, + { + "_id": "device-o", + "_vid": "device-o", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-o", + "resourceId": "device:device-o" + }, + { + "_id": "device-p", + "_vid": "device-p", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-p", + "resourceId": "device:device-p" + }, + { + "_id": "device-q", + "_vid": "device-q", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-q", + "resourceId": "device:device-q" + }, + { + "_id": "device-c", + "_vid": "device-c", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-c", + "resourceId": "device:device-c" + }, + { + "_id": "device-r", + "_vid": "device-r", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-r", + "resourceId": "device:device-r" + }, + { + "_id": "device-s", + "_vid": "device-s", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-s", + "resourceId": "device:device-s" + }, + { + "_id": "device-t", + "_vid": "device-t", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-t", + "resourceId": "device:device-t" + }, + { + "_id": "device-u", + "_vid": "device-u", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-u", + "resourceId": "device:device-u" + }, + { + "_id": "device-v", + "_vid": "device-v", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-v", + "resourceId": "device:device-v" + }, + { + "_id": "device-w", + "_vid": "device-w", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-w", + "resourceId": "device:device-w" + }, + { + "_id": "device-x", + "_vid": "device-x", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-x", + "resourceId": "device:device-x" + }, + { + "_id": "device-y", + "_vid": "device-y", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-y", + "resourceId": "device:device-y" + }, + { + "_id": "device-d", + "_vid": "device-d", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-d", + "resourceId": "device:device-d" + }, + { + "_id": "device-e", + "_vid": "device-e", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-e", + "resourceId": "device:device-e" + }, + { + "_id": "device-f", + "_vid": "device-f", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-f", + "resourceId": "device:device-f" + }, + { + "_id": "device-g", + "_vid": "device-g", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-g", + "resourceId": "device:device-g" + }, + { + "_id": "virtual-0", + "_vid": "virtual-0", + "descriptor": { + "label": "Virtual Device 1" + }, + "deviceId": "virtual.0", + "resourceId": "device:virtual-0" + }, + { + "_id": "virtual-1", + "_vid": "virtual-1", + "descriptor": { + "label": "Virtual Device 1" + }, + "deviceId": "virtual.1", + "resourceId": "device:virtual-1" + } + ] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_fail_booking.json b/tests/fixtures/inspect/2025.4.9/update_topology_fail_booking.json new file mode 100644 index 0000000..8fa64a7 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/update_topology_fail_booking.json @@ -0,0 +1,49 @@ +{ + "data": { + "items": [], + "res": { + "msg": [ + "Validation failed" + ], + "ok": false + }, + "validation": { + "createIds": [], + "details": { + "booking-1001": { + "isCancel": false, + "isProduct": false, + "resolvable": false, + "rev": "2-2026-06-10T19:54:01.297948842Z[UTC]", + "status": -22, + "type": "generic" + }, + "booking-1002": { + "isCancel": false, + "isProduct": false, + "resolvable": false, + "rev": "2-2026-06-16T14:47:24.964022664Z[UTC]", + "status": -22, + "type": "generic" + } + }, + "result": { + "msg": [ + "A required edge was not found. (main); A required edge was not found. (redundant)" + ], + "ok": false + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_fail_remove.json b/tests/fixtures/inspect/2025.4.9/update_topology_fail_remove.json new file mode 100644 index 0000000..9beb95e --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/update_topology_fail_remove.json @@ -0,0 +1,30 @@ +{ + "data": { + "items": [], + "res": { + "msg": [ + "Failed to update local edges: Cannot remove non-existent object with key nonexistent-edge-id-xyz!" + ], + "ok": false + }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": [], + "ok": true + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_success.json b/tests/fixtures/inspect/2025.4.9/update_topology_success.json new file mode 100644 index 0000000..30681d9 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/update_topology_success.json @@ -0,0 +1,40 @@ +{ + "data": { + "items": [ + { + "external": null, + "id": "device-a.dev.module-1.port-out-1.out::device-b.dev.module-1.port-in-1.in", + "idx": 0, + "res": { + "msg": [ + "" + ], + "ok": true + } + } + ], + "res": { + "msg": [], + "ok": true + }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": [], + "ok": true + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} From e0ee9b8eb138a84e6f18991258065d4cfaf94d31 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:35:31 +0200 Subject: [PATCH 04/15] inspect app implementation --- docs/getting-started-guide/05_Inspect.md | 170 ++++ docs/getting-started-guide/README.md | 3 +- docs/inspect-app/README.md | 15 +- docs/inspect-app/concepts.md | 11 +- .../decisions/0006-commit-write-model.md | 6 +- .../decisions/0009-write-consistency.md | 30 +- .../0010-post-commit-snapshot-refresh.md | 24 +- docs/inspect-app/endpoints.md | 60 ++ docs/inspect-app/implementation-plan.md | 286 +++++++ docs/inspect-app/models.md | 76 +- pyproject.toml | 6 +- .../apps/inspect/__init__.py | 9 +- .../apps/inspect/app/__init__.py | 0 .../apps/inspect/app/actions.py | 97 +++ .../apps/inspect/app/read.py | 134 ++++ .../apps/inspect/app/write.py | 135 ++++ .../apps/inspect/changeset.py | 550 +++++++++++++ .../apps/inspect/domain/device.py | 57 +- .../apps/inspect/domain/edge.py | 4 +- .../apps/inspect/domain/port.py | 2 +- .../apps/inspect/errors.py | 103 +++ .../apps/inspect/inspect_api.py | 166 ++++ .../apps/inspect/inspect_app.py | 70 ++ .../apps/inspect/model/actions.py | 121 +++ .../apps/inspect/model/collector.py | 40 + .../apps/inspect/model/update_topology.py | 26 +- .../apps/inspect/queries.py | 131 ++++ .../apps/inspect/snapshot.py | 737 ++++++++++++------ .../apps/videoipath_app.py | 10 + .../connector/vip_rest_connector.py | 17 +- tests/e2e/__init__.py | 0 tests/e2e/conftest.py | 39 + tests/e2e/inspect/__init__.py | 0 tests/e2e/inspect/scenario.py | 215 +++++ tests/e2e/inspect/test_e2e_leaf_spine.py | 189 +++++ .../update_topology_replace_devices.json | 72 ++ .../update_topology_replace_vertices.json | 80 ++ tests/inspect/__init__.py | 0 tests/inspect/conftest.py | 23 + tests/inspect/test_actions.py | 68 ++ tests/inspect/test_api.py | 101 +++ tests/inspect/test_changeset.py | 345 ++++++++ tests/inspect/test_models.py | 119 +++ tests/inspect/test_queries.py | 47 ++ tests/inspect/test_snapshot.py | 261 +++++++ 45 files changed, 4308 insertions(+), 347 deletions(-) create mode 100644 docs/getting-started-guide/05_Inspect.md create mode 100644 docs/inspect-app/implementation-plan.md create mode 100644 src/videoipath_automation_tool/apps/inspect/app/__init__.py create mode 100644 src/videoipath_automation_tool/apps/inspect/app/actions.py create mode 100644 src/videoipath_automation_tool/apps/inspect/app/read.py create mode 100644 src/videoipath_automation_tool/apps/inspect/app/write.py create mode 100644 src/videoipath_automation_tool/apps/inspect/changeset.py create mode 100644 src/videoipath_automation_tool/apps/inspect/errors.py create mode 100644 src/videoipath_automation_tool/apps/inspect/inspect_api.py create mode 100644 src/videoipath_automation_tool/apps/inspect/inspect_app.py create mode 100644 src/videoipath_automation_tool/apps/inspect/queries.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/inspect/__init__.py create mode 100644 tests/e2e/inspect/scenario.py create mode 100644 tests/e2e/inspect/test_e2e_leaf_spine.py create mode 100644 tests/fixtures/inspect/2025.4.9/update_topology_replace_devices.json create mode 100644 tests/fixtures/inspect/2025.4.9/update_topology_replace_vertices.json create mode 100644 tests/inspect/__init__.py create mode 100644 tests/inspect/conftest.py create mode 100644 tests/inspect/test_actions.py create mode 100644 tests/inspect/test_api.py create mode 100644 tests/inspect/test_changeset.py create mode 100644 tests/inspect/test_models.py create mode 100644 tests/inspect/test_queries.py create mode 100644 tests/inspect/test_snapshot.py diff --git a/docs/getting-started-guide/05_Inspect.md b/docs/getting-started-guide/05_Inspect.md new file mode 100644 index 0000000..94774fa --- /dev/null +++ b/docs/getting-started-guide/05_Inspect.md @@ -0,0 +1,170 @@ +# Inspect App + +## 1. Introduction + +The **Inspect App** (`app.inspect`) is the read/write interface to VideoIPath's newer *Inspect* +surface: it builds a live view of the topology (devices, ports, edges, and services) and applies +topology changes with a **commit-style** write model. + +It is **purely additive** — `app.topology` and `app.inventory` keep working unchanged. Devices are +still onboarded in Inventory first; Inspect then places, connects, and monitors them. + +Two ideas shape the API: + +- **Skeleton-first snapshots** — a snapshot loads only the minimal topology (all devices and edges, + without per-port detail) up front, then *lazily hydrates* detail the first time you touch it. This + keeps the initial read fast even in large environments. A snapshot is never a single point in + time; each device and section carries its own fetch timestamp. +- **Commit-style writes** — changes are staged and applied atomically. Before sending, the change + set re-checks that nobody else modified the affected entities (compare-and-commit); after a + successful commit it refreshes only the touched entities. + +> Inspect is verified against VideoIPath **2025.4** and newer. Against older servers the app logs a +> warning; behaviour is unverified. + +The app keeps a single topology view internally — you never handle a "snapshot" object. It loads on +your first read and stays current across writes; call `app.inspect.refresh()` to reload it. + +## 2. Reading the topology + +### 2.1. Devices, ports, and edges + +Everything is read straight off `app.inspect`. Skeleton fields are available without any per-device +I/O: + +```python +dev = app.inspect.get_device("device10") +dev = app.inspect.find_device_by_label("BORDERLEAF-26B") + +print(dev.label, dev.coordinates, dev.status, dev.sync_severity, dev.tags) + +for dev in app.inspect.devices: # all devices + print(dev.id, dev.label) +``` + +The first access to a device's **ports** hydrates that one device (a single scoped read), then +serves from local state: + +```python +for port in dev.ports: # triggers one hydration fetch for this device + print(port.label, port.vertex_id, port.status) + edge = port.edge # local edge-skeleton lookup, no I/O + if edge: + print("connected to", edge.to_device.label) + +for edge in dev.edges: # local, no hydration + print(edge.from_port, "->", edge.to_port, edge.status) + +for other in dev.linked_devices: # local graph walk + print(other.label) + +for edge in app.inspect.edges: # all external edges + print(edge.id, edge.status) +``` + +Hydrate many devices at once (parallel) to avoid N+1 reads: + +```python +app.inspect.preload() # all devices +app.inspect.preload(["device10", "device11"]) # a subset +``` + +### 2.2. Services + +Services load once as a section, on first access: + +```python +for service in app.inspect.services: # loads the paths section on first touch + print(service.booking_id) +``` + +### 2.3. Refreshing + +The view updates itself after your own writes. To pick up external changes, reload it: + +```python +app.inspect.refresh() # reload (skeleton; lazy detail) +app.inspect.refresh(load="full") # reload eagerly in one request +``` + +## 3. Writing to the topology + +### 3.1. Direct writes (auto-commit) + +Each direct method opens a one-change transaction and commits it immediately. If the internal view +is already loaded, the change is reflected into it via targeted refresh: + +```python +app.inspect.place_device("device12", x=1600, y=9050) +app.inspect.update_device("device12", label="BU-LEAF-A", icon_type="ipSwitchRouter") +app.inspect.update_vertex("device12.1.Ethernet1.out", use_as_endpoint=True) +app.inspect.update_edge(edge_id, weight=10) + +app.inspect.connect( + "device12.1.Ethernet1.out", + "device7.0.swp1.in", + bidirectional=True, # also stages the reverse edge + capacity=65535, +) +app.inspect.disconnect("device12.1.Ethernet1.out", "device7.0.swp1.in") +app.inspect.remove_device_from_topology("device12") +``` + +### 3.2. Batched, atomic changes (transaction) + +Use a transaction to stage several changes and commit them together: + +```python +with app.inspect.transaction() as tx: + tx.place_device("device12", x=100, y=200) + tx.connect(a_out, b_in, bidirectional=True) + tx.remove(edge_id) + result = tx.commit() # conflict check → POST → targeted refresh of the internal view + +print(result.ok, result.applied_ids) +``` + +Exiting the `with` block **without** committing discards the staged changes (and logs a warning). + +### 3.3. Handling concurrent changes + +If another user changed a staged entity since you staged it, `commit()` raises +`InspectCommitConflictError` and sends nothing: + +```python +from videoipath_automation_tool.apps.inspect import InspectCommitConflictError + +try: + tx.commit() +except InspectCommitConflictError as exc: + for conflict in exc.conflicts: + print(conflict.entity_id, conflict.field_diffs) + tx.rebase() # re-fetch baselines, keep your intents + tx.commit() + +# or explicitly force last-writer-wins: +tx.commit(check_conflicts=False) +``` + +A server-rejected commit (validation or apply gate) raises `InspectCommitError`, which carries the +typed `validation` details. + +## 4. Onboarding devices into the topology + +```python +from videoipath_automation_tool.apps.inspect import ConflictStrategy + +app.inspect.add_devices_to_topology([("device12", 100, 200), "device13"]) +info = app.inspect.get_sync_info(["device12"]) +app.inspect.sync_devices(["device12"], add_only=True, conflict_strategy=ConflictStrategy.STRICT) +``` + +## 5. Notes + +- Inspect uses **only** the collector API surface at runtime; it never calls the legacy + `nGraphElements` / `edgesByDevice` endpoints. +- The topology view is loaded lazily and kept internal to `app.inspect`; a pure-write workflow never + triggers a read. Reads and hydration are internally consistent under concurrent access, but a + single `VideoIPathApp` is otherwise intended for single-owner use. +- For the design rationale, see the architecture docs under + [`docs/inspect-app/`](../inspect-app/README.md). diff --git a/docs/getting-started-guide/README.md b/docs/getting-started-guide/README.md index 46d7fc6..955b7eb 100644 --- a/docs/getting-started-guide/README.md +++ b/docs/getting-started-guide/README.md @@ -1,6 +1,6 @@ # Getting Started Guide -This guide will help you get started with the VideoIPath Automation Tool. It will show you how to establish a connection to the VideoIPath server and how to manage devices in the inventory and topology. Also, it demonstrates how to configure multicast pools. +This guide will help you get started with the VideoIPath Automation Tool. It will show you how to establish a connection to the VideoIPath server and how to manage devices in the inventory and topology. Also, it demonstrates how to configure multicast pools and read/write the topology through the Inspect app. ## Table of Contents @@ -8,3 +8,4 @@ This guide will help you get started with the VideoIPath Automation Tool. It wil 2. [Managing Devices in the Inventory](02_Inventory.md) 3. [Managing Devices in the Topology](03_Topology.md) 4. [Configuring Multicast Pools](04_Multicast_Pools.md) +5. [Inspecting and Editing the Topology (Inspect App)](05_Inspect.md) diff --git a/docs/inspect-app/README.md b/docs/inspect-app/README.md index f34d474..1a12658 100644 --- a/docs/inspect-app/README.md +++ b/docs/inspect-app/README.md @@ -15,7 +15,13 @@ there is **no deprecation and no migration** planned. These docs are **living documents** — meant to be edited and refined as the design firms up and as the real Inspect API is reverse-engineered. The official [VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) -reference is now a primary source. Nothing here is implemented yet; this is the plan. +reference is now a primary source. + +> **Status: implemented.** The `app.inspect` surface described here is shipped +> (`src/videoipath_automation_tool/apps/inspect/`), with offline unit tests under +> `tests/inspect/` and a developer-run live E2E suite under `tests/e2e/inspect/`. +> All ten ADRs are Accepted. For usage, see the +> [Inspect getting-started page](../getting-started-guide/05_Inspect.md). ## Reading order @@ -62,6 +68,7 @@ reference is now a primary source. Nothing here is implemented yet; this is the - **Proposed / Accepted / Superseded** — for ADRs, see [the decisions index](./decisions/README.md). -> ADRs currently capture **context and options only** — decisions are open. -> Promote an ADR to **Accepted** once agreed, and tick off `[VERIFY]` items in -> [concepts.md](./concepts.md) as they are confirmed. +> All ADRs are **Accepted** and the `[VERIFY]` items in +> [concepts.md](./concepts.md) / [endpoints.md](./endpoints.md) were confirmed +> against VideoIPath 2025.4.9. The app is implemented; these docs are retained as +> the design record. diff --git a/docs/inspect-app/concepts.md b/docs/inspect-app/concepts.md index f69dd5c..80cf72d 100644 --- a/docs/inspect-app/concepts.md +++ b/docs/inspect-app/concepts.md @@ -415,19 +415,16 @@ Write consistency & post-commit refresh ([ADR-0009](./decisions/0009-write-consi - [x] UI edit/commit flows avoid `nGraphElements`: the Inspect UI bundle contains zero references; the edge edit flow calls `lookupInspectEdgesByIds` with both pair directions ([ADR-0008](./decisions/0008-collector-only-endpoints.md)) - [x] Batched lookups for compare-and-commit baselines: `…ByIds` actions take id lists natively - [x] Direct edge-pair addressing for targeted refresh: `externalEdgesByDeviceKey//` returns the single pair item -- [ ] Collector propagation delay: time between `updateTopology` OK and the change being visible in collector reads (drives the ADR-0010 retry window) — needs a live write test (coordinate nudge + revert) +- [x] Collector propagation delay: measured via device coordinate commit + revert (three samples) — change visible on the **first poll ~25 ms after the POST returned**; no post-commit retry window needed ([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)) +- [x] `replaceDevices` payload shape: the **edit form** (`lookupInspectDevice.fields`; `coordinates`/`localAssignedTags` mandatory) — the raw persisted `baseDevice` element is rejected with HTTP 400; round-trip verified byte-identical except `_rev` ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) +- [x] `replaceVertices` payload shape: the **vertex edit form** (`lookupInspectVertexById.fields`) — verified via desc round-trip on a live vertex (byte-identical revert). **Update-only**: an unknown vertex id fails validation with *"Vertex with id … was not found in graph"* — standalone vertices cannot be created via `updateTopology` (they originate from device sync / virtual-device definitions) ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) - [ ] Re-check on every server upgrade: does `updateTopology` gain `_rev` enforcement (would allow replacing client-side compare-and-commit) ## 6. Open questions _All VERIFY items are resolved (results merged into [endpoints.md](./endpoints.md) and the §5.1 checklist) or documented as -confirmed limitations / unregistered stubs — with one exception:_ - -- **Collector propagation delay after a commit** - ([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)) — requires a - live write test (coordinate nudge + revert) to size the post-commit retry - window. +confirmed limitations / unregistered stubs._ Remaining follow-up (only if a future server version registers new actions): diff --git a/docs/inspect-app/decisions/0006-commit-write-model.md b/docs/inspect-app/decisions/0006-commit-write-model.md index 12edd17..6f97054 100644 --- a/docs/inspect-app/decisions/0006-commit-write-model.md +++ b/docs/inspect-app/decisions/0006-commit-write-model.md @@ -51,9 +51,9 @@ arrays mean “no change” for that category: | Field | Shape | Purpose | | ----- | ----- | ------- | -| `replaceDevices` | `Record` | Upsert devices | -| `replaceVertices` | `Record` | Upsert vertices | -| `replaceEdges` | `Record<"fromId::toId", edge>` | Upsert edges (composite key) | +| `replaceDevices` | `Record` — the `lookupInspectDevice.fields` shape (`coordinates`, `localAssignedTags` mandatory; raw `baseDevice` element rejected — verified 2025.4.9) | Upsert devices | +| `replaceVertices` | `Record` — the `lookupInspectVertexById.fields` shape (verified 2025.4.9); **update-only**: unknown ids fail validation | Update vertices | +| `replaceEdges` | `Record<"fromId::toId", edge>` — raw persisted edge form (composite key) | Upsert edges | | `replaceResourceTransforms` | `Record` | Upsert resource transforms | | `addExternalEdges` | `edge[]` | Add external edges | | `remove` | `string[]` | Remove entities by id | diff --git a/docs/inspect-app/decisions/0009-write-consistency.md b/docs/inspect-app/decisions/0009-write-consistency.md index 072cf56..9c05fb2 100644 --- a/docs/inspect-app/decisions/0009-write-consistency.md +++ b/docs/inspect-app/decisions/0009-write-consistency.md @@ -75,10 +75,17 @@ between our read and our commit (lost update). What the server gives us - devices: `lookupInspectDevice` — editable device form (coordinates, descriptor, iconType, sdpStrategy, tags). - Edges come back in the persisted `replaceEdges` shape directly; for - devices and vertices the lookup returns the *edit form*, and the ACL maps - it to the persisted `replace*` shape (e.g. `coordinates` ↔ `maps[]`) — the - same mapping the UI performs. The baseline serves double duty: it is the + The lookup forms **are** the write shapes — no client-side mapping + (all three verified 2025.4.9 by live commit + byte-identical revert): + `replaceEdges` takes the persisted edge form exactly as + `lookupInspectEdgesByIds` returns it; `replaceDevices` takes exactly + `lookupInspectDevice`'s `fields` object (`coordinates`/`localAssignedTags` + mandatory; the raw persisted `baseDevice` element is rejected with HTTP + 400 — the server maps `coordinates` → `maps[]` itself); `replaceVertices` + takes exactly `lookupInspectVertexById`'s `fields` object. Note + `replaceVertices` is **update-only**: an unknown vertex id fails + validation (*"Vertex … was not found in graph"*) — vertices originate from + device sync, not from commits. The baseline serves double duty: it is the basis for building the full `replace*` payload (caller mutations applied on top) *and* the reference for conflict detection, compared on the editable fields. For `remove` entries the baseline is the element's @@ -119,7 +126,14 @@ between our read and our commit (lost update). What the server gives us snapshot is a read surface, not a write token ([ADR-0007](./0007-lazy-snapshot-loading.md), ADR-0010 for post-commit refresh). -- The device/vertex lookups return *edit forms*, not raw persisted elements — - the ACL's edit-form ↔ `replace*`-shape mapping must be exact, or replaces - clobber unmapped fields. Fixture-test the round-trip per element kind - (edges need none; devices/vertices do). +- The lookup→write round-trip is exact for devices **and vertices** (both + verified: commit + revert left the persisted element byte-identical except + `_rev`) and trivial for edges. One caveat, applying to devices and vertices + alike: the lookups (and the collector) return the **effective** label — the + persisted `descriptor` merged with the `fDescriptor` fallback. + `descriptor` is stored verbatim on commit, so a client that round-trips the + lookup unchanged pins the fallback label into `descriptor` (the label stops + tracking device-reported names). The persisted-vs-fallback distinction is + not observable on the Inspect surface; the UI has the same property. + Document it; don't touch `descriptor`/label fields unless the caller set + them. diff --git a/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md b/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md index c06f04d..c970fac 100644 --- a/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md +++ b/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md @@ -20,8 +20,9 @@ exists"), so the state must catch up — efficiently: - The change set knows precisely which entities it touched, and the commit response confirms them (`data.items[]` with per-item `res.ok`). - The collector is a **status-plane projection** of the config store the - commit writes to; the change may become visible only after a propagation - delay (**[VERIFY]** below). + commit writes to; measured on 2025.4.9, the projection updates effectively + synchronously with the commit (~25 ms to first-poll visibility — see + Decision, point 5). ## Options @@ -73,12 +74,12 @@ result is success (`res.ok` and `validation.result.ok`): or other section-level data was loaded, mark it stale and let the next access re-load it (ADR-0007 lazy path). Commits change services only indirectly; eager re-reads here are usually wasted. -5. **Propagation handling**: the re-fetch verifies the committed change is - visible (e.g. a replaced field has the committed value) and retries briefly - if the projection lags; give up after a small bounded window and surface - the staleness (log + fetch timestamp), never hang. - **[VERIFY]** the actual delay between `updateTopology` OK and collector - visibility on a real instance — if it is consistently ~0, drop the retry. +5. **Propagation handling**: measured on 2025.4.9 (device coordinate commit, + three samples): the change was visible in collector reads on the **first + poll ~25 ms after the POST returned** — the projection updates effectively + synchronously with the commit. No retry window is needed; the targeted + re-fetch doubles as the verification read (log if the committed value is + unexpectedly absent, don't loop). A failed commit changes nothing server-side (reject-before-apply, verified) — the snapshot is left untouched. @@ -93,8 +94,9 @@ the snapshot is left untouched. - The refresh step needs the affected-set derivation to be exact; the id conventions it relies on are already documented (concepts.md §3.1) and fixture-tested. -- Remaining `[VERIFY]` (tracked in concepts.md §5.1): the collector - propagation delay after a commit — requires a live write test (coordinate - nudge + revert) to size the retry window. +- All refresh mechanics are verified on 2025.4.9: direct pair addressing, + scoped single-device reads, and ~synchronous collector propagation + (~25 ms to first-poll visibility). Re-measure propagation if a future + server version decouples the collector projection from the commit path. - Together with ADR-0009: `commit()` = pre-commit conflict check → POST → result evaluation → targeted refresh. One documented lifecycle. diff --git a/docs/inspect-app/endpoints.md b/docs/inspect-app/endpoints.md index 492ec9c..b4aecc6 100644 --- a/docs/inspect-app/endpoints.md +++ b/docs/inspect-app/endpoints.md @@ -918,6 +918,13 @@ Response (single form; `…ByIds` nests this per id): Fixture: `tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json`. +The `fields` object is **exactly the accepted `replaceVertices` payload shape** +(verified 2025.4.9 — see +[`updateTopology`](#post-restv2actionsstatuscollectorupdatetopology); +update-only). The same effective-label caveat as `lookupInspectDevice` +applies: `label` here can carry the `fDescriptor` fallback while the persisted +`descriptor.label` is empty. + ### `POST /rest/v2/actions/status/collector/lookupNodeInfo` / `…/lookupEdgeInfo` / `…/lookupDeviceVertices` **Verified 2025.4.9 — display-oriented**, not baselines. `lookupNodeInfo` @@ -998,6 +1005,13 @@ Model coverage: - `InspectApiAssignedTags` - `InspectApiLookupInspectDeviceFields` +The `fields` object is **exactly the accepted `replaceDevices` payload shape** +(verified 2025.4.9 — see +[`updateTopology`](#post-restv2actionsstatuscollectorupdatetopology)). Note +that `descriptor` here carries the *effective* label (persisted `descriptor` +merged with the `fDescriptor` fallback); committing it verbatim pins the +fallback label into the persisted `descriptor`. + Invalid object-style request example: ```json @@ -1399,11 +1413,57 @@ Failure modes (verified 2025.4.9): in one commit fails entirely (`items: []`) — reject-before-apply. - **`force: true` does not bypass apply-gate errors** (`res.ok` stays `false` for non-existent remove keys). +- **`replaceDevices` takes the edit form, not the persisted element** + (verified 2025.4.9 via a device coordinate commit + revert): sending the raw + `baseDevice` element (`maps[]`, `fDescriptor`, `type`, …) is rejected with + HTTP 400 conversion errors — `coordinates` and `localAssignedTags` are + **mandatory**. The accepted shape is exactly `lookupInspectDevice`'s + `fields` object; the server maps `coordinates` → `maps[]` itself: + + ```json + { + "replaceDevices": { + "device-a": { + "coordinates": { "x": 1600.0, "y": 9050.0 }, + "descriptor": { "desc": "", "label": "" }, + "iconSize": "medium", + "iconType": "default", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": [], + "virtualDeviceFields": null + } + } + } + ``` + + `descriptor` is stored **verbatim** (an empty persisted descriptor stayed + empty across commit + revert; the element round-tripped byte-identical + except `_rev`). Caveat: the lookups return the *effective* label (persisted + `descriptor` merged with the `fDescriptor` fallback) — a client that + round-trips them unchanged pins the fallback label into `descriptor`. + `replaceEdges` takes the raw persisted edge form (captured UI commit + + verified apply). +- **`replaceVertices` takes the vertex edit form and is update-only** + (verified 2025.4.9 via a `desc` round-trip on a live vertex, byte-identical + revert): the accepted shape is exactly `lookupInspectVertexById`'s `fields` + object. Committing a **new** vertex id passes schema conversion but fails + validation with *"Vertex with id … was not found in graph"* — standalone + vertices cannot be created through `updateTopology`; they originate from + device sync (`syncDevices`) or virtual-device definitions. +- **Collector propagation**: a committed change is visible in collector reads + ~25 ms after the POST returns (three samples, first poll each time) — the + projection updates synchronously with the commit for practical purposes. - Writes appear in `nGraphElements` under the composite `fromId::toId` key with a bumped `_rev`; a parallel UUID-keyed document may also exist for the same edge. - `resolvable: true` has not been observed on 2025.4.9. - Fixtures: `tests/fixtures/inspect/2025.4.9/update_topology_success.json`, + `…/update_topology_replace_devices.json` (edit-form request, success + response, and the raw-element rejection error), + `…/update_topology_replace_vertices.json` (vertex edit-form request, + success response, and the update-only validation failure), `…/update_topology_fail_remove.json`, `…/update_topology_fail_booking.json`. Applied-change response (verified on 2025.4.9 — edge `weight` change): diff --git a/docs/inspect-app/implementation-plan.md b/docs/inspect-app/implementation-plan.md new file mode 100644 index 0000000..d1d5370 --- /dev/null +++ b/docs/inspect-app/implementation-plan.md @@ -0,0 +1,286 @@ +# Inspect App — Implementation Plan + +> Status: **Implemented** · Last updated: 2026-07-09 +> +> Turns the concept phase ([concepts.md](./concepts.md), [endpoints.md](./endpoints.md), +> [ADRs 0001–0010](./decisions/README.md)) into the shipped `app.inspect` surface. +> All wire facts referenced below are live-verified against VideoIPath 2025.4.9. +> +> **Shipped:** code under `src/videoipath_automation_tool/apps/inspect/`; offline tests under +> `tests/inspect/`; developer-run live E2E suite (`-m e2e`) under `tests/e2e/inspect/`; user guide +> at [getting-started 05_Inspect](../getting-started-guide/05_Inspect.md). Milestones M1–M5 below +> are complete. + +## 1. Context + +The inspect-app concept phase is complete: all wire formats are captured and live-verified against VideoIPath 2025.4.9 ([endpoints.md](./endpoints.md)), and ten ADRs pin the architecture. This plan turns the concepts into the shipped `app.inspect` package surface. The existing code under `src/videoipath_automation_tool/apps/inspect/` (snapshot.py, domain/, model/) is a **draft** — it predates ADR-0007/0009/0010 (it parses one full collector response, no lazy hydration, no writes) and will be reworked in place. + +Governing decisions (all Accepted): + +| ADR | Decision (implementation-relevant core) | +| --- | --- | +| [0001](./decisions/0001-api-paradigm.md)/[0003](./decisions/0003-websocket-subscriptions.md) | Request/response only; no WebSocket API | +| [0004](./decisions/0004-async-strategy.md) | Sync public API; internal thread-pool parallelism for multi-request operations | +| [0005](./decisions/0005-e2e-testing.md) | E2E = developer-run live-server tests via `tests/.env.test`; offline fixture tests feed from `tests/fixtures/inspect//` | +| [0006](./decisions/0006-commit-write-model.md) | Commit-style writes: hybrid — direct auto-commit methods + explicit transaction context manager; success = `header.ok ∧ data.res.ok ∧ data.validation.result.ok` | +| [0007](./decisions/0007-lazy-snapshot-loading.md) | Skeleton-first snapshot (devices + edges scoped queries), transparent per-entity lazy hydration, section-level lazy loads, accreting state, `load="full"` eager mode | +| [0008](./decisions/0008-collector-only-endpoints.md) | Collector-only endpoints: never call `nGraphElements` / `edgesByDevice` at runtime | +| [0009](./decisions/0009-write-consistency.md) | Client-side compare-and-commit; baselines from `lookupInspectDevice` / `lookupInspectVertexByIds` / `lookupInspectEdgesByIds` (lookup forms **are** the write shapes); typed conflict error; explicit override | +| [0010](./decisions/0010-post-commit-snapshot-refresh.md) | Post-commit targeted refresh: local removes, per-device/per-pair scoped re-reads (~25 ms propagation, no retry loop), sections marked stale | + +Verified server facts the code must encode: `replaceDevices`/`replaceVertices` take **edit forms** (`coordinates`+`localAssignedTags` mandatory for devices), `replaceVertices` is **update-only**, `replaceEdges` takes the raw persisted edge form, last-writer-wins (no `_rev` anywhere on the Inspect surface), reject-before-apply atomicity, HTTP 414 for over-long projections, `"_noId"` suppresses expansion, effective-label merging in lookups/collector. + +## 2. Public API (the user-facing contract — design this first, code to it) + +### 2.1 Entry point + +```python +app = VideoIPathApp(...) +snapshot = app.inspect.get_snapshot() # skeleton: all devices + edges, 2 parallel GETs +snapshot = app.inspect.get_snapshot(load="full") # eager /** fallback (point-in-time) +``` + +### 2.2 Reads (snapshot + domain objects, ADR-0007) + +```python +dev = snapshot.get_device("device12") # skeleton-backed, no I/O +dev = snapshot.find_device_by_label("BU-LEAF-A") # exact; find_devices_by_label for lists +devs = snapshot.devices # list[InspectDevice] +edges = snapshot.edges # list[InspectEdge] (pair-level) +svcs = snapshot.services # lazy: loads inspect/paths section on first touch + +dev.label, dev.id, dev.coordinates, dev.status, dev.sync_severity, dev.tags # skeleton fields +dev.ports # first access → one GET nodeStatus//, then local +dev.edges # local (edge skeleton) +dev.services # triggers paths section load once +dev.linked_devices # local graph walk +port.vertex_id, port.status, port.edge, port.device +edge.from_device, edge.to_port, edge.status, edge.pair_id + +snapshot.preload(devices=[...]) # batch hydration, thread pool (ADR-0004) +snapshot.refresh() # returns a NEW snapshot (never mutates) +snapshot.fetched_at(entity_or_section) # freshness introspection +``` + +Contract (already documented in models.md): at most one hydration fetch per entity/section; getters can raise connector errors; snapshot is not a single point in time. + +### 2.3 Writes (change set, ADR-0006/0009/0010) + +```python +# Convenience direct writes (auto-commit, one updateTopology each): +app.inspect.place_device("device12", x=1600, y=9050) +app.inspect.update_device("device12", label="BU-LEAF-A", icon_type="switch") +app.inspect.update_vertex("device12.1.Ethernet1.out", use_as_endpoint=True) +app.inspect.update_edge(edge_id, weight=10) +app.inspect.connect("device12.1.Ethernet1.out", "device7.0.swp1.in", + bidirectional=True, capacity=65535) # paired edges +app.inspect.disconnect(edge_or_pair_id) +app.inspect.remove_device_from_topology("device12") # remove baseDevice element + +# Batched, atomic (primary path for pipelines): +with app.inspect.transaction() as tx: + tx.place_device("device12", x=100, y=200) + tx.connect(a_out, b_in, bidirectional=True) + tx.remove(edge_id) + result = tx.commit() # conflict check → POST → targeted snapshot refresh +# exit without commit → discard + warning log; exception → discard + +result.ok, result.applied_ids, result.validation # typed CommitResult + +# Conflict handling (ADR-0009): +try: + tx.commit() +except InspectCommitConflictError as e: + e.conflicts # [{entity_id, kind, field_diffs}] + tx.rebase() # re-fetch baselines, re-apply staged intents; then commit again +tx.commit(check_conflicts=False) # explicit last-writer-wins +``` + +Semantics: staging an entity fetches its baseline via the lookups (batched); caller mutations are field-level *intents* applied onto the baseline at commit-build time; `remove` baselines assert existence. Direct writes are sugar: open implicit tx → stage → commit. + +### 2.4 Topology device/sync actions (network actions) + +```python +app.inspect.add_devices_to_topology([("device12", 100, 200), ...]) # addDevices action +app.inspect.sync_devices(["device12"], add_only=True, conflict_strategy=ConflictStrategy.STRICT) +info = app.inspect.get_sync_info(["device12"]) # lookupSyncInfo +``` + +### 2.5 Errors (new `apps/inspect/errors.py`) + +`InspectError` (base) → `InspectCommitError` (carries `CommitResult` with `res.msg` + `validation` details), `InspectCommitConflictError`, `InspectEntityNotFoundError`, `InspectQueryTooLongError` (HTTP 414 → message points at `load="full"`). Connector-level errors pass through unchanged. + +## 3. Module layout (target) + +``` +apps/inspect/ +├── __init__.py # public re-exports +├── inspect_app.py # InspectApp: composes read + write + actions mixins +├── inspect_api.py # raw API layer: all HTTP calls, typed responses +├── queries.py # scoped-query/projection builder + frozen projection constants +├── errors.py +├── app/ # user-facing method mixins (inventory-app pattern) +│ ├── read.py # get_snapshot, find_* helpers +│ ├── write.py # direct writes + transaction() +│ └── actions.py # add/sync devices, sync info +├── snapshot.py # InspectSnapshot: state, indexes, hydration, refresh hooks +├── changeset.py # InspectTransaction, staging entries, baselines, commit +├── domain/ # InspectDevice/Port/Edge/Service (extend existing) +└── model/ # InspectApi* DTOs (extend existing: collector.py, actions.py, + # update_topology.py, ngraph.py, common.py) +``` + +Follows the established package idioms: `*App` composed from mixins (like `InventoryApp`), `*API` raw layer, Pydantic DTOs under `model/`, snake_case fields with wire aliases. + +## 4. Implementation detail per component + +### 4.1 Connector changes (small, prerequisite) + +`connector/vip_rest_connector.py`: +- **POST allow-list**: add prefix `/rest/v2/actions/status/network/` (for `addDevices`/`syncDevices`). GET already covers `/rest/v2/data/status/`; POST already covers `…/actions/status/collector/`. +- **`/...` wildcard block**: GET currently raises on any `"/..."` in the path — scoped projections *require* `/.../` segments. Move this check behind the existing `node_check`/`url_validation` flags or add `allow_projection: bool = False` param that InspectAPI sets. Do not weaken behavior for existing callers. +- **`node_check=False`** for scoped reads (partial trees don't contain all envelope nodes). +- URL-encode query paths (space `%20`, `"` `%22`, parens, `=`) in `queries.py`, not in the connector. + +### 4.2 `queries.py` — the projection catalogue + +Single home for every verified query string (they are contract, not ad-hoc): +- `DEVICE_SKELETON` — trimmed msg-13 projection with `modules/"_noId"` (the full UI projection 414s; the trimmed variant is verified at ~8.6 KB/27 devices). +- `DEVICE_DETAIL(device_id)` — direct-id form `nodeStatus//…` with `modules/*` detail projection. +- `EDGE_SKELETON` — msg-14 lean projection; `EDGE_PAIR(pair_id)` — direct pair-key form. +- `PATHS_SECTION` — msg-12 serviceFields+path projection. +- Encoding helper `encode_query(path) -> str` + length guard raising `InspectQueryTooLongError` before sending (proxy limit). +Unit-test each constant against the fixtures (round-trip: query → fixture response parses into DTOs). + +### 4.3 `inspect_api.py` — raw layer (one method per endpoint) + +| Method | Endpoint | +| ------ | -------- | +| `get_collector_full()` | `GET …/status/collector/**` | +| `get_device_skeleton()` / `get_device_detail(id)` | scoped nodeStatus queries | +| `get_edge_skeleton()` / `get_edge_pair(pair_id)` | scoped externalEdgesByDeviceKey | +| `get_paths_section()` | scoped inspect/paths | +| `lookup_inspect_device(id)` | POST lookupInspectDevice | +| `lookup_vertices(ids)` | POST lookupInspectVertexByIds | +| `lookup_edges(ids)` | POST lookupInspectEdgesByIds | +| `lookup_sync_info(ids)` | POST lookupSyncInfo | +| `update_topology(delta)` | POST updateTopology | +| `add_devices(items)` / `sync_devices(req)` | POST network actions | +| `get_registered_actions()` | GET actions schema (version gating) | + +Each returns typed DTOs; each logs at DEBUG. No business logic here. + +### 4.4 DTO additions (`model/`) + +- `collector.py`: already covers nodeStatus/paths/edges — verify field coverage against the skeleton/detail fixtures; add missing fields (`relatedNodeTags`, `ptpDeviceStatus`, `tagsInfo`, `meta` extras) as **optional** so skeleton and full payloads parse with one model set (skeleton items simply have `modules={}` / fields absent). +- `actions.py`: add `InspectApiLookupEdgesByIdsRequest/Response(+Item)`, `InspectApiLookupVertexByIdRequest/Response`, `InspectApiVertexEditForm` (the `fields` object incl. `typeFields`), reuse existing lookup DTOs. +- `update_topology.py`: encode the **verified per-kind shapes** — `InspectApiDeviceEditForm` (== lookupInspectDevice.fields; used in `replaceDevices`), `InspectApiVertexEditForm` (`replaceVertices`), persisted edge model (`replaceEdges`, reuse/align with `ngraph.py` edge). `CommitResponse` with `items[]`, `res`, `validation{createIds, details, result}`. +- All DTOs: `model_config = ConfigDict(extra="allow")` on read models (unknown-field tolerance across server versions), strict on write payload models. + +### 4.5 `snapshot.py` rework (keep public getters, change internals) + +State per ADR-0007/0010: +- `_records`: skeleton-indexed devices/edges as today, plus `_hydration: dict[str, _EntityState]` where `_EntityState = (level: SKELETON|FULL, fetched_at: datetime)`; `_sections: dict[str, _SectionState]` (paths, maintenanceBookings…). +- Constructor takes `fetcher: InspectAPI | None` + parsed skeleton payloads. `from_full_response(response)` classmethod → everything marked FULL, fetcher optional (fixtures/offline: lazy loading inert; raise a clear error if an unloaded section is touched with no fetcher). +- `_ensure_device_detail(device_id)`: no-op if FULL; else `get_device_detail`, re-parse item, replace record, rebuild that device's port index entries, drop that device's cached domain wrappers, stamp `fetched_at`. Same pattern `_ensure_section("paths")`. +- **Concurrency**: a `threading.Lock` around merge operations (preload uses a pool); document snapshot as not-thread-safe for general use, but hydration merges are internally consistent. +- `preload(devices=None)`: ThreadPoolExecutor (bounded, e.g. 8) over `_ensure_device_detail` (ADR-0004). +- **Post-commit hooks** (called by changeset, ADR-0010): `_apply_removals(ids)`, `_refresh_devices(ids)`, `_refresh_edge_pairs(pair_ids)`, `_mark_section_stale("paths")`. +- Keep `raw_response` only for `load="full"` snapshots; skeleton snapshots expose `raw_skeleton` parts instead. + +### 4.6 Domain objects (`domain/`) + +Property → data-source mapping (drives which getter triggers hydration): + +| Property | Source | Hydrates? | +| -------- | ------ | --------- | +| `InspectDevice.id/label/pid/coordinates/status/sync_severity/tags/icon` | skeleton | no | +| `InspectDevice.ports`, `InspectPort.*` | device detail | yes (device) | +| `InspectDevice.edges`, `InspectEdge.*` | edge skeleton | no | +| `InspectDevice.services`, `InspectService.*`, `InspectEdge.services` | paths section | yes (section) | +| `InspectPort.edge` | edge skeleton index | no | + +Add `InspectDevice.is_hydrated` / `snapshot.fetched_at(...)` for introspection. Keep frozen-dataclass wrappers + snapshot back-reference pattern from the draft. + +### 4.7 `changeset.py` — transaction, baselines, commit + +- `_StagedEntry(kind: DEVICE|VERTEX|EDGE, id, baseline: DTO, intents: dict[field, value] | REMOVE)`. +- **Staging**: first touch of an entity fetches baselines via *batched* lookups (`lookup_edges`, `lookup_vertices` accept lists; device lookup is per-id). Staging validates the entity exists (translate lookup miss → `InspectEntityNotFoundError`); vertices/devices cannot be *created* (update-only — enforced client-side with a clear message). +- `connect(a_vertex, b_vertex, bidirectional=True, **edge_fields)`: builds one/two `replaceEdges` entries keyed `a::b` (and `b::a`) from a default edge template (capacity 65535, redundancyMode "Any", weightFactors default — the verified persisted-edge defaults); no baseline needed for *new* edge keys, but stage a lookup to detect "already exists" and require explicit `overwrite=True`. +- **Payload build**: intents applied onto baselines → per-kind write DTOs (devices/vertices: edit forms verbatim — **no** field mapping; edges: persisted form). Label/desc caveat: `descriptor` fields are only included in the diff-intents if the caller set them (never round-trip the effective label silently). +- **Commit sequence** (one method, well-logged): + 1. re-fetch baselines (batched), deep-compare vs staged baselines over editable fields (exclude volatile: statuses, `assignedTags.inherited`); mismatch → `InspectCommitConflictError` (all conflicts collected, nothing sent); + 2. POST `update_topology`; + 3. evaluate three-flag success; failure → `InspectCommitError` with typed validation details; + 4. targeted snapshot refresh (if the tx was created from a snapshot): removes applied locally, affected devices/pairs re-fetched, paths section marked stale. Affected-set derivation from staged keys + `items[]` using the id conventions (vertex id → owning device; edge key → pair id). +- `rebase()`: refresh baselines, keep intents, drop conflicts resolution to caller when intent-field itself conflicts. +- Transaction is single-use; `discard()` clears; context-manager exit without commit logs a warning (explicitly decided: **no auto-commit on exit** — commit must be visible in user code). + +### 4.8 `InspectApp` + package integration + +- `inspect_app.py`: composes mixins; ctor `(vip_connector, logger)` like the other apps. +- `videoipath_app.py`: add lazy `inspect` property + `self._inspect_api = self.inspect._inspect_api` in the DEV block; placeholder `self._inspect = None`. +- **Version gating**: on `InspectApp` init, check `get_server_version()` ≥ 2025.4 (first verified: 2025.4.9); below → log warning "Inspect API surface unverified for this server version". Optionally probe `get_registered_actions()` at DEBUG. +- `apps/inspect/__init__.py`: export `InspectApp`, domain classes, `InspectSnapshot`, errors, `ConflictStrategy`. + +## 5. Milestones (PR-sized, each independently shippable) + +1. **M1 – Connector + queries + API layer (read)**: connector changes, `queries.py`, `inspect_api.py` read methods, DTO gap-fill; offline fixture tests green. +2. **M2 – Snapshot rework + domain (read path complete)**: hydration states, sections, preload, `from_full_response`; `app.inspect.get_snapshot()`; VideoIPathApp property; unit tests with fake fetcher (hydration counts, merge idempotency). +3. **M3 – Lookups + actions**: lookup API methods + DTOs, `get_sync_info`, `add_devices_to_topology`, `sync_devices`. +4. **M4 – Change set + writes**: `changeset.py`, direct write sugar, commit lifecycle incl. conflict check + targeted refresh; unit tests with mocked API (payload-shape assertions against the four updateTopology fixtures; conflict scenarios; refresh-hook calls). +5. **M5 – E2E suite** (below) + getting-started doc page (`docs/getting-started-guide/0X_Inspect.md`) + [models.md](./models.md)/README sync. + +## 6. Testing + +### 6.1 Offline (runs in CI, every push) + +- **Fixture contract tests** (`tests/inspect/test_models.py`): every fixture in `tests/fixtures/inspect/2025.4.9/` parses into its DTO; write-payload builders reproduce the fixture requests byte-for-byte (devices/vertices edit forms, edge form, no-op). +- **Snapshot unit tests** (`tests/inspect/test_snapshot.py`): fake fetcher returning fixture payloads — skeleton indexes, exactly-one hydration fetch per device, section laziness, preload fan-out, post-commit hooks (removal drops indexes; refresh replaces records; stale section reloads). +- **Changeset unit tests** (`tests/inspect/test_changeset.py`): staging→baseline capture, intent application, conflict detection (mutated baseline → error with field diffs), `check_conflicts=False` bypass, three-flag result evaluation incl. the captured failure fixtures, affected-set derivation. +- **Query tests**: encoding, 414 length guard, projection constants stability. + +### 6.2 E2E — live instance, leaf-spine scenario (ADR-0005) + +Location `tests/e2e/inspect/`, pytest marker `e2e` (excluded by default via `addopts = -m "not e2e"`), gated on `VIPAT_E2E_ENABLED=1` in `tests/.env.test` **plus** a server-version allowlist check; every created element uses label prefix `E2E-` and tag `#vipat-e2e` so setup/teardown is idempotent and safe on a shared local instance. + +**Scenario: `LeafSpineScenario` (module `tests/e2e/inspect/scenario.py`)** — a fixed dataclass-defined network modelled on the real rig (red/blue planes, leaf-spine, endpoints): + +| Role | Devices (virtual) | Ports (ip vertices, in/out pairs) | +| ---- | ----------------- | -------------------------------- | +| Spines | `E2E-SPINE-A`, `E2E-SPINE-B` | 4 × downlink (`swp1..4`) | +| Leaves | `E2E-LEAF-A1`, `E2E-LEAF-A2` (red), `E2E-LEAF-B1`, `E2E-LEAF-B2` (blue) | 2 × uplink, 4 × host port | +| Endpoints | `E2E-ENC-1`, `E2E-ENC-2`, `E2E-DEC-1`, `E2E-DEC-2` | `eth-a` (red), `eth-b` (blue) | + +Edge matrix (all bidirectional pairs): each leaf ↔ its plane's spine (uplinks, capacity 65535), each endpoint `eth-a` ↔ a red leaf host port and `eth-b` ↔ a blue leaf host port. Grid coordinates per role (spines y=0, leaves y=200, endpoints y=400). + +**Build path** (respects verified server semantics): virtual devices + their vertices are created via the *topology app* (`create_virtual_device()` + `update_device` — vertices cannot be created through `updateTopology`, and virtual devices need no hardware); everything Inspect *owns* is then done through `app.inspect`: placement, connect (paired edges), edits, removals. Scenario object exposes `build(app)`, `teardown(app)` (delete by tag/prefix — edges first, then devices), `assert_clean(app)`. + +**Test list** (`test_e2e_leaf_spine.py`, ordered by dependency, session-scoped scenario fixture): +1. `test_build_and_skeleton_read` — build; skeleton snapshot sees all 10 devices with labels/coordinates; edge count == matrix size; **no** hydration fetches occurred (spy counter). +2. `test_lazy_hydration` — access `dev.ports` on one leaf → exactly one detail fetch; ports/vertex ids match scenario; other devices still skeleton. +3. `test_connectivity_graph` — `linked_devices` of each endpoint == its two leaves; leaf ↔ spine adjacency matches the matrix; `port.edge.to_device` round-trips. +4. `test_edge_pair_refresh` — `update_edge(weight=…)` on one uplink via direct write; assert commit result + snapshot pair record updated (targeted refresh) without full reload. +5. `test_transaction_atomicity` — tx with one valid edge edit + one `remove` of a non-existent id → `InspectCommitError`, nothing applied (weight unchanged on server). +6. `test_conflict_detection` — stage edit for edge E; mutate E out-of-band (second `VideoIPathApp` instance); `commit()` → `InspectCommitConflictError` listing the field; `rebase()` + commit succeeds. +7. `test_device_placement_roundtrip` — `place_device` new coordinates; re-snapshot skeleton shows them; revert. +8. `test_disconnect_connect_cycle` — disconnect one endpoint pair, assert gone from snapshot + collector; reconnect; assert identical to scenario matrix. +9. `test_full_vs_skeleton_equivalence` — `load="full"` snapshot and skeleton+preload produce identical device/port/edge index contents for scenario entities. +10. `test_teardown` — teardown; snapshot contains no `E2E-` devices/edges; runs also as autouse session-finalizer so aborted runs clean up. + +Never touched: non-`E2E-` devices, bookings/services (read-only asserts only), profiles/security sections. + +## 7. Risks & mitigations + +- **Connector `/...`-block relaxation** could affect existing callers → new opt-in parameter only; existing default behavior unchanged; unit test both. +- **Skeleton projection length vs. proxies** (414 verified for full UI projection) → pre-flight length guard + documented `load="full"` fallback. +- **Version drift** (all verification on 2025.4.9) → version gate + `get_registered_actions()` probe; concepts.md upgrade checklist already mandates re-verification; read DTOs `extra="allow"`. +- **Draft-code compatibility**: `InspectSnapshot.from_response()` exists in the draft — keep as alias of `from_full_response` (nothing published depends on it yet; package unreleased for inspect, so breaking the draft internals is acceptable). +- **Shared-instance E2E safety** → tag/prefix discipline, env gating, teardown-finalizer, no writes outside `E2E-` namespace (test 4/6 operate on scenario-owned edges only). + +## 8. Verification of the implementation + +- `poetry run pytest` (offline suite) green in CI. +- `poetry run pytest -m e2e tests/e2e/inspect` against the local 2025.4.9 instance (`tests/.env.test`) — full scenario build → tests → teardown leaves the instance in its pre-run state (asserted by test 10). +- `poetry run ruff check src/ tests/` clean. +- Manual smoke in DEV env: `app._inspect_api` exposure, DEBUG logs show skeleton (2 GETs) then exactly one hydration GET on first `ports` access. diff --git a/docs/inspect-app/models.md b/docs/inspect-app/models.md index 0ac0e20..6e5c999 100644 --- a/docs/inspect-app/models.md +++ b/docs/inspect-app/models.md @@ -13,6 +13,11 @@ The implementation uses two layers: `InspectEdge`, and `InspectService`. They are built from an `InspectSnapshot` and resolve relations from internal indexes instead of making extra HTTP calls. +`InspectSnapshot` is an **internal** component: `InspectApp` owns a single instance, +builds it lazily on the first read, and keeps it current across writes. Users never +construct or hold it — all reads and writes go through `app.inspect` (`get_device`, +`devices`, `edges`, `services`, `refresh`, …), the same way as the other apps. + Wire-shape examples and endpoint references live in [endpoints.md](./endpoints.md). All examples below are anonymized. @@ -41,8 +46,7 @@ internal state. The concrete queries are documented in Typical read flow: ```python -snapshot = app.inspect.get_snapshot() # skeleton fetch (devices + edges) -device = snapshot.get_device_by_id("device-a") # local: skeleton-backed +device = app.inspect.get_device("device-a") # loads the internal view lazily; skeleton-backed ports = device.ports # first access: hydrates device-a, then local edges = device.edges # local: edge skeleton services = device.services # first access: loads the paths section, then local @@ -178,8 +182,8 @@ Represents one topology device/node with status, sync hints, and relations. Lookup: ```python -device = snapshot.get_device_by_id("device-a") -matches = snapshot.find_devices_by_name("Example Device A") +device = app.inspect.get_device("device-a") +matches = app.inspect.find_devices_by_label("Example Device A") ``` ### `InspectPort` @@ -614,41 +618,52 @@ In Python these shapes live in `ngraph.py`. valid no-op. Non-empty maps upsert graph elements by ID, `addExternalEdges` adds edge objects, and `remove` deletes graph elements by ID. +The per-kind payload shapes differ (all verified 2025.4.9, +[endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)): +**devices use the edit form** (`lookupInspectDevice.fields`; `coordinates` and +`localAssignedTags` are mandatory — the raw persisted `baseDevice` element is +rejected), **vertices use the edit form** (`lookupInspectVertexById.fields`) +and are **update-only** (unknown ids fail validation — vertices come from +device sync, not commits), and **edges use the raw persisted edge form** +(`lookupInspectEdgesByIds` returns it directly). + ```json { "header": { "id": 0 }, "data": { "replaceDevices": { "device-a": { - "_id": "device-a", - "_vid": "device-a", - "type": "baseDevice", - "descriptor": { "label": "Example Device A", "desc": "" }, - "fDescriptor": { "label": "Example Device A", "desc": "" }, - "tags": [] - } - }, - "replaceVertices": { - "vertex-a-out": { - "_id": "vertex-a-out", - "_vid": "vertex-a-out", - "type": "ipVertex", - "deviceId": "device-a", - "descriptor": { "label": "Output Vertex", "desc": "" }, - "fDescriptor": { "label": "Output Vertex", "desc": "" }, - "tags": [] + "coordinates": { "x": 1600.0, "y": 9050.0 }, + "descriptor": { "label": "", "desc": "" }, + "iconSize": "medium", + "iconType": "default", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": [], + "virtualDeviceFields": null } }, + "replaceVertices": {}, "replaceEdges": { - "vertex-a-out::vertex-b-in": { - "_id": "edge-a-b-0001", - "_vid": "edge-a-b-0001", - "type": "unidirectionalEdge", - "fromId": "vertex-a-out", - "toId": "vertex-b-in", + "device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in": { + "active": true, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, "descriptor": { "label": "", "desc": "" }, + "excludeFormats": [], "fDescriptor": { "label": "", "desc": "" }, - "tags": [] + "fromId": "device-a.module-1.port-out-1.out", + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": "device-b.module-1.port-in-1.in", + "weight": 1, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "max": 100, "weight": 0 } + } } }, "replaceResourceTransforms": {}, @@ -707,8 +722,9 @@ catches up with **targeted scoped re-reads** fetch timestamps; - loaded sections (e.g. services) are marked stale and re-load lazily on next access; -- the refresh verifies the committed values are visible and retries briefly if - the collector projection lags the config store. +- the collector projection updates effectively synchronously with the commit + (measured ~25 ms to visibility on 2025.4.9), so the targeted re-read doubles + as the verification — no retry loop. A failed commit changes nothing server-side (reject-before-apply), so the snapshot is left untouched. diff --git a/pyproject.toml b/pyproject.toml index cb03eae..9b0f3fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,10 +49,12 @@ pytest-cov = "^7.1.0" pytest-dotenv = "^0.5.2" [tool.pytest.ini_options] -addopts = "-x -p no:warnings --cov-report=term --cov-report=term-missing --no-cov-on-fail --cov=src --ignore=__intern" +addopts = "-x -p no:warnings --cov-report=term --cov-report=term-missing --no-cov-on-fail --cov=src --ignore=__intern -m \"not e2e\"" env_override_existing_values = 0 env_files = ["tests/.env.test"] - +markers = [ + "e2e: developer-run tests against a live VideoIPath instance (gated on VIPAT_E2E_ENABLED=1; excluded by default). Run with '-m e2e'.", +] [virtualenvs] in-project = true diff --git a/src/videoipath_automation_tool/apps/inspect/__init__.py b/src/videoipath_automation_tool/apps/inspect/__init__.py index b4bc85c..57ca1c8 100644 --- a/src/videoipath_automation_tool/apps/inspect/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/__init__.py @@ -1,3 +1,10 @@ +from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy as ConflictStrategy +from videoipath_automation_tool.apps.inspect.changeset import CommitResult as CommitResult +from videoipath_automation_tool.apps.inspect.changeset import InspectTransaction as InspectTransaction from videoipath_automation_tool.apps.inspect.domain import * +from videoipath_automation_tool.apps.inspect.errors import * +from videoipath_automation_tool.apps.inspect.inspect_app import InspectApp as InspectApp from videoipath_automation_tool.apps.inspect.model import * -from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot as InspectSnapshot + +# InspectSnapshot is an internal implementation detail of InspectApp ([ADR-0007]); it is not part of +# the public API. Interact with the topology entirely through ``app.inspect``. diff --git a/src/videoipath_automation_tool/apps/inspect/app/__init__.py b/src/videoipath_automation_tool/apps/inspect/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/videoipath_automation_tool/apps/inspect/app/actions.py b/src/videoipath_automation_tool/apps/inspect/app/actions.py new file mode 100644 index 0000000..7464ba4 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/app/actions.py @@ -0,0 +1,97 @@ +"""Topology device/sync network actions (addDevices, syncDevices, lookupSyncInfo). + +These wrap the ``actions/status/network/*`` and ``lookupSyncInfo`` endpoints used by the Inspect +device-onboarding-into-topology workflows. Placement and connections themselves go through the +change set ([ADR-0006]); these actions handle bringing a device's driver-reported topology into +the graph and keeping it in sync. +""" + +from __future__ import annotations + +import logging +from enum import IntEnum +from typing import Iterable, Protocol, Union + +from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiAddDevicesItem, + InspectApiLookupSyncInfoItem, +) + + +class ConflictStrategy(IntEnum): + """Conflict handling for ``syncDevices`` (values verified from the Inspect UI bundle).""" + + STRICT = 0 + INVALIDATE_SERVICES = 1 + CANCEL_SERVICES = 2 + + +# (device_id, x, y) or just device_id (placed at 0,0). +AddDeviceSpec = Union[str, tuple[str, float, float]] + + +class _HasInspectApi(Protocol): + _inspect_api: InspectAPI + _logger: logging.Logger + + +class InspectActionsMixin: + _inspect_api: InspectAPI + _logger: logging.Logger + + def get_sync_info(self: _HasInspectApi, device_ids: list[str]) -> dict[str, InspectApiLookupSyncInfoItem]: + """Per-device sync differences (what would be added/removed/updated on the next sync).""" + if not device_ids: + raise ValueError("device_ids must not be empty.") + return self._inspect_api.lookup_sync_info(device_ids).data + + def add_devices_to_topology(self: _HasInspectApi, devices: Iterable[AddDeviceSpec]) -> bool: + """Add onboarded devices to the topology graph (``addDevices`` network action). + + Args: + devices: device ids, or ``(device_id, x, y)`` tuples to place them. + + Returns: + bool: whether the action reported success (``data.ok``). + """ + items = [_to_add_item(spec) for spec in devices] + response = self._inspect_api.add_devices(items) + if not response.data.ok: + self._logger.warning(f"addDevices reported failure: {response.data.msg}") + return response.data.ok + + def sync_devices( + self: _HasInspectApi, + device_ids: list[str], + add_only: bool = True, + conflict_strategy: ConflictStrategy = ConflictStrategy.STRICT, + ) -> bool: + """Synchronize devices' driver-reported topology into the graph (``syncDevices`` action). + + Args: + device_ids: devices to synchronize. + add_only: only add new elements; do not remove/update existing ones. + conflict_strategy: how to handle conflicts with active services. + + Returns: + bool: whether the action reported success (``data.ok``). + """ + if not device_ids: + raise ValueError("device_ids must not be empty.") + response = self._inspect_api.sync_devices( + device_ids, add_only=add_only, conflict_strategy=int(conflict_strategy) + ) + if not response.data.ok: + self._logger.warning(f"syncDevices reported failure: {response.data.msg}") + return response.data.ok + + +def _to_add_item(spec: AddDeviceSpec) -> InspectApiAddDevicesItem: + if isinstance(spec, str): + return InspectApiAddDevicesItem(id=spec, x=0, y=0) + device_id, x, y = spec + return InspectApiAddDevicesItem(id=device_id, x=x, y=y) + + +__all__ = ["InspectActionsMixin", "ConflictStrategy", "AddDeviceSpec"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/read.py b/src/videoipath_automation_tool/apps/inspect/app/read.py new file mode 100644 index 0000000..00bcdb2 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/app/read.py @@ -0,0 +1,134 @@ +"""Read-side user methods for the Inspect app. + +The Inspect app owns a single internal :class:`InspectSnapshot` ([ADR-0007]); users never handle it +directly. It is built lazily on the first read and reused (skeleton-first, then hydrated on demand). +Writes update it in place; :meth:`refresh` rebuilds it from the server. +""" + +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from typing import TYPE_CHECKING, Literal, Optional, Protocol + +from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + +LoadMode = Literal["skeleton", "full"] + + +class _HasInspectState(Protocol): + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + _load_mode: LoadMode + + +class InspectReadMixin: + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + _load_mode: LoadMode + + # --- Internal snapshot lifecycle --- + + def _get_snapshot(self: _HasInspectState) -> InspectSnapshot: + """Return the internal snapshot, building it on first access ([ADR-0007]).""" + if self._snapshot is None: + self._snapshot = self._load_snapshot(self._load_mode) + return self._snapshot + + def _load_snapshot(self: _HasInspectState, load: LoadMode) -> InspectSnapshot: + if load == "full": + self._logger.debug("Loading full (eager) Inspect snapshot.") + return InspectSnapshot.from_full_response( + self._inspect_api.get_collector_full(), fetcher=self._inspect_api + ) + self._logger.debug("Loading skeleton Inspect snapshot (devices + edges in parallel).") + with ThreadPoolExecutor(max_workers=2) as pool: + devices_future = pool.submit(self._inspect_api.get_device_skeleton) + edges_future = pool.submit(self._inspect_api.get_edge_skeleton) + devices = devices_future.result() + edges = edges_future.result() + return InspectSnapshot(fetcher=self._inspect_api, device_items=devices, edge_items=edges) + + def refresh(self: _HasInspectState, load: Optional[LoadMode] = None) -> None: + """Reload the topology from the server, discarding the current internal view. + + Args: + load: ``"skeleton"`` (fast; lazy detail) or ``"full"`` (eager, point-in-time). Defaults + to the mode the app was last using. + """ + if load is not None: + self._load_mode = load + self._snapshot = self._load_snapshot(self._load_mode) + + # --- Devices --- + + @property + def devices(self: _HasInspectState) -> list["InspectDevice"]: + """All devices in the topology (skeleton-backed; no per-device detail I/O).""" + return self._get_snapshot().devices + + def get_device(self: _HasInspectState, device_id: str) -> Optional["InspectDevice"]: + """A device by id, or ``None`` if it is not in the topology.""" + return self._get_snapshot().get_device(device_id) + + # Backwards-compatible alias. + get_device_by_id = get_device + + def find_device_by_label(self: _HasInspectState, label: str) -> Optional["InspectDevice"]: + """The first device whose (effective) label matches exactly, or ``None``.""" + return self._get_snapshot().find_device_by_label(label) + + def find_devices_by_label(self: _HasInspectState, label: str) -> list["InspectDevice"]: + """All devices whose (effective) label matches exactly.""" + return self._get_snapshot().find_devices_by_label(label) + + def find_device_id_by_label(self: _HasInspectState, label: str) -> Optional[str]: + """Resolve a device id from its display label.""" + device = self._get_snapshot().find_device_by_label(label) + return device.id if device is not None else None + + def preload(self: _HasInspectState, devices: Optional[list[str]] = None) -> None: + """Hydrate device detail for many devices in parallel (avoids N+1 on bulk detail access).""" + self._get_snapshot().preload(devices) + + def is_device_hydrated(self: _HasInspectState, device_id: str) -> bool: + """Whether a device's full detail (modules/ports) has been loaded.""" + return self._get_snapshot().is_device_hydrated(device_id) + + def fetched_at(self: _HasInspectState, device_id: str) -> Optional[datetime]: + """When the given device's current data was fetched (freshness introspection).""" + return self._get_snapshot().fetched_at(device_id) + + # --- Edges --- + + @property + def edges(self: _HasInspectState) -> list["InspectEdge"]: + """All external edges (device-pair connectivity).""" + return self._get_snapshot().edges + + # --- Services --- + + @property + def services(self: _HasInspectState) -> list["InspectService"]: + """All services/paths (loads the services section on first access).""" + return self._get_snapshot().services + + def get_service_by_booking_id(self: _HasInspectState, booking_id: str) -> Optional["InspectService"]: + """A service by its booking id, or ``None``.""" + return self._get_snapshot().get_service_by_booking_id(booking_id) + + def get_services_for_device(self: _HasInspectState, device_id: str) -> list["InspectService"]: + """All services whose path traverses the given device.""" + return self._get_snapshot().get_services_for_device(device_id) + + +__all__ = ["InspectReadMixin", "LoadMode"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py new file mode 100644 index 0000000..cc39602 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -0,0 +1,135 @@ +"""User-facing topology writes: direct auto-commit sugar + the explicit transaction ([ADR-0006]). + +Direct methods (``place_device``, ``update_device``, ``connect`` …) each open a single-change +transaction and commit it immediately. For batched, atomic changes use ``transaction()`` as a +context manager and call ``commit()`` explicitly. + +Every write is bound to the app's internal snapshot: on a successful commit the touched entities are +refreshed in place ([ADR-0010]) — but only if the snapshot has already been loaded, so a pure-write +workflow never triggers an unnecessary topology read. +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional, Protocol + +from videoipath_automation_tool.apps.inspect.changeset import CommitResult, InspectTransaction +from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +class _HasInspectState(Protocol): + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + + +class InspectWriteMixin: + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + + def transaction(self: _HasInspectState) -> InspectTransaction: + """Open a batched, atomic change set bound to the app's internal snapshot.""" + return InspectTransaction(self._inspect_api, snapshot=self._snapshot, logger=self._logger) + + def place_device(self: _HasInspectState, device_id: str, x: float, y: float) -> CommitResult: + """Move a device to grid coordinates (single auto-committed change).""" + with self.transaction() as tx: + tx.place_device(device_id, x, y) + return tx.commit() + + def update_device( + self: _HasInspectState, + device_id: str, + *, + label: Optional[str] = None, + icon_type: Optional[str] = None, + sdp_strategy: Optional[str] = None, + tags: Optional[list[str]] = None, + coordinates: Optional[dict[str, float]] = None, + ) -> CommitResult: + """Edit a device's placement/appearance fields (single auto-committed change).""" + with self.transaction() as tx: + tx.update_device( + device_id, + label=label, + icon_type=icon_type, + sdp_strategy=sdp_strategy, + tags=tags, + coordinates=coordinates, + ) + return tx.commit() + + def update_vertex( + self: _HasInspectState, + vertex_id: str, + *, + use_as_endpoint: Optional[bool] = None, + label: Optional[str] = None, + tags: Optional[list[str]] = None, + ) -> CommitResult: + """Edit a vertex (single auto-committed change; update-only).""" + with self.transaction() as tx: + tx.update_vertex(vertex_id, use_as_endpoint=use_as_endpoint, label=label, tags=tags) + return tx.commit() + + def update_edge( + self: _HasInspectState, + edge_id: str, + *, + weight: Optional[int] = None, + capacity: Optional[int] = None, + bandwidth: Optional[float] = None, + redundancy_mode: Optional[str] = None, + active: Optional[bool] = None, + tags: Optional[list[str]] = None, + ) -> CommitResult: + """Edit an existing edge (single auto-committed change).""" + with self.transaction() as tx: + tx.update_edge( + edge_id, + weight=weight, + capacity=capacity, + bandwidth=bandwidth, + redundancy_mode=redundancy_mode, + active=active, + tags=tags, + ) + return tx.commit() + + def connect( + self: _HasInspectState, + from_vertex: str, + to_vertex: str, + *, + bidirectional: bool = True, + overwrite: bool = False, + **edge_fields: Any, + ) -> CommitResult: + """Create an edge (and its reverse if bidirectional) between two vertices.""" + with self.transaction() as tx: + tx.connect(from_vertex, to_vertex, bidirectional=bidirectional, overwrite=overwrite, **edge_fields) + return tx.commit() + + def disconnect( + self: _HasInspectState, + from_vertex: str, + to_vertex: str, + *, + bidirectional: bool = True, + ) -> CommitResult: + """Remove the edge (and its reverse if bidirectional) between two vertices.""" + with self.transaction() as tx: + tx.disconnect(from_vertex, to_vertex, bidirectional=bidirectional) + return tx.commit() + + def remove_device_from_topology(self: _HasInspectState, device_id: str) -> CommitResult: + """Remove a device (its baseDevice element) from the topology graph.""" + with self.transaction() as tx: + tx.remove_device(device_id) + return tx.commit() + + +__all__ = ["InspectWriteMixin"] diff --git a/src/videoipath_automation_tool/apps/inspect/changeset.py b/src/videoipath_automation_tool/apps/inspect/changeset.py new file mode 100644 index 0000000..6e3e7cd --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/changeset.py @@ -0,0 +1,550 @@ +"""Change set / transaction for Inspect topology writes ([ADR-0006]/[ADR-0009]/[ADR-0010]). + +A transaction stages topology changes, captures a per-entity baseline via the lookup endpoints +(the lookup forms *are* the ``updateTopology`` write shapes — [ADR-0009]), and applies them +atomically on ``commit()``. Commit runs a client-side compare-and-commit conflict check against +freshly re-fetched baselines, then a single ``updateTopology`` POST, then a three-flag success +evaluation ([ADR-0006]). On success it drives a targeted snapshot refresh ([ADR-0010]) instead of +a full reload. + +Caller mutations are field-level *intents* recorded against the staged baseline and applied at +commit-build time, so ``rebase()`` can re-fetch baselines and re-apply the same intents. + +Verified server facts encoded here (2025.4.9): +- ``replaceDevices`` / ``replaceVertices`` take the lookup *edit form*; ``replaceVertices`` is + update-only (vertices cannot be created via ``updateTopology``). +- ``descriptor`` is *mandatory* in the device edit form, so it is always round-tripped from the + baseline; ``descriptor.label`` is only changed when the caller sets a label explicitly (the + Inspect UI has the same behaviour — the persisted descriptor is not distinguishable from the + effective one on the collector surface). +- ``replaceEdges`` takes the raw persisted edge form; there is no ``_rev`` anywhere (last-writer-wins). +- Apply is reject-before-apply (all-or-nothing), so a detected conflict aborts the whole commit. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Optional + +from videoipath_automation_tool.apps.inspect.errors import ( + InspectCommitConflictError, + InspectCommitError, + InspectConflict, + InspectEntityNotFoundError, +) +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiEdgeForm, + InspectApiLookupInspectDeviceFields, + InspectApiVertexEditForm, +) +from videoipath_automation_tool.apps.inspect.model.update_topology import ( + InspectApiUpdateTopologyData, + InspectApiUpdateTopologyResponse, +) + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +# Staged-entry kinds. +_DEVICE = "device" +_VERTEX = "vertex" +_EDGE = "edge" + + +@dataclass(frozen=True) +class CommitResult: + """Outcome of a successful commit ([ADR-0006]); a failed commit raises ``InspectCommitError``.""" + + applied_ids: list[str] + created_ids: list[str] + response: InspectApiUpdateTopologyResponse + + @property + def ok(self) -> bool: + return True + + @property + def validation(self) -> Any: + return self.response.data.validation + + +@dataclass +class _Staged: + kind: str + entity_id: str + # The write-shape baseline (edit/edge form) as fetched at stage time; None for a raw remove. + baseline_form: Any | None = None + # JSON dump of the baseline at stage time, used for the compare-and-commit conflict check. + baseline_dump: dict[str, Any] | None = None + # Field-level intents (wire field names; dotted for one level of nesting, e.g. "descriptor.label"). + intents: dict[str, Any] = field(default_factory=dict) + remove: bool = False + is_new: bool = False + + +def _device_of(entity_id: str) -> str: + """Owning device id of a vertex/port id (``device12.1.Ethernet1.out`` -> ``device12``).""" + return entity_id.split(".", 1)[0] + + +def _reverse_vertex(vertex_id: str) -> str: + """Flip the trailing direction of an IP vertex id (``.out`` <-> ``.in``).""" + if vertex_id.endswith(".out"): + return vertex_id[: -len(".out")] + ".in" + if vertex_id.endswith(".in"): + return vertex_id[: -len(".in")] + ".out" + return vertex_id + + +def _edge_key(from_id: str, to_id: str) -> str: + return f"{from_id}::{to_id}" + + +def _apply_intents(form: Any, intents: dict[str, Any]) -> None: + for key, value in intents.items(): + if "." in key: + head, tail = key.split(".", 1) + setattr(getattr(form, head), tail, value) + else: + setattr(form, key, value) + + +class InspectTransaction: + """Single-use, atomic batch of Inspect topology changes. + + Stage changes with the ``place_device`` / ``update_*`` / ``connect`` / ``disconnect`` / ``remove`` + methods, then call ``commit()``. The transaction cannot be reused after commit or discard; use + it as a context manager to guarantee cleanup (exit without commit discards and logs a warning). + """ + + def __init__( + self, + api: "InspectAPI", + snapshot: Optional["InspectSnapshot"] = None, + logger: Optional[logging.Logger] = None, + ) -> None: + self._api = api + self._snapshot = snapshot + self._logger = logger or logging.getLogger("videoipath_automation_tool_inspect_txn") + self._entries: dict[tuple[str, str], _Staged] = {} + self._committed = False + self._discarded = False + + # --- Context manager --- + + def __enter__(self) -> "InspectTransaction": + return self + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + if exc_type is not None: + self.discard() + return + if not self._committed and not self._discarded: + if self._entries: + self._logger.warning( + "Inspect transaction exited with %d staged change(s) and no commit(); discarding.", + len(self._entries), + ) + self.discard() + + # --- Introspection --- + + @property + def staged(self) -> list[tuple[str, str]]: + return list(self._entries) + + def __len__(self) -> int: + return len(self._entries) + + # --- Staging: devices --- + + def place_device(self, device_id: str, x: float, y: float) -> "InspectTransaction": + """Move a device to grid coordinates (``replaceDevices``).""" + entry = self._stage_device(device_id) + entry.intents["coordinates"] = {"x": x, "y": y} + return self + + def update_device( + self, + device_id: str, + *, + label: Optional[str] = None, + icon_type: Optional[str] = None, + sdp_strategy: Optional[str] = None, + tags: Optional[list[str]] = None, + coordinates: Optional[dict[str, float]] = None, + ) -> "InspectTransaction": + """Edit a device's placement/appearance fields (``replaceDevices``). + + ``descriptor`` is always round-tripped from the baseline (it is mandatory server-side); + ``label`` is applied to ``descriptor.label`` only when set here. + """ + entry = self._stage_device(device_id) + if label is not None: + entry.intents["descriptor.label"] = label + if icon_type is not None: + entry.intents["iconType"] = icon_type + if sdp_strategy is not None: + entry.intents["sdpStrategy"] = sdp_strategy + if tags is not None: + entry.intents["tags"] = list(tags) + if coordinates is not None: + entry.intents["coordinates"] = dict(coordinates) + return self + + # --- Staging: vertices --- + + def update_vertex( + self, + vertex_id: str, + *, + use_as_endpoint: Optional[bool] = None, + label: Optional[str] = None, + tags: Optional[list[str]] = None, + ) -> "InspectTransaction": + """Edit a vertex (``replaceVertices``; update-only — vertices cannot be created here).""" + entry = self._stage_vertex(vertex_id) + if use_as_endpoint is not None: + entry.intents["useAsEndpoint"] = use_as_endpoint + if label is not None: + entry.intents["label"] = label + if tags is not None: + entry.intents["tags"] = list(tags) + return self + + # --- Staging: edges --- + + def update_edge( + self, + edge_id: str, + *, + weight: Optional[int] = None, + capacity: Optional[int] = None, + bandwidth: Optional[float] = None, + redundancy_mode: Optional[str] = None, + active: Optional[bool] = None, + tags: Optional[list[str]] = None, + ) -> "InspectTransaction": + """Edit an existing edge (``replaceEdges``).""" + entry = self._stage_edge(edge_id) + if weight is not None: + entry.intents["weight"] = weight + if capacity is not None: + entry.intents["capacity"] = capacity + if bandwidth is not None: + entry.intents["bandwidth"] = bandwidth + if redundancy_mode is not None: + entry.intents["redundancyMode"] = redundancy_mode + if active is not None: + entry.intents["active"] = active + if tags is not None: + entry.intents["tags"] = list(tags) + return self + + def connect( + self, + from_vertex: str, + to_vertex: str, + *, + bidirectional: bool = True, + overwrite: bool = False, + **edge_fields: Any, + ) -> "InspectTransaction": + """Create an edge from an out-vertex to an in-vertex (``replaceEdges``). + + With ``bidirectional`` (default) the reverse edge (``to``'s out -> ``from``'s in) is staged + too. Set ``overwrite=True`` to replace an edge that already exists. ``edge_fields`` override + the persisted-edge defaults (e.g. ``weight``, ``capacity``, ``bandwidth``, ``redundancyMode``). + """ + self._stage_new_edge(from_vertex, to_vertex, overwrite=overwrite, edge_fields=edge_fields) + if bidirectional: + self._stage_new_edge( + _reverse_vertex(to_vertex), _reverse_vertex(from_vertex), overwrite=overwrite, edge_fields=edge_fields + ) + return self + + def disconnect(self, from_vertex: str, to_vertex: str, *, bidirectional: bool = True) -> "InspectTransaction": + """Remove the edge from ``from_vertex`` to ``to_vertex`` (and its reverse if bidirectional).""" + self.remove(_edge_key(from_vertex, to_vertex)) + if bidirectional: + self.remove(_edge_key(_reverse_vertex(to_vertex), _reverse_vertex(from_vertex))) + return self + + # --- Staging: removals --- + + def remove(self, entity_id: str) -> "InspectTransaction": + """Remove any entity by id (device / vertex / edge key). Last-writer-wins (no conflict check).""" + self._ensure_open() + kind = _EDGE if "::" in entity_id else (_VERTEX if "." in entity_id else _DEVICE) + self._entries[(kind, entity_id)] = _Staged(kind=kind, entity_id=entity_id, remove=True) + return self + + def remove_device(self, device_id: str) -> "InspectTransaction": + """Remove a device (its baseDevice element) from the topology graph.""" + return self.remove(device_id) + + # --- Commit lifecycle --- + + def commit(self, check_conflicts: bool = True) -> CommitResult: + """Validate, send, and (on success) refresh. Raises on conflict or server rejection. + + Raises: + InspectCommitConflictError: a staged entity changed on the server since staging. + InspectCommitError: the server rejected the commit (validation or apply gate). + """ + self._ensure_open() + if not self._entries: + raise ValueError("Nothing staged; commit aborted.") + + if check_conflicts: + self._check_conflicts() + + delta = self._build_delta() + response = self._api.update_topology(delta) + if not response.committed: + raise InspectCommitError(response) + + self._committed = True + applied_ids = [e.entity_id for e in self._entries.values()] + result = CommitResult( + applied_ids=applied_ids, + created_ids=list(response.data.validation.createIds), + response=response, + ) + self._refresh_snapshot() + self._logger.debug("Inspect commit applied %d change(s): %s", len(applied_ids), applied_ids) + return result + + def rebase(self) -> "InspectTransaction": + """Re-fetch baselines for all staged entities, keeping the recorded intents. + + Use after ``InspectCommitConflictError`` to move the staged changes onto current server + state; then ``commit()`` again. Intents that themselves target a concurrently-changed field + will overwrite that change (last-writer-wins for the intent). + """ + self._ensure_open() + for entry in self._entries.values(): + if entry.remove or entry.is_new or entry.baseline_form is None: + continue + fresh = self._fetch_baseline(entry.kind, entry.entity_id) + entry.baseline_form = fresh + entry.baseline_dump = fresh.model_dump(mode="json") + return self + + def discard(self) -> None: + """Drop all staged changes; the transaction can no longer be committed.""" + self._entries.clear() + self._discarded = True + + # --- Internal: staging helpers --- + + def _ensure_open(self) -> None: + if self._committed: + raise RuntimeError("This transaction was already committed; open a new one.") + if self._discarded: + raise RuntimeError("This transaction was discarded; open a new one.") + + def _stage_device(self, device_id: str) -> _Staged: + return self._stage(_DEVICE, device_id) + + def _stage_vertex(self, vertex_id: str) -> _Staged: + return self._stage(_VERTEX, vertex_id) + + def _stage_edge(self, edge_id: str) -> _Staged: + return self._stage(_EDGE, edge_id) + + def _stage(self, kind: str, entity_id: str) -> _Staged: + self._ensure_open() + key = (kind, entity_id) + existing = self._entries.get(key) + if existing is not None and not existing.remove: + return existing + baseline = self._fetch_baseline(kind, entity_id) + entry = _Staged( + kind=kind, + entity_id=entity_id, + baseline_form=baseline, + baseline_dump=baseline.model_dump(mode="json"), + ) + self._entries[key] = entry + return entry + + def _stage_new_edge( + self, from_vertex: str, to_vertex: str, *, overwrite: bool, edge_fields: dict[str, Any] + ) -> None: + self._ensure_open() + edge_id = _edge_key(from_vertex, to_vertex) + existing = self._lookup_edge_form(edge_id) + if existing is not None and not overwrite: + raise ValueError( + f"Edge '{edge_id}' already exists; pass overwrite=True to replace it." + ) + form = existing.model_copy(deep=True) if existing is not None else InspectApiEdgeForm(fromId=from_vertex, toId=to_vertex) + form.fromId = from_vertex + form.toId = to_vertex + entry = _Staged( + kind=_EDGE, + entity_id=edge_id, + baseline_form=form, + baseline_dump=None if existing is None else existing.model_dump(mode="json"), + intents=dict(edge_fields), + is_new=existing is None, + ) + self._entries[(_EDGE, edge_id)] = entry + + # --- Internal: baselines --- + + def _fetch_baseline(self, kind: str, entity_id: str) -> Any: + if kind == _DEVICE: + return self._lookup_device_form(entity_id, required=True) + if kind == _VERTEX: + return self._lookup_vertex_form(entity_id, required=True) + form = self._lookup_edge_form(entity_id) + if form is None: + raise InspectEntityNotFoundError(entity_id, kind="edge") + return form + + def _lookup_device_form(self, device_id: str, required: bool) -> Optional[InspectApiLookupInspectDeviceFields]: + try: + response = self._api.lookup_inspect_device(device_id) + except Exception as exc: # connector-level miss + if required: + raise InspectEntityNotFoundError(device_id, kind="device") from exc + return None + return response.data.fields + + def _lookup_vertex_form(self, vertex_id: str, required: bool) -> Optional[InspectApiVertexEditForm]: + response = self._api.lookup_vertices([vertex_id]) + item = response.data.get(vertex_id) + if item is None: + if required: + raise InspectEntityNotFoundError(vertex_id, kind="vertex") + return None + return item.fields + + def _lookup_edge_form(self, edge_id: str) -> Optional[InspectApiEdgeForm]: + response = self._api.lookup_edges([edge_id]) + item = response.data.get(edge_id) + return item.edge if item is not None else None + + # --- Internal: conflict check (compare-and-commit, ADR-0009) --- + + def _check_conflicts(self) -> None: + current = self._refetch_baselines() + conflicts: list[InspectConflict] = [] + for entry in self._entries.values(): + if entry.remove or entry.is_new or entry.baseline_dump is None: + continue + key = (entry.kind, entry.entity_id) + server_form = current.get(key) + if server_form is None: + conflicts.append( + InspectConflict(entry.entity_id, entry.kind, {"__exists__": (True, False)}) + ) + continue + server_dump = server_form.model_dump(mode="json") + if server_dump != entry.baseline_dump: + diffs = _field_diffs(entry.baseline_dump, server_dump) + conflicts.append(InspectConflict(entry.entity_id, entry.kind, diffs)) + if conflicts: + raise InspectCommitConflictError(conflicts) + + def _refetch_baselines(self) -> dict[tuple[str, str], Any]: + """Batched re-fetch of every conflict-checkable staged entity's current server form.""" + vertex_ids = [e.entity_id for e in self._entries.values() if e.kind == _VERTEX and _checkable(e)] + edge_ids = [e.entity_id for e in self._entries.values() if e.kind == _EDGE and _checkable(e)] + device_ids = [e.entity_id for e in self._entries.values() if e.kind == _DEVICE and _checkable(e)] + + current: dict[tuple[str, str], Any] = {} + if vertex_ids: + data = self._api.lookup_vertices(vertex_ids).data + for vid in vertex_ids: + item = data.get(vid) + if item is not None: + current[(_VERTEX, vid)] = item.fields + if edge_ids: + data = self._api.lookup_edges(edge_ids).data + for eid in edge_ids: + item = data.get(eid) + if item is not None: + current[(_EDGE, eid)] = item.edge + for did in device_ids: + form = self._lookup_device_form(did, required=False) + if form is not None: + current[(_DEVICE, did)] = form + return current + + # --- Internal: payload build --- + + def _build_delta(self) -> InspectApiUpdateTopologyData: + delta = InspectApiUpdateTopologyData() + for entry in self._entries.values(): + if entry.remove: + delta.remove.append(entry.entity_id) + continue + form = entry.baseline_form.model_copy(deep=True) + _apply_intents(form, entry.intents) + if entry.kind == _DEVICE: + delta.replaceDevices[entry.entity_id] = form + elif entry.kind == _VERTEX: + delta.replaceVertices[entry.entity_id] = form + else: + delta.replaceEdges[entry.entity_id] = form + return delta + + # --- Internal: post-commit targeted refresh (ADR-0010) --- + + def _refresh_snapshot(self) -> None: + if self._snapshot is None: + return + removed_ids: list[str] = [] + device_ids: set[str] = set() + pair_ids: set[str] = set() + for entry in self._entries.values(): + if entry.remove: + removed_ids.append(entry.entity_id) + if entry.kind == _EDGE: + pair_ids.update(_pair_ids_for_edge(entry.entity_id)) + continue + if entry.kind == _DEVICE: + device_ids.add(entry.entity_id) + elif entry.kind == _VERTEX: + device_ids.add(_device_of(entry.entity_id)) + else: + pair_ids.update(_pair_ids_for_edge(entry.entity_id)) + self._snapshot.apply_post_commit( + removed_ids=removed_ids, + device_ids=list(device_ids), + pair_ids=list(pair_ids), + mark_paths_stale=True, + ) + + +def _checkable(entry: _Staged) -> bool: + return not entry.remove and not entry.is_new and entry.baseline_dump is not None + + +def _field_diffs(baseline: dict[str, Any], current: dict[str, Any]) -> dict[str, tuple[object, object]]: + diffs: dict[str, tuple[object, object]] = {} + for key in set(baseline) | set(current): + before = baseline.get(key) + after = current.get(key) + if before != after: + diffs[key] = (before, after) + return diffs + + +def _pair_ids_for_edge(edge_id: str) -> tuple[str, ...]: + """Both possible collector pair-key orderings for an edge (one is a no-op on refresh).""" + if "::" not in edge_id: + return () + from_id, to_id = edge_id.split("::", 1) + dev_a, dev_b = _device_of(from_id), _device_of(to_id) + if dev_a == dev_b: + return (f"{dev_a}::{dev_b}",) + return (f"{dev_a}::{dev_b}", f"{dev_b}::{dev_a}") + + +__all__ = ["InspectTransaction", "CommitResult"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index 3b49ce2..b395829 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -1,58 +1,75 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from typing import TYPE_CHECKING from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary -from videoipath_automation_tool.apps.inspect.snapshot import _DeviceRecord if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService - from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + from videoipath_automation_tool.apps.inspect.snapshot import _DeviceRecord, InspectSnapshot @dataclass(frozen=True, slots=True) class InspectDevice: + """A topology device/node. Skeleton fields (id, label, coordinates, status, sync, tags) are + available immediately; ``ports`` and ``services`` lazily hydrate from the server on first + access ([ADR-0007]). The record is resolved live from the snapshot, so a held reference sees + hydrated/refreshed data transparently.""" + snapshot: InspectSnapshot - record: _DeviceRecord + id: str - @property - def id(self) -> str: - return self.record.device_id + def _record(self) -> "_DeviceRecord": + record = self.snapshot.get_device_record(self.id) + if record is None: + raise KeyError(f"Device '{self.id}' is no longer present in the snapshot.") + return record @property def label(self) -> str | None: - return self.record.label + return self._record().label @property def pid(self) -> str | None: - return self.record.pid + return self._record().pid + + @property + def is_virtual(self) -> bool | None: + meta = self._record().node.meta + return meta.isVirtual if meta is not None else None + + @property + def icon_type(self) -> str | None: + meta = self._record().node.meta + return meta.iconType if meta is not None else None @property def status(self) -> InspectApiStatusSummary | None: - if self.record.node is None: - return None - return self.record.node.status + return self._record().node.status @property def sync_severity(self) -> int | str | None: - if self.record.node is None: - return None - return self.record.node.syncSeverity + return self._record().node.syncSeverity @property def tags(self) -> tuple[str, ...]: - if self.record.node is None: - return () - return tuple(self.record.node.tags) + return tuple(self._record().node.tags) @property def coordinates(self) -> dict[str, float | int | str | None] | None: - if self.record.node is None or self.record.node.meta is None: - return None - return self.record.node.meta.coordinates + return self._record().node.coordinates + + @property + def is_hydrated(self) -> bool: + return self.snapshot.is_device_hydrated(self.id) + + @property + def fetched_at(self) -> datetime | None: + return self.snapshot.fetched_at(self.id) @property def ports(self) -> list[InspectPort]: diff --git a/src/videoipath_automation_tool/apps/inspect/domain/edge.py b/src/videoipath_automation_tool/apps/inspect/domain/edge.py index 60d94cb..3186b37 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/edge.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/edge.py @@ -60,7 +60,9 @@ def max_bandwidth(self) -> float | int | None: @property def status(self) -> InspectApiExternalEdgeLiveStatus | None: - return self.indexed.edge.status + """Live status for this edge. In the lean skeleton only the pair-level status is present; + the per-edge status (per direction) appears in the full edge shape.""" + return self.indexed.edge.status or self.indexed.pair_status @property def services(self) -> list[InspectService]: diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py index 5120e6a..7260d59 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/port.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -27,7 +27,7 @@ def id(self) -> str | None: @property def label(self) -> str | None: - return self.indexed.port.label + return self.indexed.port.effective_label @property def device(self) -> InspectDevice | None: diff --git a/src/videoipath_automation_tool/apps/inspect/errors.py b/src/videoipath_automation_tool/apps/inspect/errors.py new file mode 100644 index 0000000..3ac0682 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/errors.py @@ -0,0 +1,103 @@ +"""Typed exceptions for the Inspect app. + +The Inspect surface has semantics that a raw HTTP envelope cannot express: +commit success is a three-flag check (see [ADR-0006]), concurrent writes are +detected client-side (see [ADR-0009]), and over-long projection URLs are +rejected by the proxy before they reach the server. These exceptions carry the +structured detail callers need to react without parsing raw responses. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.model.update_topology import ( + InspectApiUpdateTopologyResponse, + ) + + +class InspectError(Exception): + """Base class for all Inspect app errors.""" + + +class InspectEntityNotFoundError(InspectError): + """A device, vertex, or edge referenced by a read or write does not exist on the server.""" + + def __init__(self, entity_id: str, kind: str = "entity") -> None: + self.entity_id = entity_id + self.kind = kind + super().__init__(f"Inspect {kind} '{entity_id}' was not found on the server.") + + +class InspectQueryTooLongError(InspectError): + """A scoped collector query URL exceeds the server/proxy URI length limit (HTTP 414). + + The Inspect package trims its skeleton projections to stay within the limit; this is + raised only if a caller-built query is too long. Fall back to ``refresh(load="full")``. + """ + + def __init__(self, length: int, limit: int) -> None: + self.length = length + self.limit = limit + super().__init__( + f"Collector query URL is {length} characters, exceeding the {limit}-character limit. " + f"Use a shorter projection or 'app.inspect.refresh(load=\"full\")'." + ) + + +class InspectCommitError(InspectError): + """An ``updateTopology`` commit failed (validation gate or apply gate). + + HTTP/envelope success is not commit success: the server can return ``header.ok == true`` + while ``data.res.ok`` or ``data.validation.result.ok`` is ``false``. This carries the full + typed response so callers can inspect ``validation.details`` and ``res.msg``. + """ + + def __init__(self, response: "InspectApiUpdateTopologyResponse") -> None: + self.response = response + self.result = response.data.res + self.validation = response.data.validation + messages = list(self.result.msg) + list(self.validation.result.msg) + detail = "; ".join(m for m in messages if m) or "commit rejected by the server" + super().__init__(f"Inspect commit failed: {detail}") + + +class InspectConflict: + """One entity whose server state changed between staging and commit (see [ADR-0009]).""" + + def __init__(self, entity_id: str, kind: str, field_diffs: dict[str, tuple[object, object]]) -> None: + self.entity_id = entity_id + self.kind = kind + # field -> (staged_baseline_value, current_server_value) + self.field_diffs = field_diffs + + def __repr__(self) -> str: + return f"InspectConflict(entity_id={self.entity_id!r}, kind={self.kind!r}, fields={list(self.field_diffs)})" + + +class InspectCommitConflictError(InspectError): + """A concurrent modification was detected before the commit was sent; nothing was written. + + The commit is aborted as a whole (matching the server's all-or-nothing apply). Callers can + inspect ``conflicts``, then either ``transaction.rebase()`` onto fresh state and retry, or + re-commit with ``check_conflicts=False`` to force last-writer-wins. + """ + + def __init__(self, conflicts: list[InspectConflict]) -> None: + self.conflicts = conflicts + ids = ", ".join(c.entity_id for c in conflicts) + super().__init__( + f"Concurrent modification detected for {len(conflicts)} entity(ies) [{ids}]; commit aborted. " + f"Rebase the transaction or commit with check_conflicts=False to override." + ) + + +__all__ = [ + "InspectError", + "InspectEntityNotFoundError", + "InspectQueryTooLongError", + "InspectCommitError", + "InspectConflict", + "InspectCommitConflictError", +] diff --git a/src/videoipath_automation_tool/apps/inspect/inspect_api.py b/src/videoipath_automation_tool/apps/inspect/inspect_api.py new file mode 100644 index 0000000..eafed14 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/inspect_api.py @@ -0,0 +1,166 @@ +"""Raw Inspect API layer: one method per verified endpoint, typed responses, no business logic. + +All reads use the collector namespace only ([ADR-0008]); scoped queries come from +``queries.py`` ([ADR-0007]). Writes go through ``updateTopology`` and the ``addDevices`` / +``syncDevices`` network actions. +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from videoipath_automation_tool.apps.inspect import queries +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiAddDevicesItem, + InspectApiAddDevicesRequest, + InspectApiLookupEdgesRequest, + InspectApiLookupEdgesResponse, + InspectApiLookupInspectDeviceRequest, + InspectApiLookupInspectDeviceResponse, + InspectApiLookupSyncInfoRequest, + InspectApiLookupSyncInfoResponse, + InspectApiLookupVerticesRequest, + InspectApiLookupVerticesResponse, + InspectApiSyncDevicesRequest, + InspectApiSyncDevicesRequestData, +) +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiCollectorResponse, + InspectApiExternalEdgesByDeviceKeyItem, + InspectApiNodeStatusItem, + InspectApiPathItem, +) +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiSimpleActionResponse, +) +from videoipath_automation_tool.apps.inspect.model.update_topology import ( + InspectApiUpdateTopologyData, + InspectApiUpdateTopologyRequest, + InspectApiUpdateTopologyResponse, +) +from videoipath_automation_tool.connector.vip_connector import VideoIPathConnector +from videoipath_automation_tool.utils.cross_app_utils import create_fallback_logger + + +def _extract_items(data: dict[str, Any], *path: str) -> list[dict[str, Any]]: + """Walk ``data`` down ``path`` and return the ``_items`` list (empty if any node is absent).""" + node: Any = data + for key in path: + if not isinstance(node, dict): + return [] + node = node.get(key) + if node is None: + return [] + if isinstance(node, dict): + items = node.get("_items", []) + return items if isinstance(items, list) else [] + return [] + + +class InspectAPI: + def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging.Logger] = None): + self._logger = logger or create_fallback_logger("videoipath_automation_tool_inspect_api") + self.vip_connector = vip_connector + self._logger.debug("Inspect API initialized.") + + # --- Collector reads (scoped, ADR-0007) --- + + def get_device_skeleton(self) -> list[InspectApiNodeStatusItem]: + """All devices without module/port detail (skeleton load).""" + response = self.vip_connector.rest.get(queries.device_skeleton(), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "inspect", "nodeStatus") + return [InspectApiNodeStatusItem.model_validate(item) for item in items] + + def get_device_detail(self, device_id: str) -> Optional[InspectApiNodeStatusItem]: + """One device's full nodeStatus sub-tree (lazy hydration).""" + response = self.vip_connector.rest.get(queries.device_detail(device_id), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "inspect", "nodeStatus") + if not items: + return None + return InspectApiNodeStatusItem.model_validate(items[0]) + + def get_edge_skeleton(self) -> list[InspectApiExternalEdgesByDeviceKeyItem]: + """All external-edge device pairs, lean projection.""" + response = self.vip_connector.rest.get(queries.edge_skeleton(), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "externalEdgesByDeviceKey") + return [InspectApiExternalEdgesByDeviceKeyItem.model_validate(item) for item in items] + + def get_edge_pair(self, pair_id: str) -> Optional[InspectApiExternalEdgesByDeviceKeyItem]: + """A single external-edge device pair (targeted refresh, ADR-0010).""" + response = self.vip_connector.rest.get(queries.edge_pair(pair_id), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "externalEdgesByDeviceKey") + if not items: + return None + return InspectApiExternalEdgesByDeviceKeyItem.model_validate(items[0]) + + def get_paths_section(self) -> list[InspectApiPathItem]: + """The services/paths section.""" + response = self.vip_connector.rest.get(queries.paths_section(), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "inspect", "paths") + return [InspectApiPathItem.model_validate(item) for item in items] + + def get_collector_full(self) -> InspectApiCollectorResponse: + """The full collector aggregate (eager / fallback mode).""" + response = self.vip_connector.rest.get(queries.collector_full(), allow_projection=True) + return InspectApiCollectorResponse.model_validate({"data": response.data, "header": _header_dict(response)}) + + # --- Lookups (baselines for compare-and-commit, ADR-0009) --- + + def lookup_inspect_device(self, device_id: str) -> InspectApiLookupInspectDeviceResponse: + request = InspectApiLookupInspectDeviceRequest(data=device_id) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/lookupInspectDevice", request) + return InspectApiLookupInspectDeviceResponse.model_validate(_post_envelope(response)) + + def lookup_vertices(self, vertex_ids: list[str]) -> InspectApiLookupVerticesResponse: + request = InspectApiLookupVerticesRequest(data=vertex_ids) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/lookupInspectVertexByIds", request) + return InspectApiLookupVerticesResponse.model_validate(_post_envelope(response)) + + def lookup_edges(self, edge_ids: list[str]) -> InspectApiLookupEdgesResponse: + request = InspectApiLookupEdgesRequest(data=edge_ids) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/lookupInspectEdgesByIds", request) + return InspectApiLookupEdgesResponse.model_validate(_post_envelope(response)) + + def lookup_sync_info(self, device_ids: list[str]) -> InspectApiLookupSyncInfoResponse: + request = InspectApiLookupSyncInfoRequest(data=device_ids) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/lookupSyncInfo", request) + return InspectApiLookupSyncInfoResponse.model_validate(_post_envelope(response)) + + # --- Writes --- + + def update_topology(self, delta: InspectApiUpdateTopologyData) -> InspectApiUpdateTopologyResponse: + request = InspectApiUpdateTopologyRequest(data=delta) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/updateTopology", request) + return InspectApiUpdateTopologyResponse.model_validate(_post_envelope(response)) + + def add_devices(self, items: list[InspectApiAddDevicesItem]) -> InspectApiSimpleActionResponse: + request = InspectApiAddDevicesRequest(data=items) + response = self.vip_connector.rest.post("/rest/v2/actions/status/network/addDevices", request) + return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) + + def sync_devices( + self, device_ids: list[str], add_only: bool = True, conflict_strategy: int = 0 + ) -> InspectApiSimpleActionResponse: + request = InspectApiSyncDevicesRequest( + data=InspectApiSyncDevicesRequestData( + ids=device_ids, addOnly=add_only, conflictStrategy=conflict_strategy + ) + ) + response = self.vip_connector.rest.post("/rest/v2/actions/status/network/syncDevices", request) + return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) + + +def _header_dict(response: Any) -> dict[str, Any]: + header = getattr(response, "header", None) + if header is None: + return {} + return header.model_dump(mode="json") if hasattr(header, "model_dump") else dict(header) + + +def _post_envelope(response: Any) -> dict[str, Any]: + """Reassemble a ``{data, header}`` dict from a ResponseV2Post for DTO validation.""" + return {"data": response.data, "header": _header_dict(response)} + + +__all__ = ["InspectAPI"] diff --git a/src/videoipath_automation_tool/apps/inspect/inspect_app.py b/src/videoipath_automation_tool/apps/inspect/inspect_app.py new file mode 100644 index 0000000..00cc0b6 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/inspect_app.py @@ -0,0 +1,70 @@ +"""InspectApp — the user-facing entry point for the VideoIPath Inspect surface. + +Read-only monitoring plus commit-style topology writes, built entirely on the collector API +([ADR-0008]). Composed from focused mixins, mirroring the Inventory/Topology app layout. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from videoipath_automation_tool.apps.inspect.app.actions import InspectActionsMixin +from videoipath_automation_tool.apps.inspect.app.read import InspectReadMixin, LoadMode +from videoipath_automation_tool.apps.inspect.app.write import InspectWriteMixin +from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot +from videoipath_automation_tool.connector.vip_connector import VideoIPathConnector + +# First VideoIPath version the Inspect collector surface was verified against. +_MIN_VERIFIED_VERSION = (2025, 4) + + +class InspectApp(InspectReadMixin, InspectWriteMixin, InspectActionsMixin): + def __init__( + self, + vip_connector: VideoIPathConnector, + logger: Optional[logging.Logger] = None, + load: LoadMode = "skeleton", + ): + """Inspect App: read the topology/status and apply commit-style topology changes. + + The app keeps a single internal topology view that is loaded lazily on the first read and + kept up to date across writes; interact with it entirely through this app (``app.inspect``), + the same way as the other apps. Call :meth:`refresh` to reload it from the server. + + Args: + vip_connector (VideoIPathConnector): connector handling the VideoIPath connection. + logger (Optional[logging.Logger]): logger instance. + load (LoadMode): how the internal view is loaded — ``"skeleton"`` (default; fast, with + lazy per-device detail) or ``"full"`` (eager, point-in-time). + """ + self._logger = logger or logging.getLogger("videoipath_automation_tool_inspect_app") + self._inspect_api = InspectAPI(vip_connector=vip_connector, logger=self._logger) + self._vip_connector = vip_connector + self._load_mode: LoadMode = load + self._snapshot: Optional[InspectSnapshot] = None + self._warn_if_version_unverified() + self._logger.debug("Inspect APP initialized.") + + def _warn_if_version_unverified(self) -> None: + version = self._vip_connector.videoipath_version + parsed = _parse_version(version) + if parsed is not None and parsed < _MIN_VERIFIED_VERSION: + self._logger.warning( + f"Inspect app: VideoIPath version '{version}' predates the first verified Inspect " + f"surface ({_MIN_VERIFIED_VERSION[0]}.{_MIN_VERIFIED_VERSION[1]}). Behaviour is unverified." + ) + + +def _parse_version(version: str) -> Optional[tuple[int, int]]: + parts = version.split(".") + if len(parts) < 2: + return None + try: + return int(parts[0]), int(parts[1]) + except ValueError: + return None + + +__all__ = ["InspectApp"] diff --git a/src/videoipath_automation_tool/apps/inspect/model/actions.py b/src/videoipath_automation_tool/apps/inspect/model/actions.py index e0f329a..5b351be 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/actions.py +++ b/src/videoipath_automation_tool/apps/inspect/model/actions.py @@ -46,6 +46,116 @@ class InspectApiLookupInspectDeviceResponse(InspectApiBaseModel): header: InspectApiRestV2Header +# --- Vertex lookup / edit form (also the replaceVertices write shape, verified 2025.4.9) --- + + +class InspectApiVertexTypeFields(InspectApiBaseModel): + ipAddress: str | None = None + ipNetmask: str | None = None + public: bool | None = None + supportsCpipeCfg: bool | None = None + supportsIgmpCfg: bool | None = None + supportsMacForwardingCfg: bool | None = None + supportsNsoCfg: bool | None = None + supportsOpenflowCfg: bool | None = None + supportsStaticIgmpCfg: bool | None = None + supportsVlanCfg: bool | None = None + supportsVplsCfg: bool | None = None + type: str | None = None + vlanId: str | None = None + vrfId: str | None = None + + +class InspectApiVertexEditForm(InspectApiBaseModel): + """The vertex ``fields`` object returned by ``lookupInspectVertexById`` — this exact shape is + what ``replaceVertices`` accepts (update-only; verified 2025.4.9).""" + + active: bool | None = None + controlProps: Any | None = None + custom: dict[str, Any] = Field(default_factory=dict) + desc: str = "" + destinationMonitorLeader: bool | None = None + extraAlertFilters: list[Any] = Field(default_factory=list) + label: str = "" + localAssignedTags: list[str] = Field(default_factory=list) + queueable: bool | None = None + sipsMode: str | None = None + tags: list[str] = Field(default_factory=list) + typeFields: InspectApiVertexTypeFields | None = None + useAsEndpoint: bool | None = None + + +class InspectApiLookupVertexRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: str + + +class InspectApiLookupVerticesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: list[str] + + +class InspectApiLookupVertexResponseData(InspectApiBaseModel): + assignedTags: InspectApiAssignedTags | None = None + context: dict[str, Any] | None = None + customSchemas: dict[str, Any] = Field(default_factory=dict) + fields: InspectApiVertexEditForm + id: str + isVirtual: bool | None = None + vertexType: str | None = None + + +class InspectApiLookupVertexResponse(InspectApiBaseModel): + data: InspectApiLookupVertexResponseData + header: InspectApiRestV2Header + + +class InspectApiLookupVerticesResponse(InspectApiBaseModel): + data: dict[str, InspectApiLookupVertexResponseData] + header: InspectApiRestV2Header + + +# --- Edge lookup (also the replaceEdges write shape, verified 2025.4.9) --- + + +class InspectApiEdgeForm(InspectApiBaseModel): + """The persisted edge object returned by ``lookupInspectEdgesByIds`` — this exact shape is what + ``replaceEdges`` accepts (verified 2025.4.9). No ``_id`` / ``_rev`` / ``type`` in the write form.""" + + active: bool = True + bandwidth: float | int = -1.0 + capacity: int = 65535 + conflictPri: int | str = 0 + descriptor: InspectApiDescriptor = Field(default_factory=InspectApiDescriptor) + excludeFormats: list[str] = Field(default_factory=list) + fDescriptor: InspectApiDescriptor = Field(default_factory=InspectApiDescriptor) + fromId: str + includeFormats: list[str] = Field(default_factory=list) + redundancyMode: str = "Any" + tags: list[str] = Field(default_factory=list) + toId: str + weight: int = 1 + weightFactors: dict[str, Any] = Field( + default_factory=lambda: {"bandwidth": {"weight": 0}, "service": {"max": 100, "weight": 0}} + ) + + +class InspectApiLookupEdgesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: list[str] + + +class InspectApiLookupEdgeResponseItem(InspectApiBaseModel): + edge: InspectApiEdgeForm + fromDevice: str | None = None + toDevice: str | None = None + + +class InspectApiLookupEdgesResponse(InspectApiBaseModel): + data: dict[str, InspectApiLookupEdgeResponseItem] + header: InspectApiRestV2Header + + class InspectApiLookupSyncInfoRequest(InspectApiBaseModel): header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) data: list[str] @@ -90,6 +200,10 @@ class InspectApiSyncDevicesRequest(InspectApiBaseModel): "InspectApiAddDevicesItem", "InspectApiAddDevicesRequest", "InspectApiAssignedTags", + "InspectApiEdgeForm", + "InspectApiLookupEdgeResponseItem", + "InspectApiLookupEdgesRequest", + "InspectApiLookupEdgesResponse", "InspectApiLookupInspectDeviceFields", "InspectApiLookupInspectDeviceRequest", "InspectApiLookupInspectDeviceResponse", @@ -97,6 +211,13 @@ class InspectApiSyncDevicesRequest(InspectApiBaseModel): "InspectApiLookupSyncInfoItem", "InspectApiLookupSyncInfoRequest", "InspectApiLookupSyncInfoResponse", + "InspectApiLookupVerticesRequest", + "InspectApiLookupVerticesResponse", + "InspectApiLookupVertexRequest", + "InspectApiLookupVertexResponse", + "InspectApiLookupVertexResponseData", "InspectApiSyncDevicesRequest", "InspectApiSyncDevicesRequestData", + "InspectApiVertexEditForm", + "InspectApiVertexTypeFields", ] diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py index 1939fa7..ea79520 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/collector.py +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -10,6 +10,7 @@ InspectApiDescriptor, InspectApiEndpointStatus, InspectApiRestV2Header, + InspectApiStatusContext, InspectServiceStatus, InspectApiStatusSummary, ) @@ -66,6 +67,14 @@ class InspectApiPathItem(InspectApiBaseModel): class InspectApiNodeMeta(InspectApiBaseModel): coordinates: dict[str, float | int | str | None] | None = None + hwPanelType: str | None = None + iconSize: str | None = None + iconType: str | None = None + isCore: bool | None = None + isVirtual: bool | None = None + sdpStrategy: str | None = None + siteId: str | None = None + tags: list[str] = Field(default_factory=list) class InspectApiVertexInfoFields(InspectApiBaseModel): @@ -102,16 +111,27 @@ class InspectApiPathDescriptionItem(InspectApiBaseModel): class InspectPortStatus(InspectApiBaseModel): id: str | None = Field(default=None, alias="_id") vid: str | None = Field(default=None, alias="_vid") + context: InspectApiStatusContext | None = None + descriptor: InspectApiDescriptor | None = None label: str | None = None pid: str | None = None + resourceId: str | None = None status: InspectApiStatusSummary | None = None vertexInfo: InspectApiSingleVertexInfo | InspectApiDoubleVertexInfo | dict[str, Any] | None = None pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) + @property + def effective_label(self) -> str | None: + if self.descriptor is not None and self.descriptor.label: + return self.descriptor.label + return self.label + class InspectApiModuleStatus(InspectApiBaseModel): id: str | None = Field(default=None, alias="_id") vid: str | None = Field(default=None, alias="_vid") + context: InspectApiStatusContext | None = None + descriptor: InspectApiDescriptor | None = None label: str | None = None pid: str | None = None ports: dict[str, InspectPortStatus] | list[InspectPortStatus] = Field(default_factory=dict) @@ -121,7 +141,10 @@ class InspectApiModuleStatus(InspectApiBaseModel): class InspectApiNodeStatusItem(InspectApiBaseModel): id: str = Field(alias="_id") vid: str | None = Field(default=None, alias="_vid") + context: InspectApiStatusContext | None = None + descriptor: InspectApiDescriptor | None = None deviceId: str | None = None + fDescriptor: InspectApiDescriptor | None = None hasEndpoints: bool | None = None label: str | None = None meta: InspectApiNodeMeta | None = None @@ -129,9 +152,26 @@ class InspectApiNodeStatusItem(InspectApiBaseModel): pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) pid: str | None = None ptpDeviceStatus: dict[str, Any] | None = None + relatedNodeTags: list[str] = Field(default_factory=list) + resourceId: str | None = None status: InspectApiStatusSummary | None = None syncSeverity: int | str | None = None tags: list[str] = Field(default_factory=list) + tagsInfo: dict[str, Any] | None = None + + @property + def effective_label(self) -> str | None: + """The label the UI shows: user ``descriptor.label``, falling back to the device-reported + ``fDescriptor.label`` (and finally the legacy top-level ``label`` field, if present).""" + if self.descriptor is not None and self.descriptor.label: + return self.descriptor.label + if self.fDescriptor is not None and self.fDescriptor.label: + return self.fDescriptor.label + return self.label + + @property + def coordinates(self) -> dict[str, float | int | str | None] | None: + return self.meta.coordinates if self.meta is not None else None class InspectApiExternalEdgeLiveStatus(InspectApiBaseModel): diff --git a/src/videoipath_automation_tool/apps/inspect/model/update_topology.py b/src/videoipath_automation_tool/apps/inspect/model/update_topology.py index f15af97..f1aa9c9 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/update_topology.py +++ b/src/videoipath_automation_tool/apps/inspect/model/update_topology.py @@ -4,32 +4,39 @@ from pydantic import Field +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiEdgeForm, + InspectApiLookupInspectDeviceFields, + InspectApiVertexEditForm, +) from videoipath_automation_tool.apps.inspect.model.common import ( InspectApiBaseModel, InspectApiPostRequestHeader, InspectApiRestV2Header, ) from videoipath_automation_tool.apps.inspect.model.ngraph import ( - InspectApiBaseDevice, - InspectApiCodecVertex, - InspectApiGenericVertex, - InspectApiIpVertex, InspectApiNGraphResourceTransform, - InspectApiUnidirectionalEdge, ) -InspectApiReplaceVertex = InspectApiIpVertex | InspectApiCodecVertex | InspectApiGenericVertex | dict[str, Any] -InspectApiReplaceDevice = InspectApiBaseDevice | dict[str, Any] +# The verified per-kind write shapes (2025.4.9): +# - replaceDevices takes the device *edit form* (lookupInspectDevice.fields); the raw baseDevice +# element is rejected (coordinates + localAssignedTags are mandatory). +# - replaceVertices takes the vertex *edit form* (lookupInspectVertexById.fields); update-only. +# - replaceEdges takes the raw persisted edge form (lookupInspectEdgesByIds). +# dict fallbacks keep the models permissive for hand-built payloads. +InspectApiReplaceDevice = InspectApiLookupInspectDeviceFields | dict[str, Any] +InspectApiReplaceVertex = InspectApiVertexEditForm | dict[str, Any] +InspectApiReplaceEdge = InspectApiEdgeForm | dict[str, Any] InspectApiReplaceResourceTransform = InspectApiNGraphResourceTransform | dict[str, Any] class InspectApiUpdateTopologyData(InspectApiBaseModel): replaceDevices: dict[str, InspectApiReplaceDevice] = Field(default_factory=dict) replaceVertices: dict[str, InspectApiReplaceVertex] = Field(default_factory=dict) - replaceEdges: dict[str, InspectApiUnidirectionalEdge] = Field(default_factory=dict) + replaceEdges: dict[str, InspectApiReplaceEdge] = Field(default_factory=dict) replaceResourceTransforms: dict[str, InspectApiReplaceResourceTransform] = Field(default_factory=dict) - addExternalEdges: list[InspectApiUnidirectionalEdge] = Field(default_factory=list) + addExternalEdges: list[InspectApiEdgeForm | dict[str, Any]] = Field(default_factory=list) remove: list[str] = Field(default_factory=list) force: bool = False @@ -76,6 +83,7 @@ def committed(self) -> bool: __all__ = [ "InspectApiReplaceDevice", + "InspectApiReplaceEdge", "InspectApiReplaceResourceTransform", "InspectApiReplaceVertex", "InspectApiUpdateTopologyData", diff --git a/src/videoipath_automation_tool/apps/inspect/queries.py b/src/videoipath_automation_tool/apps/inspect/queries.py new file mode 100644 index 0000000..439048a --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/queries.py @@ -0,0 +1,131 @@ +"""Scoped collector query catalogue for the Inspect app. + +Every query string here is **verified live against VideoIPath 2025.4.9** and is the +concrete basis for the skeleton-first + lazy-hydration loading model ([ADR-0007]). +The Inspect UI issues the same paths over WebSocket subscriptions; the package +issues them as REST GETs (see docs/inspect-app/endpoints.md). + +Projection grammar used below (verified): +- ``*`` select all items of a collection +- ``"_noId"`` suppress a sub-tree (returns ``{}`` / omits the key) +- ``field/**`` select a field's full sub-tree +- ``a,b`` select several leaf fields at the current level +- ``/.../`` pop one level back up in the projection tree + +The full UI projection is too long for a REST GET (HTTP 414); the skeleton +projections here are deliberately trimmed to stay well within the URI limit. +""" + +from __future__ import annotations + +import urllib.parse + +from videoipath_automation_tool.apps.inspect.errors import InspectQueryTooLongError + +# Base for all collector data reads. +_DATA = "/rest/v2/data" + +# Conservative URI length ceiling (path only). Verified queries are ~200-370 chars; +# the full UI projection (thousands of chars) triggers HTTP 414 behind the proxy. +MAX_QUERY_LENGTH = 4000 + +# Characters that are meaningful in the projection grammar and must survive encoding. +# Everything else (spaces, double quotes, ...) is percent-encoded. +_SAFE = "/*,'=()" + + +# --- Verified query fragments (path relative to /rest/v2/data) --- + +# Device skeleton: identity + descriptor + meta (incl. coordinates) + status + syncSeverity +# + tags, with the module sub-tree suppressed ("_noId"). ~200 char URL, ~30 KB / 30 devices. +_DEVICE_SKELETON = ( + "/status/collector/inspect/nodeStatus/*" + "/deviceId,resourceId,syncSeverity" + '/.../descriptor/**' + "/.../.../meta/**" + "/.../.../status/**" + "/.../.../tags/*" + '/.../.../modules/"_noId"' +) + +# Edge skeleton (lean): device-pair keys, edge ids, endpoint port context+labels, and the +# pair-level status severities. No pathDescriptions, no bandwidth values. ~370 char URL. +_EDGE_LEAN_TAIL = ( + "/primary,secondary/devicePid,label" + "/.../.../status/alarm,bandwidth,maintenance,ptp" + "/.../.../primary/data/*/id" + "/.../fromStatus,toStatus/label" + "/.../context/devicePid,modulePid,portPid" + "/.../.../.../.../.../.../secondary/data/*/id" + "/.../fromStatus,toStatus/label" + "/.../context/devicePid,modulePid,portPid" +) +_EDGE_SKELETON = "/status/collector/externalEdgesByDeviceKey/*" + _EDGE_LEAN_TAIL + +# Services / paths section: serviceFields (endpoints, labels, status) + per-hop path structure. +_PATHS_SECTION = ( + "/status/collector/inspect/paths/*" + "/serviceFields/bid,from,fromLabel,isMain,to,toLabel" + "/.../generic/descriptor/**" + "/.../.../serviceStatus/**" + "/.../.../.../path/*/bid,ipDesc" + "/.../structure/deviceId,deviceLabel,devicePid" + "/.../inputStatus,outputStatus/label,pid" +) + +# Full aggregate (eager / fallback mode). +_COLLECTOR_FULL = "/status/collector/**" + + +def encode(path: str) -> str: + """Percent-encode a collector query path, preserving projection-grammar characters.""" + return urllib.parse.quote(path, safe=_SAFE) + + +def _build(path: str) -> str: + encoded = encode(_DATA + path) + if len(encoded) > MAX_QUERY_LENGTH: + raise InspectQueryTooLongError(len(encoded), MAX_QUERY_LENGTH) + return encoded + + +def device_skeleton() -> str: + """GET path for the device skeleton (all devices, no module/port detail).""" + return _build(_DEVICE_SKELETON) + + +def device_detail(device_id: str) -> str: + """GET path for one device's full nodeStatus sub-tree (modules, ports, vertexInfo, ...).""" + return _build(f"/status/collector/inspect/nodeStatus/{device_id}/**") + + +def edge_skeleton() -> str: + """GET path for the lean edge skeleton (all device pairs, connectivity + status severities).""" + return _build(_EDGE_SKELETON) + + +def edge_pair(pair_id: str) -> str: + """GET path for a single external-edge device pair (targeted refresh, [ADR-0010]).""" + return _build(f"/status/collector/externalEdgesByDeviceKey/{pair_id}" + _EDGE_LEAN_TAIL) + + +def paths_section() -> str: + """GET path for the services/paths section.""" + return _build(_PATHS_SECTION) + + +def collector_full() -> str: + """GET path for the full collector aggregate (eager / fallback mode).""" + return _build(_COLLECTOR_FULL) + + +__all__ = [ + "MAX_QUERY_LENGTH", + "encode", + "device_skeleton", + "device_detail", + "edge_skeleton", + "edge_pair", + "paths_section", + "collector_full", +] diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index b685d5c..07e78bc 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -1,10 +1,29 @@ +"""InspectSnapshot: skeleton-first, lazily-hydrated, accreting read state ([ADR-0007]). + +A snapshot is built from two scoped skeleton reads (all devices without module/port detail, all +external-edge pairs). Detail is hydrated on demand — the first access to a device's ports fetches +that one device's full nodeStatus sub-tree and merges it into the same internal indexes; services +load once as a section. The snapshot is never a single point in time: each device and section +carries its own fetch timestamp. ``refresh()`` builds a *new* snapshot; state is never reused +across snapshots. + +After a successful commit the change set calls the post-commit hooks here to update only the +touched entities via targeted scoped re-reads ([ADR-0010]) instead of a full reload. +""" + from __future__ import annotations -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Iterator +import threading +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import TYPE_CHECKING, Any, Iterator, Optional from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiCollectorResponse, + InspectApiExternalEdgeLiveStatus, + InspectApiExternalEdgesByDeviceKeyItem, InspectApiExternalEdgeStatus, InspectApiModuleStatus, InspectApiNodeStatusItem, @@ -17,14 +36,34 @@ from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService + from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +_PRELOAD_WORKERS = 8 -@dataclass(frozen=True, slots=True) + +class HydrationLevel(str, Enum): + SKELETON = "skeleton" + FULL = "full" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass class _DeviceRecord: device_id: str - label: str | None - pid: str | None - node: InspectApiNodeStatusItem | None + node: InspectApiNodeStatusItem + level: HydrationLevel + fetched_at: datetime = field(default_factory=_now) + + @property + def label(self) -> str | None: + return self.node.effective_label + + @property + def pid(self) -> str | None: + return self.node.pid or self.node.deviceId @dataclass(frozen=True, slots=True) @@ -39,6 +78,7 @@ class _IndexedEdge: edge_id: str pair_id: str edge: InspectApiExternalEdgeStatus + pair_status: InspectApiExternalEdgeLiveStatus | None primary_device_id: str | None secondary_device_id: str | None from_device_id: str | None @@ -48,175 +88,486 @@ class _IndexedEdge: class InspectSnapshot: - def __init__(self, response: InspectApiCollectorResponse) -> None: - self._response = response - collector = response.data.status.collector - - self._node_status_items = collector.inspect.node_status_items - self._path_items = collector.inspect.path_items - self._external_edge_items = collector.external_edges_by_device_key_items - + def __init__( + self, + fetcher: Optional["InspectAPI"] = None, + device_items: Optional[list[InspectApiNodeStatusItem]] = None, + edge_items: Optional[list[InspectApiExternalEdgesByDeviceKeyItem]] = None, + *, + device_level: HydrationLevel = HydrationLevel.SKELETON, + path_items: Optional[list[InspectApiPathItem]] = None, + ) -> None: + self._fetcher = fetcher + self._lock = threading.RLock() + self._created_at = _now() + + # Core indexes self._devices_by_id: dict[str, _DeviceRecord] = {} self._devices_by_label: dict[str, list[str]] = {} - self._paths_by_booking_id: dict[str, InspectApiPathItem] = {} - self._services_by_device_id: dict[str, list[str]] = {} + self._edge_pairs: dict[str, InspectApiExternalEdgesByDeviceKeyItem] = {} + self._edges_by_device_id: dict[str, list[_IndexedEdge]] = {} + self._edge_by_port_key: dict[tuple[str, str], _IndexedEdge] = {} + + # Per-device port indexes (populated on hydration) self._ports_by_device_id: dict[str, list[_IndexedPort]] = {} self._port_by_key: dict[tuple[str, str], _IndexedPort] = {} self._ports_by_pid: dict[str, list[_IndexedPort]] = {} - self._edges_by_device_id: dict[str, list[_IndexedEdge]] = {} - self._edge_by_port_key: dict[tuple[str, str], _IndexedEdge] = {} - self._device_cache: dict[str, InspectDevice] = {} - self._port_cache: dict[tuple[str, str], InspectPort] = {} - self._edge_cache: dict[str, InspectEdge] = {} - self._service_cache: dict[str, InspectService] = {} - self._ports_for_device_cache: dict[str, list[InspectPort]] = {} - self._edges_for_device_cache: dict[str, list[InspectEdge]] = {} - self._services_for_device_cache: dict[str, list[InspectService]] = {} + # Section: services / paths + self._paths_by_booking_id: dict[str, InspectApiPathItem] = {} + self._services_by_device_id: dict[str, list[str]] = {} + self._section_loaded: dict[str, bool] = {"paths": False} + self._section_fetched_at: dict[str, datetime] = {} + + # Domain-object caches + self._device_cache: dict[str, "InspectDevice"] = {} + self._edge_cache: dict[str, "InspectEdge"] = {} + self._service_cache: dict[str, "InspectService"] = {} - self._build_device_indexes() - self._build_path_indexes() - self._build_port_indexes() - self._build_edge_indexes() + for node in device_items or []: + self._index_device(node, device_level) + for pair in edge_items or []: + self._index_edge_pair(pair) + if path_items is not None: + self._index_paths(path_items) + self._section_loaded["paths"] = True + self._section_fetched_at["paths"] = self._created_at - @property - def raw_response(self) -> InspectApiCollectorResponse: - return self._response + # --- Construction --- @classmethod - def from_response(cls, response: InspectApiCollectorResponse) -> InspectSnapshot: - return cls(response) + def from_full_response( + cls, response: InspectApiCollectorResponse, fetcher: Optional["InspectAPI"] = None + ) -> "InspectSnapshot": + """Build a fully-hydrated snapshot from one full collector aggregate (eager / fallback mode).""" + collector = response.data.status.collector + return cls( + fetcher=fetcher, + device_items=collector.inspect.node_status_items, + edge_items=collector.external_edges_by_device_key_items, + device_level=HydrationLevel.FULL, + path_items=collector.inspect.path_items, + ) + + # Backwards-compatible alias for the original draft API. + from_response = from_full_response - def get_device_by_id(self, device_id: str) -> InspectDevice | None: + # --- Freshness / introspection --- + + @property + def created_at(self) -> datetime: + return self._created_at + + def fetched_at(self, device_id: str) -> datetime | None: record = self._devices_by_id.get(device_id) - if record is None: - return None - return self._wrap_device(record) - - def find_devices_by_name(self, label: str) -> list[InspectDevice]: - devices: list[InspectDevice] = [] - for device_id in self._devices_by_label.get(label, []): - device = self.get_device_by_id(device_id) - if device is not None: - devices.append(device) - return devices - - def get_devices(self) -> list[InspectDevice]: - return [self._wrap_device(record) for record in self._devices_by_id.values()] - - def get_service_by_booking_id(self, booking_id: str) -> InspectService | None: - path_item = self._paths_by_booking_id.get(booking_id) - if path_item is None: - return None - return self._wrap_service(path_item) + return record.fetched_at if record else None - def get_services(self) -> list[InspectService]: - return [self._wrap_service(item) for item in self._path_items] + def section_fetched_at(self, section: str = "paths") -> datetime | None: + return self._section_fetched_at.get(section) - def get_port(self, device_id: str, port_id: str) -> InspectPort | None: - indexed = self._port_by_key.get((device_id, port_id)) - if indexed is None: - return None - return self._wrap_port(indexed) + def is_device_hydrated(self, device_id: str) -> bool: + record = self._devices_by_id.get(device_id) + return record is not None and record.level is HydrationLevel.FULL + + # --- Device reads --- - def find_port_by_id(self, port_id: str) -> InspectPort | None: - indexed_ports = self._ports_by_pid.get(port_id) - if not indexed_ports: + def get_device(self, device_id: str) -> Optional["InspectDevice"]: + if device_id not in self._devices_by_id: return None - return self._wrap_port(indexed_ports[0]) + return self._wrap_device(device_id) - def get_ports_for_device(self, device_id: str) -> list[InspectPort]: - cached = self._ports_for_device_cache.get(device_id) - if cached is not None: - return cached - ports = [self._wrap_port(indexed) for indexed in self._ports_by_device_id.get(device_id, [])] - self._ports_for_device_cache[device_id] = ports - return ports + # Backwards-compatible alias. + get_device_by_id = get_device - def get_edge_for_port(self, device_id: str, port_id: str) -> InspectEdge | None: - indexed = self._edge_by_port_key.get((device_id, port_id)) - if indexed is None: - return None - return self._wrap_edge(indexed) + def find_device_by_label(self, label: str) -> Optional["InspectDevice"]: + ids = self._devices_by_label.get(label, []) + return self._wrap_device(ids[0]) if ids else None + + def find_devices_by_label(self, label: str) -> list["InspectDevice"]: + return [self._wrap_device(device_id) for device_id in self._devices_by_label.get(label, [])] + + # Backwards-compatible alias. + find_devices_by_name = find_devices_by_label + + @property + def devices(self) -> list["InspectDevice"]: + return [self._wrap_device(device_id) for device_id in self._devices_by_id] + + def get_devices(self, detail: bool = False) -> list["InspectDevice"]: + if detail: + self.preload() + return self.devices + + def get_device_record(self, device_id: str) -> Optional[_DeviceRecord]: + """Internal: return the (possibly hydrated) record for a device; used by domain objects.""" + return self._devices_by_id.get(device_id) + + # --- Port reads (trigger hydration) --- + + def get_ports_for_device(self, device_id: str) -> list["InspectPort"]: + self._ensure_device_detail(device_id) + return [self._wrap_port(indexed) for indexed in self._ports_by_device_id.get(device_id, [])] + + def get_port(self, device_id: str, port_id: str) -> Optional["InspectPort"]: + self._ensure_device_detail(device_id) + indexed = self._port_by_key.get((device_id, port_id)) + return self._wrap_port(indexed) if indexed else None + + def find_port_by_id(self, port_id: str) -> Optional["InspectPort"]: + indexed = self._ports_by_pid.get(port_id) + return self._wrap_port(indexed[0]) if indexed else None + + # --- Edge reads (no hydration) --- - def get_edges(self) -> list[InspectEdge]: + @property + def edges(self) -> list["InspectEdge"]: seen: set[str] = set() - edges: list[InspectEdge] = [] + result: list["InspectEdge"] = [] for indexed_edges in self._edges_by_device_id.values(): for indexed in indexed_edges: if indexed.edge_id in seen: continue seen.add(indexed.edge_id) - edges.append(self._wrap_edge(indexed)) - return edges + result.append(self._wrap_edge(indexed)) + return result - def get_edges_for_device(self, device_id: str) -> list[InspectEdge]: - cached = self._edges_for_device_cache.get(device_id) - if cached is not None: - return cached + def get_edges(self) -> list["InspectEdge"]: + return self.edges + + def get_edges_for_device(self, device_id: str) -> list["InspectEdge"]: seen: set[str] = set() - edges: list[InspectEdge] = [] + result: list["InspectEdge"] = [] for indexed in self._edges_by_device_id.get(device_id, []): if indexed.edge_id in seen: continue seen.add(indexed.edge_id) - edges.append(self._wrap_edge(indexed)) - self._edges_for_device_cache[device_id] = edges - return edges + result.append(self._wrap_edge(indexed)) + return result - def get_services_for_device(self, device_id: str) -> list[InspectService]: - cached = self._services_for_device_cache.get(device_id) - if cached is not None: - return cached - services: list[InspectService] = [] - for booking_id in self._services_by_device_id.get(device_id, []): - service = self.get_service_by_booking_id(booking_id) - if service is not None: - services.append(service) - self._services_for_device_cache[device_id] = services - return services + def get_edge_for_port(self, device_id: str, port_id: str) -> Optional["InspectEdge"]: + indexed = self._edge_by_port_key.get((device_id, port_id)) + return self._wrap_edge(indexed) if indexed else None - def get_linked_devices(self, device_id: str) -> list[InspectDevice]: + def get_linked_devices(self, device_id: str) -> list["InspectDevice"]: linked: set[str] = set() for indexed in self._edges_by_device_id.get(device_id, []): for candidate in (indexed.from_device_id, indexed.to_device_id): if candidate and candidate != device_id: linked.add(candidate) + return [d for lid in sorted(linked) if (d := self.get_device(lid)) is not None] + + # --- Service reads (section, trigger section load) --- + + @property + def services(self) -> list["InspectService"]: + self._ensure_section_paths() + return [self._wrap_service(item) for item in self._paths_by_booking_id.values()] + + def get_services(self) -> list["InspectService"]: + return self.services + + def get_service_by_booking_id(self, booking_id: str) -> Optional["InspectService"]: + self._ensure_section_paths() + item = self._paths_by_booking_id.get(booking_id) + return self._wrap_service(item) if item else None + + def get_services_for_device(self, device_id: str) -> list["InspectService"]: + self._ensure_section_paths() + result: list["InspectService"] = [] for booking_id in self._services_by_device_id.get(device_id, []): - path_item = self._paths_by_booking_id.get(booking_id) - if path_item is None: + item = self._paths_by_booking_id.get(booking_id) + if item is not None: + result.append(self._wrap_service(item)) + return result + + # --- Bulk preload (ADR-0004) --- + + def preload(self, devices: Optional[list[str]] = None) -> None: + """Hydrate multiple devices in parallel to avoid N+1 when detail is needed for many.""" + target = devices if devices is not None else list(self._devices_by_id) + pending = [d for d in target if not self.is_device_hydrated(d)] + if not pending or self._fetcher is None: + for device_id in pending: + self._ensure_device_detail(device_id) + return + with ThreadPoolExecutor(max_workers=min(_PRELOAD_WORKERS, len(pending))) as pool: + list(pool.map(self._ensure_device_detail, pending)) + + # --- Refresh --- + + def refresh(self) -> "InspectSnapshot": + """Return a *new* snapshot from a fresh skeleton read (never mutates this one).""" + if self._fetcher is None: + raise RuntimeError("This snapshot has no fetcher and cannot be refreshed; build a new snapshot instead.") + return InspectSnapshot( + fetcher=self._fetcher, + device_items=self._fetcher.get_device_skeleton(), + edge_items=self._fetcher.get_edge_skeleton(), + ) + + # --- Post-commit hooks (ADR-0010) --- + + def apply_post_commit( + self, + removed_ids: Optional[list[str]] = None, + device_ids: Optional[list[str]] = None, + pair_ids: Optional[list[str]] = None, + mark_paths_stale: bool = True, + ) -> None: + """Targeted refresh after a successful commit: drop removed entities locally, re-fetch the + affected devices and edge pairs, and mark the services section stale.""" + self._apply_removals(removed_ids or []) + if self._fetcher is not None: + for device_id in device_ids or []: + if device_id in self._devices_by_id: + self._refresh_device(device_id) + for pair_id in pair_ids or []: + self._refresh_edge_pair(pair_id) + if mark_paths_stale: + with self._lock: + self._section_loaded["paths"] = False + self._paths_by_booking_id.clear() + self._services_by_device_id.clear() + + # --- Internal: hydration --- + + def _ensure_device_detail(self, device_id: str) -> None: + record = self._devices_by_id.get(device_id) + if record is None or record.level is HydrationLevel.FULL or self._fetcher is None: + return + # The collector keys nodeStatus by the item's own id (dash form for virtual devices, + # e.g. 'virtual-2'), which differs from the public device id ('virtual.2'). Use it here. + detail = self._fetcher.get_device_detail(record.node.id or device_id) + if detail is None: + # No further detail to load (e.g. virtual devices expose no modules); mark hydrated + # so we honour the at-most-one-fetch contract instead of re-fetching on every access. + with self._lock: + current = self._devices_by_id.get(device_id) + if current is not None and current.level is not HydrationLevel.FULL: + current.level = HydrationLevel.FULL + return + with self._lock: + current = self._devices_by_id.get(device_id) + if current is None or current.level is HydrationLevel.FULL: + return + self._devices_by_id[device_id] = _DeviceRecord( + device_id=device_id, node=detail, level=HydrationLevel.FULL + ) + self._device_cache.pop(device_id, None) + self._rebuild_device_ports(device_id, detail) + + def _refresh_device(self, device_id: str) -> None: + if self._fetcher is None: + return + record = self._devices_by_id.get(device_id) + detail = self._fetcher.get_device_detail(record.node.id if record else device_id) + if detail is None: + return + with self._lock: + self._devices_by_id[device_id] = _DeviceRecord( + device_id=device_id, node=detail, level=HydrationLevel.FULL + ) + self._device_cache.pop(device_id, None) + self._rebuild_device_ports(device_id, detail) + + def _refresh_edge_pair(self, pair_id: str) -> None: + if self._fetcher is None: + return + pair = self._fetcher.get_edge_pair(pair_id) + with self._lock: + self._drop_edge_pair(pair_id) + if pair is not None: + self._index_edge_pair(pair) + + def _ensure_section_paths(self) -> None: + if self._section_loaded.get("paths") or self._fetcher is None: + return + items = self._fetcher.get_paths_section() + with self._lock: + if self._section_loaded.get("paths"): + return + self._index_paths(items) + self._section_loaded["paths"] = True + self._section_fetched_at["paths"] = _now() + self._service_cache.clear() + + # --- Internal: indexing --- + + def _index_device(self, node: InspectApiNodeStatusItem, level: HydrationLevel) -> None: + device_id = node.deviceId or node.id + if not device_id: + return + record = _DeviceRecord(device_id=device_id, node=node, level=level) + self._devices_by_id[device_id] = record + label = record.label + if label: + ids = self._devices_by_label.setdefault(label, []) + if device_id not in ids: + ids.append(device_id) + if level is HydrationLevel.FULL: + self._rebuild_device_ports(device_id, node) + + def _rebuild_device_ports(self, device_id: str, node: InspectApiNodeStatusItem) -> None: + # Drop existing port index entries for this device + old = self._ports_by_device_id.pop(device_id, []) + for indexed in old: + port_id = _port_id_from_status(indexed.port) + if port_id is not None: + self._port_by_key.pop((device_id, port_id), None) + remaining = [p for p in self._ports_by_pid.get(port_id, []) if p.device_id != device_id] + if remaining: + self._ports_by_pid[port_id] = remaining + else: + self._ports_by_pid.pop(port_id, None) + # Rebuild + entries: list[_IndexedPort] = [] + for module in _iter_modules(node.modules): + module_id = module.pid or module.id + for port in _iter_ports(module.ports): + indexed = _IndexedPort(device_id=device_id, module_id=module_id, port=port) + entries.append(indexed) + port_id = _port_id_from_status(port) + if port_id is None: + continue + self._port_by_key[(device_id, port_id)] = indexed + self._ports_by_pid.setdefault(port_id, []).append(indexed) + self._ports_by_device_id[device_id] = entries + + def _resolve_device_id(self, pid: str | None) -> str | None: + """Map an edge ``devicePid`` to the canonical device id. + + For physical devices the pid equals the device id. For virtual devices the collector reports + the pid in dash-encoded form (``virtual-2``) while the device id is dot form (``virtual.2``); + reconcile the two so edges index under the same key the device is stored under. + """ + if not pid or pid in self._devices_by_id: + return pid + dotted = pid.replace("-", ".") + return dotted if dotted in self._devices_by_id else pid + + def _index_edge_pair(self, pair_item: InspectApiExternalEdgesByDeviceKeyItem) -> None: + self._edge_pairs[pair_item.id] = pair_item + primary_device_id = self._resolve_device_id(pair_item.primary.devicePid) + secondary_device_id = self._resolve_device_id(pair_item.secondary.devicePid) + for side, device_id in ( + (pair_item.primary, primary_device_id), + (pair_item.secondary, secondary_device_id), + ): + if not device_id: continue - for segment in path_item.path: + for edge in side.data.values(): + from_device_id = ( + self._resolve_device_id(_device_id_from_context(edge.fromStatus.context if edge.fromStatus else None)) + or primary_device_id + ) + from_port_id = _port_id_from_endpoint(edge.fromStatus) + to_device_id = ( + self._resolve_device_id(_device_id_from_context(edge.toStatus.context if edge.toStatus else None)) + or secondary_device_id + ) + to_port_id = _port_id_from_endpoint(edge.toStatus) + indexed = _IndexedEdge( + edge_id=edge.id, + pair_id=pair_item.id, + edge=edge, + pair_status=pair_item.status, + primary_device_id=primary_device_id, + secondary_device_id=secondary_device_id, + from_device_id=from_device_id, + from_port_id=from_port_id, + to_device_id=to_device_id, + to_port_id=to_port_id, + ) + self._edges_by_device_id.setdefault(device_id, []).append(indexed) + for endpoint_device_id, port_id in ( + (from_device_id, from_port_id), + (to_device_id, to_port_id), + ): + if endpoint_device_id and port_id: + self._edge_by_port_key[(endpoint_device_id, port_id)] = indexed + + def _drop_edge_pair(self, pair_id: str) -> None: + self._edge_pairs.pop(pair_id, None) + for device_id, edges in list(self._edges_by_device_id.items()): + kept = [e for e in edges if e.pair_id != pair_id] + if kept: + self._edges_by_device_id[device_id] = kept + else: + self._edges_by_device_id.pop(device_id, None) + for key, indexed in list(self._edge_by_port_key.items()): + if indexed.pair_id == pair_id: + self._edge_by_port_key.pop(key, None) + for edge_id, edge in list(self._edge_cache.items()): + if edge.pair_id == pair_id: + self._edge_cache.pop(edge_id, None) + + def _index_paths(self, path_items: list[InspectApiPathItem]) -> None: + for item in path_items: + booking_id = item.serviceFields.bid + self._paths_by_booking_id[booking_id] = item + device_ids: set[str] = set() + for segment in item.path: structure = segment.structure - if structure and structure.deviceId and structure.deviceId != device_id: - linked.add(structure.deviceId) - return [device for linked_id in sorted(linked) if (device := self.get_device_by_id(linked_id)) is not None] - - def _wrap_device(self, record: _DeviceRecord) -> InspectDevice: + if structure and structure.deviceId: + device_ids.add(structure.deviceId) + for device_id in device_ids: + ids = self._services_by_device_id.setdefault(device_id, []) + if booking_id not in ids: + ids.append(booking_id) + + def _apply_removals(self, removed_ids: list[str]) -> None: + if not removed_ids: + return + with self._lock: + for removed in removed_ids: + # Device removal + record = self._devices_by_id.pop(removed, None) + if record is not None: + label = record.label + if label and label in self._devices_by_label: + self._devices_by_label[label] = [ + d for d in self._devices_by_label[label] if d != removed + ] + if not self._devices_by_label[label]: + self._devices_by_label.pop(label, None) + self._device_cache.pop(removed, None) + self._ports_by_device_id.pop(removed, None) + self._edges_by_device_id.pop(removed, None) + # Edge removal by edge id or pair id + if "::" in removed: + self._drop_edge_id(removed) + + def _drop_edge_id(self, edge_or_pair_id: str) -> None: + for device_id, edges in list(self._edges_by_device_id.items()): + kept = [e for e in edges if e.edge_id != edge_or_pair_id and e.pair_id != edge_or_pair_id] + if kept != edges: + if kept: + self._edges_by_device_id[device_id] = kept + else: + self._edges_by_device_id.pop(device_id, None) + for key, indexed in list(self._edge_by_port_key.items()): + if indexed.edge_id == edge_or_pair_id or indexed.pair_id == edge_or_pair_id: + self._edge_by_port_key.pop(key, None) + self._edge_cache.pop(edge_or_pair_id, None) + + # --- Internal: domain wrappers (cached) --- + + def _wrap_device(self, device_id: str) -> "InspectDevice": from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice - cached = self._device_cache.get(record.device_id) + cached = self._device_cache.get(device_id) if cached is not None: return cached - device = InspectDevice(snapshot=self, record=record) - self._device_cache[record.device_id] = device + device = InspectDevice(snapshot=self, id=device_id) + self._device_cache[device_id] = device return device - def _wrap_port(self, indexed: _IndexedPort) -> InspectPort: + def _wrap_port(self, indexed: _IndexedPort) -> "InspectPort": from videoipath_automation_tool.apps.inspect.domain.port import InspectPort - port_id = _port_id_from_status(indexed.port) - if port_id is not None: - key = (indexed.device_id, port_id) - cached = self._port_cache.get(key) - if cached is not None: - return cached - port = InspectPort(snapshot=self, indexed=indexed) - self._port_cache[key] = port - return port return InspectPort(snapshot=self, indexed=indexed) - def _wrap_edge(self, indexed: _IndexedEdge) -> InspectEdge: + def _wrap_edge(self, indexed: _IndexedEdge) -> "InspectEdge": from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge cached = self._edge_cache.get(indexed.edge_id) @@ -226,7 +577,7 @@ def _wrap_edge(self, indexed: _IndexedEdge) -> InspectEdge: self._edge_cache[indexed.edge_id] = edge return edge - def _wrap_service(self, path_item: InspectApiPathItem) -> InspectService: + def _wrap_service(self, path_item: InspectApiPathItem) -> "InspectService": from videoipath_automation_tool.apps.inspect.domain.service import InspectService booking_id = path_item.serviceFields.bid @@ -237,127 +588,8 @@ def _wrap_service(self, path_item: InspectApiPathItem) -> InspectService: self._service_cache[booking_id] = service return service - def _build_device_indexes(self) -> None: - for node in self._node_status_items: - device_id = node.deviceId or node.pid or node.id - if not device_id: - continue - self._upsert_device_record( - _DeviceRecord( - device_id=device_id, - label=node.label, - pid=node.pid, - node=node, - ) - ) - - for path_item in self._path_items: - for segment in path_item.path: - structure = segment.structure - if structure is None or not structure.deviceId: - continue - existing = self._devices_by_id.get(structure.deviceId) - if existing is not None and existing.node is not None: - continue - self._upsert_device_record( - _DeviceRecord( - device_id=structure.deviceId, - label=structure.deviceLabel, - pid=structure.devicePid, - node=existing.node if existing else None, - ) - ) - - def _upsert_device_record(self, record: _DeviceRecord) -> None: - existing = self._devices_by_id.get(record.device_id) - if existing is not None and existing.node is not None and record.node is None: - merged = existing - elif existing is not None and record.node is not None: - merged = _DeviceRecord( - device_id=record.device_id, - label=record.label or existing.label, - pid=record.pid or existing.pid, - node=record.node, - ) - else: - merged = record - - self._devices_by_id[merged.device_id] = merged - if merged.label: - labels = self._devices_by_label.setdefault(merged.label, []) - if merged.device_id not in labels: - labels.append(merged.device_id) - - def _build_path_indexes(self) -> None: - for path_item in self._path_items: - booking_id = path_item.serviceFields.bid - self._paths_by_booking_id[booking_id] = path_item - device_ids: set[str] = set() - for segment in path_item.path: - structure = segment.structure - if structure and structure.deviceId: - device_ids.add(structure.deviceId) - for device_id in device_ids: - booking_ids = self._services_by_device_id.setdefault(device_id, []) - if booking_id not in booking_ids: - booking_ids.append(booking_id) - def _build_port_indexes(self) -> None: - for node in self._node_status_items: - device_id = node.deviceId or node.pid or node.id - if not device_id: - continue - for module in _iter_modules(node.modules): - module_id = module.pid or module.id - for port in _iter_ports(module.ports): - indexed = _IndexedPort(device_id=device_id, module_id=module_id, port=port) - self._ports_by_device_id.setdefault(device_id, []).append(indexed) - port_id = _port_id_from_status(port) - if port_id is None: - continue - key = (device_id, port_id) - self._port_by_key[key] = indexed - self._ports_by_pid.setdefault(port_id, []).append(indexed) - - def _build_edge_indexes(self) -> None: - for pair_item in self._external_edge_items: - primary_device_id = pair_item.primary.devicePid - secondary_device_id = pair_item.secondary.devicePid - for side, device_id in ( - (pair_item.primary, primary_device_id), - (pair_item.secondary, secondary_device_id), - ): - if not device_id: - continue - for edge in side.data.values(): - from_device_id = ( - _device_id_from_context(edge.fromStatus.context if edge.fromStatus else None) - or primary_device_id - ) - from_port_id = _port_id_from_endpoint(edge.fromStatus) - to_device_id = ( - _device_id_from_context(edge.toStatus.context if edge.toStatus else None) - or secondary_device_id - ) - to_port_id = _port_id_from_endpoint(edge.toStatus) - indexed = _IndexedEdge( - edge_id=edge.id, - pair_id=pair_item.id, - edge=edge, - primary_device_id=primary_device_id, - secondary_device_id=secondary_device_id, - from_device_id=from_device_id, - from_port_id=from_port_id, - to_device_id=to_device_id, - to_port_id=to_port_id, - ) - self._edges_by_device_id.setdefault(device_id, []).append(indexed) - for endpoint_device_id, port_id in ( - (from_device_id, from_port_id), - (to_device_id, to_port_id), - ): - if endpoint_device_id and port_id: - self._edge_by_port_key[(endpoint_device_id, port_id)] = indexed +# --- Module-level helpers (kept stable for the domain layer) --- def _device_id_from_context(context: Any) -> str | None: @@ -394,15 +626,26 @@ def _port_id_from_status(port: InspectPortStatus) -> str | None: return port_id if port_id else None -def _iter_modules(modules: dict[str, InspectApiModuleStatus] | list[InspectApiModuleStatus]) -> Iterator[InspectApiModuleStatus]: +def _iter_modules( + modules: dict[str, InspectApiModuleStatus] | list[InspectApiModuleStatus] | None, +) -> Iterator[InspectApiModuleStatus]: + if not modules: + return if isinstance(modules, dict): yield from modules.values() return yield from modules -def _iter_ports(ports: dict[str, InspectPortStatus] | list[InspectPortStatus]) -> Iterator[InspectPortStatus]: +def _iter_ports( + ports: dict[str, InspectPortStatus] | list[InspectPortStatus] | None, +) -> Iterator[InspectPortStatus]: + if not ports: + return if isinstance(ports, dict): yield from ports.values() return yield from ports + + +__all__ = ["InspectSnapshot", "HydrationLevel"] diff --git a/src/videoipath_automation_tool/apps/videoipath_app.py b/src/videoipath_automation_tool/apps/videoipath_app.py index 5081dcf..1a48590 100644 --- a/src/videoipath_automation_tool/apps/videoipath_app.py +++ b/src/videoipath_automation_tool/apps/videoipath_app.py @@ -1,6 +1,7 @@ import logging from typing import Literal, Optional +from videoipath_automation_tool.apps.inspect.inspect_app import InspectApp from videoipath_automation_tool.apps.inventory import InventoryApp from videoipath_automation_tool.apps.inventory.model.drivers import AVAILABLE_SCHEMA_VERSIONS, SELECTED_SCHEMA_VERSION from videoipath_automation_tool.apps.preferences.preferences_app import PreferencesApp @@ -221,6 +222,7 @@ def __init__( self._preferences = None self._profile = None self._security = None + self._inspect = None self._logger.info("VideoIPath Automation Tool initialized.") @@ -230,6 +232,7 @@ def __init__( self._topology_api = self.topology._topology_api self._preferences_api = self.preferences._preferences_api self._profile_api = self.profile._profile_api + self._inspect_api = self.inspect._inspect_api # --- Getters to enable lazy loading --- @property @@ -267,6 +270,13 @@ def security(self): self._security = SecurityApp(vip_connector=self._videoipath_connector, logger=self._logger) return self._security + @property + def inspect(self): + if self._inspect is None: + self._logger.debug("InspectApp first called. Initialize InspectApp.") + self._inspect = InspectApp(vip_connector=self._videoipath_connector, logger=self._logger) + return self._inspect + # --- Basic Methods --- def _determine_fallback_driver_schema_version(self) -> Optional[str]: """ diff --git a/src/videoipath_automation_tool/connector/vip_rest_connector.py b/src/videoipath_automation_tool/connector/vip_rest_connector.py index 0269d7c..fce914d 100644 --- a/src/videoipath_automation_tool/connector/vip_rest_connector.py +++ b/src/videoipath_automation_tool/connector/vip_rest_connector.py @@ -13,7 +13,10 @@ class VideoIPathRestConnector(VideoIPathBaseConnector): }, "PATCH": {"PREFIXES": {"/rest/v2/data/config/"}, "EXACT_MATCHES": set()}, "POST": { - "PREFIXES": {"/rest/v2/actions/status/collector/"}, + "PREFIXES": { + "/rest/v2/actions/status/collector/", + "/rest/v2/actions/status/network/", + }, "EXACT_MATCHES": {"/rest/v2/actions/status/pathman/validateTopologyUpdate"}, }, } @@ -24,6 +27,7 @@ def get( auth_check: bool = True, node_check: bool = True, url_validation: bool = True, + allow_projection: bool = False, version: Literal["v2"] = "v2", ) -> ResponseV2Get: """ @@ -37,6 +41,11 @@ def get( auth_check (bool, optional): If `True`, verifies authentication status in the response (default: `True`). node_check (bool, optional): If `True`, ensures that all expected nodes are present in the response data (default: `True`). url_validation (bool, optional): If `True`, validates the URL path (default: `True`). + allow_projection (bool, optional): If `True`, permits scoped projection paths that contain + `/.../` up-navigation segments (used by the Inspect collector queries). When `True`, + `node_check` is implicitly skipped because projected responses do not contain the full + node structure. Defaults to `False` (the `/...` wildcard is rejected, preserving the + behaviour of all existing callers). version (Literal["v2"], optional): The API version to use (default: "v2"). Returns: @@ -56,10 +65,14 @@ def get( if url_validation: self._validate_url(url_path, "GET") - if "/..." in url_path: + if not allow_projection and "/..." in url_path: error_message = "Wildcard '/...' is not allowed in URL path." raise ValueError(error_message) + if allow_projection: + # Projected responses only carry the selected sub-tree, so the full-node check cannot pass. + node_check = False + response = self._execute_request( method="GET", url=self._build_url(url_path), diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..cb66602 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,39 @@ +"""Gating and shared fixtures for the developer-run live-server E2E suite ([ADR-0005]). + +These tests are excluded by default (``-m "not e2e"`` in ``pyproject.toml``) and only run when: + * you pass ``-m e2e`` on the command line, **and** + * ``VIPAT_E2E_ENABLED=1`` is set (put it in ``tests/.env.test`` next to the connection vars), **and** + * the target server is a verified version (>= 2025.4). + +Everything the suite writes is namespaced (``E2E-`` label prefix + ``vipat-e2e`` tag) so a shared +local instance is safe: the scenario teardown removes only that namespace. +""" + +from __future__ import annotations + +import os + +import pytest + +from videoipath_automation_tool.apps.inspect.inspect_app import _MIN_VERIFIED_VERSION, _parse_version +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + + +def _e2e_enabled() -> bool: + return os.environ.get("VIPAT_E2E_ENABLED", "").strip() == "1" + + +@pytest.fixture(scope="session") +def app() -> VideoIPathApp: + """A live ``VideoIPathApp`` built from ``tests/.env.test``; skips unless E2E is enabled + verified.""" + if not _e2e_enabled(): + pytest.skip("E2E disabled (set VIPAT_E2E_ENABLED=1 in tests/.env.test to run).") + application = VideoIPathApp() + version = application._videoipath_connector.videoipath_version + parsed = _parse_version(version) + if parsed is None or parsed < _MIN_VERIFIED_VERSION: + pytest.skip( + f"Server version '{version}' is below the verified Inspect baseline " + f"{_MIN_VERIFIED_VERSION[0]}.{_MIN_VERIFIED_VERSION[1]}." + ) + return application diff --git a/tests/e2e/inspect/__init__.py b/tests/e2e/inspect/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/inspect/scenario.py b/tests/e2e/inspect/scenario.py new file mode 100644 index 0000000..59706ff --- /dev/null +++ b/tests/e2e/inspect/scenario.py @@ -0,0 +1,215 @@ +"""Fixed leaf-spine scenario for the Inspect E2E suite ([ADR-0005]). + +Declaratively models a small, fully-flexed leaf-spine network (two planes, red/blue), modelled on +the real local rig. Devices and their IP vertices are created through the **topology app** (vertices +cannot be created via ``updateTopology`` — [ADR-0009]); placement, connections, edits and removals +that Inspect owns are then driven through ``app.inspect``. + +Every created element is namespaced so a shared instance is safe: + * device/vertex labels start with ``E2E-``, + * every element carries the ``vipat-e2e`` tag. + +Vertex ids are **discovered** from an Inspect snapshot after creation (each vertex is created with a +unique descriptor label and looked up by ``port.label``), so the scenario does not depend on the +server's vertex-id format. + +NOTE (developer-run): the exact virtual-device build calls exercise the *experimental* topology +virtual-device API; confirm labels/ids on the first live run against your instance. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +E2E_PREFIX = "E2E-" +E2E_TAG = "vipat-e2e" + +# Grid coordinates per role tier. +_Y_SPINE = 0.0 +_Y_LEAF = 200.0 +_Y_ENDPOINT = 400.0 +_X_STEP = 300.0 + + +@dataclass(frozen=True) +class DeviceSpec: + label: str + icon_type: str + ports: tuple[str, ...] + x: float + y: float + + +@dataclass(frozen=True) +class LinkSpec: + """A bidirectional connection between two ports (built as two directed edges).""" + + from_device: str + from_port: str + to_device: str + to_port: str + + +def _vertex_token(device_label: str, port: str, direction: str) -> str: + """Unique descriptor label used to discover the created vertex on the Inspect surface.""" + return f"{device_label}|{port}|{direction}" + + +def _build_specs() -> tuple[list[DeviceSpec], list[LinkSpec]]: + spines = [ + DeviceSpec("E2E-SPINE-A", "ipSwitchRouter", tuple(f"downlink{i}" for i in range(4)), 0 * _X_STEP, _Y_SPINE), + DeviceSpec("E2E-SPINE-B", "ipSwitchRouter", tuple(f"downlink{i}" for i in range(4)), 3 * _X_STEP, _Y_SPINE), + ] + leaf_ports = ("uplink0", "uplink1", "host0", "host1", "host2", "host3") + leaves = [ + DeviceSpec("E2E-LEAF-A1", "ipSwitchRouter", leaf_ports, 0 * _X_STEP, _Y_LEAF), + DeviceSpec("E2E-LEAF-A2", "ipSwitchRouter", leaf_ports, 1 * _X_STEP, _Y_LEAF), + DeviceSpec("E2E-LEAF-B1", "ipSwitchRouter", leaf_ports, 2 * _X_STEP, _Y_LEAF), + DeviceSpec("E2E-LEAF-B2", "ipSwitchRouter", leaf_ports, 3 * _X_STEP, _Y_LEAF), + ] + endpoint_ports = ("eth-a", "eth-b") + endpoints = [ + DeviceSpec("E2E-ENC-1", "encoder", endpoint_ports, 0 * _X_STEP, _Y_ENDPOINT), + DeviceSpec("E2E-ENC-2", "encoder", endpoint_ports, 1 * _X_STEP, _Y_ENDPOINT), + DeviceSpec("E2E-DEC-1", "decoder", endpoint_ports, 2 * _X_STEP, _Y_ENDPOINT), + DeviceSpec("E2E-DEC-2", "decoder", endpoint_ports, 3 * _X_STEP, _Y_ENDPOINT), + ] + + links: list[LinkSpec] = [ + # Uplinks: each leaf's uplink0 to a downlink on its plane's spine. + LinkSpec("E2E-LEAF-A1", "uplink0", "E2E-SPINE-A", "downlink0"), + LinkSpec("E2E-LEAF-A2", "uplink0", "E2E-SPINE-A", "downlink1"), + LinkSpec("E2E-LEAF-B1", "uplink0", "E2E-SPINE-B", "downlink0"), + LinkSpec("E2E-LEAF-B2", "uplink0", "E2E-SPINE-B", "downlink1"), + # Endpoint red-plane (eth-a) and blue-plane (eth-b) attachments. + LinkSpec("E2E-ENC-1", "eth-a", "E2E-LEAF-A1", "host0"), + LinkSpec("E2E-ENC-1", "eth-b", "E2E-LEAF-B1", "host0"), + LinkSpec("E2E-ENC-2", "eth-a", "E2E-LEAF-A1", "host1"), + LinkSpec("E2E-ENC-2", "eth-b", "E2E-LEAF-B1", "host1"), + LinkSpec("E2E-DEC-1", "eth-a", "E2E-LEAF-A2", "host0"), + LinkSpec("E2E-DEC-1", "eth-b", "E2E-LEAF-B2", "host0"), + LinkSpec("E2E-DEC-2", "eth-a", "E2E-LEAF-A2", "host1"), + LinkSpec("E2E-DEC-2", "eth-b", "E2E-LEAF-B2", "host1"), + ] + return [*spines, *leaves, *endpoints], links + + +class LeafSpineScenario: + """Builds, inspects, and tears down the fixed leaf-spine network on a live instance.""" + + def __init__(self) -> None: + self.devices, self.links = _build_specs() + # Resolved after build(): + self.device_ids: dict[str, str] = {} # label -> device id + self._vertex_ids: dict[str, str] = {} # token -> inspect vertex id + + # --- Introspection helpers --- + + @property + def device_labels(self) -> list[str]: + return [d.label for d in self.devices] + + def device_id(self, label: str) -> str: + return self.device_ids[label] + + def out_vertex(self, label: str, port: str) -> str: + return self._vertex_ids[_vertex_token(label, port, "out")] + + def in_vertex(self, label: str, port: str) -> str: + return self._vertex_ids[_vertex_token(label, port, "in")] + + def expected_edge_count(self) -> int: + # Two directed edges per bidirectional link. + return len(self.links) * 2 + + # --- Build --- + + def build(self, app: "VideoIPathApp") -> None: + for spec in self.devices: + device_id = self._create_device(app, spec) + self.device_ids[spec.label] = device_id + # Place the devices on the grid (they are already graph elements after creation). + with app.inspect.transaction() as tx: + for spec in self.devices: + tx.place_device(self.device_ids[spec.label], spec.x, spec.y) + tx.commit() + self._connect_links(app) + + def _create_device(self, app: "VideoIPathApp", spec: DeviceSpec) -> str: + device = app.topology.create_virtual_device() + device.configuration.base_device.label = spec.label + device.configuration.base_device.tags = [E2E_TAG] + device.add_virtual_module() + for port in spec.ports: + for direction, api_dir in (("out", "Out"), ("in", "In")): + vertex = device.add_virtual_ip_vertex(vertex_direction=api_dir) + vertex.label = _vertex_token(spec.label, port, direction) + vertex.tags = [E2E_TAG] + app.topology.add_device_initially(device) + # add_device_initially assigns the next free virtual id onto the base device; read it + # directly rather than looking up by label (the label index is only eventually consistent). + device_id = device.configuration.base_device.id + if not device_id: + raise RuntimeError(f"Device '{spec.label}' has no id after creation.") + # Virtual-device vertices do not surface as ports in the Inspect nodeStatus, so capture + # their ids straight from the created graph elements (keyed by the descriptor label we set). + for vtx in device.configuration.ip_vertices: + self._vertex_ids[vtx.label] = vtx.id + return device_id + + def _connect_links(self, app: "VideoIPathApp") -> None: + with app.inspect.transaction() as tx: + for link in self.links: + tx.connect( + self.out_vertex(link.from_device, link.from_port), + self.in_vertex(link.to_device, link.to_port), + bidirectional=False, + ) + tx.connect( + self.out_vertex(link.to_device, link.to_port), + self.in_vertex(link.from_device, link.from_port), + bidirectional=False, + ) + tx.commit() + + # --- Teardown --- + + def teardown(self, app: "VideoIPathApp") -> None: + """Remove all scenario edges then all scenario devices. Safe to call repeatedly. + + Discovers the currently-present E2E devices and their edges from a live snapshot (by the + ``E2E-`` label prefix), so it also cleans up orphaned runs and never tries to remove an edge + that is already gone. + """ + app.inspect.refresh() + # Only touch devices that currently exist (discovered by label prefix), so teardown is + # idempotent — a second call finds nothing and is a no-op. + known_ids = {d.id for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)} + if not known_ids: + return + # Edges first (only the ones that actually exist). + edge_ids = [ + edge.id + for edge in app.inspect.edges + if (edge.from_device and edge.from_device.id in known_ids) + or (edge.to_device and edge.to_device.id in known_ids) + ] + if edge_ids: + with app.inspect.transaction() as tx: + for edge_id in edge_ids: + tx.remove(edge_id) + tx.commit(check_conflicts=False) + for device_id in known_ids: + try: + app.topology.remove_device_by_id(device_id, ignore_affected_services=True) + except Exception: # already gone / not an nGraphElement — best-effort cleanup + pass + + def assert_clean(self, app: "VideoIPathApp") -> None: + app.inspect.refresh() + leftover = [d.label for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)] + assert not leftover, f"E2E devices still present after teardown: {leftover}" diff --git a/tests/e2e/inspect/test_e2e_leaf_spine.py b/tests/e2e/inspect/test_e2e_leaf_spine.py new file mode 100644 index 0000000..c39343b --- /dev/null +++ b/tests/e2e/inspect/test_e2e_leaf_spine.py @@ -0,0 +1,189 @@ +"""End-to-end Inspect tests against a live instance using the fixed leaf-spine scenario. + +Developer-run only (see ``tests/e2e/conftest.py``). Ordered by dependency and sharing one +session-scoped scenario that is built once and torn down at the end (an autouse finalizer also +cleans up if a run aborts). All writes stay inside the ``E2E-`` / ``vipat-e2e`` namespace. + +Everything goes through ``app.inspect`` — the app owns its topology view internally; tests call +``app.inspect.refresh()`` to get a fresh read and then use ``app.inspect.devices`` / ``get_device`` / +``edges`` directly. + +Run with:: + + poetry run pytest -m e2e tests/e2e/inspect +""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from .scenario import E2E_PREFIX, LeafSpineScenario + +pytestmark = pytest.mark.e2e + + +@pytest.fixture(scope="session") +def scenario(app: VideoIPathApp): + scn = LeafSpineScenario() + scn.teardown(app) # clean slate from any aborted prior run + scn.build(app) + yield scn + scn.teardown(app) + scn.assert_clean(app) + + +class _FetchSpy: + """Wraps get_device_detail to count hydration fetches.""" + + def __init__(self, api): + self._api = api + self._orig = api.get_device_detail + self.count = 0 + + def __enter__(self): + def counting(device_id): + self.count += 1 + return self._orig(device_id) + + self._api.get_device_detail = counting + return self + + def __exit__(self, *exc): + self._api.get_device_detail = self._orig + + +def test_build_and_skeleton_read(app, scenario): + app.inspect.refresh() + with _FetchSpy(app.inspect._inspect_api) as spy: + labels = {d.label for d in app.inspect.devices} + for expected in scenario.device_labels: + assert expected in labels + known = set(scenario.device_ids.values()) + scenario_edges = [ + e + for e in app.inspect.edges + if (e.from_device and e.from_device.id in known) or (e.to_device and e.to_device.id in known) + ] + assert len(scenario_edges) >= scenario.expected_edge_count() + assert spy.count == 0 # skeleton read triggers no hydration + + +def test_lazy_hydration(app, scenario): + # Virtual devices do not expose ports in nodeStatus; this asserts the hydration *mechanic* + # (exactly one detail fetch per device, cached thereafter, hydration state flips). + app.inspect.refresh() + leaf_id = scenario.device_id("E2E-LEAF-A1") + other_id = scenario.device_id("E2E-SPINE-A") + assert not app.inspect.is_device_hydrated(leaf_id) + with _FetchSpy(app.inspect._inspect_api) as spy: + _ = app.inspect.get_device(leaf_id).ports # triggers one detail fetch + assert spy.count == 1 + _ = app.inspect.get_device(leaf_id).ports # cached, no further fetch + assert spy.count == 1 + assert app.inspect.is_device_hydrated(leaf_id) + assert not app.inspect.is_device_hydrated(other_id) + + +def test_connectivity_graph(app, scenario): + app.inspect.refresh() + enc1 = app.inspect.get_device(scenario.device_id("E2E-ENC-1")) + linked = {d.label for d in enc1.linked_devices} + assert linked == {"E2E-LEAF-A1", "E2E-LEAF-B1"} + # Spine adjacency: SPINE-A is linked to both red leaves. + spine_a = app.inspect.get_device(scenario.device_id("E2E-SPINE-A")) + spine_links = {d.label for d in spine_a.linked_devices} + assert {"E2E-LEAF-A1", "E2E-LEAF-A2"} <= spine_links + + +def test_edge_pair_refresh(app, scenario): + app.inspect.refresh() # load the internal view so the write can update it in place + edge_id = f"{scenario.out_vertex('E2E-LEAF-A1', 'uplink0')}::{scenario.in_vertex('E2E-SPINE-A', 'downlink0')}" + result = app.inspect.update_edge(edge_id, weight=7) + assert result.ok + # The edge survives the targeted post-commit refresh without a full reload. + assert any(e.id == edge_id for e in app.inspect.edges) + app.inspect.update_edge(edge_id, weight=1) # revert + + +def test_transaction_atomicity(app, scenario): + edge_id = f"{scenario.out_vertex('E2E-LEAF-A2', 'uplink0')}::{scenario.in_vertex('E2E-SPINE-A', 'downlink1')}" + with pytest.raises(InspectCommitError): + with app.inspect.transaction() as tx: + tx.update_edge(edge_id, weight=13) + tx.remove("does-not-exist::also-not-real") + tx.commit() + # Nothing applied: the edge is still present on the server. + app.inspect.refresh() + assert any(e.id == edge_id for e in app.inspect.edges) + + +def test_conflict_detection(app, scenario): + edge_id = f"{scenario.out_vertex('E2E-LEAF-B1', 'uplink0')}::{scenario.in_vertex('E2E-SPINE-B', 'downlink0')}" + tx = app.inspect.transaction() + tx.update_edge(edge_id, weight=21) + # Out-of-band change via a second app instance. + other = VideoIPathApp() + other.inspect.update_edge(edge_id, weight=9) + with pytest.raises(InspectCommitConflictError) as exc: + tx.commit() + assert any(c.entity_id == edge_id for c in exc.value.conflicts) + tx.rebase() + tx.commit() + app.inspect.update_edge(edge_id, weight=1) # revert + + +def test_device_placement_roundtrip(app, scenario): + device_id = scenario.device_id("E2E-ENC-1") + app.inspect.place_device(device_id, 4200, 4200) + app.inspect.refresh() + coords = app.inspect.get_device(device_id).coordinates + assert coords is not None and coords["x"] == 4200 and coords["y"] == 4200 + # revert to scenario coordinates + spec = next(d for d in scenario.devices if d.label == "E2E-ENC-1") + app.inspect.place_device(device_id, spec.x, spec.y) + + +def test_disconnect_connect_cycle(app, scenario): + a_out = scenario.out_vertex("E2E-DEC-2", "eth-a") + b_in = scenario.in_vertex("E2E-LEAF-A2", "host1") + b_out = scenario.out_vertex("E2E-LEAF-A2", "host1") + a_in = scenario.in_vertex("E2E-DEC-2", "eth-a") + app.inspect.disconnect(a_out, b_in, bidirectional=False) + app.inspect.disconnect(b_out, a_in, bidirectional=False) + app.inspect.refresh() + assert not any(e.id == f"{a_out}::{b_in}" for e in app.inspect.edges) + # reconnect + app.inspect.connect(a_out, b_in, bidirectional=False) + app.inspect.connect(b_out, a_in, bidirectional=False) + app.inspect.refresh() + assert any(e.id == f"{a_out}::{b_in}" for e in app.inspect.edges) + + +def test_full_vs_skeleton_equivalence(app, scenario): + def graph_view() -> dict: + return { + device_id: ( + app.inspect.get_device(device_id).label, + frozenset(d.label for d in app.inspect.get_device(device_id).linked_devices), + ) + for device_id in scenario.device_ids.values() + } + + app.inspect.refresh(load="skeleton") + app.inspect.preload(list(scenario.device_ids.values())) + skeleton = graph_view() + app.inspect.refresh(load="full") + full = graph_view() + assert skeleton == full + app.inspect.refresh(load="skeleton") # restore default load mode for later tests + + +def test_teardown(app, scenario): + # Explicit teardown assertion; the fixture finalizer repeats it for aborted runs. + scenario.teardown(app) + scenario.assert_clean(app) + app.inspect.refresh() + assert not any((d.label or "").startswith(E2E_PREFIX) for d in app.inspect.devices) diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_replace_devices.json b/tests/fixtures/inspect/2025.4.9/update_topology_replace_devices.json new file mode 100644 index 0000000..be47ae5 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/update_topology_replace_devices.json @@ -0,0 +1,72 @@ +{ + "request": { + "header": { "id": 0 }, + "data": { + "replaceDevices": { + "device-a": { + "coordinates": { "x": 1600.0, "y": 9050.0 }, + "descriptor": { "desc": "", "label": "" }, + "iconSize": "medium", + "iconType": "default", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": [], + "virtualDeviceFields": null + } + }, + "replaceVertices": {}, + "replaceEdges": {}, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } + }, + "response": { + "data": { + "items": [ + { + "external": null, + "id": "device-a", + "idx": null, + "res": { "msg": [""], "ok": true } + } + ], + "res": { "msg": [], "ok": true }, + "validation": { + "createIds": [], + "details": {}, + "result": { "msg": [], "ok": true } + } + }, + "header": { "ok": true, "code": "OK" } + }, + "rejected_request_error": { + "note": "Sending the raw persisted baseDevice element (maps[], fDescriptor, type) instead of the edit form is rejected:", + "header": { + "auth": true, + "caption": "Invalid Request", + "code": "INVALID_REQUEST", + "errorDetails": [ + { + "msg": "Mandatory field 'localAssignedTags' not present in input", + "path": ["replaceDevices", "device-a", "localAssignedTags"], + "type": "conversionError" + }, + { + "msg": "Mandatory field 'coordinates' not present in input", + "path": ["replaceDevices", "device-a", "coordinates"], + "type": "conversionError" + } + ], + "id": "0", + "msg": [ + "Mandatory field 'localAssignedTags' not present in input", + "Mandatory field 'coordinates' not present in input" + ], + "ok": false, + "user": "api-user" + } + } +} diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_replace_vertices.json b/tests/fixtures/inspect/2025.4.9/update_topology_replace_vertices.json new file mode 100644 index 0000000..bc6fd19 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/update_topology_replace_vertices.json @@ -0,0 +1,80 @@ +{ + "request": { + "header": { "id": 0 }, + "data": { + "replaceDevices": {}, + "replaceVertices": { + "device-a.module-1.port-out-1.out": { + "active": true, + "controlProps": null, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "ipAddress": null, + "ipNetmask": null, + "public": false, + "supportsCpipeCfg": false, + "supportsIgmpCfg": false, + "supportsMacForwardingCfg": false, + "supportsNsoCfg": false, + "supportsOpenflowCfg": false, + "supportsStaticIgmpCfg": false, + "supportsVlanCfg": false, + "supportsVplsCfg": false, + "type": "ip", + "vlanId": null, + "vrfId": null + }, + "useAsEndpoint": false + } + }, + "replaceEdges": {}, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } + }, + "response": { + "data": { + "items": [ + { + "external": null, + "id": "device-a.module-1.port-out-1.out", + "idx": null, + "res": { "msg": [""], "ok": true } + } + ], + "res": { "msg": [], "ok": true }, + "validation": { + "createIds": [], + "details": {}, + "result": { "msg": [], "ok": true } + } + }, + "header": { "ok": true, "code": "OK" } + }, + "unknown_id_validation_failure": { + "note": "Committing a vertex id that does not exist in the graph passes schema conversion but fails validation — replaceVertices is update-only:", + "data": { + "items": [], + "res": { "msg": ["Validation failed"], "ok": false }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": ["Vertex with id device-a.module-9.new-port.out was not found in graph"], + "ok": false + } + } + }, + "header": { "ok": true, "code": "OK" } + } +} diff --git a/tests/inspect/__init__.py b/tests/inspect/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/inspect/conftest.py b/tests/inspect/conftest.py new file mode 100644 index 0000000..075cab1 --- /dev/null +++ b/tests/inspect/conftest.py @@ -0,0 +1,23 @@ +"""Shared fixtures for offline Inspect tests.""" + +import json +from pathlib import Path + +import pytest + +FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "inspect" / "2025.4.9" + + +def load_fixture(name: str) -> dict: + with open(FIXTURE_DIR / name) as handle: + return json.load(handle) + + +@pytest.fixture +def fixtures_dir() -> Path: + return FIXTURE_DIR + + +@pytest.fixture +def load(): + return load_fixture diff --git a/tests/inspect/test_actions.py b/tests/inspect/test_actions.py new file mode 100644 index 0000000..f9a0da8 --- /dev/null +++ b/tests/inspect/test_actions.py @@ -0,0 +1,68 @@ +"""Network-action mixin tests with a fake connector.""" + +from types import SimpleNamespace + +import pytest + +from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy, InspectActionsMixin +from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI + + +def _ok_header(): + return SimpleNamespace( + model_dump=lambda mode="json": { + "auth": True, "caption": "OK", "code": "OK", "errorCodes": [], "errorDetails": [], + "id": "0", "msg": [], "ok": True, "user": "api-user", + } + ) + + +class FakeRest: + def __init__(self, post_data): + self._post_data = post_data + self.post_calls = [] + + def post(self, url_path, body, **kwargs): + self.post_calls.append((url_path, body.model_dump(mode="json", by_alias=True))) + return SimpleNamespace(data=self._post_data, header=_ok_header()) + + +class _App(InspectActionsMixin): + def __init__(self, post_data): + import logging + + self._logger = logging.getLogger("test") + self._inspect_api = InspectAPI(SimpleNamespace(rest=FakeRest(post_data)), self._logger) + + +def test_conflict_strategy_values(): + assert int(ConflictStrategy.STRICT) == 0 + assert int(ConflictStrategy.INVALIDATE_SERVICES) == 1 + assert int(ConflictStrategy.CANCEL_SERVICES) == 2 + + +def test_add_devices_builds_placement_items(): + app = _App({"msg": [], "ok": True}) + assert app.add_devices_to_topology([("device12", 100, 200), "device13"]) is True + _, payload = app._inspect_api.vip_connector.rest.post_calls[0] + assert payload["data"] == [{"id": "device12", "x": 100, "y": 200}, {"id": "device13", "x": 0, "y": 0}] + + +def test_sync_devices_passes_strategy(): + app = _App({"msg": [], "ok": True}) + app.sync_devices(["device12"], add_only=True, conflict_strategy=ConflictStrategy.CANCEL_SERVICES) + _, payload = app._inspect_api.vip_connector.rest.post_calls[0] + assert payload["data"] == {"ids": ["device12"], "addOnly": True, "conflictStrategy": 2} + + +def test_sync_devices_reports_failure(): + app = _App({"msg": ["No topology reported by the device"], "ok": False}) + assert app.sync_devices(["device12"]) is False + + +def test_empty_lists_rejected(): + app = _App({"msg": [], "ok": True}) + with pytest.raises(ValueError): + app.sync_devices([]) + with pytest.raises(ValueError): + app.get_sync_info([]) diff --git a/tests/inspect/test_api.py b/tests/inspect/test_api.py new file mode 100644 index 0000000..a8458bf --- /dev/null +++ b/tests/inspect/test_api.py @@ -0,0 +1,101 @@ +"""InspectAPI wiring tests with a fake REST connector: verifies each method hits the right +endpoint, passes allow_projection for scoped reads, and parses responses into DTOs.""" + +from types import SimpleNamespace + +from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyData + + +class FakeRest: + def __init__(self, get_data=None, post_data=None): + self._get_data = get_data or {} + self._post_data = post_data or {} + self.get_calls = [] + self.post_calls = [] + + def get(self, url_path, allow_projection=False, **kwargs): + self.get_calls.append((url_path, allow_projection)) + return SimpleNamespace(data=self._get_data, header=_ok_header()) + + def post(self, url_path, body, **kwargs): + self.post_calls.append((url_path, body)) + return SimpleNamespace(data=self._post_data, header=_ok_header()) + + +def _ok_header(): + return SimpleNamespace( + model_dump=lambda mode="json": { + "auth": True, "caption": "OK", "code": "OK", "errorCodes": [], "errorDetails": [], + "id": "0", "msg": [], "ok": True, "user": "api-user", + } + ) + + +def _connector(get_data=None, post_data=None): + rest = FakeRest(get_data=get_data, post_data=post_data) + return SimpleNamespace(rest=rest), rest + + +def _collector(node_items=None, edge_items=None, path_items=None): + return { + "status": { + "collector": { + "inspect": { + "nodeStatus": {"_items": node_items or []}, + "paths": {"_items": path_items or []}, + }, + "externalEdgesByDeviceKey": {"_items": edge_items or []}, + } + } + } + + +def test_device_skeleton_uses_projection_and_parses(load): + node_items = load("skeleton_nodestatus_short.json")["data"]["status"]["collector"]["inspect"]["nodeStatus"]["_items"] + conn, rest = _connector(get_data=_collector(node_items=node_items)) + api = InspectAPI(conn) + devices = api.get_device_skeleton() + assert len(devices) == len(node_items) + assert rest.get_calls[0][1] is True # allow_projection + + +def test_edge_skeleton_parses(load): + edge_items = load("edge_skeleton.json")["data"]["status"]["collector"]["externalEdgesByDeviceKey"]["_items"] + conn, rest = _connector(get_data=_collector(edge_items=edge_items)) + api = InspectAPI(conn) + edges = api.get_edge_skeleton() + assert len(edges) == len(edge_items) + + +def test_device_detail_returns_none_when_absent(): + conn, rest = _connector(get_data=_collector(node_items=[])) + api = InspectAPI(conn) + assert api.get_device_detail("deviceX") is None + + +def test_lookup_edges_hits_correct_endpoint(load): + conn, rest = _connector(post_data=load("lookup_inspect_edges_by_ids.json")["data"]) + api = InspectAPI(conn) + resp = api.lookup_edges(["a::b"]) + assert rest.post_calls[0][0].endswith("/lookupInspectEdgesByIds") + assert resp.data + + +def test_update_topology_posts_delta(): + conn, rest = _connector( + post_data={"items": [], "res": {"msg": [], "ok": True}, "validation": {"details": {}, "result": {"msg": [], "ok": True}}} + ) + api = InspectAPI(conn) + resp = api.update_topology(InspectApiUpdateTopologyData()) + assert rest.post_calls[0][0].endswith("/updateTopology") + assert resp.committed is True + + +def test_add_and_sync_devices_endpoints(): + conn, rest = _connector(post_data={"msg": [], "ok": True}) + api = InspectAPI(conn) + api.add_devices([]) + api.sync_devices([], add_only=True, conflict_strategy=0) + assert rest.post_calls[0][0].endswith("/network/addDevices") + assert rest.post_calls[1][0].endswith("/network/syncDevices") diff --git a/tests/inspect/test_changeset.py b/tests/inspect/test_changeset.py new file mode 100644 index 0000000..7b5165e --- /dev/null +++ b/tests/inspect/test_changeset.py @@ -0,0 +1,345 @@ +"""Change-set / transaction unit tests with a fake API (offline). + +Covers staging + intent application, payload-shape assertions against the verified write forms, +the three-flag commit result, conflict detection (compare-and-commit), the ``check_conflicts=False`` +bypass, ``rebase``, and the post-commit targeted-refresh hook derivation. +""" + +from types import SimpleNamespace + +import pytest + +from videoipath_automation_tool.apps.inspect.changeset import InspectTransaction +from videoipath_automation_tool.apps.inspect.errors import ( + InspectCommitConflictError, + InspectCommitError, + InspectEntityNotFoundError, +) +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiLookupEdgeResponseItem, + InspectApiLookupInspectDeviceResponse, + InspectApiLookupVertexResponseData, +) +from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyResponse + +from .conftest import load_fixture + +_OK_HEADER = { + "auth": True, "caption": "OK", "code": "OK", "errorCodes": [], "errorDetails": [], + "id": "0", "msg": [], "ok": True, "user": "api-user", +} + +A_OUT = "device12.1.Ethernet1.out" +B_IN = "device7.0.swp1.in" +EDGE_ID = f"{A_OUT}::{B_IN}" + + +def _device_response(label="Dev A", coordinates=None, icon_type="switch", tags=None): + return InspectApiLookupInspectDeviceResponse.model_validate( + { + "data": { + "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, + "fields": { + "coordinates": coordinates or {"x": 0, "y": 0}, + "descriptor": {"desc": "", "label": label}, + "iconSize": "medium", + "iconType": icon_type, + "localAssignedTags": [], + "sdpStrategy": None, + "siteId": None, + "tags": tags or [], + "virtualDeviceFields": None, + }, + }, + "header": _OK_HEADER, + } + ) + + +def _vertex_data(): + fixture = load_fixture("lookup_inspect_vertex_by_id.json") + return InspectApiLookupVertexResponseData.model_validate(fixture["data"]) + + +def _edge_item(weight=1): + edge = { + "active": True, "bandwidth": -1.0, "capacity": 65535, "conflictPri": 0, + "descriptor": {"desc": "", "label": ""}, "excludeFormats": [], + "fDescriptor": {"desc": "", "label": ""}, "fromId": A_OUT, "includeFormats": [], + "redundancyMode": "Any", "tags": [], "toId": B_IN, "weight": weight, + "weightFactors": {"bandwidth": {"weight": 0}, "service": {"max": 100, "weight": 0}}, + } + return InspectApiLookupEdgeResponseItem.model_validate({"edge": edge, "fromDevice": "device12", "toDevice": "device7"}) + + +class FakeAPI: + def __init__(self): + self.devices = {} + self.vertices = {} + self.edges = {} + self.update_response = InspectApiUpdateTopologyResponse.model_validate(load_fixture("update_topology_success.json")) + self.update_calls = [] + self.lookup_device_calls = [] + self.lookup_vertices_calls = [] + self.lookup_edges_calls = [] + + def lookup_inspect_device(self, device_id): + self.lookup_device_calls.append(device_id) + if device_id not in self.devices: + raise KeyError(device_id) + return self.devices[device_id] + + def lookup_vertices(self, ids): + self.lookup_vertices_calls.append(list(ids)) + return SimpleNamespace(data={i: self.vertices[i] for i in ids if i in self.vertices}) + + def lookup_edges(self, ids): + self.lookup_edges_calls.append(list(ids)) + return SimpleNamespace(data={i: self.edges[i] for i in ids if i in self.edges}) + + def update_topology(self, delta): + self.update_calls.append(delta) + return self.update_response + + +class FakeSnapshot: + def __init__(self): + self.calls = [] + + def apply_post_commit(self, **kwargs): + self.calls.append(kwargs) + + +def _txn(api, snapshot=None): + return InspectTransaction(api, snapshot=snapshot) + + +# --- Staging + payload shape --- + + +def test_connect_builds_edge_payload(): + api = FakeAPI() + with _txn(api) as tx: + tx.connect(A_OUT, B_IN, bidirectional=False, weight=10) + tx.commit() + delta = api.update_calls[0] + assert list(delta.replaceEdges) == [EDGE_ID] + edge = delta.replaceEdges[EDGE_ID] + assert edge.fromId == A_OUT and edge.toId == B_IN + assert edge.weight == 10 and edge.capacity == 65535 and edge.redundancyMode == "Any" + + +def test_connect_bidirectional_stages_reverse_edge(): + api = FakeAPI() + with _txn(api) as tx: + tx.connect(A_OUT, B_IN, bidirectional=True) + tx.commit() + keys = set(api.update_calls[0].replaceEdges) + assert keys == {EDGE_ID, "device7.0.swp1.out::device12.1.Ethernet1.in"} + + +def test_connect_existing_edge_requires_overwrite(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + with pytest.raises(ValueError, match="already exists"): + tx.connect(A_OUT, B_IN, bidirectional=False) + + +def test_update_edge_applies_intent(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + tx.update_edge(EDGE_ID, weight=42) + tx.commit() + assert api.update_calls[0].replaceEdges[EDGE_ID].weight == 42 + + +def test_update_edge_missing_raises_not_found(): + api = FakeAPI() + with _txn(api) as tx: + with pytest.raises(InspectEntityNotFoundError): + tx.update_edge(EDGE_ID, weight=42) + + +def test_update_device_only_changes_label_when_set(): + api = FakeAPI() + api.devices["device12"] = _device_response(label="Original", icon_type="switch") + with _txn(api) as tx: + tx.update_device("device12", icon_type="router") + tx.commit() + form = api.update_calls[0].replaceDevices["device12"] + assert form.descriptor.label == "Original" # round-tripped, not cleared (descriptor mandatory) + assert form.iconType == "router" + + +def test_update_device_sets_label(): + api = FakeAPI() + api.devices["device12"] = _device_response(label="Original") + with _txn(api) as tx: + tx.update_device("device12", label="BU-LEAF-A") + tx.commit() + assert api.update_calls[0].replaceDevices["device12"].descriptor.label == "BU-LEAF-A" + + +def test_place_device_sets_coordinates(): + api = FakeAPI() + api.devices["device12"] = _device_response() + with _txn(api) as tx: + tx.place_device("device12", 1600, 9050) + tx.commit() + assert api.update_calls[0].replaceDevices["device12"].coordinates == {"x": 1600, "y": 9050} + + +def test_update_vertex_sets_endpoint(): + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, use_as_endpoint=True) + tx.commit() + assert api.update_calls[0].replaceVertices[A_OUT].useAsEndpoint is True + + +def test_remove_appends_to_remove_list(): + api = FakeAPI() + with _txn(api) as tx: + tx.remove(EDGE_ID) + tx.commit() + assert api.update_calls[0].remove == [EDGE_ID] + + +def test_disconnect_removes_both_directions(): + api = FakeAPI() + with _txn(api) as tx: + tx.disconnect(A_OUT, B_IN, bidirectional=True) + tx.commit() + assert set(api.update_calls[0].remove) == {EDGE_ID, "device7.0.swp1.out::device12.1.Ethernet1.in"} + + +# --- Commit result / failure --- + + +def test_commit_success_returns_result(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + tx.update_edge(EDGE_ID, weight=5) + result = tx.commit() + assert result.ok is True + assert result.applied_ids == [EDGE_ID] + + +def test_commit_failure_raises_commit_error(): + api = FakeAPI() + api.update_response = InspectApiUpdateTopologyResponse.model_validate(load_fixture("update_topology_fail_remove.json")) + with _txn(api) as tx: + tx.remove("nonexistent-edge-id-xyz") + with pytest.raises(InspectCommitError) as exc: + tx.commit() + assert "non-existent" in str(exc.value) + + +def test_empty_commit_rejected(): + api = FakeAPI() + tx = _txn(api) + with pytest.raises(ValueError, match="Nothing staged"): + tx.commit() + + +# --- Conflict detection (ADR-0009) --- + + +def test_conflict_detected_on_baseline_change(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item(weight=1) + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=99) + api.edges[EDGE_ID] = _edge_item(weight=5) # concurrent out-of-band change + with pytest.raises(InspectCommitConflictError) as exc: + tx.commit() + conflict = exc.value.conflicts[0] + assert conflict.entity_id == EDGE_ID + assert "weight" in conflict.field_diffs + assert conflict.field_diffs["weight"] == (1, 5) + assert not api.update_calls # nothing sent + + +def test_conflict_check_bypass(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item(weight=1) + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=99) + api.edges[EDGE_ID] = _edge_item(weight=5) + tx.commit(check_conflicts=False) + assert api.update_calls # sent despite the change + + +def test_conflict_when_entity_vanishes(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=7) + api.edges.clear() # removed out-of-band + with pytest.raises(InspectCommitConflictError) as exc: + tx.commit() + assert "__exists__" in exc.value.conflicts[0].field_diffs + + +def test_rebase_refetches_baseline(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item(weight=1) + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=99) + api.edges[EDGE_ID] = _edge_item(weight=5) + with pytest.raises(InspectCommitConflictError): + tx.commit() + tx.rebase() + tx.commit() # now baseline == server, no conflict + assert api.update_calls[0].replaceEdges[EDGE_ID].weight == 99 + + +# --- Post-commit refresh hook derivation (ADR-0010) --- + + +def test_post_commit_refresh_derives_affected_ids(): + api = FakeAPI() + snapshot = FakeSnapshot() + with _txn(api, snapshot=snapshot) as tx: + tx.connect(A_OUT, B_IN, bidirectional=True) + tx.commit() + call = snapshot.calls[0] + assert set(call["pair_ids"]) == {"device12::device7", "device7::device12"} + assert call["device_ids"] == [] + assert call["mark_paths_stale"] is True + + +def test_post_commit_refresh_vertex_maps_to_device(): + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + snapshot = FakeSnapshot() + with _txn(api, snapshot=snapshot) as tx: + tx.update_vertex(A_OUT, use_as_endpoint=True) + tx.commit() + assert snapshot.calls[0]["device_ids"] == ["device12"] + + +# --- Lifecycle --- + + +def test_reuse_after_commit_rejected(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=5) + tx.commit() + with pytest.raises(RuntimeError, match="already committed"): + tx.update_edge(EDGE_ID, weight=6) + + +def test_context_exit_without_commit_discards(caplog): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + tx.update_edge(EDGE_ID, weight=5) + assert not api.update_calls + assert tx._discarded diff --git a/tests/inspect/test_models.py b/tests/inspect/test_models.py new file mode 100644 index 0000000..3d2f25d --- /dev/null +++ b/tests/inspect/test_models.py @@ -0,0 +1,119 @@ +"""Contract tests: every fixture parses into its DTO, and write-payload builders reproduce +the verified request shapes byte-for-byte.""" + +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiLookupEdgesResponse, + InspectApiLookupInspectDeviceResponse, + InspectApiLookupVertexResponse, +) +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiExternalEdgesByDeviceKeyItem, + InspectApiNodeStatusItem, + InspectApiPathItem, +) +from videoipath_automation_tool.apps.inspect.model.update_topology import ( + InspectApiUpdateTopologyData, + InspectApiUpdateTopologyResponse, +) + + +def _node_items(payload): + return payload["data"]["status"]["collector"]["inspect"]["nodeStatus"]["_items"] + + +def test_device_skeleton_items_parse_and_expose_effective_label(load): + items = _node_items(load("skeleton_nodestatus_short.json")) + assert items + for raw in items: + node = InspectApiNodeStatusItem.model_validate(raw) + assert node.id + assert node.effective_label # comes from descriptor.label, not a top-level label + + +def test_device_detail_parses_modules_and_ports(load): + items = _node_items(load("device_hydration_modules_ports.json")) + node = InspectApiNodeStatusItem.model_validate(items[0]) + assert node.modules + module = next(iter(node.modules.values())) + assert module.ports + + +def test_edge_skeleton_items_parse(load): + items = load("edge_skeleton.json")["data"]["status"]["collector"]["externalEdgesByDeviceKey"]["_items"] + assert len(items) > 0 + edge = InspectApiExternalEdgesByDeviceKeyItem.model_validate(items[0]) + assert "::" in edge.id + assert edge.primary is not None + + +def test_paths_fixture_parses(load): + items = load("inspect_paths_limit5.json")["data"]["status"]["collector"]["inspect"]["paths"]["_items"] + for raw in items: + path = InspectApiPathItem.model_validate(raw) + assert path.serviceFields.bid + + +def test_lookup_edges_returns_full_persisted_edge_form(load): + resp = InspectApiLookupEdgesResponse.model_validate(load("lookup_inspect_edges_by_ids.json")) + key = next(iter(resp.data)) + edge = resp.data[key].edge + assert edge.fromId and edge.toId + assert edge.capacity == 65535 + + +def test_lookup_vertex_edit_form(load): + resp = InspectApiLookupVertexResponse.model_validate(load("lookup_inspect_vertex_by_id.json")) + assert resp.data.fields.typeFields is not None + assert resp.data.vertexType in ("In", "Out", "Internal", None) + + +def test_lookup_device_fields(): + resp = InspectApiLookupInspectDeviceResponse.model_validate(_device_lookup_fixture()) + assert resp.data.fields.coordinates is not None + + +def _device_lookup_fixture(): + # lookupInspectDevice example from endpoints.md (anonymized) + return { + "data": { + "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, + "fields": { + "coordinates": {"x": 500, "y": 8150}, + "descriptor": {"desc": "", "label": "Example Device A"}, + "iconSize": "medium", + "iconType": "gateway", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": None, + "tags": [], + "virtualDeviceFields": None, + }, + }, + "header": {"auth": True, "caption": "OK", "code": "OK", "id": "0", "msg": [], "ok": True, "user": "api-user"}, + } + + +def test_replace_devices_payload_roundtrips_byte_for_byte(load): + fixture = load("update_topology_replace_devices.json") + data = InspectApiUpdateTopologyData.model_validate(fixture["request"]["data"]) + built = data.model_dump(mode="json", by_alias=True) + assert built["replaceDevices"] == fixture["request"]["data"]["replaceDevices"] + + +def test_replace_vertices_payload_roundtrips_byte_for_byte(load): + fixture = load("update_topology_replace_vertices.json") + data = InspectApiUpdateTopologyData.model_validate(fixture["request"]["data"]) + built = data.model_dump(mode="json", by_alias=True) + assert built["replaceVertices"] == fixture["request"]["data"]["replaceVertices"] + + +def test_commit_success_and_failure_flags(load): + ok = InspectApiUpdateTopologyResponse.model_validate(load("update_topology_success.json")) + assert ok.committed is True + + fail_booking = InspectApiUpdateTopologyResponse.model_validate(load("update_topology_fail_booking.json")) + assert fail_booking.committed is False + assert fail_booking.data.validation.details # per-entity detail present + + fail_remove = InspectApiUpdateTopologyResponse.model_validate(load("update_topology_fail_remove.json")) + assert fail_remove.committed is False diff --git a/tests/inspect/test_queries.py b/tests/inspect/test_queries.py new file mode 100644 index 0000000..b0b3dd2 --- /dev/null +++ b/tests/inspect/test_queries.py @@ -0,0 +1,47 @@ +import urllib.parse + +import pytest + +from videoipath_automation_tool.apps.inspect import queries +from videoipath_automation_tool.apps.inspect.errors import InspectQueryTooLongError + + +def test_all_queries_within_length_limit(): + for build in (queries.device_skeleton, queries.edge_skeleton, queries.paths_section, queries.collector_full): + path = build() + assert len(path) < queries.MAX_QUERY_LENGTH + assert path.startswith("/rest/v2/data/status/collector/") + + +def test_device_skeleton_suppresses_modules_and_selects_skeleton_fields(): + path = queries.device_skeleton() + decoded = urllib.parse.unquote(path) + assert 'modules/"_noId"' in decoded + assert "nodeStatus/*" in decoded + for field in ("descriptor", "meta", "status", "syncSeverity", "tags"): + assert field in decoded + + +def test_device_detail_uses_direct_id_and_full_subtree(): + path = queries.device_detail("device12") + assert path.endswith("/nodeStatus/device12/**") + + +def test_edge_pair_targets_single_pair(): + path = queries.edge_pair("device12::device7") + decoded = urllib.parse.unquote(path) + assert "externalEdgesByDeviceKey/device12::device7" in decoded + + +def test_encode_preserves_grammar_characters_and_encodes_quotes_and_spaces(): + encoded = queries.encode("/a b/*/x,y/'z'/\"q\"") + assert "%20" in encoded # space encoded + assert "%22" in encoded # double-quote encoded + assert "/*/" in encoded # star preserved + assert "'z'" in encoded # single-quote preserved + + +def test_query_too_long_raises(monkeypatch): + monkeypatch.setattr(queries, "MAX_QUERY_LENGTH", 10) + with pytest.raises(InspectQueryTooLongError): + queries.device_skeleton() diff --git a/tests/inspect/test_snapshot.py b/tests/inspect/test_snapshot.py new file mode 100644 index 0000000..d009e5d --- /dev/null +++ b/tests/inspect/test_snapshot.py @@ -0,0 +1,261 @@ +"""Snapshot unit tests with a fake fetcher: skeleton indexes, exactly-one hydration per device, +section laziness, preload fan-out, refresh, and post-commit targeted refresh.""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiExternalEdgesByDeviceKeyItem, + InspectApiNodeStatusItem, + InspectApiPathItem, +) +from videoipath_automation_tool.apps.inspect.snapshot import HydrationLevel, InspectSnapshot + + +# --- Test data builders (small synthetic leaf-spine-ish graph) --- + + +def _skeleton_node(device_id: str, label: str, x: float = 0.0, y: float = 0.0, sync: int = 0): + return InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "_vid": device_id, + "deviceId": device_id, + "descriptor": {"desc": "", "label": label}, + "meta": {"coordinates": {"x": x, "y": y}, "isVirtual": True, "iconType": "switch"}, + "status": {"sa": 0, "severity": 0}, + "syncSeverity": sync, + "tags": ["#e2e"], + "modules": {}, + } + ) + + +def _detail_node(device_id: str, label: str, port_pids: list[str]): + ports = { + pid: { + "pid": pid, + "descriptor": {"label": pid.split(".")[-1]}, + "status": {"sa": 0, "severity": 0}, + "vertexInfo": {"type": "single", "id": pid.replace(".dev.", "."), "vertexType": "Out"}, + } + for pid in port_pids + } + return InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "_vid": device_id, + "deviceId": device_id, + "descriptor": {"desc": "", "label": label}, + "meta": {"coordinates": {"x": 0, "y": 0}}, + "status": {"sa": 0, "severity": 0}, + "syncSeverity": 0, + "modules": {f"{device_id}.dev.0": {"pid": f"{device_id}.dev.0", "ports": ports}}, + } + ) + + +def _edge_pair(dev_a: str, dev_b: str, port_a: str, port_b: str): + edge_id = f"{port_a}::{port_b}" + return InspectApiExternalEdgesByDeviceKeyItem.model_validate( + { + "_id": f"{dev_a}::{dev_b}", + "_vid": f"{dev_a}::{dev_b}", + "primary": { + "devicePid": dev_a, + "label": dev_a, + "data": { + edge_id: { + "id": edge_id, + "fromStatus": {"context": {"devicePid": dev_a, "portPid": port_a}, "label": "out"}, + "toStatus": {"context": {"devicePid": dev_b, "portPid": port_b}, "label": "in"}, + } + }, + }, + "secondary": {"devicePid": dev_b, "label": dev_b, "data": {}}, + "status": {"alarm": 0, "bandwidth": 0, "maintenance": 0, "ptp": 0}, + } + ) + + +def _path_item(booking: str, dev_a: str, dev_b: str): + return InspectApiPathItem.model_validate( + { + "_id": f"{booking}::main", + "_vid": f"_:{booking}::main", + "serviceFields": {"bid": booking, "isMain": True, "fromLabel": "src", "toLabel": "dst"}, + "path": [ + {"bid": booking, "structure": {"deviceId": dev_a, "devicePid": dev_a}}, + {"bid": booking, "structure": {"deviceId": dev_b, "devicePid": dev_b}}, + ], + } + ) + + +class FakeFetcher: + """Records how many detail/section fetches occur so laziness can be asserted.""" + + def __init__(self): + self.device_detail_calls: list[str] = [] + self.edge_pair_calls: list[str] = [] + self.section_calls = 0 + self.skeleton_calls = 0 + self._details = { + "spine-a": _detail_node("spine-a", "SPINE-A", ["spine-a.dev.0.swp1", "spine-a.dev.0.swp2"]), + "leaf-a": _detail_node("leaf-a", "LEAF-A", ["leaf-a.dev.0.up1", "leaf-a.dev.0.host1"]), + } + self._paths = [_path_item("1001", "leaf-a", "spine-a")] + + def get_device_skeleton(self): + self.skeleton_calls += 1 + return [_skeleton_node("spine-a", "SPINE-A"), _skeleton_node("leaf-a", "LEAF-A")] + + def get_edge_skeleton(self): + return [_edge_pair("leaf-a", "spine-a", "leaf-a.dev.0.up1", "spine-a.dev.0.swp1")] + + def get_device_detail(self, device_id): + self.device_detail_calls.append(device_id) + return self._details.get(device_id) + + def get_edge_pair(self, pair_id): + self.edge_pair_calls.append(pair_id) + a, b = pair_id.split("::") + return _edge_pair(a, b, f"{a}.dev.0.up1", f"{b}.dev.0.swp1") + + def get_paths_section(self): + self.section_calls += 1 + return self._paths + + +@pytest.fixture +def snapshot(): + fetcher = FakeFetcher() + snap = InspectSnapshot( + fetcher=fetcher, + device_items=fetcher.get_device_skeleton(), + edge_items=fetcher.get_edge_skeleton(), + ) + fetcher.skeleton_calls = 0 # reset after construction + return snap, fetcher + + +def test_skeleton_indexes_devices_and_edges(snapshot): + snap, fetcher = snapshot + assert {d.id for d in snap.devices} == {"spine-a", "leaf-a"} + leaf = snap.get_device("leaf-a") + assert leaf.label == "LEAF-A" + assert leaf.is_virtual is True + assert leaf.coordinates == {"x": 0.0, "y": 0.0} + assert len(snap.edges) == 1 + assert fetcher.device_detail_calls == [] # nothing hydrated yet + + +def test_find_by_label(snapshot): + snap, _ = snapshot + assert snap.find_device_by_label("SPINE-A").id == "spine-a" + + +def test_ports_trigger_exactly_one_hydration(snapshot): + snap, fetcher = snapshot + leaf = snap.get_device("leaf-a") + assert leaf.is_hydrated is False + ports = leaf.ports + assert {p.id for p in ports} == {"leaf-a.dev.0.up1", "leaf-a.dev.0.host1"} + assert leaf.is_hydrated is True + # Access again → no second fetch + _ = leaf.ports + assert fetcher.device_detail_calls == ["leaf-a"] + # Other device still skeleton + assert snap.get_device("spine-a").is_hydrated is False + + +def test_edges_do_not_trigger_hydration(snapshot): + snap, fetcher = snapshot + edge = snap.edges[0] + assert edge.from_device.id == "leaf-a" + assert edge.to_device.id == "spine-a" + assert edge.status is not None + assert fetcher.device_detail_calls == [] + + +def test_edge_from_port_triggers_owning_device_hydration(snapshot): + snap, fetcher = snapshot + edge = snap.edges[0] + port = edge.from_port + assert port is not None + assert port.id == "leaf-a.dev.0.up1" + assert fetcher.device_detail_calls == ["leaf-a"] + + +def test_services_section_is_lazy(snapshot): + snap, fetcher = snapshot + assert fetcher.section_calls == 0 + services = snap.services + assert {s.booking_id for s in services} == {"1001"} + _ = snap.services # second access + assert fetcher.section_calls == 1 + assert {d.id for d in snap.get_services_for_device("leaf-a")[0].path_devices} == {"leaf-a", "spine-a"} + + +def test_linked_devices_from_edges(snapshot): + snap, _ = snapshot + assert {d.id for d in snap.get_device("leaf-a").linked_devices} == {"spine-a"} + + +def test_preload_hydrates_all(snapshot): + snap, fetcher = snapshot + snap.preload() + assert set(fetcher.device_detail_calls) == {"spine-a", "leaf-a"} + assert snap.get_device("spine-a").is_hydrated + + +def test_refresh_returns_new_snapshot(snapshot): + snap, fetcher = snapshot + new = snap.refresh() + assert new is not snap + assert fetcher.skeleton_calls == 1 + assert {d.id for d in new.devices} == {"spine-a", "leaf-a"} + + +def test_post_commit_removes_locally_and_refreshes_pair(snapshot): + snap, fetcher = snapshot + # remove a device locally + snap.apply_post_commit(removed_ids=["spine-a"], mark_paths_stale=False) + assert snap.get_device("spine-a") is None + assert {d.id for d in snap.devices} == {"leaf-a"} + + +def test_post_commit_refreshes_edge_pair(snapshot): + snap, fetcher = snapshot + snap.apply_post_commit(pair_ids=["leaf-a::spine-a"]) + assert "leaf-a::spine-a" in fetcher.edge_pair_calls + + +def test_post_commit_marks_paths_stale(snapshot): + snap, fetcher = snapshot + _ = snap.services # load section + assert fetcher.section_calls == 1 + snap.apply_post_commit(mark_paths_stale=True) + _ = snap.services # reload + assert fetcher.section_calls == 2 + + +def test_full_snapshot_is_hydrated_without_fetcher(): + fetcher = FakeFetcher() + # Build a full snapshot manually (device_level=FULL, path_items provided) + snap = InspectSnapshot( + fetcher=None, + device_items=[fetcher._details["leaf-a"], fetcher._details["spine-a"]], + edge_items=[_edge_pair("leaf-a", "spine-a", "leaf-a.dev.0.up1", "spine-a.dev.0.swp1")], + device_level=HydrationLevel.FULL, + path_items=fetcher._paths, + ) + leaf = snap.get_device("leaf-a") + assert leaf.is_hydrated + assert len(leaf.ports) == 2 + # sections available without a fetcher + assert {s.booking_id for s in snap.services} == {"1001"} + # refresh without a fetcher raises + with pytest.raises(RuntimeError): + snap.refresh() From 5e51d3c6367bb17f6e530c0ddd74d5afc7f2bf34 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:03:00 +0200 Subject: [PATCH 05/15] e2e test improvements --- docs/inspect-app/implementation-plan.md | 40 ++- tests/e2e/inspect/leaf_spine_topology.json | 377 +++++++++++++++++++++ tests/e2e/inspect/scenario.py | 299 ++++++++-------- tests/e2e/inspect/test_e2e_leaf_spine.py | 157 +++++---- 4 files changed, 631 insertions(+), 242 deletions(-) create mode 100644 tests/e2e/inspect/leaf_spine_topology.json diff --git a/docs/inspect-app/implementation-plan.md b/docs/inspect-app/implementation-plan.md index d1d5370..edf38a0 100644 --- a/docs/inspect-app/implementation-plan.md +++ b/docs/inspect-app/implementation-plan.md @@ -240,33 +240,31 @@ Add `InspectDevice.is_hydrated` / `snapshot.fetched_at(...)` for introspection. - **Changeset unit tests** (`tests/inspect/test_changeset.py`): staging→baseline capture, intent application, conflict detection (mutated baseline → error with field diffs), `check_conflicts=False` bypass, three-flag result evaluation incl. the captured failure fixtures, affected-set derivation. - **Query tests**: encoding, 414 length guard, projection constants stability. -### 6.2 E2E — live instance, leaf-spine scenario (ADR-0005) +### 6.2 E2E — live instance, topology replica (ADR-0005) -Location `tests/e2e/inspect/`, pytest marker `e2e` (excluded by default via `addopts = -m "not e2e"`), gated on `VIPAT_E2E_ENABLED=1` in `tests/.env.test` **plus** a server-version allowlist check; every created element uses label prefix `E2E-` and tag `#vipat-e2e` so setup/teardown is idempotent and safe on a shared local instance. +Location `tests/e2e/inspect/`, pytest marker `e2e` (excluded by default via `addopts = -m "not e2e"`), gated on `VIPAT_E2E_ENABLED=1` in `tests/.env.test` **plus** a server-version allowlist check. Every created element uses label prefix `E2E-` (in both inventory and inspect) and the `vipat-e2e` tag. -**Scenario: `LeafSpineScenario` (module `tests/e2e/inspect/scenario.py`)** — a fixed dataclass-defined network modelled on the real rig (red/blue planes, leaf-spine, endpoints): +**Namespace & lifecycle:** cleanup runs **at startup** (`scenario.cleanup(app)`), not on teardown — so after a run the built topology **persists** in VideoIPath for manual inspection, and the next run starts from a clean namespace. Mutating tests revert their own changes, so the persisted end state is the complete topology. -| Role | Devices (virtual) | Ports (ip vertices, in/out pairs) | -| ---- | ----------------- | -------------------------------- | -| Spines | `E2E-SPINE-A`, `E2E-SPINE-B` | 4 × downlink (`swp1..4`) | -| Leaves | `E2E-LEAF-A1`, `E2E-LEAF-A2` (red), `E2E-LEAF-B1`, `E2E-LEAF-B2` (blue) | 2 × uplink, 4 × host port | -| Endpoints | `E2E-ENC-1`, `E2E-ENC-2`, `E2E-DEC-1`, `E2E-DEC-2` | `eth-a` (red), `eth-b` (blue) | +**Scenario: `LeafSpineScenario` (module `tests/e2e/inspect/scenario.py`)** — an anonymized replica of the local instance's topology, captured to `leaf_spine_topology.json` (indices/coordinates/link-pairs only; no real names/ids). Built with **virtual (mock-driver) devices only** via the real user flow: -Edge matrix (all bidirectional pairs): each leaf ↔ its plane's spine (uplinks, capacity 65535), each endpoint `eth-a` ↔ a red leaf host port and `eth-b` ↔ a blue leaf host port. Grid coordinates per role (spines y=0, leaves y=200, endpoints y=400). +1. **Inventory** — `app.inventory.create_device(driver="com.nevion.mock-0.1.0")` with a router module sized to the device's degree (`num_router_ports`), then `add_device`. No hardware; the topology app is never used. +2. **Inspect** — `add_devices_to_topology` (placement at the captured coordinates), `update_device(label=…, tags=[…])` to set the E2E display label + tag, then `connect` each link (ports discovered from `device.ports`). -**Build path** (respects verified server semantics): virtual devices + their vertices are created via the *topology app* (`create_virtual_device()` + `update_device` — vertices cannot be created through `updateTopology`, and virtual devices need no hardware); everything Inspect *owns* is then done through `app.inspect`: placement, connect (paired edges), edits, removals. Scenario object exposes `build(app)`, `teardown(app)` (delete by tag/prefix — edges first, then devices), `assert_clean(app)`. +`cleanup` removes edges, then the topology node (`remove_device_from_topology`), then the inventory entry — discovering E2E devices by label in **both** inventory and inspect (catches orphans from an aborted run). -**Test list** (`test_e2e_leaf_spine.py`, ordered by dependency, session-scoped scenario fixture): -1. `test_build_and_skeleton_read` — build; skeleton snapshot sees all 10 devices with labels/coordinates; edge count == matrix size; **no** hydration fetches occurred (spy counter). -2. `test_lazy_hydration` — access `dev.ports` on one leaf → exactly one detail fetch; ports/vertex ids match scenario; other devices still skeleton. -3. `test_connectivity_graph` — `linked_devices` of each endpoint == its two leaves; leaf ↔ spine adjacency matches the matrix; `port.edge.to_device` round-trips. -4. `test_edge_pair_refresh` — `update_edge(weight=…)` on one uplink via direct write; assert commit result + snapshot pair record updated (targeted refresh) without full reload. -5. `test_transaction_atomicity` — tx with one valid edge edit + one `remove` of a non-existent id → `InspectCommitError`, nothing applied (weight unchanged on server). -6. `test_conflict_detection` — stage edit for edge E; mutate E out-of-band (second `VideoIPathApp` instance); `commit()` → `InspectCommitConflictError` listing the field; `rebase()` + commit succeeds. -7. `test_device_placement_roundtrip` — `place_device` new coordinates; re-snapshot skeleton shows them; revert. -8. `test_disconnect_connect_cycle` — disconnect one endpoint pair, assert gone from snapshot + collector; reconnect; assert identical to scenario matrix. -9. `test_full_vs_skeleton_equivalence` — `load="full"` snapshot and skeleton+preload produce identical device/port/edge index contents for scenario entities. -10. `test_teardown` — teardown; snapshot contains no `E2E-` devices/edges; runs also as autouse session-finalizer so aborted runs clean up. +**Test list** (`test_e2e_leaf_spine.py`, session-scoped scenario fixture; all reads/writes via `app.inspect`): +1. `test_all_devices_present` — every scenario device appears with its `E2E-` label. +2. `test_skeleton_read_no_hydration` — scenario edge count == `expected_edge_count()`; **no** hydration fetches during the skeleton read (spy counter). +3. `test_lazy_hydration` — `get_device(id).ports` → exactly one detail fetch, cached thereafter; hydration state flips; mock devices expose router ports. +4. `test_connectivity_graph` — `linked_devices` of the busiest device == its fixture adjacency. +5. `test_edge_pair_refresh` — `update_edge(weight=…)`; edge survives targeted refresh without a full reload; revert. +6. `test_transaction_atomicity` — tx with a valid edge edit + a `remove` of a non-existent id → `InspectCommitError`, nothing applied. +7. `test_conflict_detection` — edit an edge; mutate it out-of-band (second `VideoIPathApp`); `commit()` → `InspectCommitConflictError`; `rebase()` + commit succeeds; revert. +8. `test_device_placement_roundtrip` — `place_device` new coordinates; verify; revert. +9. `test_disconnect_reconnect_cycle` — disconnect a link's directed edges, assert gone, reconnect, assert restored. +10. `test_full_vs_skeleton_equivalence` — `load="full"` and skeleton+preload resolve the same connectivity graph. +11. `test_state_persists` — after the suite the complete topology (all devices + edges) remains. Never touched: non-`E2E-` devices, bookings/services (read-only asserts only), profiles/security sections. diff --git a/tests/e2e/inspect/leaf_spine_topology.json b/tests/e2e/inspect/leaf_spine_topology.json new file mode 100644 index 0000000..a1cf622 --- /dev/null +++ b/tests/e2e/inspect/leaf_spine_topology.json @@ -0,0 +1,377 @@ +{ + "_comment": "A dummy VideoIPath spine-leaf topology (indices/coords/links only). Built with mock (virtual) devices in the E2E suite.", + "devices": [ + { + "name": "E2E-DEV-00", + "x": -2301, + "y": 8141, + "ports": 2 + }, + { + "name": "E2E-DEV-01", + "x": -1496, + "y": 8082, + "ports": 1 + }, + { + "name": "E2E-DEV-02", + "x": 2100, + "y": 8800, + "ports": 11 + }, + { + "name": "E2E-DEV-03", + "x": 3600, + "y": 8800, + "ports": 4 + }, + { + "name": "E2E-DEV-04", + "x": 3000, + "y": 8800, + "ports": 4 + }, + { + "name": "E2E-DEV-05", + "x": 1500, + "y": 8800, + "ports": 9 + }, + { + "name": "E2E-DEV-06", + "x": 0, + "y": 0, + "ports": 1 + }, + { + "name": "E2E-DEV-07", + "x": 1600, + "y": 9300, + "ports": 2 + }, + { + "name": "E2E-DEV-08", + "x": 2000, + "y": 9300, + "ports": 2 + }, + { + "name": "E2E-DEV-09", + "x": 1600, + "y": 9050, + "ports": 1 + }, + { + "name": "E2E-DEV-10", + "x": -2000, + "y": 7800, + "ports": 3 + }, + { + "name": "E2E-DEV-11", + "x": 0, + "y": 0, + "ports": 1 + }, + { + "name": "E2E-DEV-12", + "x": 1600, + "y": 10300, + "ports": 2 + }, + { + "name": "E2E-DEV-13", + "x": 0, + "y": 0, + "ports": 1 + }, + { + "name": "E2E-DEV-14", + "x": 2000, + "y": 9800, + "ports": 2 + }, + { + "name": "E2E-DEV-15", + "x": 0, + "y": 8400, + "ports": 7 + }, + { + "name": "E2E-DEV-16", + "x": 0, + "y": 7600, + "ports": 7 + }, + { + "name": "E2E-DEV-17", + "x": -1800, + "y": 8600, + "ports": 3 + }, + { + "name": "E2E-DEV-18", + "x": 500, + "y": 7850, + "ports": 2 + }, + { + "name": "E2E-DEV-19", + "x": -2800, + "y": 8200, + "ports": 2 + }, + { + "name": "E2E-DEV-20", + "x": 3500, + "y": 9300, + "ports": 2 + }, + { + "name": "E2E-DEV-21", + "x": 767, + "y": 8434, + "ports": 1 + }, + { + "name": "E2E-DEV-22", + "x": 1600, + "y": 9800, + "ports": 2 + }, + { + "name": "E2E-DEV-23", + "x": 2000, + "y": 9550, + "ports": 2 + }, + { + "name": "E2E-DEV-24", + "x": 1600, + "y": 9550, + "ports": 2 + }, + { + "name": "E2E-DEV-25", + "x": 900, + "y": 7850, + "ports": 2 + }, + { + "name": "E2E-DEV-26", + "x": 0, + "y": 0, + "ports": 1 + }, + { + "name": "E2E-DEV-27", + "x": 3500, + "y": 9050, + "ports": 2 + }, + { + "name": "E2E-DEV-28", + "x": 3100, + "y": 9300, + "ports": 2 + }, + { + "name": "E2E-DEV-29", + "x": 1046, + "y": 8159, + "ports": 2 + } + ], + "links": [ + [ + 0, + 10, + 1 + ], + [ + 0, + 17, + 1 + ], + [ + 2, + 7, + 1 + ], + [ + 2, + 8, + 1 + ], + [ + 2, + 9, + 1 + ], + [ + 2, + 12, + 1 + ], + [ + 2, + 14, + 1 + ], + [ + 2, + 15, + 2 + ], + [ + 2, + 21, + 1 + ], + [ + 2, + 22, + 1 + ], + [ + 2, + 23, + 1 + ], + [ + 2, + 24, + 1 + ], + [ + 3, + 15, + 1 + ], + [ + 3, + 20, + 1 + ], + [ + 3, + 27, + 1 + ], + [ + 3, + 28, + 1 + ], + [ + 4, + 16, + 1 + ], + [ + 4, + 20, + 1 + ], + [ + 4, + 27, + 1 + ], + [ + 4, + 28, + 1 + ], + [ + 5, + 7, + 1 + ], + [ + 5, + 8, + 1 + ], + [ + 5, + 12, + 1 + ], + [ + 5, + 14, + 1 + ], + [ + 5, + 16, + 2 + ], + [ + 5, + 22, + 1 + ], + [ + 5, + 23, + 1 + ], + [ + 5, + 24, + 1 + ], + [ + 10, + 16, + 1 + ], + [ + 10, + 19, + 1 + ], + [ + 15, + 17, + 1 + ], + [ + 15, + 18, + 1 + ], + [ + 15, + 25, + 1 + ], + [ + 15, + 29, + 1 + ], + [ + 16, + 18, + 1 + ], + [ + 16, + 25, + 1 + ], + [ + 16, + 29, + 1 + ], + [ + 17, + 19, + 1 + ] + ] +} \ No newline at end of file diff --git a/tests/e2e/inspect/scenario.py b/tests/e2e/inspect/scenario.py index 59706ff..b66ffc2 100644 --- a/tests/e2e/inspect/scenario.py +++ b/tests/e2e/inspect/scenario.py @@ -1,25 +1,27 @@ -"""Fixed leaf-spine scenario for the Inspect E2E suite ([ADR-0005]). +"""Topology scenario for the Inspect E2E suite ([ADR-0005]). -Declaratively models a small, fully-flexed leaf-spine network (two planes, red/blue), modelled on -the real local rig. Devices and their IP vertices are created through the **topology app** (vertices -cannot be created via ``updateTopology`` — [ADR-0009]); placement, connections, edits and removals -that Inspect owns are then driven through ``app.inspect``. +Replicates the structure of the local VideoIPath instance (anonymized to indices/coordinates/links +in ``leaf_spine_topology.json``) using **virtual (mock-driver) devices only**. The build path is the +real user flow: -Every created element is namespaced so a shared instance is safe: - * device/vertex labels start with ``E2E-``, - * every element carries the ``vipat-e2e`` tag. + 1. **Inventory** — create each device with the ``com.nevion.mock`` driver (a simulated device with + configurable router ports) and ``add_device`` it. No real hardware is involved. + 2. **Inspect** — add the devices to the topology graph, set their E2E display label + tag, then + connect their ports. -Vertex ids are **discovered** from an Inspect snapshot after creation (each vertex is created with a -unique descriptor label and looked up by ``port.label``), so the scenario does not depend on the -server's vertex-id format. +The topology app is never used. -NOTE (developer-run): the exact virtual-device build calls exercise the *experimental* topology -virtual-device API; confirm labels/ids on the first live run against your instance. +Namespacing: every device carries the ``E2E-`` label prefix (in both inventory and inspect) and the +``vipat-e2e`` tag. Cleanup runs at **startup** (``cleanup``), not on teardown — so the built +topology persists in VideoIPath after a run for manual inspection, and the next run starts fresh. """ from __future__ import annotations +import json +from collections import defaultdict from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -27,189 +29,170 @@ E2E_PREFIX = "E2E-" E2E_TAG = "vipat-e2e" +MOCK_DRIVER = "com.nevion.mock-0.1.0" +TOPOLOGY_FILE = Path(__file__).parent / "leaf_spine_topology.json" -# Grid coordinates per role tier. -_Y_SPINE = 0.0 -_Y_LEAF = 200.0 -_Y_ENDPOINT = 400.0 -_X_STEP = 300.0 +# The fixture coordinates are captured from the live topology, so the replica would sit right on top +# of it. Shift the whole E2E topology into its own region of the map so it never overlaps. +POSITION_OFFSET_X = 0 +POSITION_OFFSET_Y = 3000 @dataclass(frozen=True) class DeviceSpec: - label: str - icon_type: str - ports: tuple[str, ...] - x: float - y: float + name: str + x: int + y: int + ports: int @dataclass(frozen=True) class LinkSpec: - """A bidirectional connection between two ports (built as two directed edges).""" - - from_device: str - from_port: str - to_device: str - to_port: str - - -def _vertex_token(device_label: str, port: str, direction: str) -> str: - """Unique descriptor label used to discover the created vertex on the Inspect surface.""" - return f"{device_label}|{port}|{direction}" - - -def _build_specs() -> tuple[list[DeviceSpec], list[LinkSpec]]: - spines = [ - DeviceSpec("E2E-SPINE-A", "ipSwitchRouter", tuple(f"downlink{i}" for i in range(4)), 0 * _X_STEP, _Y_SPINE), - DeviceSpec("E2E-SPINE-B", "ipSwitchRouter", tuple(f"downlink{i}" for i in range(4)), 3 * _X_STEP, _Y_SPINE), - ] - leaf_ports = ("uplink0", "uplink1", "host0", "host1", "host2", "host3") - leaves = [ - DeviceSpec("E2E-LEAF-A1", "ipSwitchRouter", leaf_ports, 0 * _X_STEP, _Y_LEAF), - DeviceSpec("E2E-LEAF-A2", "ipSwitchRouter", leaf_ports, 1 * _X_STEP, _Y_LEAF), - DeviceSpec("E2E-LEAF-B1", "ipSwitchRouter", leaf_ports, 2 * _X_STEP, _Y_LEAF), - DeviceSpec("E2E-LEAF-B2", "ipSwitchRouter", leaf_ports, 3 * _X_STEP, _Y_LEAF), - ] - endpoint_ports = ("eth-a", "eth-b") - endpoints = [ - DeviceSpec("E2E-ENC-1", "encoder", endpoint_ports, 0 * _X_STEP, _Y_ENDPOINT), - DeviceSpec("E2E-ENC-2", "encoder", endpoint_ports, 1 * _X_STEP, _Y_ENDPOINT), - DeviceSpec("E2E-DEC-1", "decoder", endpoint_ports, 2 * _X_STEP, _Y_ENDPOINT), - DeviceSpec("E2E-DEC-2", "decoder", endpoint_ports, 3 * _X_STEP, _Y_ENDPOINT), - ] - - links: list[LinkSpec] = [ - # Uplinks: each leaf's uplink0 to a downlink on its plane's spine. - LinkSpec("E2E-LEAF-A1", "uplink0", "E2E-SPINE-A", "downlink0"), - LinkSpec("E2E-LEAF-A2", "uplink0", "E2E-SPINE-A", "downlink1"), - LinkSpec("E2E-LEAF-B1", "uplink0", "E2E-SPINE-B", "downlink0"), - LinkSpec("E2E-LEAF-B2", "uplink0", "E2E-SPINE-B", "downlink1"), - # Endpoint red-plane (eth-a) and blue-plane (eth-b) attachments. - LinkSpec("E2E-ENC-1", "eth-a", "E2E-LEAF-A1", "host0"), - LinkSpec("E2E-ENC-1", "eth-b", "E2E-LEAF-B1", "host0"), - LinkSpec("E2E-ENC-2", "eth-a", "E2E-LEAF-A1", "host1"), - LinkSpec("E2E-ENC-2", "eth-b", "E2E-LEAF-B1", "host1"), - LinkSpec("E2E-DEC-1", "eth-a", "E2E-LEAF-A2", "host0"), - LinkSpec("E2E-DEC-1", "eth-b", "E2E-LEAF-B2", "host0"), - LinkSpec("E2E-DEC-2", "eth-a", "E2E-LEAF-A2", "host1"), - LinkSpec("E2E-DEC-2", "eth-b", "E2E-LEAF-B2", "host1"), - ] - return [*spines, *leaves, *endpoints], links + a: int # device index + b: int # device index + count: int # number of parallel bidirectional links -class LeafSpineScenario: - """Builds, inspects, and tears down the fixed leaf-spine network on a live instance.""" +def _vertex_slot(vertex_id: str) -> int: + """Sort key: the trailing integer of a mock router vertex id (``device59.11.7`` -> 7).""" + return int(vertex_id.rsplit(".", 1)[-1]) + +class LeafSpineScenario: def __init__(self) -> None: - self.devices, self.links = _build_specs() - # Resolved after build(): - self.device_ids: dict[str, str] = {} # label -> device id - self._vertex_ids: dict[str, str] = {} # token -> inspect vertex id + data = json.loads(TOPOLOGY_FILE.read_text()) + # Bake the offset into the specs so build placement and the placement-revert test agree. + self.devices = [ + DeviceSpec(d["name"], d["x"] + POSITION_OFFSET_X, d["y"] + POSITION_OFFSET_Y, d["ports"]) + for d in data["devices"] + ] + self.links = [LinkSpec(a, b, n) for a, b, n in data["links"]] + self.device_ids: dict[str, str] = {} # E2E name -> VideoIPath device id + self._out: dict[str, list[str]] = {} # name -> ordered out-vertex ids + self._in: dict[str, list[str]] = {} # name -> ordered in-vertex ids - # --- Introspection helpers --- + # --- Introspection --- @property - def device_labels(self) -> list[str]: - return [d.label for d in self.devices] + def device_names(self) -> list[str]: + return [d.name for d in self.devices] - def device_id(self, label: str) -> str: - return self.device_ids[label] + def device_id(self, name: str) -> str: + return self.device_ids[name] - def out_vertex(self, label: str, port: str) -> str: - return self._vertex_ids[_vertex_token(label, port, "out")] - - def in_vertex(self, label: str, port: str) -> str: - return self._vertex_ids[_vertex_token(label, port, "in")] + def adjacency(self) -> dict[str, set[str]]: + """Undirected neighbour set per device name (from the fixture).""" + adj: dict[str, set[str]] = {d.name: set() for d in self.devices} + for link in self.links: + na, nb = self.devices[link.a].name, self.devices[link.b].name + adj[na].add(nb) + adj[nb].add(na) + return adj def expected_edge_count(self) -> int: # Two directed edges per bidirectional link. - return len(self.links) * 2 + return sum(link.count for link in self.links) * 2 - # --- Build --- + def a_link(self) -> tuple[str, str]: + """A representative connected device pair (names).""" + link = self.links[0] + return self.devices[link.a].name, self.devices[link.b].name - def build(self, app: "VideoIPathApp") -> None: - for spec in self.devices: - device_id = self._create_device(app, spec) - self.device_ids[spec.label] = device_id - # Place the devices on the grid (they are already graph elements after creation). - with app.inspect.transaction() as tx: - for spec in self.devices: - tx.place_device(self.device_ids[spec.label], spec.x, spec.y) - tx.commit() - self._connect_links(app) - - def _create_device(self, app: "VideoIPathApp", spec: DeviceSpec) -> str: - device = app.topology.create_virtual_device() - device.configuration.base_device.label = spec.label - device.configuration.base_device.tags = [E2E_TAG] - device.add_virtual_module() - for port in spec.ports: - for direction, api_dir in (("out", "Out"), ("in", "In")): - vertex = device.add_virtual_ip_vertex(vertex_direction=api_dir) - vertex.label = _vertex_token(spec.label, port, direction) - vertex.tags = [E2E_TAG] - app.topology.add_device_initially(device) - # add_device_initially assigns the next free virtual id onto the base device; read it - # directly rather than looking up by label (the label index is only eventually consistent). - device_id = device.configuration.base_device.id - if not device_id: - raise RuntimeError(f"Device '{spec.label}' has no id after creation.") - # Virtual-device vertices do not surface as ports in the Inspect nodeStatus, so capture - # their ids straight from the created graph elements (keyed by the descriptor label we set). - for vtx in device.configuration.ip_vertices: - self._vertex_ids[vtx.label] = vtx.id - return device_id - - def _connect_links(self, app: "VideoIPathApp") -> None: - with app.inspect.transaction() as tx: - for link in self.links: - tx.connect( - self.out_vertex(link.from_device, link.from_port), - self.in_vertex(link.to_device, link.to_port), - bidirectional=False, - ) - tx.connect( - self.out_vertex(link.to_device, link.to_port), - self.in_vertex(link.from_device, link.from_port), - bidirectional=False, - ) - tx.commit() + # --- Startup cleanup (removes any prior E2E state) --- - # --- Teardown --- + def cleanup(self, app: "VideoIPathApp") -> None: + """Remove every E2E device (and its edges) so a run starts from a clean namespace. - def teardown(self, app: "VideoIPathApp") -> None: - """Remove all scenario edges then all scenario devices. Safe to call repeatedly. - - Discovers the currently-present E2E devices and their edges from a live snapshot (by the - ``E2E-`` label prefix), so it also cleans up orphaned runs and never tries to remove an edge - that is already gone. + A device lives in two places — the inventory and the inspect topology graph — and removing + it from one does not remove it from the other. So we discover E2E devices by their ``E2E-`` + label in **both** (catching orphans from an aborted run) and remove edges, the topology node, + and the inventory entry. """ + inventory_labels = app.inventory._inventory_api.fetch_devices_user_defined_labels_as_dict() + inventory_ids = {i for i, label in inventory_labels.items() if (label or "").startswith(E2E_PREFIX)} app.inspect.refresh() - # Only touch devices that currently exist (discovered by label prefix), so teardown is - # idempotent — a second call finds nothing and is a no-op. - known_ids = {d.id for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)} - if not known_ids: + topology_ids = {d.id for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)} + all_ids = inventory_ids | topology_ids + if not all_ids: return - # Edges first (only the ones that actually exist). + # Edges first (only those that actually exist). edge_ids = [ edge.id for edge in app.inspect.edges - if (edge.from_device and edge.from_device.id in known_ids) - or (edge.to_device and edge.to_device.id in known_ids) + if (edge.from_device and edge.from_device.id in all_ids) + or (edge.to_device and edge.to_device.id in all_ids) ] if edge_ids: with app.inspect.transaction() as tx: for edge_id in edge_ids: tx.remove(edge_id) tx.commit(check_conflicts=False) - for device_id in known_ids: + # Remove the topology node, then the inventory entry. + for device_id in topology_ids: + try: + app.inspect.remove_device_from_topology(device_id) + except Exception: # best-effort cleanup + pass + for device_id in inventory_ids: try: - app.topology.remove_device_by_id(device_id, ignore_affected_services=True) - except Exception: # already gone / not an nGraphElement — best-effort cleanup + app.inventory.remove_device(device_id=device_id, check_remove=False) + except Exception: # best-effort cleanup pass - def assert_clean(self, app: "VideoIPathApp") -> None: + # --- Build --- + + def build(self, app: "VideoIPathApp") -> None: + self._create_inventory_devices(app) + # Add to the inspect topology graph at their coordinates. + app.inspect.add_devices_to_topology([(self.device_ids[s.name], s.x, s.y) for s in self.devices]) + # Configure them in inspect: E2E display label + tag. + with app.inspect.transaction() as tx: + for spec in self.devices: + tx.update_device(self.device_ids[spec.name], label=spec.name, tags=[E2E_TAG]) + tx.commit() + self._discover_ports(app) + self._connect(app) + + def _create_inventory_devices(self, app: "VideoIPathApp") -> None: + for i, spec in enumerate(self.devices): + device = app.inventory.create_device(driver=MOCK_DRIVER) + device.configuration.label = spec.name + device.configuration.address = f"10.99.{i // 256}.{i % 256}" + settings = device.configuration.custom_settings + settings.num_router_modules = 1 + settings.num_router_ports = spec.ports + settings.num_codec_modules = 0 + online = app.inventory.add_device(device=device, address_check=False) + self.device_ids[spec.name] = online.configuration.device_id + + def _discover_ports(self, app: "VideoIPathApp") -> None: app.inspect.refresh() - leftover = [d.label for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)] - assert not leftover, f"E2E devices still present after teardown: {leftover}" + app.inspect.preload(list(self.device_ids.values())) + for spec in self.devices: + device = app.inspect.get_device(self.device_ids[spec.name]) + outs: list[str] = [] + ins: list[str] = [] + for port in device.ports: + label = port.label or "" + if not port.vertex_id: + continue + if "Router Out" in label: + outs.append(port.vertex_id) + elif "Router In" in label: + ins.append(port.vertex_id) + self._out[spec.name] = sorted(outs, key=_vertex_slot) + self._in[spec.name] = sorted(ins, key=_vertex_slot) + + def _connect(self, app: "VideoIPathApp") -> None: + slot: dict[str, int] = defaultdict(int) + with app.inspect.transaction() as tx: + for link in self.links: + na, nb = self.devices[link.a].name, self.devices[link.b].name + for _ in range(link.count): + ka, kb = slot[na], slot[nb] + slot[na] += 1 + slot[nb] += 1 + # Bidirectional link between interface ka on A and kb on B. + tx.connect(self._out[na][ka], self._in[nb][kb], bidirectional=False) + tx.connect(self._out[nb][kb], self._in[na][ka], bidirectional=False) + tx.commit() diff --git a/tests/e2e/inspect/test_e2e_leaf_spine.py b/tests/e2e/inspect/test_e2e_leaf_spine.py index c39343b..7b40431 100644 --- a/tests/e2e/inspect/test_e2e_leaf_spine.py +++ b/tests/e2e/inspect/test_e2e_leaf_spine.py @@ -1,12 +1,13 @@ -"""End-to-end Inspect tests against a live instance using the fixed leaf-spine scenario. +"""End-to-end Inspect tests against a live instance, replicating the local topology. -Developer-run only (see ``tests/e2e/conftest.py``). Ordered by dependency and sharing one -session-scoped scenario that is built once and torn down at the end (an autouse finalizer also -cleans up if a run aborts). All writes stay inside the ``E2E-`` / ``vipat-e2e`` namespace. +Developer-run only (see ``tests/e2e/conftest.py``). A session-scoped fixture cleans up any prior +E2E state **at startup** and then builds the topology with virtual (mock-driver) devices via the +inventory → inspect flow. There is **no teardown**: the built topology persists in VideoIPath after +the run (the next run cleans it up and rebuilds). Mutating tests revert their own changes, so the +persisted end state is the complete topology. -Everything goes through ``app.inspect`` — the app owns its topology view internally; tests call -``app.inspect.refresh()`` to get a fresh read and then use ``app.inspect.devices`` / ``get_device`` / -``edges`` directly. +Everything goes through ``app.inventory`` (device creation) and ``app.inspect`` (topology). The +topology app is never used. Run with:: @@ -20,7 +21,8 @@ from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp -from .scenario import E2E_PREFIX, LeafSpineScenario +from .scenario import LeafSpineScenario + pytestmark = pytest.mark.e2e @@ -28,11 +30,9 @@ @pytest.fixture(scope="session") def scenario(app: VideoIPathApp): scn = LeafSpineScenario() - scn.teardown(app) # clean slate from any aborted prior run + scn.cleanup(app) # startup cleanup only — no teardown, state persists after the run scn.build(app) - yield scn - scn.teardown(app) - scn.assert_clean(app) + return scn class _FetchSpy: @@ -55,73 +55,98 @@ def __exit__(self, *exc): self._api.get_device_detail = self._orig -def test_build_and_skeleton_read(app, scenario): +def _edges_between(app, id_a: str, id_b: str): + return [ + e + for e in app.inspect.edges + if e.from_device and e.to_device and {e.from_device.id, e.to_device.id} == {id_a, id_b} + ] + + +def _busiest_device(scenario) -> str: + adjacency = scenario.adjacency() + return max(scenario.device_names, key=lambda n: len(adjacency[n])) + + +def test_all_devices_present(app, scenario): + app.inspect.refresh() + labels = {d.label for d in app.inspect.devices} + for name in scenario.device_names: + assert name in labels + + +def test_skeleton_read_no_hydration(app, scenario): app.inspect.refresh() with _FetchSpy(app.inspect._inspect_api) as spy: - labels = {d.label for d in app.inspect.devices} - for expected in scenario.device_labels: - assert expected in labels known = set(scenario.device_ids.values()) scenario_edges = [ e for e in app.inspect.edges if (e.from_device and e.from_device.id in known) or (e.to_device and e.to_device.id in known) ] - assert len(scenario_edges) >= scenario.expected_edge_count() - assert spy.count == 0 # skeleton read triggers no hydration + assert len(scenario_edges) == scenario.expected_edge_count() + assert spy.count == 0 # skeleton read triggers no per-device hydration def test_lazy_hydration(app, scenario): - # Virtual devices do not expose ports in nodeStatus; this asserts the hydration *mechanic* - # (exactly one detail fetch per device, cached thereafter, hydration state flips). app.inspect.refresh() - leaf_id = scenario.device_id("E2E-LEAF-A1") - other_id = scenario.device_id("E2E-SPINE-A") - assert not app.inspect.is_device_hydrated(leaf_id) + name = _busiest_device(scenario) + device_id = scenario.device_id(name) + other_id = scenario.device_id(next(n for n in scenario.device_names if n != name)) + assert not app.inspect.is_device_hydrated(device_id) with _FetchSpy(app.inspect._inspect_api) as spy: - _ = app.inspect.get_device(leaf_id).ports # triggers one detail fetch + ports = app.inspect.get_device(device_id).ports # one detail fetch assert spy.count == 1 - _ = app.inspect.get_device(leaf_id).ports # cached, no further fetch + _ = app.inspect.get_device(device_id).ports # cached assert spy.count == 1 - assert app.inspect.is_device_hydrated(leaf_id) + assert len(ports) > 0 # mock devices expose router ports + assert app.inspect.is_device_hydrated(device_id) assert not app.inspect.is_device_hydrated(other_id) def test_connectivity_graph(app, scenario): app.inspect.refresh() - enc1 = app.inspect.get_device(scenario.device_id("E2E-ENC-1")) - linked = {d.label for d in enc1.linked_devices} - assert linked == {"E2E-LEAF-A1", "E2E-LEAF-B1"} - # Spine adjacency: SPINE-A is linked to both red leaves. - spine_a = app.inspect.get_device(scenario.device_id("E2E-SPINE-A")) - spine_links = {d.label for d in spine_a.linked_devices} - assert {"E2E-LEAF-A1", "E2E-LEAF-A2"} <= spine_links + adjacency = scenario.adjacency() + name = _busiest_device(scenario) + device = app.inspect.get_device(scenario.device_id(name)) + linked = {d.label for d in device.linked_devices} + assert linked == adjacency[name] def test_edge_pair_refresh(app, scenario): - app.inspect.refresh() # load the internal view so the write can update it in place - edge_id = f"{scenario.out_vertex('E2E-LEAF-A1', 'uplink0')}::{scenario.in_vertex('E2E-SPINE-A', 'downlink0')}" + app.inspect.refresh() + name_a, name_b = scenario.a_link() + id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) + edges = _edges_between(app, id_a, id_b) + assert edges + edge_id = edges[0].id result = app.inspect.update_edge(edge_id, weight=7) assert result.ok - # The edge survives the targeted post-commit refresh without a full reload. + # Survives the targeted post-commit refresh without a full reload. assert any(e.id == edge_id for e in app.inspect.edges) app.inspect.update_edge(edge_id, weight=1) # revert def test_transaction_atomicity(app, scenario): - edge_id = f"{scenario.out_vertex('E2E-LEAF-A2', 'uplink0')}::{scenario.in_vertex('E2E-SPINE-A', 'downlink1')}" + name_a, name_b = scenario.a_link() + id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) + app.inspect.refresh() + edge_id = _edges_between(app, id_a, id_b)[0].id with pytest.raises(InspectCommitError): with app.inspect.transaction() as tx: tx.update_edge(edge_id, weight=13) tx.remove("does-not-exist::also-not-real") tx.commit() - # Nothing applied: the edge is still present on the server. + # Nothing applied: the edge is still present. app.inspect.refresh() assert any(e.id == edge_id for e in app.inspect.edges) def test_conflict_detection(app, scenario): - edge_id = f"{scenario.out_vertex('E2E-LEAF-B1', 'uplink0')}::{scenario.in_vertex('E2E-SPINE-B', 'downlink0')}" + app.inspect.refresh() + name_a, name_b = scenario.a_link() + id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) + edge_id = _edges_between(app, id_a, id_b)[0].id tx = app.inspect.transaction() tx.update_edge(edge_id, weight=21) # Out-of-band change via a second app instance. @@ -136,30 +161,30 @@ def test_conflict_detection(app, scenario): def test_device_placement_roundtrip(app, scenario): - device_id = scenario.device_id("E2E-ENC-1") + name = scenario.device_names[0] + spec = next(s for s in scenario.devices if s.name == name) + device_id = scenario.device_id(name) app.inspect.place_device(device_id, 4200, 4200) app.inspect.refresh() coords = app.inspect.get_device(device_id).coordinates assert coords is not None and coords["x"] == 4200 and coords["y"] == 4200 - # revert to scenario coordinates - spec = next(d for d in scenario.devices if d.label == "E2E-ENC-1") - app.inspect.place_device(device_id, spec.x, spec.y) - - -def test_disconnect_connect_cycle(app, scenario): - a_out = scenario.out_vertex("E2E-DEC-2", "eth-a") - b_in = scenario.in_vertex("E2E-LEAF-A2", "host1") - b_out = scenario.out_vertex("E2E-LEAF-A2", "host1") - a_in = scenario.in_vertex("E2E-DEC-2", "eth-a") - app.inspect.disconnect(a_out, b_in, bidirectional=False) - app.inspect.disconnect(b_out, a_in, bidirectional=False) + app.inspect.place_device(device_id, spec.x, spec.y) # revert + + +def test_disconnect_reconnect_cycle(app, scenario): + app.inspect.refresh() + name_a, name_b = scenario.a_link() + id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) + directed = [tuple(e.id.split("::", 1)) for e in _edges_between(app, id_a, id_b)] + assert directed + for from_vertex, to_vertex in directed: + app.inspect.disconnect(from_vertex, to_vertex, bidirectional=False) app.inspect.refresh() - assert not any(e.id == f"{a_out}::{b_in}" for e in app.inspect.edges) - # reconnect - app.inspect.connect(a_out, b_in, bidirectional=False) - app.inspect.connect(b_out, a_in, bidirectional=False) + assert not _edges_between(app, id_a, id_b) + for from_vertex, to_vertex in directed: + app.inspect.connect(from_vertex, to_vertex, bidirectional=False) app.inspect.refresh() - assert any(e.id == f"{a_out}::{b_in}" for e in app.inspect.edges) + assert len(_edges_between(app, id_a, id_b)) == len(directed) def test_full_vs_skeleton_equivalence(app, scenario): @@ -178,12 +203,18 @@ def graph_view() -> dict: app.inspect.refresh(load="full") full = graph_view() assert skeleton == full - app.inspect.refresh(load="skeleton") # restore default load mode for later tests + app.inspect.refresh(load="skeleton") # restore default load mode -def test_teardown(app, scenario): - # Explicit teardown assertion; the fixture finalizer repeats it for aborted runs. - scenario.teardown(app) - scenario.assert_clean(app) +def test_state_persists(app, scenario): + # There is no teardown: after the suite the complete topology remains in VideoIPath. app.inspect.refresh() - assert not any((d.label or "").startswith(E2E_PREFIX) for d in app.inspect.devices) + labels = {d.label for d in app.inspect.devices} + assert all(name in labels for name in scenario.device_names) + known = set(scenario.device_ids.values()) + scenario_edges = [ + e + for e in app.inspect.edges + if (e.from_device and e.from_device.id in known) or (e.to_device and e.to_device.id in known) + ] + assert len(scenario_edges) == scenario.expected_edge_count() From 664f979e97e15d107822e829f61a6451fdeb55d8 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:21:15 +0200 Subject: [PATCH 06/15] add tags e2e test --- docs/getting-started-guide/05_Inspect.md | 6 +- .../apps/inspect/changeset.py | 10 ++- .../apps/inspect/domain/port.py | 6 ++ .../apps/inspect/model/collector.py | 12 ++++ tests/e2e/inspect/scenario.py | 71 ++++++++++++++++++- tests/e2e/inspect/test_e2e_leaf_spine.py | 18 ++++- tests/inspect/test_changeset.py | 12 ++++ tests/inspect/test_models.py | 12 ++++ 8 files changed, 142 insertions(+), 5 deletions(-) diff --git a/docs/getting-started-guide/05_Inspect.md b/docs/getting-started-guide/05_Inspect.md index 94774fa..0f96ca4 100644 --- a/docs/getting-started-guide/05_Inspect.md +++ b/docs/getting-started-guide/05_Inspect.md @@ -47,7 +47,7 @@ serves from local state: ```python for port in dev.ports: # triggers one hydration fetch for this device - print(port.label, port.vertex_id, port.status) + print(port.label, port.vertex_id, port.status, port.tags) edge = port.edge # local edge-skeleton lookup, no I/O if edge: print("connected to", edge.to_device.label) @@ -100,6 +100,10 @@ app.inspect.update_device("device12", label="BU-LEAF-A", icon_type="ipSwitchRout app.inspect.update_vertex("device12.1.Ethernet1.out", use_as_endpoint=True) app.inspect.update_edge(edge_id, weight=10) +# Assign catalog tags to a port (an Inspect-only capability). Tags are referenced by their +# "Category~~name" id; read them back with port.tags. +app.inspect.update_vertex("device12.1.Ethernet1.out", tags=["Video~~1080p50"]) + app.inspect.connect( "device12.1.Ethernet1.out", "device7.0.swp1.in", diff --git a/src/videoipath_automation_tool/apps/inspect/changeset.py b/src/videoipath_automation_tool/apps/inspect/changeset.py index 6e3e7cd..ea8777d 100644 --- a/src/videoipath_automation_tool/apps/inspect/changeset.py +++ b/src/videoipath_automation_tool/apps/inspect/changeset.py @@ -204,14 +204,20 @@ def update_vertex( label: Optional[str] = None, tags: Optional[list[str]] = None, ) -> "InspectTransaction": - """Edit a vertex (``replaceVertices``; update-only — vertices cannot be created here).""" + """Edit a vertex/port (``replaceVertices``; update-only — vertices cannot be created here). + + ``tags`` assigns catalog tags to the port (as ``Category~~name`` ids); this is the + Inspect-only port-tagging capability, written to the vertex ``localAssignedTags``. + """ entry = self._stage_vertex(vertex_id) if use_as_endpoint is not None: entry.intents["useAsEndpoint"] = use_as_endpoint if label is not None: entry.intents["label"] = label if tags is not None: - entry.intents["tags"] = list(tags) + # Port tag assignment is the vertex's localAssignedTags (verified 2025.4.9); the + # separate ``fields.tags`` list does not register as an assigned tag. + entry.intents["localAssignedTags"] = list(tags) return self # --- Staging: edges --- diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py index 7260d59..cb52497 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/port.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -41,6 +41,12 @@ def module_id(self) -> str | None: def status(self) -> InspectApiStatusSummary | None: return self.indexed.port.status + @property + def tags(self) -> list[str]: + """Tags assigned to this port (as ``Category~~name`` ids). Assign them with + ``app.inspect.update_vertex(port.vertex_id, tags=[...])``.""" + return self.indexed.port.assigned_tags + @property def vertex_id(self) -> str | None: return self._vertex_id_from(self.indexed.port) diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py index ea79520..5b470b2 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/collector.py +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -115,11 +115,23 @@ class InspectPortStatus(InspectApiBaseModel): descriptor: InspectApiDescriptor | None = None label: str | None = None pid: str | None = None + relatedNodeTags: list[str] = Field(default_factory=list) resourceId: str | None = None status: InspectApiStatusSummary | None = None + tagsInfo: dict[str, Any] | None = None vertexInfo: InspectApiSingleVertexInfo | InspectApiDoubleVertexInfo | dict[str, Any] | None = None pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) + @property + def assigned_tags(self) -> list[str]: + """Effective tags assigned to this port (from ``tagsInfo.assigned.all``).""" + if not self.tagsInfo: + return [] + assigned = self.tagsInfo.get("assigned") + if isinstance(assigned, dict) and isinstance(assigned.get("all"), list): + return list(assigned["all"]) + return [] + @property def effective_label(self) -> str | None: if self.descriptor is not None and self.descriptor.label: diff --git a/tests/e2e/inspect/scenario.py b/tests/e2e/inspect/scenario.py index b66ffc2..c93259e 100644 --- a/tests/e2e/inspect/scenario.py +++ b/tests/e2e/inspect/scenario.py @@ -22,7 +22,9 @@ from collections import defaultdict from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any + +import requests if TYPE_CHECKING: from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp @@ -32,6 +34,31 @@ MOCK_DRIVER = "com.nevion.mock-0.1.0" TOPOLOGY_FILE = Path(__file__).parent / "leaf_spine_topology.json" +# A test video tag, created via a simple API request in setup and assigned to a port via the inspect +# app. Tag references are ``Category~~name`` ids; it lives in the existing "Video" format category. +TEST_TAG_CATEGORY = "Video" +TEST_TAG_NAME = "E2E-VIDEO-TAG" +TEST_TAG_ID = f"{TEST_TAG_CATEGORY}~~{TEST_TAG_NAME}" + + +def _raw_request(app: "VideoIPathApp", method: str, path: str, body: dict[str, Any]) -> requests.Response: + """A minimal authenticated REST call (for tag-catalog management, which the package's connector + allow-list intentionally does not cover).""" + rc = app._videoipath_connector.rest + response = getattr(requests, method)( + rc._build_url(path), json=body, auth=(rc._username, rc._password), verify=rc.verify_ssl_cert + ) + response.raise_for_status() + return response + + +def _tag_category(app: "VideoIPathApp", category: str) -> dict[str, Any] | None: + trees = app._videoipath_connector.rest.get("/rest/v2/data/config/tags/tagTrees/**") + for item in trees.data["config"]["tags"]["tagTrees"].get("_items", []): + if item.get("_id") == category: + return item + return None + # The fixture coordinates are captured from the live topology, so the replica would sit right on top # of it. Shift the whole E2E topology into its own region of the map so it never overlaps. POSITION_OFFSET_X = 0 @@ -98,6 +125,45 @@ def a_link(self) -> tuple[str, str]: link = self.links[0] return self.devices[link.a].name, self.devices[link.b].name + # --- Test tag catalog (simple API requests) --- + + def create_test_tag(self, app: "VideoIPathApp") -> None: + """Create the E2E test video tag in the catalog (idempotent).""" + category = _tag_category(app, TEST_TAG_CATEGORY) + if category is None: + raise RuntimeError(f"Tag category '{TEST_TAG_CATEGORY}' not found on the server.") + children = dict(category.get("children") or {}) + children[TEST_TAG_NAME] = {"exclusive": False, "children": {}} + body = { + "actions": [ + { + "_action": "update", + "_id": TEST_TAG_CATEGORY, + "_rev": category["_rev"], + "children": children, + "type": category.get("type", "format"), + "exclusive": category.get("exclusive", False), + "formatTagLinks": category.get("formatTagLinks", {}), + "locationTypes": category.get("locationTypes", []), + } + ] + } + _raw_request(app, "patch", "/rest/v2/data/config/tags/tagTrees", body) + + def test_tag_exists(self, app: "VideoIPathApp") -> bool: + category = _tag_category(app, TEST_TAG_CATEGORY) + return category is not None and TEST_TAG_NAME in (category.get("children") or {}) + + def delete_test_tag(self, app: "VideoIPathApp") -> None: + """Force-delete the test tag (removes it and any port bindings) if it exists.""" + if self.test_tag_exists(app): + _raw_request( + app, + "post", + "/rest/v2/actions/status/tags/forceDeleteTag", + {"header": {"id": 0}, "data": {"tagId": TEST_TAG_ID}}, + ) + # --- Startup cleanup (removes any prior E2E state) --- def cleanup(self, app: "VideoIPathApp") -> None: @@ -108,6 +174,7 @@ def cleanup(self, app: "VideoIPathApp") -> None: label in **both** (catching orphans from an aborted run) and remove edges, the topology node, and the inventory entry. """ + self.delete_test_tag(app) inventory_labels = app.inventory._inventory_api.fetch_devices_user_defined_labels_as_dict() inventory_ids = {i for i, label in inventory_labels.items() if (label or "").startswith(E2E_PREFIX)} app.inspect.refresh() @@ -152,6 +219,8 @@ def build(self, app: "VideoIPathApp") -> None: tx.commit() self._discover_ports(app) self._connect(app) + # Create the test video tag so the port-tagging test can assign it. + self.create_test_tag(app) def _create_inventory_devices(self, app: "VideoIPathApp") -> None: for i, spec in enumerate(self.devices): diff --git a/tests/e2e/inspect/test_e2e_leaf_spine.py b/tests/e2e/inspect/test_e2e_leaf_spine.py index 7b40431..d8bfedd 100644 --- a/tests/e2e/inspect/test_e2e_leaf_spine.py +++ b/tests/e2e/inspect/test_e2e_leaf_spine.py @@ -21,7 +21,7 @@ from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp -from .scenario import LeafSpineScenario +from .scenario import TEST_TAG_ID, LeafSpineScenario pytestmark = pytest.mark.e2e @@ -160,6 +160,22 @@ def test_conflict_detection(app, scenario): app.inspect.update_edge(edge_id, weight=1) # revert +def test_assign_tag_to_port(app, scenario): + # Inspect-only capability: assign a catalog tag to a port. The tag was created in setup. + assert scenario.test_tag_exists(app) + app.inspect.refresh() + name = scenario.a_link()[0] + device_id = scenario.device_id(name) + vertex_id = next( + p.vertex_id for p in app.inspect.get_device(device_id).ports if p.vertex_id and "Router In" in (p.label or "") + ) + result = app.inspect.update_vertex(vertex_id, tags=[TEST_TAG_ID]) + assert result.ok + app.inspect.refresh() + port = next(p for p in app.inspect.get_device(device_id).ports if p.vertex_id == vertex_id) + assert TEST_TAG_ID in port.tags + + def test_device_placement_roundtrip(app, scenario): name = scenario.device_names[0] spec = next(s for s in scenario.devices if s.name == name) diff --git a/tests/inspect/test_changeset.py b/tests/inspect/test_changeset.py index 7b5165e..e69be74 100644 --- a/tests/inspect/test_changeset.py +++ b/tests/inspect/test_changeset.py @@ -200,6 +200,18 @@ def test_update_vertex_sets_endpoint(): assert api.update_calls[0].replaceVertices[A_OUT].useAsEndpoint is True +def test_update_vertex_assigns_tags_as_local(): + # Port tag assignment goes to localAssignedTags, not the plain fields.tags list. + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, tags=["Video~~T"]) + tx.commit() + form = api.update_calls[0].replaceVertices[A_OUT] + assert form.localAssignedTags == ["Video~~T"] + assert form.tags == [] + + def test_remove_appends_to_remove_list(): api = FakeAPI() with _txn(api) as tx: diff --git a/tests/inspect/test_models.py b/tests/inspect/test_models.py index 3d2f25d..47ccf89 100644 --- a/tests/inspect/test_models.py +++ b/tests/inspect/test_models.py @@ -10,6 +10,7 @@ InspectApiExternalEdgesByDeviceKeyItem, InspectApiNodeStatusItem, InspectApiPathItem, + InspectPortStatus, ) from videoipath_automation_tool.apps.inspect.model.update_topology import ( InspectApiUpdateTopologyData, @@ -117,3 +118,14 @@ def test_commit_success_and_failure_flags(load): fail_remove = InspectApiUpdateTopologyResponse.model_validate(load("update_topology_fail_remove.json")) assert fail_remove.committed is False + + +def test_port_assigned_tags_from_tags_info(): + port = InspectPortStatus.model_validate( + { + "_id": "device-a.1.p1", + "tagsInfo": {"assigned": {"all": ["Video~~T"], "inherited": {}, "local": {"Video~~T": {}}}}, + } + ) + assert port.assigned_tags == ["Video~~T"] + assert InspectPortStatus.model_validate({"_id": "device-a.1.p2"}).assigned_tags == [] From 1deafb50d10783617937f8c32bb3a07ec62ba428 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:49:14 +0200 Subject: [PATCH 07/15] improve agent instructions --- .claude/rules/agent-rules-layout.md | 67 ++++++++++++++++++++++++++++ .claude/rules/python-quality.md | 46 +++++++++++++++++++ .claude/rules/python-style.md | 55 +++++++++++++++++++++++ .claude/rules/python-testing.md | 62 +++++++++++++++++++++++++ .cursor/rules/agent-rules-layout.mdc | 1 + .cursor/rules/python-quality.mdc | 1 + .cursor/rules/python-style.mdc | 1 + .cursor/rules/python-testing.mdc | 1 + AGENTS.md | 11 +++++ CLAUDE.md | 1 + 10 files changed, 246 insertions(+) create mode 100644 .claude/rules/agent-rules-layout.md create mode 100644 .claude/rules/python-quality.md create mode 100644 .claude/rules/python-style.md create mode 100644 .claude/rules/python-testing.md create mode 120000 .cursor/rules/agent-rules-layout.mdc create mode 120000 .cursor/rules/python-quality.mdc create mode 120000 .cursor/rules/python-style.mdc create mode 120000 .cursor/rules/python-testing.mdc create mode 120000 CLAUDE.md diff --git a/.claude/rules/agent-rules-layout.md b/.claude/rules/agent-rules-layout.md new file mode 100644 index 0000000..b246ca6 --- /dev/null +++ b/.claude/rules/agent-rules-layout.md @@ -0,0 +1,67 @@ +--- +description: Agent rules directory layout and symlink conventions +alwaysApply: false +globs: .claude/rules/**,.cursor/rules/** +paths: + - ".claude/rules/**/*.md" + - ".cursor/rules/**/*.mdc" +--- + +# Agent rules layout + +This repo shares agent rules between **Claude Code** and **Cursor** via symlinks. + +## Canonical source + +- **Edit rules here:** `.claude/rules/*.md` +- **Do not** put rule content directly in `.cursor/rules/` — those files are symlinks. + +## Cursor bridge + +Cursor requires `.mdc` files in `.cursor/rules/`. Each `.mdc` is a symlink to the matching `.md`: + +```text +.claude/rules/my-rule.md ← canonical (commit this) +.cursor/rules/my-rule.mdc ← symlink → ../.claude/rules/my-rule.md +``` + +## Dual frontmatter + +Every rule file needs frontmatter for both tools: + +```yaml +--- +description: Short summary for Cursor rule picker +alwaysApply: false +globs: path/to/match/** +paths: + - "path/to/match/**" +--- +``` + +- **Cursor** uses `globs`, `alwaysApply`, and `description`. +- **Claude Code** uses `paths`; omit `paths` for always-on rules. +- Each tool ignores the other's keys. + +## Adding a rule + +1. Create `.claude/rules/.md` with dual frontmatter and content. +2. Add the Cursor symlink: + +```bash +ln -s ../.claude/rules/.md .cursor/rules/.mdc +``` + +3. List the new rule in the **Agent rules** section of `AGENTS.md`. + +## Removing a rule + +1. Delete `.claude/rules/.md`. +2. Delete `.cursor/rules/.mdc` (the symlink, not a separate file). +3. Remove its entry from `AGENTS.md`. + +## Do not + +- Symlink the entire `.cursor/rules` directory to `.claude/rules` (extension mismatch: `.mdc` vs `.md`). +- Duplicate rule content in both directories. +- Commit real files under `.cursor/rules/` — only symlinks belong there. diff --git a/.claude/rules/python-quality.md b/.claude/rules/python-quality.md new file mode 100644 index 0000000..0dddc52 --- /dev/null +++ b/.claude/rules/python-quality.md @@ -0,0 +1,46 @@ +--- +description: Python code quality conventions for src/ and tests/ +alwaysApply: false +globs: src/**/*.py,tests/**/*.py +paths: + - "src/**/*.py" + - "tests/**/*.py" +--- + +# Python code quality + +## Structured data + +- Use **Pydantic `BaseModel`** for API responses, settings, and schemas. +- Use **`@dataclass`** only for simple internal structs that do not need validation. +- Prefer typed models over plain `dict` for structured data. + +## Exceptions + +- Raise **specific, descriptive exceptions** rather than bare `Exception`. +- Follow domain error hierarchies: a base exception class plus typed subclasses with context attributes (see `src/videoipath_automation_tool/apps/inspect/errors.py`). + +## Context managers + +- Use `with` for files, locks, test spies, and connector wrappers. + +## Quality gates + +Before finishing Python changes, run: + +```bash +poetry run ruff check --fix src/ tests/ +poetry run ruff format src/ tests/ +``` + +Pre-commit hooks mirror these commands (see `.pre-commit-config.yaml`). + +## Generated and sensitive code + +- Do **not** hand-edit `src/videoipath_automation_tool/apps/inventory/model/drivers.py` — regenerate with `set-videoipath-version `. +- All committed data must follow the anonymization rules in `AGENTS.md`. + +## Change scope + +- Keep diffs minimal and focused. +- Match surrounding patterns: mixins, module docstrings, `TYPE_CHECKING` imports, and existing naming in the file you edit. diff --git a/.claude/rules/python-style.md b/.claude/rules/python-style.md new file mode 100644 index 0000000..92ac676 --- /dev/null +++ b/.claude/rules/python-style.md @@ -0,0 +1,55 @@ +--- +description: Python coding style for src/ and tests/ +alwaysApply: false +globs: src/**/*.py,tests/**/*.py +paths: + - "src/**/*.py" + - "tests/**/*.py" +--- + +# Python coding style + +## Type hints + +- Annotate all function parameters and return types. +- Use `from __future__ import annotations` in new modules. +- Use `TYPE_CHECKING` blocks for imports needed only for type hints. + +## Formatting and naming + +- Follow PEP 8 with **snake_case** for functions, variables, and modules. +- Max line length is **120** characters (ruff formatter default in this project — not Black's 88). +- Format with **ruff**, not Black: + +```bash +poetry run ruff format src/ tests/ +``` + +## Strings and paths + +- Use **f-strings** for string formatting; avoid `%` formatting and `.format()`. +- Use **`pathlib.Path`** over `os.path` for filesystem operations. + +## Comprehensions and readability + +- Prefer list/dict/set comprehensions over explicit loops when the result stays readable. +- Do not sacrifice clarity for brevity. + +## Layout and whitespace + +- Group **logically related lines** together (e.g. setup, core logic, cleanup). +- Separate groups with a **single blank line**; use an extra blank line between larger sections when it aids scanning. +- Do not sprinkle blank lines randomly, and do not leave long unbroken blocks when a visual break would help. +- Within a function, keep the main path easy to follow: inputs and validation first, then the core work, then return/cleanup. + +## Public before private + +- Place **public** API first so readers see the most relevant surface when scrolling: public classes, methods, functions, and module-level constants. +- Place **private** members after public ones: names prefixed with `_` (attributes, methods, functions, nested helpers) and internal implementation details. +- In classes: public methods first, then `_`-prefixed helpers and internal state accessors. +- In modules: public exports and user-facing functions first; private helpers and module-internal constants at the bottom. +- A short section comment (e.g. `# --- Internal ---`) is fine when a class or module has a large private block. + +## Resources + +- Use **context managers** (`with`) for files, locks, and other resources that need cleanup. diff --git a/.claude/rules/python-testing.md b/.claude/rules/python-testing.md new file mode 100644 index 0000000..873425d --- /dev/null +++ b/.claude/rules/python-testing.md @@ -0,0 +1,62 @@ +--- +description: Python testing conventions for src/ and tests/ +alwaysApply: false +globs: src/**/*.py,tests/**/*.py +paths: + - "src/**/*.py" + - "tests/**/*.py" +--- + +# Python testing + +## Framework and commands + +- Use **pytest** for all tests. +- Default suite (excludes e2e): + +```bash +poetry run pytest +``` + +- Single file: + +```bash +poetry run pytest tests/validators/test_device_id.py +``` + +Default `addopts` in `pyproject.toml` run coverage on `src/` and exclude e2e (`-m "not e2e"`). + +## Assertions + +- Use `pytest.raises(SpecificError)` with the exact exception type. +- Do not catch or assert against bare `Exception` when a domain error exists. + +## Unit and offline tests + +- Mock external I/O with fake connectors and lightweight stand-ins (see `tests/inspect/test_actions.py`). +- Load JSON fixtures from `tests/fixtures/` using `pathlib.Path`. +- Put shared fixtures in `conftest.py` at the appropriate directory level. +- Use session-scoped fixtures only when setup is expensive and reuse is intentional. + +## E2E tests + +- Live-server tests live under `tests/e2e/` only. +- Mark with `@pytest.mark.e2e`; they are excluded from the default suite. +- Require `VIPAT_E2E_ENABLED=1` in `tests/.env.test` and run explicitly: + +```bash +poetry run pytest -m e2e tests/e2e/inspect +``` + +- Namespace all writes with the `E2E-` label prefix and `vipat-e2e` tag. +- Do not add e2e tests to the default CI/offline run. + +## Test data + +- All fixture and test data must follow anonymization rules in `AGENTS.md`. +- Preserve structure and relationships; replace real hostnames, IPs, and customer identifiers with generic placeholders. + +## Coverage + +- Default runs report coverage on `src/`. +- Do not disable coverage flags without a clear reason. diff --git a/.cursor/rules/agent-rules-layout.mdc b/.cursor/rules/agent-rules-layout.mdc new file mode 120000 index 0000000..484590f --- /dev/null +++ b/.cursor/rules/agent-rules-layout.mdc @@ -0,0 +1 @@ +../.claude/rules/agent-rules-layout.md \ No newline at end of file diff --git a/.cursor/rules/python-quality.mdc b/.cursor/rules/python-quality.mdc new file mode 120000 index 0000000..5a47a4e --- /dev/null +++ b/.cursor/rules/python-quality.mdc @@ -0,0 +1 @@ +../.claude/rules/python-quality.md \ No newline at end of file diff --git a/.cursor/rules/python-style.mdc b/.cursor/rules/python-style.mdc new file mode 120000 index 0000000..8598caa --- /dev/null +++ b/.cursor/rules/python-style.mdc @@ -0,0 +1 @@ +../.claude/rules/python-style.md \ No newline at end of file diff --git a/.cursor/rules/python-testing.mdc b/.cursor/rules/python-testing.mdc new file mode 120000 index 0000000..94109c9 --- /dev/null +++ b/.cursor/rules/python-testing.mdc @@ -0,0 +1 @@ +../.claude/rules/python-testing.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4556da3..dc0ced0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,17 @@ get-videoipath-version list-videoipath-versions ``` +## Agent rules + +Global repo guidance lives in this file. Path-scoped Python rules are in `.claude/rules/`: + +- `python-style.md` — type hints, ruff formatting, naming, pathlib +- `python-quality.md` — Pydantic, exceptions, lint gates, change scope +- `python-testing.md` — pytest, fixtures, fakes, e2e gating +- `agent-rules-layout.md` — how to add/remove rules and maintain `.cursor/rules/` symlinks + +Cursor reads the same content via symlinks in `.cursor/rules/*.mdc`. Canonical rule files live in `.claude/rules/`; see `agent-rules-layout.md` when creating or deleting rules. Python rules load when working on files under `src/` or `tests/`. + ## Architecture ### Three-layer design diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 65b16e1b3abdc34d9bda778235ac94fc1d195c48 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:04:31 +0200 Subject: [PATCH 08/15] always use pydantic for consistency, remove dataclass usage --- .claude/rules/python-quality.md | 4 +- .../apps/inspect/app/read.py | 4 +- .../apps/inspect/changeset.py | 26 ++++++------ .../apps/inspect/domain/device.py | 9 ++--- .../apps/inspect/domain/edge.py | 8 ++-- .../apps/inspect/domain/port.py | 9 ++--- .../apps/inspect/domain/service.py | 8 ++-- .../apps/inspect/inspect_api.py | 4 +- .../apps/inspect/model/collector.py | 3 +- .../apps/inspect/model/common.py | 10 +++++ .../apps/inspect/queries.py | 2 +- .../apps/inspect/snapshot.py | 31 ++++++-------- tests/e2e/inspect/scenario.py | 19 +++++---- tests/inspect/test_actions.py | 11 ++++- tests/inspect/test_api.py | 21 ++++++++-- tests/inspect/test_changeset.py | 40 ++++++++++++++----- 16 files changed, 124 insertions(+), 85 deletions(-) diff --git a/.claude/rules/python-quality.md b/.claude/rules/python-quality.md index 0dddc52..de5ec19 100644 --- a/.claude/rules/python-quality.md +++ b/.claude/rules/python-quality.md @@ -11,9 +11,9 @@ paths: ## Structured data -- Use **Pydantic `BaseModel`** for API responses, settings, and schemas. -- Use **`@dataclass`** only for simple internal structs that do not need validation. +- Use **Pydantic `BaseModel`** for all structured data — API responses, settings, internal records, result types, and domain wrappers. Do not use `@dataclass`. - Prefer typed models over plain `dict` for structured data. +- Reuse project base models where they exist (e.g. `InspectApiBaseModel` for API shapes; inspect-internal bases for in-memory models). ## Exceptions diff --git a/src/videoipath_automation_tool/apps/inspect/app/read.py b/src/videoipath_automation_tool/apps/inspect/app/read.py index 00bcdb2..47bdac7 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/read.py +++ b/src/videoipath_automation_tool/apps/inspect/app/read.py @@ -47,9 +47,7 @@ def _get_snapshot(self: _HasInspectState) -> InspectSnapshot: def _load_snapshot(self: _HasInspectState, load: LoadMode) -> InspectSnapshot: if load == "full": self._logger.debug("Loading full (eager) Inspect snapshot.") - return InspectSnapshot.from_full_response( - self._inspect_api.get_collector_full(), fetcher=self._inspect_api - ) + return InspectSnapshot.from_full_response(self._inspect_api.get_collector_full(), fetcher=self._inspect_api) self._logger.debug("Loading skeleton Inspect snapshot (devices + edges in parallel).") with ThreadPoolExecutor(max_workers=2) as pool: devices_future = pool.submit(self._inspect_api.get_device_skeleton) diff --git a/src/videoipath_automation_tool/apps/inspect/changeset.py b/src/videoipath_automation_tool/apps/inspect/changeset.py index ea8777d..6cc52b2 100644 --- a/src/videoipath_automation_tool/apps/inspect/changeset.py +++ b/src/videoipath_automation_tool/apps/inspect/changeset.py @@ -24,9 +24,10 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Optional +from pydantic import Field + from videoipath_automation_tool.apps.inspect.errors import ( InspectCommitConflictError, InspectCommitError, @@ -38,6 +39,7 @@ InspectApiLookupInspectDeviceFields, InspectApiVertexEditForm, ) +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel, InspectInternalModel from videoipath_automation_tool.apps.inspect.model.update_topology import ( InspectApiUpdateTopologyData, InspectApiUpdateTopologyResponse, @@ -53,8 +55,7 @@ _EDGE = "edge" -@dataclass(frozen=True) -class CommitResult: +class CommitResult(InspectFrozenModel): """Outcome of a successful commit ([ADR-0006]); a failed commit raises ``InspectCommitError``.""" applied_ids: list[str] @@ -70,8 +71,7 @@ def validation(self) -> Any: return self.response.data.validation -@dataclass -class _Staged: +class _Staged(InspectInternalModel): kind: str entity_id: str # The write-shape baseline (edit/edge form) as fetched at stage time; None for a raw remove. @@ -79,7 +79,7 @@ class _Staged: # JSON dump of the baseline at stage time, used for the compare-and-commit conflict check. baseline_dump: dict[str, Any] | None = None # Field-level intents (wire field names; dotted for one level of nesting, e.g. "descriptor.label"). - intents: dict[str, Any] = field(default_factory=dict) + intents: dict[str, Any] = Field(default_factory=dict) remove: bool = False is_new: bool = False @@ -384,10 +384,12 @@ def _stage_new_edge( edge_id = _edge_key(from_vertex, to_vertex) existing = self._lookup_edge_form(edge_id) if existing is not None and not overwrite: - raise ValueError( - f"Edge '{edge_id}' already exists; pass overwrite=True to replace it." - ) - form = existing.model_copy(deep=True) if existing is not None else InspectApiEdgeForm(fromId=from_vertex, toId=to_vertex) + raise ValueError(f"Edge '{edge_id}' already exists; pass overwrite=True to replace it.") + form = ( + existing.model_copy(deep=True) + if existing is not None + else InspectApiEdgeForm(fromId=from_vertex, toId=to_vertex) + ) form.fromId = from_vertex form.toId = to_vertex entry = _Staged( @@ -446,9 +448,7 @@ def _check_conflicts(self) -> None: key = (entry.kind, entry.entity_id) server_form = current.get(key) if server_form is None: - conflicts.append( - InspectConflict(entry.entity_id, entry.kind, {"__exists__": (True, False)}) - ) + conflicts.append(InspectConflict(entry.entity_id, entry.kind, {"__exists__": (True, False)})) continue server_dump = server_form.model_dump(mode="json") if server_dump != entry.baseline_dump: diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index b395829..f440938 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -1,20 +1,19 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING -from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary +from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary, InspectFrozenModel +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService - from videoipath_automation_tool.apps.inspect.snapshot import _DeviceRecord, InspectSnapshot + from videoipath_automation_tool.apps.inspect.snapshot import _DeviceRecord -@dataclass(frozen=True, slots=True) -class InspectDevice: +class InspectDevice(InspectFrozenModel): """A topology device/node. Skeleton fields (id, label, coordinates, status, sync, tags) are available immediately; ``ports`` and ``services`` lazily hydrate from the server on first access ([ADR-0007]). The record is resolved live from the snapshot, so a held reference sees diff --git a/src/videoipath_automation_tool/apps/inspect/domain/edge.py b/src/videoipath_automation_tool/apps/inspect/domain/edge.py index 3186b37..597040d 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/edge.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/edge.py @@ -1,20 +1,18 @@ from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING from videoipath_automation_tool.apps.inspect.model.collector import InspectApiExternalEdgeLiveStatus -from videoipath_automation_tool.apps.inspect.snapshot import _IndexedEdge +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _IndexedEdge if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService - from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot -@dataclass(frozen=True, slots=True) -class InspectEdge: +class InspectEdge(InspectFrozenModel): snapshot: InspectSnapshot indexed: _IndexedEdge diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py index cb52497..15a22aa 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/port.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -1,23 +1,20 @@ from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiSingleVertexInfo, InspectPortStatus, ) -from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary -from videoipath_automation_tool.apps.inspect.snapshot import _IndexedPort, _port_id_from_status +from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary, InspectFrozenModel +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _IndexedPort, _port_id_from_status if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge - from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot -@dataclass(frozen=True, slots=True) -class InspectPort: +class InspectPort(InspectFrozenModel): snapshot: InspectSnapshot indexed: _IndexedPort diff --git a/src/videoipath_automation_tool/apps/inspect/domain/service.py b/src/videoipath_automation_tool/apps/inspect/domain/service.py index 3263a17..e9342ae 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/service.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/service.py @@ -1,19 +1,17 @@ from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING from videoipath_automation_tool.apps.inspect.model.collector import InspectApiPathItem, InspectServiceStatus -from videoipath_automation_tool.apps.inspect.snapshot import _port_id_from_endpoint +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _port_id_from_endpoint if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.port import InspectPort - from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot -@dataclass(frozen=True, slots=True) -class InspectService: +class InspectService(InspectFrozenModel): snapshot: InspectSnapshot path_item: InspectApiPathItem diff --git a/src/videoipath_automation_tool/apps/inspect/inspect_api.py b/src/videoipath_automation_tool/apps/inspect/inspect_api.py index eafed14..93cd286 100644 --- a/src/videoipath_automation_tool/apps/inspect/inspect_api.py +++ b/src/videoipath_automation_tool/apps/inspect/inspect_api.py @@ -143,9 +143,7 @@ def sync_devices( self, device_ids: list[str], add_only: bool = True, conflict_strategy: int = 0 ) -> InspectApiSimpleActionResponse: request = InspectApiSyncDevicesRequest( - data=InspectApiSyncDevicesRequestData( - ids=device_ids, addOnly=add_only, conflictStrategy=conflict_strategy - ) + data=InspectApiSyncDevicesRequestData(ids=device_ids, addOnly=add_only, conflictStrategy=conflict_strategy) ) response = self.vip_connector.rest.post("/rest/v2/actions/status/network/syncDevices", request) return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py index 5b470b2..3d6370d 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/collector.py +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -257,8 +257,7 @@ class InspectApiCollector(InspectApiBaseModel): @property def external_edges_by_device_key_items(self) -> list[InspectApiExternalEdgesByDeviceKeyItem]: return [ - InspectApiExternalEdgesByDeviceKeyItem.model_validate(item) - for item in self.externalEdgesByDeviceKey.items + InspectApiExternalEdgesByDeviceKeyItem.model_validate(item) for item in self.externalEdgesByDeviceKey.items ] @property diff --git a/src/videoipath_automation_tool/apps/inspect/model/common.py b/src/videoipath_automation_tool/apps/inspect/model/common.py index 68e9539..7986fe6 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/common.py +++ b/src/videoipath_automation_tool/apps/inspect/model/common.py @@ -9,6 +9,14 @@ class InspectApiBaseModel(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_assignment=True, extra="allow") +class InspectInternalModel(BaseModel): + model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True) + + +class InspectFrozenModel(InspectInternalModel): + model_config = ConfigDict(frozen=True, slots=True, validate_assignment=True, arbitrary_types_allowed=True) + + class InspectApiDescriptor(InspectApiBaseModel): desc: str = "" label: str = "" @@ -74,6 +82,8 @@ class InspectApiActionValidationErrorResponse(InspectApiBaseModel): __all__ = [ "InspectApiActionValidationErrorResponse", "InspectApiBaseModel", + "InspectFrozenModel", + "InspectInternalModel", "InspectApiCollection", "InspectApiDescriptor", "InspectApiEndpointStatus", diff --git a/src/videoipath_automation_tool/apps/inspect/queries.py b/src/videoipath_automation_tool/apps/inspect/queries.py index 439048a..c095bac 100644 --- a/src/videoipath_automation_tool/apps/inspect/queries.py +++ b/src/videoipath_automation_tool/apps/inspect/queries.py @@ -41,7 +41,7 @@ _DEVICE_SKELETON = ( "/status/collector/inspect/nodeStatus/*" "/deviceId,resourceId,syncSeverity" - '/.../descriptor/**' + "/.../descriptor/**" "/.../.../meta/**" "/.../.../status/**" "/.../.../tags/*" diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index 07e78bc..8109e87 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -15,11 +15,12 @@ import threading from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass, field from datetime import datetime, timezone from enum import Enum from typing import TYPE_CHECKING, Any, Iterator, Optional +from pydantic import Field + from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiCollectorResponse, InspectApiExternalEdgeLiveStatus, @@ -30,6 +31,7 @@ InspectApiPathItem, InspectPortStatus, ) +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel, InspectInternalModel if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice @@ -50,12 +52,11 @@ def _now() -> datetime: return datetime.now(timezone.utc) -@dataclass -class _DeviceRecord: +class _DeviceRecord(InspectInternalModel): device_id: str node: InspectApiNodeStatusItem level: HydrationLevel - fetched_at: datetime = field(default_factory=_now) + fetched_at: datetime = Field(default_factory=_now) @property def label(self) -> str | None: @@ -66,15 +67,13 @@ def pid(self) -> str | None: return self.node.pid or self.node.deviceId -@dataclass(frozen=True, slots=True) -class _IndexedPort: +class _IndexedPort(InspectFrozenModel): device_id: str module_id: str | None port: InspectPortStatus -@dataclass(frozen=True, slots=True) -class _IndexedEdge: +class _IndexedEdge(InspectFrozenModel): edge_id: str pair_id: str edge: InspectApiExternalEdgeStatus @@ -350,9 +349,7 @@ def _ensure_device_detail(self, device_id: str) -> None: current = self._devices_by_id.get(device_id) if current is None or current.level is HydrationLevel.FULL: return - self._devices_by_id[device_id] = _DeviceRecord( - device_id=device_id, node=detail, level=HydrationLevel.FULL - ) + self._devices_by_id[device_id] = _DeviceRecord(device_id=device_id, node=detail, level=HydrationLevel.FULL) self._device_cache.pop(device_id, None) self._rebuild_device_ports(device_id, detail) @@ -364,9 +361,7 @@ def _refresh_device(self, device_id: str) -> None: if detail is None: return with self._lock: - self._devices_by_id[device_id] = _DeviceRecord( - device_id=device_id, node=detail, level=HydrationLevel.FULL - ) + self._devices_by_id[device_id] = _DeviceRecord(device_id=device_id, node=detail, level=HydrationLevel.FULL) self._device_cache.pop(device_id, None) self._rebuild_device_ports(device_id, detail) @@ -457,7 +452,9 @@ def _index_edge_pair(self, pair_item: InspectApiExternalEdgesByDeviceKeyItem) -> continue for edge in side.data.values(): from_device_id = ( - self._resolve_device_id(_device_id_from_context(edge.fromStatus.context if edge.fromStatus else None)) + self._resolve_device_id( + _device_id_from_context(edge.fromStatus.context if edge.fromStatus else None) + ) or primary_device_id ) from_port_id = _port_id_from_endpoint(edge.fromStatus) @@ -525,9 +522,7 @@ def _apply_removals(self, removed_ids: list[str]) -> None: if record is not None: label = record.label if label and label in self._devices_by_label: - self._devices_by_label[label] = [ - d for d in self._devices_by_label[label] if d != removed - ] + self._devices_by_label[label] = [d for d in self._devices_by_label[label] if d != removed] if not self._devices_by_label[label]: self._devices_by_label.pop(label, None) self._device_cache.pop(removed, None) diff --git a/tests/e2e/inspect/scenario.py b/tests/e2e/inspect/scenario.py index c93259e..c12161d 100644 --- a/tests/e2e/inspect/scenario.py +++ b/tests/e2e/inspect/scenario.py @@ -20,12 +20,13 @@ import json from collections import defaultdict -from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any import requests +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel + if TYPE_CHECKING: from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp @@ -59,22 +60,21 @@ def _tag_category(app: "VideoIPathApp", category: str) -> dict[str, Any] | None: return item return None + # The fixture coordinates are captured from the live topology, so the replica would sit right on top # of it. Shift the whole E2E topology into its own region of the map so it never overlaps. POSITION_OFFSET_X = 0 POSITION_OFFSET_Y = 3000 -@dataclass(frozen=True) -class DeviceSpec: +class DeviceSpec(InspectFrozenModel): name: str x: int y: int ports: int -@dataclass(frozen=True) -class LinkSpec: +class LinkSpec(InspectFrozenModel): a: int # device index b: int # device index count: int # number of parallel bidirectional links @@ -90,10 +90,15 @@ def __init__(self) -> None: data = json.loads(TOPOLOGY_FILE.read_text()) # Bake the offset into the specs so build placement and the placement-revert test agree. self.devices = [ - DeviceSpec(d["name"], d["x"] + POSITION_OFFSET_X, d["y"] + POSITION_OFFSET_Y, d["ports"]) + DeviceSpec( + name=d["name"], + x=d["x"] + POSITION_OFFSET_X, + y=d["y"] + POSITION_OFFSET_Y, + ports=d["ports"], + ) for d in data["devices"] ] - self.links = [LinkSpec(a, b, n) for a, b, n in data["links"]] + self.links = [LinkSpec(a=a, b=b, count=n) for a, b, n in data["links"]] self.device_ids: dict[str, str] = {} # E2E name -> VideoIPath device id self._out: dict[str, list[str]] = {} # name -> ordered out-vertex ids self._in: dict[str, list[str]] = {} # name -> ordered in-vertex ids diff --git a/tests/inspect/test_actions.py b/tests/inspect/test_actions.py index f9a0da8..0f4894d 100644 --- a/tests/inspect/test_actions.py +++ b/tests/inspect/test_actions.py @@ -11,8 +11,15 @@ def _ok_header(): return SimpleNamespace( model_dump=lambda mode="json": { - "auth": True, "caption": "OK", "code": "OK", "errorCodes": [], "errorDetails": [], - "id": "0", "msg": [], "ok": True, "user": "api-user", + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", } ) diff --git a/tests/inspect/test_api.py b/tests/inspect/test_api.py index a8458bf..efd4b87 100644 --- a/tests/inspect/test_api.py +++ b/tests/inspect/test_api.py @@ -26,8 +26,15 @@ def post(self, url_path, body, **kwargs): def _ok_header(): return SimpleNamespace( model_dump=lambda mode="json": { - "auth": True, "caption": "OK", "code": "OK", "errorCodes": [], "errorDetails": [], - "id": "0", "msg": [], "ok": True, "user": "api-user", + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", } ) @@ -52,7 +59,9 @@ def _collector(node_items=None, edge_items=None, path_items=None): def test_device_skeleton_uses_projection_and_parses(load): - node_items = load("skeleton_nodestatus_short.json")["data"]["status"]["collector"]["inspect"]["nodeStatus"]["_items"] + node_items = load("skeleton_nodestatus_short.json")["data"]["status"]["collector"]["inspect"]["nodeStatus"][ + "_items" + ] conn, rest = _connector(get_data=_collector(node_items=node_items)) api = InspectAPI(conn) devices = api.get_device_skeleton() @@ -84,7 +93,11 @@ def test_lookup_edges_hits_correct_endpoint(load): def test_update_topology_posts_delta(): conn, rest = _connector( - post_data={"items": [], "res": {"msg": [], "ok": True}, "validation": {"details": {}, "result": {"msg": [], "ok": True}}} + post_data={ + "items": [], + "res": {"msg": [], "ok": True}, + "validation": {"details": {}, "result": {"msg": [], "ok": True}}, + } ) api = InspectAPI(conn) resp = api.update_topology(InspectApiUpdateTopologyData()) diff --git a/tests/inspect/test_changeset.py b/tests/inspect/test_changeset.py index e69be74..f2f86d5 100644 --- a/tests/inspect/test_changeset.py +++ b/tests/inspect/test_changeset.py @@ -25,8 +25,15 @@ from .conftest import load_fixture _OK_HEADER = { - "auth": True, "caption": "OK", "code": "OK", "errorCodes": [], "errorDetails": [], - "id": "0", "msg": [], "ok": True, "user": "api-user", + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", } A_OUT = "device12.1.Ethernet1.out" @@ -63,13 +70,24 @@ def _vertex_data(): def _edge_item(weight=1): edge = { - "active": True, "bandwidth": -1.0, "capacity": 65535, "conflictPri": 0, - "descriptor": {"desc": "", "label": ""}, "excludeFormats": [], - "fDescriptor": {"desc": "", "label": ""}, "fromId": A_OUT, "includeFormats": [], - "redundancyMode": "Any", "tags": [], "toId": B_IN, "weight": weight, + "active": True, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": {"desc": "", "label": ""}, + "excludeFormats": [], + "fDescriptor": {"desc": "", "label": ""}, + "fromId": A_OUT, + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": B_IN, + "weight": weight, "weightFactors": {"bandwidth": {"weight": 0}, "service": {"max": 100, "weight": 0}}, } - return InspectApiLookupEdgeResponseItem.model_validate({"edge": edge, "fromDevice": "device12", "toDevice": "device7"}) + return InspectApiLookupEdgeResponseItem.model_validate( + {"edge": edge, "fromDevice": "device12", "toDevice": "device7"} + ) class FakeAPI: @@ -77,7 +95,9 @@ def __init__(self): self.devices = {} self.vertices = {} self.edges = {} - self.update_response = InspectApiUpdateTopologyResponse.model_validate(load_fixture("update_topology_success.json")) + self.update_response = InspectApiUpdateTopologyResponse.model_validate( + load_fixture("update_topology_success.json") + ) self.update_calls = [] self.lookup_device_calls = [] self.lookup_vertices_calls = [] @@ -243,7 +263,9 @@ def test_commit_success_returns_result(): def test_commit_failure_raises_commit_error(): api = FakeAPI() - api.update_response = InspectApiUpdateTopologyResponse.model_validate(load_fixture("update_topology_fail_remove.json")) + api.update_response = InspectApiUpdateTopologyResponse.model_validate( + load_fixture("update_topology_fail_remove.json") + ) with _txn(api) as tx: tx.remove("nonexistent-edge-id-xyz") with pytest.raises(InspectCommitError) as exc: From 3763af9e6c1aedc0ddf9ab59568be64e04c59b78 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:30:11 +0200 Subject: [PATCH 09/15] improve test setup --- .claude/rules/python-testing.md | 45 ++++++++++++----- .env.example | 11 ---- .env.template | 13 +++++ .vscode/launch.json | 64 ++++++++++++++++++++++-- .vscode/settings.json | 1 + .vscode/tasks.json | 14 ++++++ AGENTS.md | 20 ++++++-- docs/development-and-release.md | 53 ++++++++++++++++++++ poetry.lock | 16 ------ pyproject.toml | 7 +-- src/vipat_cli_scripts/project_env.py | 36 +++++++++++++ src/vipat_cli_scripts/test_runner.py | 33 ++++++++++++ tests/.env.test | 7 --- tests/conftest.py | 23 +++++++++ tests/e2e/conftest.py | 15 ++++-- tests/e2e/inspect/test_e2e_leaf_spine.py | 2 +- 16 files changed, 297 insertions(+), 63 deletions(-) delete mode 100644 .env.example create mode 100644 .env.template create mode 100644 src/vipat_cli_scripts/project_env.py create mode 100644 src/vipat_cli_scripts/test_runner.py delete mode 100644 tests/.env.test create mode 100644 tests/conftest.py diff --git a/.claude/rules/python-testing.md b/.claude/rules/python-testing.md index 873425d..02bf778 100644 --- a/.claude/rules/python-testing.md +++ b/.claude/rules/python-testing.md @@ -12,19 +12,40 @@ paths: ## Framework and commands - Use **pytest** for all tests. -- Default suite (excludes e2e): +- Prefer the dedicated entry points over bare `pytest`: ```bash -poetry run pytest +poetry run test-unit # offline/unit suite (CI default) +poetry run test-e2e # live-server e2e (loads .env) +poetry run test # unit then e2e sequentially + +# Single file or test (extra args pass through to test-unit / test-e2e) +poetry run test-unit tests/validators/test_device_id.py +poetry run test-e2e tests/e2e/inspect/test_e2e_leaf_spine.py::test_name ``` -- Single file: +- `poetry run pytest` also runs unit tests only (e2e excluded via `addopts` in `pyproject.toml`). -```bash -poetry run pytest tests/validators/test_device_id.py -``` +### VS Code + +Use the launch configs in `.vscode/launch.json`: + +- **Unit Tests** / **Unit Tests (current file)** — offline suite +- **E2E Tests** / **E2E Tests (current file)** — live-server suite + +Or run **Tests** from `.vscode/tasks.json` (`poetry run test`). -Default `addopts` in `pyproject.toml` run coverage on `src/` and exclude e2e (`-m "not e2e"`). +## Unit vs e2e separation + +| Layer | Unit | E2E | +|-------|------|-----| +| Location | `tests/` except `tests/e2e/` | `tests/e2e/` only | +| Marker | unmarked | `@pytest.mark.e2e` | +| Env | `tests/conftest.py` (dummy values) | `.env` (copy from `.env.template`) | +| Run command | `test-unit` / `pytest` | `test-e2e` | +| CI | yes | no | + +Default `addopts` run coverage on `src/` and exclude e2e (`-m "not e2e"`). ## Assertions @@ -34,6 +55,7 @@ Default `addopts` in `pyproject.toml` run coverage on `src/` and exclude e2e (`- ## Unit and offline tests - Mock external I/O with fake connectors and lightweight stand-ins (see `tests/inspect/test_actions.py`). +- Dummy `VIPAT_*` env vars are set in `tests/conftest.py` (autouse fixture; skipped for e2e). - Load JSON fixtures from `tests/fixtures/` using `pathlib.Path`. - Put shared fixtures in `conftest.py` at the appropriate directory level. - Use session-scoped fixtures only when setup is expensive and reuse is intentional. @@ -42,12 +64,9 @@ Default `addopts` in `pyproject.toml` run coverage on `src/` and exclude e2e (`- - Live-server tests live under `tests/e2e/` only. - Mark with `@pytest.mark.e2e`; they are excluded from the default suite. -- Require `VIPAT_E2E_ENABLED=1` in `tests/.env.test` and run explicitly: - -```bash -poetry run pytest -m e2e tests/e2e/inspect -``` - +- E2e entry points (`poetry run test-e2e`, `poetry run test`, VS Code **E2E Tests**) load `.env` and enable the suite automatically. E2e runs use `--no-cov`. +- Copy `.env.template` to `.env` (gitignored), set connection vars. The e2e conftest loads `.env` automatically when present. +- Run with `poetry run test-e2e` (no extra env vars on the command line). - Namespace all writes with the `E2E-` label prefix and `vipat-e2e` tag. - Do not add e2e tests to the default CI/offline run. diff --git a/.env.example b/.env.example deleted file mode 100644 index 4022041..0000000 --- a/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -VIPAT_ENVIRONMENT=DEV -VIPAT_VIDEOIPATH_SERVER_ADDRESS=vip.company.com -VIPAT_VIDEOIPATH_USERNAME=user_with_api_access -VIPAT_VIDEOIPATH_PASSWORD=veryStrongPassword -VIPAT_USE_HTTPS=true -VIPAT_VERIFY_SSL_CERT=false -VIPAT_LOG_LEVEL=INFO -VIPAT_ADVANCED_DRIVER_SCHEMA_CHECK=true -VIPAT_TIMEOUT_HTTP_GET=10 -VIPAT_TIMEOUT_HTTP_PATCH=10 -VIPAT_TIMEOUT_HTTP_POST=10 \ No newline at end of file diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..9179638 --- /dev/null +++ b/.env.template @@ -0,0 +1,13 @@ +# Copy to .env (gitignored) for local development and live-server e2e tests. + +VIPAT_ENVIRONMENT=DEV +VIPAT_VIDEOIPATH_SERVER_ADDRESS=vip-server.example +VIPAT_VIDEOIPATH_USERNAME=test-user +VIPAT_VIDEOIPATH_PASSWORD=test-password +VIPAT_USE_HTTPS=true +VIPAT_VERIFY_SSL_CERT=false +VIPAT_LOG_LEVEL=INFO +VIPAT_ADVANCED_DRIVER_SCHEMA_CHECK=true +VIPAT_TIMEOUT_HTTP_GET=10 +VIPAT_TIMEOUT_HTTP_PATCH=10 +VIPAT_TIMEOUT_HTTP_POST=10 diff --git a/.vscode/launch.json b/.vscode/launch.json index 78c16ab..fe83c16 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,14 +2,72 @@ "version": "0.2.0", "configurations": [ { - "name": "Tests", + "name": "Unit Tests", "type": "debugpy", "request": "launch", "module": "pytest", + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}", "args": [ - "-v" + "-m", + "not e2e", + "--ignore=tests/e2e" ], "console": "integratedTerminal" + }, + { + "name": "E2E Tests", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}", + "args": [ + "-m", + "e2e", + "tests/e2e", + "--no-cov" + ], + "env": { + "VIPAT_E2E_ENABLED": "1" + }, + "envFile": "${workspaceFolder}/.env", + "console": "integratedTerminal" + }, + { + "name": "Unit Tests (current file)", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}", + "args": [ + "-m", + "not e2e", + "--ignore=tests/e2e", + "${file}" + ], + "console": "integratedTerminal" + }, + { + "name": "E2E Tests (current file)", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}", + "args": [ + "-m", + "e2e", + "tests/e2e", + "--no-cov", + "${file}" + ], + "env": { + "VIPAT_E2E_ENABLED": "1" + }, + "envFile": "${workspaceFolder}/.env", + "console": "integratedTerminal" } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 5bf2cbe..9b87748 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnType": true diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 55282d3..2c5cd45 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,6 +1,20 @@ { "version": "2.0.0", "tasks": [ + { + "label": "Tests", + "type": "shell", + "command": "poetry", + "args": [ + "run", + "test" + ], + "group": { + "kind": "test", + "isDefault": false + }, + "problemMatcher": [] + }, { "label": "Generate Data Model", "problemMatcher": [], diff --git a/AGENTS.md b/AGENTS.md index dc0ced0..dd46a55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,11 +34,21 @@ Inspect fixtures can be anonymized with `scripts/anonymize_inspect_fixtures.py` # Install all dependencies (including dev and test groups) poetry install --with dev,test -# Run all tests -poetry run pytest +# Run unit tests (offline; same as CI default) +poetry run test-unit + +# Run e2e tests against a live server (loads connection vars from .env — see .env.template) +poetry run test-e2e + +# Run unit then e2e sequentially +poetry run test -# Run a single test file -poetry run pytest tests/validators/test_device_id.py +# Single file or test (extra args pass through) +poetry run test-unit tests/validators/test_device_id.py +poetry run test-e2e tests/e2e/inspect/test_e2e_leaf_spine.py::test_name + +# Bare pytest also runs unit tests only (e2e excluded via pyproject addopts) +poetry run pytest # Lint with auto-fix poetry run ruff check --fix src/ tests/ @@ -116,7 +126,7 @@ Driver schemas (Pydantic models for device `custom_settings`) are auto-generated ### Settings and environment variables -All configuration is loaded via `Settings` (`settings.py`, backed by `pydantic-settings`). Variables are prefixed `VIPAT_`. Copy `.env.example` to `.env` for local development. Tests use `tests/.env.test`. +All configuration is loaded via `Settings` (`settings.py`, backed by `pydantic-settings`). Variables are prefixed `VIPAT_`. Copy `.env.template` to `.env` for local development and e2e tests (gitignored). Unit tests set dummy `VIPAT_*` values in `tests/conftest.py`. Key variables: | Variable | Default | Notes | diff --git a/docs/development-and-release.md b/docs/development-and-release.md index 0077964..91d5c22 100644 --- a/docs/development-and-release.md +++ b/docs/development-and-release.md @@ -52,6 +52,59 @@ gitGraph commit ``` +## Testing + +Tests use **pytest**. Install dev and test dependencies first: + +```bash +poetry install --with dev,test +``` + +### Unit and e2e suites + +The suite is split into offline **unit tests** and developer-run **e2e tests** against a live VideoIPath instance: + +| | Unit | E2E | +|---|---|---| +| Location | `tests/` (except `tests/e2e/`) | `tests/e2e/` | +| Marker | _(none)_ | `@pytest.mark.e2e` | +| Environment | Dummy `VIPAT_*` values in `tests/conftest.py` | Project root `.env` (see `.env.template`) | +| CI / default run | yes | no | + +**Commands** (prefer these over bare `pytest`): + +```bash +poetry run test-unit # offline suite; same as CI +poetry run test-e2e # live-server suite +poetry run test # unit, then e2e (stops on first failure) + +# Single file or test — extra args pass through +poetry run test-unit tests/validators/test_device_id.py +poetry run test-e2e tests/e2e/inspect/test_e2e_leaf_spine.py::test_name +``` + +`poetry run pytest` also runs unit tests only (e2e is excluded via `addopts` in `pyproject.toml`). All default runs report coverage on `src/`. + +### E2E setup + +1. Copy `.env.template` to `.env` (gitignored) and set your server connection variables (`VIPAT_VIDEOIPATH_SERVER_ADDRESS`, credentials, etc.). +2. Run `poetry run test-e2e` or `poetry run test` — connection vars are loaded from `.env` automatically; no extra env vars on the command line. + +E2E writes are namespaced (`E2E-` label prefix, `vipat-e2e` tag) so a shared dev instance stays safe. + +### VS Code + +Launch configs in `.vscode/launch.json`: + +- **Unit Tests** / **Unit Tests (current file)** — offline suite +- **E2E Tests** / **E2E Tests (current file)** — live-server suite (loads `.env`, uses the project `.venv`) + +Or run **Tests** from `.vscode/tasks.json` (`poetry run test`). + +### Test data + +Committed fixtures and examples must be anonymized — see `AGENTS.md` for placeholder conventions. + ## Publishing / Releasing new Package Versions In order to publish a new version of the package, update the version in the `pyproject.toml` and create a new GitHub Release associated with a version tag. The release notes should contain all relevant changes, especially breaking changes, features & bug fixes. diff --git a/poetry.lock b/poetry.lock index bae3156..e95452e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -749,22 +749,6 @@ pytest = ">=7" [package.extras] testing = ["process-tests", "pytest-xdist", "virtualenv"] -[[package]] -name = "pytest-dotenv" -version = "0.5.2" -description = "A py.test plugin that parses environment files before running tests" -optional = false -python-versions = "*" -groups = ["test"] -files = [ - {file = "pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732"}, - {file = "pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f"}, -] - -[package.dependencies] -pytest = ">=5.0.0" -python-dotenv = ">=0.9.1" - [[package]] name = "python-discovery" version = "1.3.1" diff --git a/pyproject.toml b/pyproject.toml index 9b0f3fd..653a403 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,12 +46,10 @@ pre-commit = "^4.6.0" [tool.poetry.group.test.dependencies] pytest = "^9.1.1" pytest-cov = "^7.1.0" -pytest-dotenv = "^0.5.2" +python-dotenv = "^1.1.0" [tool.pytest.ini_options] addopts = "-x -p no:warnings --cov-report=term --cov-report=term-missing --no-cov-on-fail --cov=src --ignore=__intern -m \"not e2e\"" -env_override_existing_values = 0 -env_files = ["tests/.env.test"] markers = [ "e2e: developer-run tests against a live VideoIPath instance (gated on VIPAT_E2E_ENABLED=1; excluded by default). Run with '-m e2e'.", ] @@ -63,6 +61,9 @@ in-project = true set-videoipath-version = "vipat_cli_scripts.generate_all:main" get-videoipath-version = "vipat_cli_scripts.version_utils:get_videoipath_version" list-videoipath-versions = "vipat_cli_scripts.version_utils:list_videoipath_versions" +test-unit = "vipat_cli_scripts.test_runner:run_unit" +test-e2e = "vipat_cli_scripts.test_runner:run_e2e" +test = "vipat_cli_scripts.test_runner:run" [tool.ruff] include = ["pyproject.toml", "src/**/*.py", "tests/**/*.py"] diff --git a/src/vipat_cli_scripts/project_env.py b/src/vipat_cli_scripts/project_env.py new file mode 100644 index 0000000..474f0fd --- /dev/null +++ b/src/vipat_cli_scripts/project_env.py @@ -0,0 +1,36 @@ +"""Load project-root ``.env`` for e2e and local development.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from dotenv import load_dotenv + +_PROJECT_ROOT = Path(__file__).resolve().parents[2] +_ENV_FILE = _PROJECT_ROOT / ".env" + +_LEGACY_ENV_ALIASES = { + "VIDEOIPATH_SERVER_ADDRESS": "VIPAT_VIDEOIPATH_SERVER_ADDRESS", + "VIDEOIPATH_USERNAME": "VIPAT_VIDEOIPATH_USERNAME", + "VIDEOIPATH_PASSWORD": "VIPAT_VIDEOIPATH_PASSWORD", + "VIDEOIPATH_USE_HTTPS": "VIPAT_USE_HTTPS", + "VIDEOIPATH_VERIFY_SSL": "VIPAT_VERIFY_SSL_CERT", + "VIDEOIPATH_VERIFY_SSL_CERT": "VIPAT_VERIFY_SSL_CERT", +} + + +def load_project_env() -> None: + """Load ``.env`` from the project root and normalize legacy variable names.""" + if _ENV_FILE.is_file(): + load_dotenv(_ENV_FILE, override=True) + for legacy, canonical in _LEGACY_ENV_ALIASES.items(): + legacy_value = os.environ.get(legacy) + if legacy_value and not os.environ.get(canonical): + os.environ[canonical] = legacy_value + + +def prepare_e2e_env() -> None: + """Load ``.env`` and enable the e2e gate for explicit e2e test runs.""" + load_project_env() + os.environ["VIPAT_E2E_ENABLED"] = "1" diff --git a/src/vipat_cli_scripts/test_runner.py b/src/vipat_cli_scripts/test_runner.py new file mode 100644 index 0000000..9a290da --- /dev/null +++ b/src/vipat_cli_scripts/test_runner.py @@ -0,0 +1,33 @@ +"""Pytest entry points for unit, e2e, and combined test suites.""" + +from __future__ import annotations + +import sys + +import pytest + +from vipat_cli_scripts.project_env import prepare_e2e_env + +_UNIT_ARGS = ["-m", "not e2e", "--ignore=tests/e2e"] +_E2E_ARGS = ["-m", "e2e", "tests/e2e", "--no-cov"] + + +def _run(args: list[str], *, extra: list[str] | None = None) -> int: + return pytest.main([*args, *(extra if extra is not None else sys.argv[1:])]) + + +def run_unit() -> None: + raise SystemExit(_run(_UNIT_ARGS)) + + +def run_e2e() -> None: + prepare_e2e_env() + raise SystemExit(_run(_E2E_ARGS)) + + +def run() -> None: + rc = _run(_UNIT_ARGS, extra=[]) + if rc != 0: + raise SystemExit(rc) + prepare_e2e_env() + raise SystemExit(_run(_E2E_ARGS, extra=[])) diff --git a/tests/.env.test b/tests/.env.test deleted file mode 100644 index 9985962..0000000 --- a/tests/.env.test +++ /dev/null @@ -1,7 +0,0 @@ -VIPAT_ENVIRONMENT=DEV -VIPAT_VIDEOIPATH_SERVER_ADDRESS=vip.company.com -VIPAT_VIDEOIPATH_USERNAME=user_with_api_access -VIPAT_VIDEOIPATH_PASSWORD=veryStrongPassword -VIPAT_USE_HTTPS=true -VIPAT_VERIFY_SSL_CERT=false -VIPAT_LOG_LEVEL=DEBUG \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a0bcd3c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +"""Shared pytest configuration for offline unit tests.""" + +from __future__ import annotations + +import pytest + +UNIT_TEST_ENV = { + "VIPAT_ENVIRONMENT": "DEV", + "VIPAT_VIDEOIPATH_SERVER_ADDRESS": "vip-server.example", + "VIPAT_VIDEOIPATH_USERNAME": "test-user", + "VIPAT_VIDEOIPATH_PASSWORD": "test-password", + "VIPAT_USE_HTTPS": "true", + "VIPAT_VERIFY_SSL_CERT": "false", + "VIPAT_LOG_LEVEL": "DEBUG", +} + + +@pytest.fixture(autouse=True) +def _unit_test_env(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None: + if request.node.get_closest_marker("e2e"): + return + for key, value in UNIT_TEST_ENV.items(): + monkeypatch.setenv(key, value) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index cb66602..459a113 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,10 +1,13 @@ """Gating and shared fixtures for the developer-run live-server E2E suite ([ADR-0005]). These tests are excluded by default (``-m "not e2e"`` in ``pyproject.toml``) and only run when: - * you pass ``-m e2e`` on the command line, **and** - * ``VIPAT_E2E_ENABLED=1`` is set (put it in ``tests/.env.test`` next to the connection vars), **and** + * you invoke an e2e entry point (``poetry run test-e2e``, ``poetry run test``, or VS Code **E2E Tests**), **and** + * connection vars are set in the project root ``.env`` (copy from ``.env.template``), **and** * the target server is a verified version (>= 2025.4). +E2e entry points load ``.env`` and enable the suite automatically. E2e runs never collect coverage +(``--no-cov``). + Everything the suite writes is namespaced (``E2E-`` label prefix + ``vipat-e2e`` tag) so a shared local instance is safe: the scenario teardown removes only that namespace. """ @@ -17,6 +20,9 @@ from videoipath_automation_tool.apps.inspect.inspect_app import _MIN_VERIFIED_VERSION, _parse_version from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp +from vipat_cli_scripts.project_env import load_project_env + +load_project_env() def _e2e_enabled() -> bool: @@ -25,9 +31,10 @@ def _e2e_enabled() -> bool: @pytest.fixture(scope="session") def app() -> VideoIPathApp: - """A live ``VideoIPathApp`` built from ``tests/.env.test``; skips unless E2E is enabled + verified.""" + """A live ``VideoIPathApp`` built from the project ``.env``; skips unless E2E is enabled + verified.""" + load_project_env() if not _e2e_enabled(): - pytest.skip("E2E disabled (set VIPAT_E2E_ENABLED=1 in tests/.env.test to run).") + pytest.skip("E2E disabled (use poetry run test-e2e, poetry run test, or the VS Code E2E launch config).") application = VideoIPathApp() version = application._videoipath_connector.videoipath_version parsed = _parse_version(version) diff --git a/tests/e2e/inspect/test_e2e_leaf_spine.py b/tests/e2e/inspect/test_e2e_leaf_spine.py index d8bfedd..3656a24 100644 --- a/tests/e2e/inspect/test_e2e_leaf_spine.py +++ b/tests/e2e/inspect/test_e2e_leaf_spine.py @@ -11,7 +11,7 @@ Run with:: - poetry run pytest -m e2e tests/e2e/inspect + poetry run test-e2e """ from __future__ import annotations From 7428e875f169f7529f0248319ba91b7121415fbe Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:18:01 +0200 Subject: [PATCH 10/15] e2e test rework --- .claude/rules/python-testing.md | 2 +- AGENTS.md | 2 +- docs/development-and-release.md | 2 +- pyproject.toml | 1 + tests/e2e/conftest.py | 52 ++- tests/e2e/helpers.py | 286 ++++++++++++++++ tests/e2e/inspect/leaf_spine_topology.json | 377 --------------------- tests/e2e/inspect/scenario.py | 272 --------------- tests/e2e/inspect/test_e2e_inspect.py | 167 +++++++++ tests/e2e/inspect/test_e2e_leaf_spine.py | 236 ------------- tests/e2e/test_e2e_workflow.py | 172 ++++++++++ 11 files changed, 680 insertions(+), 889 deletions(-) create mode 100644 tests/e2e/helpers.py delete mode 100644 tests/e2e/inspect/leaf_spine_topology.json delete mode 100644 tests/e2e/inspect/scenario.py create mode 100644 tests/e2e/inspect/test_e2e_inspect.py delete mode 100644 tests/e2e/inspect/test_e2e_leaf_spine.py create mode 100644 tests/e2e/test_e2e_workflow.py diff --git a/.claude/rules/python-testing.md b/.claude/rules/python-testing.md index 02bf778..102df3a 100644 --- a/.claude/rules/python-testing.md +++ b/.claude/rules/python-testing.md @@ -21,7 +21,7 @@ poetry run test # unit then e2e sequentially # Single file or test (extra args pass through to test-unit / test-e2e) poetry run test-unit tests/validators/test_device_id.py -poetry run test-e2e tests/e2e/inspect/test_e2e_leaf_spine.py::test_name +poetry run test-e2e tests/e2e/inspect/test_e2e_inspect.py::test_name ``` - `poetry run pytest` also runs unit tests only (e2e excluded via `addopts` in `pyproject.toml`). diff --git a/AGENTS.md b/AGENTS.md index dd46a55..23efa7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ poetry run test # Single file or test (extra args pass through) poetry run test-unit tests/validators/test_device_id.py -poetry run test-e2e tests/e2e/inspect/test_e2e_leaf_spine.py::test_name +poetry run test-e2e tests/e2e/inspect/test_e2e_inspect.py::test_name # Bare pytest also runs unit tests only (e2e excluded via pyproject addopts) poetry run pytest diff --git a/docs/development-and-release.md b/docs/development-and-release.md index 91d5c22..c0840ce 100644 --- a/docs/development-and-release.md +++ b/docs/development-and-release.md @@ -80,7 +80,7 @@ poetry run test # unit, then e2e (stops on first failure) # Single file or test — extra args pass through poetry run test-unit tests/validators/test_device_id.py -poetry run test-e2e tests/e2e/inspect/test_e2e_leaf_spine.py::test_name +poetry run test-e2e tests/e2e/inspect/test_e2e_inspect.py::test_name ``` `poetry run pytest` also runs unit tests only (e2e is excluded via `addopts` in `pyproject.toml`). All default runs report coverage on `src/`. diff --git a/pyproject.toml b/pyproject.toml index 653a403..02b627d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ python-dotenv = "^1.1.0" addopts = "-x -p no:warnings --cov-report=term --cov-report=term-missing --no-cov-on-fail --cov=src --ignore=__intern -m \"not e2e\"" markers = [ "e2e: developer-run tests against a live VideoIPath instance (gated on VIPAT_E2E_ENABLED=1; excluded by default). Run with '-m e2e'.", + "incremental: sequential workflow steps; later steps are skipped when an earlier one fails.", ] [virtualenvs] diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 459a113..24a308c 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -9,12 +9,17 @@ (``--no-cov``). Everything the suite writes is namespaced (``E2E-`` label prefix + ``vipat-e2e`` tag) so a shared -local instance is safe: the scenario teardown removes only that namespace. +local instance is safe. Cleanup has two layers: a session-start sweep removes any leftovers from +prior runs, and the per-test ``topology_builder`` fixture removes exactly the devices a test +created (also on failure). The sequential workflow suite intentionally leaves its topology behind +for manual inspection; the next run's sweep removes it. """ from __future__ import annotations import os +from itertools import count +from typing import Iterator import pytest @@ -22,8 +27,12 @@ from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp from vipat_cli_scripts.project_env import load_project_env +from .helpers import TopologyBuilder, remove_devices, sweep_e2e_namespace + load_project_env() +_STEP_FAILED_KEY = pytest.StashKey[str]() + def _e2e_enabled() -> bool: return os.environ.get("VIPAT_E2E_ENABLED", "").strip() == "1" @@ -44,3 +53,44 @@ def app() -> VideoIPathApp: f"{_MIN_VERIFIED_VERSION[0]}.{_MIN_VERIFIED_VERSION[1]}." ) return application + + +@pytest.fixture(scope="session", autouse=True) +def e2e_sweep(app: VideoIPathApp) -> None: + """Session-start sweep: remove every ``E2E-`` device left over from a prior run.""" + sweep_e2e_namespace(app) + + +@pytest.fixture(scope="session") +def e2e_addresses() -> Iterator[str]: + """Session-wide device address allocator (private ``10.99.0.0/16`` range), so addresses never collide.""" + return (f"10.99.{i // 256}.{i % 256}" for i in count(1)) + + +@pytest.fixture +def topology_builder(app: VideoIPathApp, e2e_addresses: Iterator[str]) -> Iterator[TopologyBuilder]: + """A per-test topology factory; teardown removes exactly the devices the test created.""" + builder = TopologyBuilder(app, e2e_addresses) + yield builder + remove_devices(app, set(builder.device_ids)) + + +# --- Sequential workflow support (``@pytest.mark.incremental``) --- +# Later steps of a sequential suite are skipped (not failed) once an earlier step fails. With the +# default ``-x`` in ``addopts`` the run stops at the first failure anyway; these hooks make the +# behavior sensible without it too (e.g. ``--maxfail=0``). + + +def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo) -> None: + if "incremental" not in item.keywords or item.parent is None: + return + if call.excinfo is not None and not call.excinfo.errisinstance(pytest.skip.Exception): + item.parent.stash.setdefault(_STEP_FAILED_KEY, item.name) + + +def pytest_runtest_setup(item: pytest.Item) -> None: + if "incremental" not in item.keywords or item.parent is None: + return + failed = item.parent.stash.get(_STEP_FAILED_KEY, None) + if failed is not None: + pytest.skip(f"previous workflow step failed ({failed})") diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py new file mode 100644 index 0000000..0544ef3 --- /dev/null +++ b/tests/e2e/helpers.py @@ -0,0 +1,286 @@ +"""Shared helpers for the developer-run live-server E2E suite ([ADR-0005]). + +Everything the suite writes is namespaced (``E2E-`` label prefix + ``vipat-e2e`` tag) so a shared +local instance stays safe. Two cleanup layers use that namespace: + +* ``sweep_e2e_namespace`` — session-start sweep that removes **every** ``E2E-`` device (and its + edges) left over from a prior run, in both the inventory and the inspect topology graph. +* ``remove_devices`` — targeted removal of exactly the devices a single test created (used by the + per-test ``topology_builder`` teardown). + +Devices are always virtual (mock-driver); no real hardware is involved. The build path is the real +user flow: **inventory** (create + add device) → **inspect** (add to topology graph, label + tag, +connect ports). +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING, Any, Iterator +from uuid import uuid4 + +import requests + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +E2E_PREFIX = "E2E-" +E2E_TAG = "vipat-e2e" +MOCK_DRIVER = "com.nevion.mock-0.1.0" + +# A test video tag, created via a simple API request and assigned to a port via the inspect app. +# Tag references are ``Category~~name`` ids; it lives in the existing "Video" format category. +TEST_TAG_CATEGORY = "Video" +TEST_TAG_NAME = "E2E-VIDEO-TAG" +TEST_TAG_ID = f"{TEST_TAG_CATEGORY}~~{TEST_TAG_NAME}" + + +def unique_label(base: str) -> str: + """A per-test unique device label, always under the ``E2E-`` prefix so the sweep catches orphans.""" + return f"{E2E_PREFIX}T-{uuid4().hex[:6].upper()}-{base}" + + +def create_mock_device(app: "VideoIPathApp", *, label: str, address: str, ports: int) -> str: + """Create a virtual (mock-driver) device in the inventory and return its device id.""" + device = app.inventory.create_device(driver=MOCK_DRIVER) + device.configuration.label = label + device.configuration.address = address + settings = device.configuration.custom_settings + settings.num_router_modules = 1 + settings.num_router_ports = ports + settings.num_codec_modules = 0 + online = app.inventory.add_device(device=device, address_check=False) + return online.configuration.device_id + + +def discover_router_vertices(app: "VideoIPathApp", device_ids: list[str]) -> dict[str, tuple[list[str], list[str]]]: + """Per device id: the (out, in) router vertex ids, each sorted by port slot. + + Refreshes the snapshot and preloads the devices in parallel to avoid N+1 detail fetches. + """ + app.inspect.refresh() + app.inspect.preload(device_ids) + vertices: dict[str, tuple[list[str], list[str]]] = {} + for device_id in device_ids: + device = app.inspect.get_device(device_id) + if device is None: + raise LookupError(f"Device '{device_id}' not found in the inspect topology.") + outs: list[str] = [] + ins: list[str] = [] + for port in device.ports: + label = port.label or "" + if not port.vertex_id: + continue + if "Router Out" in label: + outs.append(port.vertex_id) + elif "Router In" in label: + ins.append(port.vertex_id) + vertices[device_id] = (sorted(outs, key=_vertex_slot), sorted(ins, key=_vertex_slot)) + return vertices + + +def edges_between(app: "VideoIPathApp", id_a: str, id_b: str) -> list["InspectEdge"]: + """All directed edges between two devices (either direction).""" + return [ + e + for e in app.inspect.edges + if e.from_device and e.to_device and {e.from_device.id, e.to_device.id} == {id_a, id_b} + ] + + +def remove_devices(app: "VideoIPathApp", device_ids: set[str]) -> None: + """Remove the given devices — edges first, then the topology node, then the inventory entry. + + A device lives in two places — the inventory and the inspect topology graph — and removing it + from one does not remove it from the other. Removal is best-effort so cleanup errors never mask + a test failure. + """ + if not device_ids: + return + app.inspect.refresh() + edge_ids = [ + edge.id + for edge in app.inspect.edges + if (edge.from_device and edge.from_device.id in device_ids) + or (edge.to_device and edge.to_device.id in device_ids) + ] + if edge_ids: + with app.inspect.transaction() as tx: + for edge_id in edge_ids: + tx.remove(edge_id) + tx.commit(check_conflicts=False) + topology_ids = {d.id for d in app.inspect.devices} & device_ids + for device_id in topology_ids: + try: + app.inspect.remove_device_from_topology(device_id) + except Exception: # best-effort cleanup + pass + for device_id in device_ids: + try: + app.inventory.remove_device(device_id=device_id, check_remove=False) + except Exception: # best-effort cleanup + pass + + +def sweep_e2e_namespace(app: "VideoIPathApp") -> None: + """Remove every ``E2E-`` device (and the test tag) so a run starts from a clean namespace. + + Discovers devices by their ``E2E-`` label in **both** the inventory and the topology graph, + catching orphans from an aborted run as well as the intentionally persisted workflow topology. + """ + delete_test_tag(app) + inventory_labels = app.inventory._inventory_api.fetch_devices_user_defined_labels_as_dict() + inventory_ids = {i for i, label in inventory_labels.items() if (label or "").startswith(E2E_PREFIX)} + app.inspect.refresh() + topology_ids = {d.id for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)} + remove_devices(app, inventory_ids | topology_ids) + + +class TopologyBuilder: + """Builds a minimal per-test topology of mock devices and tracks their ids for teardown. + + ``add_devices`` mirrors the real user flow (inventory → topology graph → label + tag); ``link`` + connects the next free port pair of two devices bidirectionally (two directed edges). + """ + + def __init__(self, app: "VideoIPathApp", addresses: Iterator[str], *, x: int = 0, y: int = 4200) -> None: + self._app = app + self._addresses = addresses + self._x = x + self._y = y + self.device_ids: list[str] = [] + self.labels: dict[str, str] = {} # device id -> unique E2E label + self._out: dict[str, list[str]] = {} # device id -> ordered out-vertex ids + self._in: dict[str, list[str]] = {} # device id -> ordered in-vertex ids + self._slot: dict[str, int] = defaultdict(int) # device id -> next free port slot + + def add_devices(self, specs: list[tuple[str, int]]) -> list[str]: + """Create mock devices from ``(base_label, ports)`` specs and add them to the topology graph. + + Labels are made unique per test via :func:`unique_label`; returns the new device ids. + """ + created: list[str] = [] + for base_label, ports in specs: + label = unique_label(base_label) + device_id = create_mock_device(self._app, label=label, address=next(self._addresses), ports=ports) + self.labels[device_id] = label + created.append(device_id) + offset = len(self.device_ids) + self._app.inspect.add_devices_to_topology( + [(device_id, self._x + (offset + i) * 300, self._y) for i, device_id in enumerate(created)] + ) + with self._app.inspect.transaction() as tx: + for device_id in created: + tx.update_device(device_id, label=self.labels[device_id], tags=[E2E_TAG]) + tx.commit() + self.device_ids.extend(created) + return created + + def discover(self) -> None: + """Discover the router port vertices of all tracked devices (needed before ``link``).""" + for device_id, (outs, ins) in discover_router_vertices(self._app, self.device_ids).items(): + self._out[device_id] = outs + self._in[device_id] = ins + + def link(self, id_a: str, id_b: str) -> None: + """Connect the next free port pair of two devices bidirectionally (two directed edges).""" + if id_a not in self._out or id_b not in self._out: + self.discover() + ka, kb = self._slot[id_a], self._slot[id_b] + self._slot[id_a] += 1 + self._slot[id_b] += 1 + with self._app.inspect.transaction() as tx: + tx.connect(self._out[id_a][ka], self._in[id_b][kb], bidirectional=False) + tx.connect(self._out[id_b][kb], self._in[id_a][ka], bidirectional=False) + tx.commit() + + +class FetchSpy: + """Wraps get_device_detail to count hydration fetches.""" + + def __init__(self, api): + self._api = api + self._orig = api.get_device_detail + self.count = 0 + + def __enter__(self): + def counting(device_id): + self.count += 1 + return self._orig(device_id) + + self._api.get_device_detail = counting + return self + + def __exit__(self, *exc): + self._api.get_device_detail = self._orig + + +# --- Test tag catalog (simple API requests) --- + + +def create_test_tag(app: "VideoIPathApp") -> None: + """Create the E2E test video tag in the catalog (idempotent).""" + category = _tag_category(app, TEST_TAG_CATEGORY) + if category is None: + raise RuntimeError(f"Tag category '{TEST_TAG_CATEGORY}' not found on the server.") + children = dict(category.get("children") or {}) + children[TEST_TAG_NAME] = {"exclusive": False, "children": {}} + body = { + "actions": [ + { + "_action": "update", + "_id": TEST_TAG_CATEGORY, + "_rev": category["_rev"], + "children": children, + "type": category.get("type", "format"), + "exclusive": category.get("exclusive", False), + "formatTagLinks": category.get("formatTagLinks", {}), + "locationTypes": category.get("locationTypes", []), + } + ] + } + _raw_request(app, "patch", "/rest/v2/data/config/tags/tagTrees", body) + + +def test_tag_exists(app: "VideoIPathApp") -> bool: + category = _tag_category(app, TEST_TAG_CATEGORY) + return category is not None and TEST_TAG_NAME in (category.get("children") or {}) + + +def delete_test_tag(app: "VideoIPathApp") -> None: + """Force-delete the test tag (removes it and any port bindings) if it exists.""" + if test_tag_exists(app): + _raw_request( + app, + "post", + "/rest/v2/actions/status/tags/forceDeleteTag", + {"header": {"id": 0}, "data": {"tagId": TEST_TAG_ID}}, + ) + + +# --- Internal --- + + +def _vertex_slot(vertex_id: str) -> int: + """Sort key: the trailing integer of a mock router vertex id (``device59.11.7`` -> 7).""" + return int(vertex_id.rsplit(".", 1)[-1]) + + +def _raw_request(app: "VideoIPathApp", method: str, path: str, body: dict[str, Any]) -> requests.Response: + """A minimal authenticated REST call (for tag-catalog management, which the package's connector + allow-list intentionally does not cover).""" + rc = app._videoipath_connector.rest + response = getattr(requests, method)( + rc._build_url(path), json=body, auth=(rc._username, rc._password), verify=rc.verify_ssl_cert + ) + response.raise_for_status() + return response + + +def _tag_category(app: "VideoIPathApp", category: str) -> dict[str, Any] | None: + trees = app._videoipath_connector.rest.get("/rest/v2/data/config/tags/tagTrees/**") + for item in trees.data["config"]["tags"]["tagTrees"].get("_items", []): + if item.get("_id") == category: + return item + return None diff --git a/tests/e2e/inspect/leaf_spine_topology.json b/tests/e2e/inspect/leaf_spine_topology.json deleted file mode 100644 index a1cf622..0000000 --- a/tests/e2e/inspect/leaf_spine_topology.json +++ /dev/null @@ -1,377 +0,0 @@ -{ - "_comment": "A dummy VideoIPath spine-leaf topology (indices/coords/links only). Built with mock (virtual) devices in the E2E suite.", - "devices": [ - { - "name": "E2E-DEV-00", - "x": -2301, - "y": 8141, - "ports": 2 - }, - { - "name": "E2E-DEV-01", - "x": -1496, - "y": 8082, - "ports": 1 - }, - { - "name": "E2E-DEV-02", - "x": 2100, - "y": 8800, - "ports": 11 - }, - { - "name": "E2E-DEV-03", - "x": 3600, - "y": 8800, - "ports": 4 - }, - { - "name": "E2E-DEV-04", - "x": 3000, - "y": 8800, - "ports": 4 - }, - { - "name": "E2E-DEV-05", - "x": 1500, - "y": 8800, - "ports": 9 - }, - { - "name": "E2E-DEV-06", - "x": 0, - "y": 0, - "ports": 1 - }, - { - "name": "E2E-DEV-07", - "x": 1600, - "y": 9300, - "ports": 2 - }, - { - "name": "E2E-DEV-08", - "x": 2000, - "y": 9300, - "ports": 2 - }, - { - "name": "E2E-DEV-09", - "x": 1600, - "y": 9050, - "ports": 1 - }, - { - "name": "E2E-DEV-10", - "x": -2000, - "y": 7800, - "ports": 3 - }, - { - "name": "E2E-DEV-11", - "x": 0, - "y": 0, - "ports": 1 - }, - { - "name": "E2E-DEV-12", - "x": 1600, - "y": 10300, - "ports": 2 - }, - { - "name": "E2E-DEV-13", - "x": 0, - "y": 0, - "ports": 1 - }, - { - "name": "E2E-DEV-14", - "x": 2000, - "y": 9800, - "ports": 2 - }, - { - "name": "E2E-DEV-15", - "x": 0, - "y": 8400, - "ports": 7 - }, - { - "name": "E2E-DEV-16", - "x": 0, - "y": 7600, - "ports": 7 - }, - { - "name": "E2E-DEV-17", - "x": -1800, - "y": 8600, - "ports": 3 - }, - { - "name": "E2E-DEV-18", - "x": 500, - "y": 7850, - "ports": 2 - }, - { - "name": "E2E-DEV-19", - "x": -2800, - "y": 8200, - "ports": 2 - }, - { - "name": "E2E-DEV-20", - "x": 3500, - "y": 9300, - "ports": 2 - }, - { - "name": "E2E-DEV-21", - "x": 767, - "y": 8434, - "ports": 1 - }, - { - "name": "E2E-DEV-22", - "x": 1600, - "y": 9800, - "ports": 2 - }, - { - "name": "E2E-DEV-23", - "x": 2000, - "y": 9550, - "ports": 2 - }, - { - "name": "E2E-DEV-24", - "x": 1600, - "y": 9550, - "ports": 2 - }, - { - "name": "E2E-DEV-25", - "x": 900, - "y": 7850, - "ports": 2 - }, - { - "name": "E2E-DEV-26", - "x": 0, - "y": 0, - "ports": 1 - }, - { - "name": "E2E-DEV-27", - "x": 3500, - "y": 9050, - "ports": 2 - }, - { - "name": "E2E-DEV-28", - "x": 3100, - "y": 9300, - "ports": 2 - }, - { - "name": "E2E-DEV-29", - "x": 1046, - "y": 8159, - "ports": 2 - } - ], - "links": [ - [ - 0, - 10, - 1 - ], - [ - 0, - 17, - 1 - ], - [ - 2, - 7, - 1 - ], - [ - 2, - 8, - 1 - ], - [ - 2, - 9, - 1 - ], - [ - 2, - 12, - 1 - ], - [ - 2, - 14, - 1 - ], - [ - 2, - 15, - 2 - ], - [ - 2, - 21, - 1 - ], - [ - 2, - 22, - 1 - ], - [ - 2, - 23, - 1 - ], - [ - 2, - 24, - 1 - ], - [ - 3, - 15, - 1 - ], - [ - 3, - 20, - 1 - ], - [ - 3, - 27, - 1 - ], - [ - 3, - 28, - 1 - ], - [ - 4, - 16, - 1 - ], - [ - 4, - 20, - 1 - ], - [ - 4, - 27, - 1 - ], - [ - 4, - 28, - 1 - ], - [ - 5, - 7, - 1 - ], - [ - 5, - 8, - 1 - ], - [ - 5, - 12, - 1 - ], - [ - 5, - 14, - 1 - ], - [ - 5, - 16, - 2 - ], - [ - 5, - 22, - 1 - ], - [ - 5, - 23, - 1 - ], - [ - 5, - 24, - 1 - ], - [ - 10, - 16, - 1 - ], - [ - 10, - 19, - 1 - ], - [ - 15, - 17, - 1 - ], - [ - 15, - 18, - 1 - ], - [ - 15, - 25, - 1 - ], - [ - 15, - 29, - 1 - ], - [ - 16, - 18, - 1 - ], - [ - 16, - 25, - 1 - ], - [ - 16, - 29, - 1 - ], - [ - 17, - 19, - 1 - ] - ] -} \ No newline at end of file diff --git a/tests/e2e/inspect/scenario.py b/tests/e2e/inspect/scenario.py deleted file mode 100644 index c12161d..0000000 --- a/tests/e2e/inspect/scenario.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Topology scenario for the Inspect E2E suite ([ADR-0005]). - -Replicates the structure of the local VideoIPath instance (anonymized to indices/coordinates/links -in ``leaf_spine_topology.json``) using **virtual (mock-driver) devices only**. The build path is the -real user flow: - - 1. **Inventory** — create each device with the ``com.nevion.mock`` driver (a simulated device with - configurable router ports) and ``add_device`` it. No real hardware is involved. - 2. **Inspect** — add the devices to the topology graph, set their E2E display label + tag, then - connect their ports. - -The topology app is never used. - -Namespacing: every device carries the ``E2E-`` label prefix (in both inventory and inspect) and the -``vipat-e2e`` tag. Cleanup runs at **startup** (``cleanup``), not on teardown — so the built -topology persists in VideoIPath after a run for manual inspection, and the next run starts fresh. -""" - -from __future__ import annotations - -import json -from collections import defaultdict -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import requests - -from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel - -if TYPE_CHECKING: - from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp - -E2E_PREFIX = "E2E-" -E2E_TAG = "vipat-e2e" -MOCK_DRIVER = "com.nevion.mock-0.1.0" -TOPOLOGY_FILE = Path(__file__).parent / "leaf_spine_topology.json" - -# A test video tag, created via a simple API request in setup and assigned to a port via the inspect -# app. Tag references are ``Category~~name`` ids; it lives in the existing "Video" format category. -TEST_TAG_CATEGORY = "Video" -TEST_TAG_NAME = "E2E-VIDEO-TAG" -TEST_TAG_ID = f"{TEST_TAG_CATEGORY}~~{TEST_TAG_NAME}" - - -def _raw_request(app: "VideoIPathApp", method: str, path: str, body: dict[str, Any]) -> requests.Response: - """A minimal authenticated REST call (for tag-catalog management, which the package's connector - allow-list intentionally does not cover).""" - rc = app._videoipath_connector.rest - response = getattr(requests, method)( - rc._build_url(path), json=body, auth=(rc._username, rc._password), verify=rc.verify_ssl_cert - ) - response.raise_for_status() - return response - - -def _tag_category(app: "VideoIPathApp", category: str) -> dict[str, Any] | None: - trees = app._videoipath_connector.rest.get("/rest/v2/data/config/tags/tagTrees/**") - for item in trees.data["config"]["tags"]["tagTrees"].get("_items", []): - if item.get("_id") == category: - return item - return None - - -# The fixture coordinates are captured from the live topology, so the replica would sit right on top -# of it. Shift the whole E2E topology into its own region of the map so it never overlaps. -POSITION_OFFSET_X = 0 -POSITION_OFFSET_Y = 3000 - - -class DeviceSpec(InspectFrozenModel): - name: str - x: int - y: int - ports: int - - -class LinkSpec(InspectFrozenModel): - a: int # device index - b: int # device index - count: int # number of parallel bidirectional links - - -def _vertex_slot(vertex_id: str) -> int: - """Sort key: the trailing integer of a mock router vertex id (``device59.11.7`` -> 7).""" - return int(vertex_id.rsplit(".", 1)[-1]) - - -class LeafSpineScenario: - def __init__(self) -> None: - data = json.loads(TOPOLOGY_FILE.read_text()) - # Bake the offset into the specs so build placement and the placement-revert test agree. - self.devices = [ - DeviceSpec( - name=d["name"], - x=d["x"] + POSITION_OFFSET_X, - y=d["y"] + POSITION_OFFSET_Y, - ports=d["ports"], - ) - for d in data["devices"] - ] - self.links = [LinkSpec(a=a, b=b, count=n) for a, b, n in data["links"]] - self.device_ids: dict[str, str] = {} # E2E name -> VideoIPath device id - self._out: dict[str, list[str]] = {} # name -> ordered out-vertex ids - self._in: dict[str, list[str]] = {} # name -> ordered in-vertex ids - - # --- Introspection --- - - @property - def device_names(self) -> list[str]: - return [d.name for d in self.devices] - - def device_id(self, name: str) -> str: - return self.device_ids[name] - - def adjacency(self) -> dict[str, set[str]]: - """Undirected neighbour set per device name (from the fixture).""" - adj: dict[str, set[str]] = {d.name: set() for d in self.devices} - for link in self.links: - na, nb = self.devices[link.a].name, self.devices[link.b].name - adj[na].add(nb) - adj[nb].add(na) - return adj - - def expected_edge_count(self) -> int: - # Two directed edges per bidirectional link. - return sum(link.count for link in self.links) * 2 - - def a_link(self) -> tuple[str, str]: - """A representative connected device pair (names).""" - link = self.links[0] - return self.devices[link.a].name, self.devices[link.b].name - - # --- Test tag catalog (simple API requests) --- - - def create_test_tag(self, app: "VideoIPathApp") -> None: - """Create the E2E test video tag in the catalog (idempotent).""" - category = _tag_category(app, TEST_TAG_CATEGORY) - if category is None: - raise RuntimeError(f"Tag category '{TEST_TAG_CATEGORY}' not found on the server.") - children = dict(category.get("children") or {}) - children[TEST_TAG_NAME] = {"exclusive": False, "children": {}} - body = { - "actions": [ - { - "_action": "update", - "_id": TEST_TAG_CATEGORY, - "_rev": category["_rev"], - "children": children, - "type": category.get("type", "format"), - "exclusive": category.get("exclusive", False), - "formatTagLinks": category.get("formatTagLinks", {}), - "locationTypes": category.get("locationTypes", []), - } - ] - } - _raw_request(app, "patch", "/rest/v2/data/config/tags/tagTrees", body) - - def test_tag_exists(self, app: "VideoIPathApp") -> bool: - category = _tag_category(app, TEST_TAG_CATEGORY) - return category is not None and TEST_TAG_NAME in (category.get("children") or {}) - - def delete_test_tag(self, app: "VideoIPathApp") -> None: - """Force-delete the test tag (removes it and any port bindings) if it exists.""" - if self.test_tag_exists(app): - _raw_request( - app, - "post", - "/rest/v2/actions/status/tags/forceDeleteTag", - {"header": {"id": 0}, "data": {"tagId": TEST_TAG_ID}}, - ) - - # --- Startup cleanup (removes any prior E2E state) --- - - def cleanup(self, app: "VideoIPathApp") -> None: - """Remove every E2E device (and its edges) so a run starts from a clean namespace. - - A device lives in two places — the inventory and the inspect topology graph — and removing - it from one does not remove it from the other. So we discover E2E devices by their ``E2E-`` - label in **both** (catching orphans from an aborted run) and remove edges, the topology node, - and the inventory entry. - """ - self.delete_test_tag(app) - inventory_labels = app.inventory._inventory_api.fetch_devices_user_defined_labels_as_dict() - inventory_ids = {i for i, label in inventory_labels.items() if (label or "").startswith(E2E_PREFIX)} - app.inspect.refresh() - topology_ids = {d.id for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)} - all_ids = inventory_ids | topology_ids - if not all_ids: - return - # Edges first (only those that actually exist). - edge_ids = [ - edge.id - for edge in app.inspect.edges - if (edge.from_device and edge.from_device.id in all_ids) - or (edge.to_device and edge.to_device.id in all_ids) - ] - if edge_ids: - with app.inspect.transaction() as tx: - for edge_id in edge_ids: - tx.remove(edge_id) - tx.commit(check_conflicts=False) - # Remove the topology node, then the inventory entry. - for device_id in topology_ids: - try: - app.inspect.remove_device_from_topology(device_id) - except Exception: # best-effort cleanup - pass - for device_id in inventory_ids: - try: - app.inventory.remove_device(device_id=device_id, check_remove=False) - except Exception: # best-effort cleanup - pass - - # --- Build --- - - def build(self, app: "VideoIPathApp") -> None: - self._create_inventory_devices(app) - # Add to the inspect topology graph at their coordinates. - app.inspect.add_devices_to_topology([(self.device_ids[s.name], s.x, s.y) for s in self.devices]) - # Configure them in inspect: E2E display label + tag. - with app.inspect.transaction() as tx: - for spec in self.devices: - tx.update_device(self.device_ids[spec.name], label=spec.name, tags=[E2E_TAG]) - tx.commit() - self._discover_ports(app) - self._connect(app) - # Create the test video tag so the port-tagging test can assign it. - self.create_test_tag(app) - - def _create_inventory_devices(self, app: "VideoIPathApp") -> None: - for i, spec in enumerate(self.devices): - device = app.inventory.create_device(driver=MOCK_DRIVER) - device.configuration.label = spec.name - device.configuration.address = f"10.99.{i // 256}.{i % 256}" - settings = device.configuration.custom_settings - settings.num_router_modules = 1 - settings.num_router_ports = spec.ports - settings.num_codec_modules = 0 - online = app.inventory.add_device(device=device, address_check=False) - self.device_ids[spec.name] = online.configuration.device_id - - def _discover_ports(self, app: "VideoIPathApp") -> None: - app.inspect.refresh() - app.inspect.preload(list(self.device_ids.values())) - for spec in self.devices: - device = app.inspect.get_device(self.device_ids[spec.name]) - outs: list[str] = [] - ins: list[str] = [] - for port in device.ports: - label = port.label or "" - if not port.vertex_id: - continue - if "Router Out" in label: - outs.append(port.vertex_id) - elif "Router In" in label: - ins.append(port.vertex_id) - self._out[spec.name] = sorted(outs, key=_vertex_slot) - self._in[spec.name] = sorted(ins, key=_vertex_slot) - - def _connect(self, app: "VideoIPathApp") -> None: - slot: dict[str, int] = defaultdict(int) - with app.inspect.transaction() as tx: - for link in self.links: - na, nb = self.devices[link.a].name, self.devices[link.b].name - for _ in range(link.count): - ka, kb = slot[na], slot[nb] - slot[na] += 1 - slot[nb] += 1 - # Bidirectional link between interface ka on A and kb on B. - tx.connect(self._out[na][ka], self._in[nb][kb], bidirectional=False) - tx.connect(self._out[nb][kb], self._in[na][ka], bidirectional=False) - tx.commit() diff --git a/tests/e2e/inspect/test_e2e_inspect.py b/tests/e2e/inspect/test_e2e_inspect.py new file mode 100644 index 0000000..e77b974 --- /dev/null +++ b/tests/e2e/inspect/test_e2e_inspect.py @@ -0,0 +1,167 @@ +"""Independent Inspect capability tests against a live instance. + +Every test builds its **own** minimal topology (1–3 mock devices) via the ``topology_builder`` +fixture — unique labels/addresses per test, guaranteed teardown (edges → topology node → inventory +entry) even on failure. All assertions are scoped to the test's own device ids, so the persisted +workflow topology or any other state on a shared instance never interferes. + +Developer-run only (see ``tests/e2e/conftest.py``). Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import TEST_TAG_ID, FetchSpy, TopologyBuilder, create_test_tag, delete_test_tag, edges_between + + +pytestmark = pytest.mark.e2e + + +def test_skeleton_read_no_hydration(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("SKEL-A", 2), ("SKEL-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + with FetchSpy(app.inspect._inspect_api) as spy: + assert len(edges_between(app, id_a, id_b)) == 2 + assert spy.count == 0 # skeleton read triggers no per-device hydration + + +def test_lazy_hydration(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("HYD-A", 2), ("HYD-B", 2)]) + app.inspect.refresh() + assert not app.inspect.is_device_hydrated(id_a) + with FetchSpy(app.inspect._inspect_api) as spy: + ports = app.inspect.get_device(id_a).ports # one detail fetch + assert spy.count == 1 + _ = app.inspect.get_device(id_a).ports # cached + assert spy.count == 1 + assert len(ports) > 0 # mock devices expose router ports + assert app.inspect.is_device_hydrated(id_a) + assert not app.inspect.is_device_hydrated(id_b) + + +def test_connectivity_graph(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + hub, id_b, id_c = topology_builder.add_devices([("HUB-A", 2), ("HUB-B", 2), ("HUB-C", 2)]) + topology_builder.link(hub, id_b) + topology_builder.link(hub, id_c) + app.inspect.refresh() + linked = {d.label for d in app.inspect.get_device(hub).linked_devices} + assert linked == {topology_builder.labels[id_b], topology_builder.labels[id_c]} + + +def test_edge_pair_refresh(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("EDGE-A", 2), ("EDGE-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + edge_id = edges_between(app, id_a, id_b)[0].id + result = app.inspect.update_edge(edge_id, weight=7) + assert result.ok + # Survives the targeted post-commit refresh without a full reload. + assert any(e.id == edge_id for e in app.inspect.edges) + + +def test_transaction_atomicity(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("ATOM-A", 2), ("ATOM-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + edge_id = edges_between(app, id_a, id_b)[0].id + with pytest.raises(InspectCommitError): + with app.inspect.transaction() as tx: + tx.update_edge(edge_id, weight=13) + tx.remove("does-not-exist::also-not-real") + tx.commit() + # Nothing applied: the edge is still present. + app.inspect.refresh() + assert any(e.id == edge_id for e in app.inspect.edges) + + +def test_conflict_detection(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("CONF-A", 2), ("CONF-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + edge_id = edges_between(app, id_a, id_b)[0].id + tx = app.inspect.transaction() + tx.update_edge(edge_id, weight=21) + # Out-of-band change via a second app instance. + other = VideoIPathApp() + other.inspect.update_edge(edge_id, weight=9) + with pytest.raises(InspectCommitConflictError) as exc: + tx.commit() + assert any(c.entity_id == edge_id for c in exc.value.conflicts) + tx.rebase() + tx.commit() + + +def test_assign_tag_to_port(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + # Inspect-only capability: assign a catalog tag to a port. + (device_id,) = topology_builder.add_devices([("TAG-A", 2)]) + create_test_tag(app) + try: + app.inspect.refresh() + vertex_id = next( + p.vertex_id + for p in app.inspect.get_device(device_id).ports + if p.vertex_id and "Router In" in (p.label or "") + ) + result = app.inspect.update_vertex(vertex_id, tags=[TEST_TAG_ID]) + assert result.ok + app.inspect.refresh() + port = next(p for p in app.inspect.get_device(device_id).ports if p.vertex_id == vertex_id) + assert TEST_TAG_ID in port.tags + finally: + delete_test_tag(app) # also removes the port binding + + +def test_device_placement(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + (device_id,) = topology_builder.add_devices([("PLACE-A", 2)]) + app.inspect.place_device(device_id, 4200, 4200) + app.inspect.refresh() + coords = app.inspect.get_device(device_id).coordinates + assert coords is not None and coords["x"] == 4200 and coords["y"] == 4200 + + +def test_disconnect_reconnect_cycle(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("CYC-A", 2), ("CYC-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + directed = [tuple(e.id.split("::", 1)) for e in edges_between(app, id_a, id_b)] + assert directed + for from_vertex, to_vertex in directed: + app.inspect.disconnect(from_vertex, to_vertex, bidirectional=False) + app.inspect.refresh() + assert not edges_between(app, id_a, id_b) + for from_vertex, to_vertex in directed: + app.inspect.connect(from_vertex, to_vertex, bidirectional=False) + app.inspect.refresh() + assert len(edges_between(app, id_a, id_b)) == len(directed) + + +def test_full_vs_skeleton_equivalence(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b, id_c = topology_builder.add_devices([("EQ-A", 2), ("EQ-B", 2), ("EQ-C", 2)]) + topology_builder.link(id_a, id_b) + topology_builder.link(id_b, id_c) + + def graph_view() -> dict: + return { + device_id: ( + app.inspect.get_device(device_id).label, + frozenset(d.label for d in app.inspect.get_device(device_id).linked_devices), + ) + for device_id in (id_a, id_b, id_c) + } + + try: + app.inspect.refresh(load="skeleton") + app.inspect.preload([id_a, id_b, id_c]) + skeleton = graph_view() + app.inspect.refresh(load="full") + full = graph_view() + assert skeleton == full + finally: + app.inspect.refresh(load="skeleton") # restore default load mode diff --git a/tests/e2e/inspect/test_e2e_leaf_spine.py b/tests/e2e/inspect/test_e2e_leaf_spine.py deleted file mode 100644 index 3656a24..0000000 --- a/tests/e2e/inspect/test_e2e_leaf_spine.py +++ /dev/null @@ -1,236 +0,0 @@ -"""End-to-end Inspect tests against a live instance, replicating the local topology. - -Developer-run only (see ``tests/e2e/conftest.py``). A session-scoped fixture cleans up any prior -E2E state **at startup** and then builds the topology with virtual (mock-driver) devices via the -inventory → inspect flow. There is **no teardown**: the built topology persists in VideoIPath after -the run (the next run cleans it up and rebuilds). Mutating tests revert their own changes, so the -persisted end state is the complete topology. - -Everything goes through ``app.inventory`` (device creation) and ``app.inspect`` (topology). The -topology app is never used. - -Run with:: - - poetry run test-e2e -""" - -from __future__ import annotations - -import pytest - -from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError -from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp - -from .scenario import TEST_TAG_ID, LeafSpineScenario - - -pytestmark = pytest.mark.e2e - - -@pytest.fixture(scope="session") -def scenario(app: VideoIPathApp): - scn = LeafSpineScenario() - scn.cleanup(app) # startup cleanup only — no teardown, state persists after the run - scn.build(app) - return scn - - -class _FetchSpy: - """Wraps get_device_detail to count hydration fetches.""" - - def __init__(self, api): - self._api = api - self._orig = api.get_device_detail - self.count = 0 - - def __enter__(self): - def counting(device_id): - self.count += 1 - return self._orig(device_id) - - self._api.get_device_detail = counting - return self - - def __exit__(self, *exc): - self._api.get_device_detail = self._orig - - -def _edges_between(app, id_a: str, id_b: str): - return [ - e - for e in app.inspect.edges - if e.from_device and e.to_device and {e.from_device.id, e.to_device.id} == {id_a, id_b} - ] - - -def _busiest_device(scenario) -> str: - adjacency = scenario.adjacency() - return max(scenario.device_names, key=lambda n: len(adjacency[n])) - - -def test_all_devices_present(app, scenario): - app.inspect.refresh() - labels = {d.label for d in app.inspect.devices} - for name in scenario.device_names: - assert name in labels - - -def test_skeleton_read_no_hydration(app, scenario): - app.inspect.refresh() - with _FetchSpy(app.inspect._inspect_api) as spy: - known = set(scenario.device_ids.values()) - scenario_edges = [ - e - for e in app.inspect.edges - if (e.from_device and e.from_device.id in known) or (e.to_device and e.to_device.id in known) - ] - assert len(scenario_edges) == scenario.expected_edge_count() - assert spy.count == 0 # skeleton read triggers no per-device hydration - - -def test_lazy_hydration(app, scenario): - app.inspect.refresh() - name = _busiest_device(scenario) - device_id = scenario.device_id(name) - other_id = scenario.device_id(next(n for n in scenario.device_names if n != name)) - assert not app.inspect.is_device_hydrated(device_id) - with _FetchSpy(app.inspect._inspect_api) as spy: - ports = app.inspect.get_device(device_id).ports # one detail fetch - assert spy.count == 1 - _ = app.inspect.get_device(device_id).ports # cached - assert spy.count == 1 - assert len(ports) > 0 # mock devices expose router ports - assert app.inspect.is_device_hydrated(device_id) - assert not app.inspect.is_device_hydrated(other_id) - - -def test_connectivity_graph(app, scenario): - app.inspect.refresh() - adjacency = scenario.adjacency() - name = _busiest_device(scenario) - device = app.inspect.get_device(scenario.device_id(name)) - linked = {d.label for d in device.linked_devices} - assert linked == adjacency[name] - - -def test_edge_pair_refresh(app, scenario): - app.inspect.refresh() - name_a, name_b = scenario.a_link() - id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) - edges = _edges_between(app, id_a, id_b) - assert edges - edge_id = edges[0].id - result = app.inspect.update_edge(edge_id, weight=7) - assert result.ok - # Survives the targeted post-commit refresh without a full reload. - assert any(e.id == edge_id for e in app.inspect.edges) - app.inspect.update_edge(edge_id, weight=1) # revert - - -def test_transaction_atomicity(app, scenario): - name_a, name_b = scenario.a_link() - id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) - app.inspect.refresh() - edge_id = _edges_between(app, id_a, id_b)[0].id - with pytest.raises(InspectCommitError): - with app.inspect.transaction() as tx: - tx.update_edge(edge_id, weight=13) - tx.remove("does-not-exist::also-not-real") - tx.commit() - # Nothing applied: the edge is still present. - app.inspect.refresh() - assert any(e.id == edge_id for e in app.inspect.edges) - - -def test_conflict_detection(app, scenario): - app.inspect.refresh() - name_a, name_b = scenario.a_link() - id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) - edge_id = _edges_between(app, id_a, id_b)[0].id - tx = app.inspect.transaction() - tx.update_edge(edge_id, weight=21) - # Out-of-band change via a second app instance. - other = VideoIPathApp() - other.inspect.update_edge(edge_id, weight=9) - with pytest.raises(InspectCommitConflictError) as exc: - tx.commit() - assert any(c.entity_id == edge_id for c in exc.value.conflicts) - tx.rebase() - tx.commit() - app.inspect.update_edge(edge_id, weight=1) # revert - - -def test_assign_tag_to_port(app, scenario): - # Inspect-only capability: assign a catalog tag to a port. The tag was created in setup. - assert scenario.test_tag_exists(app) - app.inspect.refresh() - name = scenario.a_link()[0] - device_id = scenario.device_id(name) - vertex_id = next( - p.vertex_id for p in app.inspect.get_device(device_id).ports if p.vertex_id and "Router In" in (p.label or "") - ) - result = app.inspect.update_vertex(vertex_id, tags=[TEST_TAG_ID]) - assert result.ok - app.inspect.refresh() - port = next(p for p in app.inspect.get_device(device_id).ports if p.vertex_id == vertex_id) - assert TEST_TAG_ID in port.tags - - -def test_device_placement_roundtrip(app, scenario): - name = scenario.device_names[0] - spec = next(s for s in scenario.devices if s.name == name) - device_id = scenario.device_id(name) - app.inspect.place_device(device_id, 4200, 4200) - app.inspect.refresh() - coords = app.inspect.get_device(device_id).coordinates - assert coords is not None and coords["x"] == 4200 and coords["y"] == 4200 - app.inspect.place_device(device_id, spec.x, spec.y) # revert - - -def test_disconnect_reconnect_cycle(app, scenario): - app.inspect.refresh() - name_a, name_b = scenario.a_link() - id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) - directed = [tuple(e.id.split("::", 1)) for e in _edges_between(app, id_a, id_b)] - assert directed - for from_vertex, to_vertex in directed: - app.inspect.disconnect(from_vertex, to_vertex, bidirectional=False) - app.inspect.refresh() - assert not _edges_between(app, id_a, id_b) - for from_vertex, to_vertex in directed: - app.inspect.connect(from_vertex, to_vertex, bidirectional=False) - app.inspect.refresh() - assert len(_edges_between(app, id_a, id_b)) == len(directed) - - -def test_full_vs_skeleton_equivalence(app, scenario): - def graph_view() -> dict: - return { - device_id: ( - app.inspect.get_device(device_id).label, - frozenset(d.label for d in app.inspect.get_device(device_id).linked_devices), - ) - for device_id in scenario.device_ids.values() - } - - app.inspect.refresh(load="skeleton") - app.inspect.preload(list(scenario.device_ids.values())) - skeleton = graph_view() - app.inspect.refresh(load="full") - full = graph_view() - assert skeleton == full - app.inspect.refresh(load="skeleton") # restore default load mode - - -def test_state_persists(app, scenario): - # There is no teardown: after the suite the complete topology remains in VideoIPath. - app.inspect.refresh() - labels = {d.label for d in app.inspect.devices} - assert all(name in labels for name in scenario.device_names) - known = set(scenario.device_ids.values()) - scenario_edges = [ - e - for e in app.inspect.edges - if (e.from_device and e.from_device.id in known) or (e.to_device and e.to_device.id in known) - ] - assert len(scenario_edges) == scenario.expected_edge_count() diff --git a/tests/e2e/test_e2e_workflow.py b/tests/e2e/test_e2e_workflow.py new file mode 100644 index 0000000..2b0c40a --- /dev/null +++ b/tests/e2e/test_e2e_workflow.py @@ -0,0 +1,172 @@ +"""Sequential end-to-end workflow: connect → inventory → inspect topology → edges, step by step. + +Each build step of the real user journey is its own test with verification, running in definition +order and sharing state via a class-scoped fixture. Once a step fails, the remaining steps are +skipped (``@pytest.mark.incremental``, see ``conftest.py``). + +The suite builds a small dedicated leaf-spine topology (4 devices, 4 bidirectional links) with +fixed ``E2E-WF-`` labels in its own map region. There is **no teardown**: the built topology +persists in VideoIPath after the run for manual inspection; the next run's session sweep removes +it. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Iterator + +import pytest +from pydantic import BaseModel + +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from .helpers import E2E_TAG, create_mock_device, discover_router_vertices, edges_between + + +pytestmark = pytest.mark.e2e + + +class DeviceSpec(InspectFrozenModel): + name: str + x: int + y: int + ports: int + + +class LinkSpec(InspectFrozenModel): + a: int # device index + b: int # device index + + +# A miniature leaf-spine in its own map region: every leaf connects to every spine. +DEVICES = [ + DeviceSpec(name="E2E-WF-SPINE-01", x=0, y=3000, ports=2), + DeviceSpec(name="E2E-WF-SPINE-02", x=600, y=3000, ports=2), + DeviceSpec(name="E2E-WF-LEAF-01", x=0, y=3400, ports=2), + DeviceSpec(name="E2E-WF-LEAF-02", x=600, y=3400, ports=2), +] +LINKS = [LinkSpec(a=2, b=0), LinkSpec(a=2, b=1), LinkSpec(a=3, b=0), LinkSpec(a=3, b=1)] + + +class WorkflowState(BaseModel): + """Mutable state shared across the sequential steps.""" + + device_ids: dict[str, str] = {} # spec name -> VideoIPath device id + out_vertices: dict[str, list[str]] = {} # device id -> ordered out-vertex ids + in_vertices: dict[str, list[str]] = {} # device id -> ordered in-vertex ids + edge_id: str | None = None # representative edge for the update step + + +@pytest.fixture(scope="class") +def state() -> WorkflowState: + return WorkflowState() + + +def _expected_adjacency() -> dict[str, set[str]]: + """Undirected neighbour set per device name (from the specs).""" + adjacency: dict[str, set[str]] = {d.name: set() for d in DEVICES} + for link in LINKS: + na, nb = DEVICES[link.a].name, DEVICES[link.b].name + adjacency[na].add(nb) + adjacency[nb].add(na) + return adjacency + + +@pytest.mark.incremental +class TestInventoryToInspectWorkflow: + def test_connect(self, app: VideoIPathApp) -> None: + app.check_connection() # raises ConnectionError on failure + assert app.get_server_version() + + def test_create_inventory_devices( + self, app: VideoIPathApp, state: WorkflowState, e2e_addresses: Iterator[str] + ) -> None: + for spec in DEVICES: + state.device_ids[spec.name] = create_mock_device( + app, label=spec.name, address=next(e2e_addresses), ports=spec.ports + ) + assert len(state.device_ids) == len(DEVICES) + + def test_verify_inventory(self, app: VideoIPathApp, state: WorkflowState) -> None: + for spec in DEVICES: + device_id = state.device_ids[spec.name] + assert app.inventory.find_device_id_by_label(spec.name) == device_id + device = app.inventory.get_device(device_id=device_id, config_only=True) + assert device.configuration.label == spec.name + + def test_add_to_topology(self, app: VideoIPathApp, state: WorkflowState) -> None: + app.inspect.add_devices_to_topology([(state.device_ids[s.name], s.x, s.y) for s in DEVICES]) + app.inspect.refresh() + topology_ids = {d.id for d in app.inspect.devices} + assert set(state.device_ids.values()) <= topology_ids + + def test_configure_labels_and_tags(self, app: VideoIPathApp, state: WorkflowState) -> None: + with app.inspect.transaction() as tx: + for spec in DEVICES: + tx.update_device(state.device_ids[spec.name], label=spec.name, tags=[E2E_TAG]) + tx.commit() + app.inspect.refresh() + for spec in DEVICES: + device = app.inspect.get_device(state.device_ids[spec.name]) + assert device is not None + assert device.label == spec.name + assert E2E_TAG in device.tags + + def test_discover_ports(self, app: VideoIPathApp, state: WorkflowState) -> None: + vertices = discover_router_vertices(app, list(state.device_ids.values())) + for spec in DEVICES: + outs, ins = vertices[state.device_ids[spec.name]] + assert len(outs) == spec.ports + assert len(ins) == spec.ports + state.out_vertices[state.device_ids[spec.name]] = outs + state.in_vertices[state.device_ids[spec.name]] = ins + + def test_connect_edges(self, app: VideoIPathApp, state: WorkflowState) -> None: + slot: dict[str, int] = defaultdict(int) + with app.inspect.transaction() as tx: + for link in LINKS: + id_a, id_b = state.device_ids[DEVICES[link.a].name], state.device_ids[DEVICES[link.b].name] + ka, kb = slot[id_a], slot[id_b] + slot[id_a] += 1 + slot[id_b] += 1 + # Bidirectional link between port slot ka on A and kb on B (two directed edges). + tx.connect(state.out_vertices[id_a][ka], state.in_vertices[id_b][kb], bidirectional=False) + tx.connect(state.out_vertices[id_b][kb], state.in_vertices[id_a][ka], bidirectional=False) + tx.commit() + app.inspect.refresh() + for link in LINKS: + id_a, id_b = state.device_ids[DEVICES[link.a].name], state.device_ids[DEVICES[link.b].name] + pair_edges = edges_between(app, id_a, id_b) + assert len(pair_edges) == 2 + state.edge_id = pair_edges[0].id + + def test_update_edge_weight(self, app: VideoIPathApp, state: WorkflowState) -> None: + assert state.edge_id is not None + result = app.inspect.update_edge(state.edge_id, weight=7) + assert result.ok + # Survives the targeted post-commit refresh without a full reload. + assert any(e.id == state.edge_id for e in app.inspect.edges) + + def test_verify_connectivity_and_persistence(self, app: VideoIPathApp, state: WorkflowState) -> None: + # A fresh read of the final state: labels, adjacency, and edge count all match the specs. + # There is no teardown — this persisted topology remains in VideoIPath after the run. + app.inspect.refresh() + labels = {d.label for d in app.inspect.devices} + assert all(spec.name in labels for spec in DEVICES) + adjacency = _expected_adjacency() + for spec in DEVICES: + device = app.inspect.get_device(state.device_ids[spec.name]) + assert device is not None + assert {d.label for d in device.linked_devices} == adjacency[spec.name] + known = set(state.device_ids.values()) + workflow_edges = [ + e + for e in app.inspect.edges + if (e.from_device and e.from_device.id in known) or (e.to_device and e.to_device.id in known) + ] + assert len(workflow_edges) == len(LINKS) * 2 From 6f772e5a4df53366619170976898f0f95d3a5571 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:02:40 +0200 Subject: [PATCH 11/15] update poetry lock --- poetry.lock | 477 +++++++++++++++++++++++----------------------------- 1 file changed, 213 insertions(+), 264 deletions(-) diff --git a/poetry.lock b/poetry.lock index e95452e..8b2ee44 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,14 +14,14 @@ files = [ [[package]] name = "certifi" -version = "2026.5.20" +version = "2026.6.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, - {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, + {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, + {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, ] [[package]] @@ -38,141 +38,105 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.4.7" +version = "3.4.9" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, - {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, - {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe"}, + {file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"}, + {file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"}, ] [[package]] @@ -190,118 +154,103 @@ files = [ [[package]] name = "coverage" -version = "7.14.1" +version = "7.15.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["test"] files = [ - {file = "coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf"}, - {file = "coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332"}, - {file = "coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59"}, - {file = "coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253"}, - {file = "coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f"}, - {file = "coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9"}, - {file = "coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548"}, - {file = "coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e"}, - {file = "coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3"}, - {file = "coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c"}, - {file = "coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a"}, - {file = "coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1"}, - {file = "coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e"}, - {file = "coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a"}, - {file = "coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793"}, - {file = "coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33"}, - {file = "coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c"}, - {file = "coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416"}, - {file = "coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42"}, - {file = "coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d"}, - {file = "coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54"}, - {file = "coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1"}, - {file = "coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce"}, - {file = "coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1"}, - {file = "coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee"}, - {file = "coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d"}, - {file = "coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee"}, - {file = "coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7"}, - {file = "coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343"}, - {file = "coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1"}, - {file = "coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd"}, - {file = "coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e"}, - {file = "coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c"}, - {file = "coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af"}, - {file = "coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2"}, - {file = "coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be"}, + {file = "coverage-7.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50913d4bf5ddafa6ca3693da5e4dd833dd1b772e0283c99ca7f7d287db67331a"}, + {file = "coverage-7.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:359e141ccd33893ce3f1ad5525f8b96083003677c82182e5907d62d4ea5799fc"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3200b6204935f928c64b2ca1f923ab8c1acb7c9de45ec61569711b34d25cccaf"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be616bf61346883b2cfdc5178669647e03531d81ab761a7e378558b7e8bcb628"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7bafc3fe1059463a8fdd97ca79972d6e2bf819d775c7d54991b5b1971201d6"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b713aa7fcf325a01d4184d848acb46fd84f78fdb0978470c636b23a06a753d91"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38e6fba2d56652fdfaf0231f8f78aeb805234a912de25dc291ee5cce5b8faa4"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:884499f42e382675be80770391983b90e0c0c774d87dbeeebf5f991cf6612b20"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:840481b12e083dbcbafab14794a8781a958edf327c8d3d70b4eee42f9b8253aa"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:276646e9481703d09f854f3b2f018f24e19fd7049ae670a92570043eb97203b1"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4de4b4d3f5545aa6c60dc4efd9c63b5b5dcc3bf00fe83146b2bdfffb8f6613bd"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5c504097b2a89b1e85bc6070d920df77daec701337e3aeef2c17775a5dd0ca90"}, + {file = "coverage-7.15.0-cp310-cp310-win32.whl", hash = "sha256:f6e80ed91f98316e86b9c137206b04b2bcfbffccbdff49bd2eb09dddb1cf14e0"}, + {file = "coverage-7.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3b3e22030f3f6f5e01a5ce69936552a5c0f6992b7698777377b99041961031f"}, + {file = "coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a"}, + {file = "coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540"}, + {file = "coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4"}, + {file = "coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6"}, + {file = "coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640"}, + {file = "coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d"}, + {file = "coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da"}, + {file = "coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77"}, + {file = "coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6"}, + {file = "coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b"}, + {file = "coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782"}, + {file = "coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37"}, + {file = "coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b"}, + {file = "coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629"}, + {file = "coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21"}, + {file = "coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324"}, + {file = "coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe"}, + {file = "coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f"}, + {file = "coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663"}, + {file = "coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da"}, + {file = "coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641"}, + {file = "coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68"}, + {file = "coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b"}, + {file = "coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080"}, + {file = "coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5"}, + {file = "coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19"}, + {file = "coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f"}, ] [package.extras] @@ -333,26 +282,26 @@ test = ["pytest (>=8.3.0,<8.4.0)", "pytest-benchmark (>=5.1.0,<5.2.0)", "pytest- [[package]] name = "distlib" -version = "0.4.0" +version = "0.4.3" description = "Distribution utilities" optional = false python-versions = "*" groups = ["dev"] files = [ - {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, - {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, + {file = "distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b"}, + {file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"}, ] [[package]] name = "filelock" -version = "3.29.0" +version = "3.29.7" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258"}, - {file = "filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90"}, + {file = "filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51"}, + {file = "filelock-3.29.7.tar.gz", hash = "sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d"}, ] [[package]] @@ -372,14 +321,14 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.16" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5"}, - {file = "idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] @@ -442,14 +391,14 @@ files = [ [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"}, - {file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"}, + {file = "platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"}, + {file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"}, ] [[package]] @@ -751,14 +700,14 @@ testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "python-discovery" -version = "1.3.1" +version = "1.4.4" description = "Python interpreter discovery" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c"}, - {file = "python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6"}, + {file = "python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe"}, + {file = "python_discovery-1.4.4.tar.gz", hash = "sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3"}, ] [package.dependencies] @@ -919,14 +868,14 @@ files = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] [[package]] @@ -964,23 +913,23 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "virtualenv" -version = "21.3.3" +version = "21.6.0" description = "Virtual Python Environment builder" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3"}, - {file = "virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328"}, + {file = "virtualenv-21.6.0-py3-none-any.whl", hash = "sha256:bce9d097950fef9d81129b333babfb7767072850c2f1acce0ec536708401bfd1"}, + {file = "virtualenv-21.6.0.tar.gz", hash = "sha256:e18a4d750f2b64dea73e72ffde3922f3c52365fabdc8157ebd3da20d031c4734"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" -python-discovery = ">=1.3.1" +python-discovery = ">=1.4.2" [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "3834715d55e7a30c87eae83f735f51328d3fc86b0324ca934084d37c11d8ab56" +content-hash = "be839f147f93f86d74e84ed19924b23b49d74c0601e5a0dce4d204394326cf57" From 935728f27235e1910208cb9aed77c851cf4a80a4 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:19:50 +0200 Subject: [PATCH 12/15] test improvements --- .claude/rules/python-testing.md | 2 +- AGENTS.md | 2 +- docs/inspect-app/concepts.md | 2 +- docs/inspect-app/endpoints.md | 10 +- docs/inspect-app/implementation-plan.md | 4 +- tests/e2e/test_e2e_workflow.py | 94 ++++++++++++++++--- tests/inspect/conftest.py | 2 +- .../2025.4.9/action_schema_collector.json | 0 .../device_hydration_modules_ports.json | 0 .../fixtures}/2025.4.9/edge_skeleton.json | 0 .../2025.4.9/inspect_paths_limit5.json | 0 .../2025.4.9/lookup_inspect_edges_by_ids.json | 0 .../2025.4.9/lookup_inspect_vertex_by_id.json | 0 .../2025.4.9/nodestatus_noid_collection.json | 0 .../2025.4.9/skeleton_nodestatus_short.json | 0 .../update_topology_fail_booking.json | 0 .../2025.4.9/update_topology_fail_remove.json | 0 .../update_topology_replace_devices.json | 0 .../update_topology_replace_vertices.json | 0 .../2025.4.9/update_topology_success.json | 0 20 files changed, 94 insertions(+), 22 deletions(-) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/action_schema_collector.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/device_hydration_modules_ports.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/edge_skeleton.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/inspect_paths_limit5.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/lookup_inspect_edges_by_ids.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/lookup_inspect_vertex_by_id.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/nodestatus_noid_collection.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/skeleton_nodestatus_short.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/update_topology_fail_booking.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/update_topology_fail_remove.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/update_topology_replace_devices.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/update_topology_replace_vertices.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/update_topology_success.json (100%) diff --git a/.claude/rules/python-testing.md b/.claude/rules/python-testing.md index 102df3a..cf7f943 100644 --- a/.claude/rules/python-testing.md +++ b/.claude/rules/python-testing.md @@ -56,7 +56,7 @@ Default `addopts` run coverage on `src/` and exclude e2e (`-m "not e2e"`). - Mock external I/O with fake connectors and lightweight stand-ins (see `tests/inspect/test_actions.py`). - Dummy `VIPAT_*` env vars are set in `tests/conftest.py` (autouse fixture; skipped for e2e). -- Load JSON fixtures from `tests/fixtures/` using `pathlib.Path`. +- Load JSON fixtures from `tests//fixtures//` using `pathlib.Path`. - Put shared fixtures in `conftest.py` at the appropriate directory level. - Use session-scoped fixtures only when setup is expensive and reuse is intentional. diff --git a/AGENTS.md b/AGENTS.md index 23efa7a..a4c5a2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Rules: 2. **Apply the same rules to docs and inline examples** — a markdown code block with a real hostname is as sensitive as a JSON fixture. 3. **Preserve structure, not identity.** Keep IDs, relationships, and field shapes realistic so tests and docs remain meaningful; only replace identifying values. 4. **Review diffs for leaks.** Scan new files for hostnames, email addresses, customer abbreviations, and internal project codenames. -5. **When in doubt, generalize.** Prefer `device-a` / `port-out-1` style names already used under `tests/fixtures/`. +5. **When in doubt, generalize.** Prefer `device-a` / `port-out-1` style names already used under `tests//fixtures/`. Inspect fixtures can be anonymized with `scripts/anonymize_inspect_fixtures.py` when available. diff --git a/docs/inspect-app/concepts.md b/docs/inspect-app/concepts.md index 80cf72d..f6fb4d5 100644 --- a/docs/inspect-app/concepts.md +++ b/docs/inspect-app/concepts.md @@ -375,7 +375,7 @@ skeleton-first load strategy (see 3. **Diff against known resources.** Compare captured paths to the prefixes the package already allows. New prefixes ⇒ new Inspect resources. 4. **Save representative payloads as fixtures.** Store sanitised JSON under - `tests/fixtures/inspect//…`. These feed the offline tests in + `tests/inspect/fixtures//…`. These feed the offline tests in [ADR-0005](./decisions/0005-e2e-testing.md) and double as living documentation of the wire format. diff --git a/docs/inspect-app/endpoints.md b/docs/inspect-app/endpoints.md index b4aecc6..0d842a7 100644 --- a/docs/inspect-app/endpoints.md +++ b/docs/inspect-app/endpoints.md @@ -319,7 +319,7 @@ module keys map to child objects whose expansion follows the projection depth only after sync completes; the earlier capture was not a projection bug. `GET …/nodeStatus//**` returns the full subtree (~21 KB for one device) — fixture: -`tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json`. +`tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json`. ### Edge skeleton — `externalEdgesByDeviceKey` lean projection @@ -846,7 +846,7 @@ Response (keyed by requested edge id): } ``` -Fixture: `tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json`. +Fixture: `tests/inspect/fixtures/2025.4.9/lookup_inspect_edges_by_ids.json`. ### `POST /rest/v2/actions/status/collector/lookupInspectVertexById` / `…ByIds` @@ -916,7 +916,7 @@ Response (single form; `…ByIds` nests this per id): } ``` -Fixture: `tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json`. +Fixture: `tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json`. The `fields` object is **exactly the accepted `replaceVertices` payload shape** (verified 2025.4.9 — see @@ -1459,7 +1459,7 @@ Failure modes (verified 2025.4.9): a bumped `_rev`; a parallel UUID-keyed document may also exist for the same edge. - `resolvable: true` has not been observed on 2025.4.9. -- Fixtures: `tests/fixtures/inspect/2025.4.9/update_topology_success.json`, +- Fixtures: `tests/inspect/fixtures/2025.4.9/update_topology_success.json`, `…/update_topology_replace_devices.json` (edit-form request, success response, and the raw-element rejection error), `…/update_topology_replace_vertices.json` (vertex edit-form request, @@ -1595,7 +1595,7 @@ references none of them; `importExport` data namespaces are empty under `status`/`config`/`experimental`). These are unregistered server stubs — no further payload probing is warranted unless a future version registers them (re-check the GET schema after upgrades). Fixture: -`tests/fixtures/inspect/2025.4.9/action_schema_collector.json`. +`tests/inspect/fixtures/2025.4.9/action_schema_collector.json`. Cross-check against the UI bundle (`/assets/index-*.js`, 2025.4.9): it references `updateTopology`, `addDevices`, `syncDevices`, `lookupSyncInfo`, diff --git a/docs/inspect-app/implementation-plan.md b/docs/inspect-app/implementation-plan.md index edf38a0..0562ee4 100644 --- a/docs/inspect-app/implementation-plan.md +++ b/docs/inspect-app/implementation-plan.md @@ -21,7 +21,7 @@ Governing decisions (all Accepted): | --- | --- | | [0001](./decisions/0001-api-paradigm.md)/[0003](./decisions/0003-websocket-subscriptions.md) | Request/response only; no WebSocket API | | [0004](./decisions/0004-async-strategy.md) | Sync public API; internal thread-pool parallelism for multi-request operations | -| [0005](./decisions/0005-e2e-testing.md) | E2E = developer-run live-server tests via `tests/.env.test`; offline fixture tests feed from `tests/fixtures/inspect//` | +| [0005](./decisions/0005-e2e-testing.md) | E2E = developer-run live-server tests via `tests/.env.test`; offline fixture tests feed from `tests//fixtures//` | | [0006](./decisions/0006-commit-write-model.md) | Commit-style writes: hybrid — direct auto-commit methods + explicit transaction context manager; success = `header.ok ∧ data.res.ok ∧ data.validation.result.ok` | | [0007](./decisions/0007-lazy-snapshot-loading.md) | Skeleton-first snapshot (devices + edges scoped queries), transparent per-entity lazy hydration, section-level lazy loads, accreting state, `load="full"` eager mode | | [0008](./decisions/0008-collector-only-endpoints.md) | Collector-only endpoints: never call `nGraphElements` / `edgesByDevice` at runtime | @@ -235,7 +235,7 @@ Add `InspectDevice.is_hydrated` / `snapshot.fetched_at(...)` for introspection. ### 6.1 Offline (runs in CI, every push) -- **Fixture contract tests** (`tests/inspect/test_models.py`): every fixture in `tests/fixtures/inspect/2025.4.9/` parses into its DTO; write-payload builders reproduce the fixture requests byte-for-byte (devices/vertices edit forms, edge form, no-op). +- **Fixture contract tests** (`tests/inspect/test_models.py`): every fixture in `tests/inspect/fixtures/2025.4.9/` parses into its DTO; write-payload builders reproduce the fixture requests byte-for-byte (devices/vertices edit forms, edge form, no-op). - **Snapshot unit tests** (`tests/inspect/test_snapshot.py`): fake fetcher returning fixture payloads — skeleton indexes, exactly-one hydration fetch per device, section laziness, preload fan-out, post-commit hooks (removal drops indexes; refresh replaces records; stale section reloads). - **Changeset unit tests** (`tests/inspect/test_changeset.py`): staging→baseline capture, intent application, conflict detection (mutated baseline → error with field diffs), `check_conflicts=False` bypass, three-flag result evaluation incl. the captured failure fixtures, affected-set derivation. - **Query tests**: encoding, 414 length guard, projection constants stability. diff --git a/tests/e2e/test_e2e_workflow.py b/tests/e2e/test_e2e_workflow.py index 2b0c40a..1b84299 100644 --- a/tests/e2e/test_e2e_workflow.py +++ b/tests/e2e/test_e2e_workflow.py @@ -4,10 +4,10 @@ order and sharing state via a class-scoped fixture. Once a step fails, the remaining steps are skipped (``@pytest.mark.incremental``, see ``conftest.py``). -The suite builds a small dedicated leaf-spine topology (4 devices, 4 bidirectional links) with -fixed ``E2E-WF-`` labels in its own map region. There is **no teardown**: the built topology -persists in VideoIPath after the run for manual inspection; the next run's session sweep removes -it. +The suite builds a full dedicated leaf-spine topology (30 devices, 40 bidirectional links — +including two parallel links) that replicates the local VideoIPath instance, with fixed +``E2E-WF-`` labels in its own map region. There is **no teardown**: the built topology persists in +VideoIPath after the run for manual inspection; the next run's session sweep removes it. Run with:: @@ -43,14 +43,84 @@ class LinkSpec(InspectFrozenModel): b: int # device index -# A miniature leaf-spine in its own map region: every leaf connects to every spine. +# A full leaf-spine in its own map region (all coordinates shifted +3000 in y so the replica never +# overlaps the live topology it mirrors). Port counts equal each device's link degree; devices with +# no links sit at the region origin. Two device pairs — (2, 15) and (5, 16) — carry parallel links, +# represented here as repeated ``LinkSpec`` entries. DEVICES = [ - DeviceSpec(name="E2E-WF-SPINE-01", x=0, y=3000, ports=2), - DeviceSpec(name="E2E-WF-SPINE-02", x=600, y=3000, ports=2), - DeviceSpec(name="E2E-WF-LEAF-01", x=0, y=3400, ports=2), - DeviceSpec(name="E2E-WF-LEAF-02", x=600, y=3400, ports=2), + DeviceSpec(name="E2E-WF-DEV-00", x=-2301, y=11141, ports=2), + DeviceSpec(name="E2E-WF-DEV-01", x=-1496, y=11082, ports=1), + DeviceSpec(name="E2E-WF-DEV-02", x=2100, y=11800, ports=11), + DeviceSpec(name="E2E-WF-DEV-03", x=3600, y=11800, ports=4), + DeviceSpec(name="E2E-WF-DEV-04", x=3000, y=11800, ports=4), + DeviceSpec(name="E2E-WF-DEV-05", x=1500, y=11800, ports=9), + DeviceSpec(name="E2E-WF-DEV-06", x=0, y=3000, ports=1), + DeviceSpec(name="E2E-WF-DEV-07", x=1600, y=12300, ports=2), + DeviceSpec(name="E2E-WF-DEV-08", x=2000, y=12300, ports=2), + DeviceSpec(name="E2E-WF-DEV-09", x=1600, y=12050, ports=1), + DeviceSpec(name="E2E-WF-DEV-10", x=-2000, y=10800, ports=3), + DeviceSpec(name="E2E-WF-DEV-11", x=0, y=3000, ports=1), + DeviceSpec(name="E2E-WF-DEV-12", x=1600, y=13300, ports=2), + DeviceSpec(name="E2E-WF-DEV-13", x=0, y=3000, ports=1), + DeviceSpec(name="E2E-WF-DEV-14", x=2000, y=12800, ports=2), + DeviceSpec(name="E2E-WF-DEV-15", x=0, y=11400, ports=7), + DeviceSpec(name="E2E-WF-DEV-16", x=0, y=10600, ports=7), + DeviceSpec(name="E2E-WF-DEV-17", x=-1800, y=11600, ports=3), + DeviceSpec(name="E2E-WF-DEV-18", x=500, y=10850, ports=2), + DeviceSpec(name="E2E-WF-DEV-19", x=-2800, y=11200, ports=2), + DeviceSpec(name="E2E-WF-DEV-20", x=3500, y=12300, ports=2), + DeviceSpec(name="E2E-WF-DEV-21", x=767, y=11434, ports=1), + DeviceSpec(name="E2E-WF-DEV-22", x=1600, y=12800, ports=2), + DeviceSpec(name="E2E-WF-DEV-23", x=2000, y=12550, ports=2), + DeviceSpec(name="E2E-WF-DEV-24", x=1600, y=12550, ports=2), + DeviceSpec(name="E2E-WF-DEV-25", x=900, y=10850, ports=2), + DeviceSpec(name="E2E-WF-DEV-26", x=0, y=3000, ports=1), + DeviceSpec(name="E2E-WF-DEV-27", x=3500, y=12050, ports=2), + DeviceSpec(name="E2E-WF-DEV-28", x=3100, y=12300, ports=2), + DeviceSpec(name="E2E-WF-DEV-29", x=1046, y=11159, ports=2), +] +LINKS = [ + LinkSpec(a=0, b=10), + LinkSpec(a=0, b=17), + LinkSpec(a=2, b=7), + LinkSpec(a=2, b=8), + LinkSpec(a=2, b=9), + LinkSpec(a=2, b=12), + LinkSpec(a=2, b=14), + LinkSpec(a=2, b=15), + LinkSpec(a=2, b=15), + LinkSpec(a=2, b=21), + LinkSpec(a=2, b=22), + LinkSpec(a=2, b=23), + LinkSpec(a=2, b=24), + LinkSpec(a=3, b=15), + LinkSpec(a=3, b=20), + LinkSpec(a=3, b=27), + LinkSpec(a=3, b=28), + LinkSpec(a=4, b=16), + LinkSpec(a=4, b=20), + LinkSpec(a=4, b=27), + LinkSpec(a=4, b=28), + LinkSpec(a=5, b=7), + LinkSpec(a=5, b=8), + LinkSpec(a=5, b=12), + LinkSpec(a=5, b=14), + LinkSpec(a=5, b=16), + LinkSpec(a=5, b=16), + LinkSpec(a=5, b=22), + LinkSpec(a=5, b=23), + LinkSpec(a=5, b=24), + LinkSpec(a=10, b=16), + LinkSpec(a=10, b=19), + LinkSpec(a=15, b=17), + LinkSpec(a=15, b=18), + LinkSpec(a=15, b=25), + LinkSpec(a=15, b=29), + LinkSpec(a=16, b=18), + LinkSpec(a=16, b=25), + LinkSpec(a=16, b=29), + LinkSpec(a=17, b=19), ] -LINKS = [LinkSpec(a=2, b=0), LinkSpec(a=2, b=1), LinkSpec(a=3, b=0), LinkSpec(a=3, b=1)] class WorkflowState(BaseModel): @@ -142,7 +212,9 @@ def test_connect_edges(self, app: VideoIPathApp, state: WorkflowState) -> None: for link in LINKS: id_a, id_b = state.device_ids[DEVICES[link.a].name], state.device_ids[DEVICES[link.b].name] pair_edges = edges_between(app, id_a, id_b) - assert len(pair_edges) == 2 + # Two directed edges per bidirectional link; a pair with parallel links has more. + parallel = sum(1 for other in LINKS if {other.a, other.b} == {link.a, link.b}) + assert len(pair_edges) == parallel * 2 state.edge_id = pair_edges[0].id def test_update_edge_weight(self, app: VideoIPathApp, state: WorkflowState) -> None: diff --git a/tests/inspect/conftest.py b/tests/inspect/conftest.py index 075cab1..c28dfec 100644 --- a/tests/inspect/conftest.py +++ b/tests/inspect/conftest.py @@ -5,7 +5,7 @@ import pytest -FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "inspect" / "2025.4.9" +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "2025.4.9" def load_fixture(name: str) -> dict: diff --git a/tests/fixtures/inspect/2025.4.9/action_schema_collector.json b/tests/inspect/fixtures/2025.4.9/action_schema_collector.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/action_schema_collector.json rename to tests/inspect/fixtures/2025.4.9/action_schema_collector.json diff --git a/tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json b/tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json rename to tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json diff --git a/tests/fixtures/inspect/2025.4.9/edge_skeleton.json b/tests/inspect/fixtures/2025.4.9/edge_skeleton.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/edge_skeleton.json rename to tests/inspect/fixtures/2025.4.9/edge_skeleton.json diff --git a/tests/fixtures/inspect/2025.4.9/inspect_paths_limit5.json b/tests/inspect/fixtures/2025.4.9/inspect_paths_limit5.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/inspect_paths_limit5.json rename to tests/inspect/fixtures/2025.4.9/inspect_paths_limit5.json diff --git a/tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_edges_by_ids.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json rename to tests/inspect/fixtures/2025.4.9/lookup_inspect_edges_by_ids.json diff --git a/tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json rename to tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json diff --git a/tests/fixtures/inspect/2025.4.9/nodestatus_noid_collection.json b/tests/inspect/fixtures/2025.4.9/nodestatus_noid_collection.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/nodestatus_noid_collection.json rename to tests/inspect/fixtures/2025.4.9/nodestatus_noid_collection.json diff --git a/tests/fixtures/inspect/2025.4.9/skeleton_nodestatus_short.json b/tests/inspect/fixtures/2025.4.9/skeleton_nodestatus_short.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/skeleton_nodestatus_short.json rename to tests/inspect/fixtures/2025.4.9/skeleton_nodestatus_short.json diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_fail_booking.json b/tests/inspect/fixtures/2025.4.9/update_topology_fail_booking.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/update_topology_fail_booking.json rename to tests/inspect/fixtures/2025.4.9/update_topology_fail_booking.json diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_fail_remove.json b/tests/inspect/fixtures/2025.4.9/update_topology_fail_remove.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/update_topology_fail_remove.json rename to tests/inspect/fixtures/2025.4.9/update_topology_fail_remove.json diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_replace_devices.json b/tests/inspect/fixtures/2025.4.9/update_topology_replace_devices.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/update_topology_replace_devices.json rename to tests/inspect/fixtures/2025.4.9/update_topology_replace_devices.json diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_replace_vertices.json b/tests/inspect/fixtures/2025.4.9/update_topology_replace_vertices.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/update_topology_replace_vertices.json rename to tests/inspect/fixtures/2025.4.9/update_topology_replace_vertices.json diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_success.json b/tests/inspect/fixtures/2025.4.9/update_topology_success.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/update_topology_success.json rename to tests/inspect/fixtures/2025.4.9/update_topology_success.json From 9337be01799384814fd05dfa704e1b8ba5aedfed Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:09:14 +0200 Subject: [PATCH 13/15] minor docs adjustments --- docs/getting-started-guide/05_Inspect.md | 16 ++++++++-------- tests/e2e/test_e2e_workflow.py | 4 +--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/getting-started-guide/05_Inspect.md b/docs/getting-started-guide/05_Inspect.md index 0f96ca4..fcc511f 100644 --- a/docs/getting-started-guide/05_Inspect.md +++ b/docs/getting-started-guide/05_Inspect.md @@ -33,29 +33,29 @@ Everything is read straight off `app.inspect`. Skeleton fields are available wit I/O: ```python -dev = app.inspect.get_device("device10") -dev = app.inspect.find_device_by_label("BORDERLEAF-26B") +device = app.inspect.get_device("device10") +device = app.inspect.find_device_by_label("BORDERLEAF-26B") -print(dev.label, dev.coordinates, dev.status, dev.sync_severity, dev.tags) +print(device.label, device.coordinates, device.status, device.sync_severity, device.tags) -for dev in app.inspect.devices: # all devices - print(dev.id, dev.label) +for device in app.inspect.devices: # all devices + print(device.id, device.label) ``` The first access to a device's **ports** hydrates that one device (a single scoped read), then serves from local state: ```python -for port in dev.ports: # triggers one hydration fetch for this device +for port in device.ports: # triggers one hydration fetch for this device print(port.label, port.vertex_id, port.status, port.tags) edge = port.edge # local edge-skeleton lookup, no I/O if edge: print("connected to", edge.to_device.label) -for edge in dev.edges: # local, no hydration +for edge in device.edges: # local, no hydration print(edge.from_port, "->", edge.to_port, edge.status) -for other in dev.linked_devices: # local graph walk +for other in device.linked_devices: # local graph walk print(other.label) for edge in app.inspect.edges: # all external edges diff --git a/tests/e2e/test_e2e_workflow.py b/tests/e2e/test_e2e_workflow.py index 1b84299..6709396 100644 --- a/tests/e2e/test_e2e_workflow.py +++ b/tests/e2e/test_e2e_workflow.py @@ -27,7 +27,6 @@ from .helpers import E2E_TAG, create_mock_device, discover_router_vertices, edges_between - pytestmark = pytest.mark.e2e @@ -43,8 +42,7 @@ class LinkSpec(InspectFrozenModel): b: int # device index -# A full leaf-spine in its own map region (all coordinates shifted +3000 in y so the replica never -# overlaps the live topology it mirrors). Port counts equal each device's link degree; devices with +# A full leaf-spine in its own map region (all coordinates shifted +3000 in y so the test data rather not conflict with other data). Port counts equal each device's link degree; devices with # no links sit at the region origin. Two device pairs — (2, 15) and (5, 16) — carry parallel links, # represented here as repeated ``LinkSpec`` entries. DEVICES = [ From d8c351f3979d6234e315b3781e0ed12de4f1ff82 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:46:56 +0200 Subject: [PATCH 14/15] inspect snapshot update improvements --- .github/workflows/ci.yml | 8 +- .../0010-post-commit-snapshot-refresh.md | 17 ++ docs/inspect-app/implementation-plan.md | 12 +- .../apps/inspect/__init__.py | 6 +- .../apps/inspect/api/__init__.py | 4 + .../apps/inspect/{ => api}/inspect_api.py | 2 +- .../apps/inspect/{ => api}/queries.py | 0 .../apps/inspect/app/__init__.py | 3 + .../apps/inspect/app/actions.py | 25 ++- .../inspect/{inspect_app.py => app/app.py} | 9 +- .../apps/inspect/app/read.py | 2 +- .../apps/inspect/app/write.py | 6 +- .../apps/inspect/snapshot.py | 148 ++++++++++++++++-- .../inspect/{changeset.py => transaction.py} | 4 +- .../apps/videoipath_app.py | 2 +- tests/e2e/conftest.py | 2 +- tests/inspect/test_actions.py | 45 +++++- tests/inspect/test_api.py | 2 +- tests/inspect/test_queries.py | 2 +- tests/inspect/test_snapshot.py | 41 +++++ ...{test_changeset.py => test_transaction.py} | 4 +- 21 files changed, 292 insertions(+), 52 deletions(-) create mode 100644 src/videoipath_automation_tool/apps/inspect/api/__init__.py rename src/videoipath_automation_tool/apps/inspect/{ => api}/inspect_api.py (99%) rename src/videoipath_automation_tool/apps/inspect/{ => api}/queries.py (100%) rename src/videoipath_automation_tool/apps/inspect/{inspect_app.py => app/app.py} (89%) rename src/videoipath_automation_tool/apps/inspect/{changeset.py => transaction.py} (99%) rename tests/inspect/{test_changeset.py => test_transaction.py} (98%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a4e1a5..e989636 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: branches: - main tags: - - 'v*' # Trigger on version tags like v1.2.3 + - "v*" # Trigger on version tags like v1.2.3 pull_request_target: branches: - main @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout repository uses: actions/checkout@v4 @@ -37,7 +37,7 @@ jobs: run: poetry install --with dev,test - name: Run tests - run: poetry run pytest + run: poetry run test-unit # 2️⃣ Release Job (Uses Prebuilt Package) release: @@ -52,7 +52,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.13" + python-version: "3.14" - name: Install Poetry run: pip install poetry diff --git a/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md b/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md index c970fac..90d65b3 100644 --- a/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md +++ b/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md @@ -84,6 +84,23 @@ result is success (`res.ok` and `validation.result.ok`): A failed commit changes nothing server-side (reject-before-apply, verified) — the snapshot is left untouched. +### Extensions + +- **Network actions** (`addDevices` / `syncDevices`) get the same automatic + update: on success they call `apply_network_refresh(device_ids)`, which + upserts the named devices (new **or** restructured) and reconciles the edge + pairs touching them. Unlike a commit, these actions do not report the exact + touched entities and can create pairs to previously-unconnected devices, so + edges are reconciled from **one** cheap edge-skeleton read scoped to pairs + touching an affected device (per-device detail stays targeted). As on the + write path, this only runs when a snapshot is already loaded. +- **Refresh is resilient, never all-or-nothing.** A scoped re-fetch that fails + (network blip) marks just that entity stale (`_stale_devices` / + `_stale_pairs`) and logs; it never propagates, so a post-write hook cannot + lose the caller's already-successful result. Stale entities are re-fetched + lazily on the next access that touches them (the ADR-0007 hydration path), + making the snapshot self-healing without a full reload. + ## Consequences - Post-commit cost is proportional to the **change set size**, not the network: diff --git a/docs/inspect-app/implementation-plan.md b/docs/inspect-app/implementation-plan.md index 0562ee4..98d70af 100644 --- a/docs/inspect-app/implementation-plan.md +++ b/docs/inspect-app/implementation-plan.md @@ -64,7 +64,7 @@ snapshot.fetched_at(entity_or_section) # freshness introspection Contract (already documented in models.md): at most one hydration fetch per entity/section; getters can raise connector errors; snapshot is not a single point in time. -### 2.3 Writes (change set, ADR-0006/0009/0010) +### 2.3 Writes (transaction, ADR-0006/0009/0010) ```python # Convenience direct writes (auto-commit, one updateTopology each): @@ -124,7 +124,7 @@ apps/inspect/ │ ├── write.py # direct writes + transaction() │ └── actions.py # add/sync devices, sync info ├── snapshot.py # InspectSnapshot: state, indexes, hydration, refresh hooks -├── changeset.py # InspectTransaction, staging entries, baselines, commit +├── transaction.py # InspectTransaction, staging entries, baselines, commit ├── domain/ # InspectDevice/Port/Edge/Service (extend existing) └── model/ # InspectApi* DTOs (extend existing: collector.py, actions.py, # update_topology.py, ngraph.py, common.py) @@ -185,7 +185,7 @@ State per ADR-0007/0010: - `_ensure_device_detail(device_id)`: no-op if FULL; else `get_device_detail`, re-parse item, replace record, rebuild that device's port index entries, drop that device's cached domain wrappers, stamp `fetched_at`. Same pattern `_ensure_section("paths")`. - **Concurrency**: a `threading.Lock` around merge operations (preload uses a pool); document snapshot as not-thread-safe for general use, but hydration merges are internally consistent. - `preload(devices=None)`: ThreadPoolExecutor (bounded, e.g. 8) over `_ensure_device_detail` (ADR-0004). -- **Post-commit hooks** (called by changeset, ADR-0010): `_apply_removals(ids)`, `_refresh_devices(ids)`, `_refresh_edge_pairs(pair_ids)`, `_mark_section_stale("paths")`. +- **Post-commit hooks** (called by transaction, ADR-0010): `_apply_removals(ids)`, `_refresh_devices(ids)`, `_refresh_edge_pairs(pair_ids)`, `_mark_section_stale("paths")`. - Keep `raw_response` only for `load="full"` snapshots; skeleton snapshots expose `raw_skeleton` parts instead. ### 4.6 Domain objects (`domain/`) @@ -202,7 +202,7 @@ Property → data-source mapping (drives which getter triggers hydration): Add `InspectDevice.is_hydrated` / `snapshot.fetched_at(...)` for introspection. Keep frozen-dataclass wrappers + snapshot back-reference pattern from the draft. -### 4.7 `changeset.py` — transaction, baselines, commit +### 4.7 `transaction.py` — transaction, baselines, commit - `_StagedEntry(kind: DEVICE|VERTEX|EDGE, id, baseline: DTO, intents: dict[field, value] | REMOVE)`. - **Staging**: first touch of an entity fetches baselines via *batched* lookups (`lookup_edges`, `lookup_vertices` accept lists; device lookup is per-id). Staging validates the entity exists (translate lookup miss → `InspectEntityNotFoundError`); vertices/devices cannot be *created* (update-only — enforced client-side with a clear message). @@ -228,7 +228,7 @@ Add `InspectDevice.is_hydrated` / `snapshot.fetched_at(...)` for introspection. 1. **M1 – Connector + queries + API layer (read)**: connector changes, `queries.py`, `inspect_api.py` read methods, DTO gap-fill; offline fixture tests green. 2. **M2 – Snapshot rework + domain (read path complete)**: hydration states, sections, preload, `from_full_response`; `app.inspect.get_snapshot()`; VideoIPathApp property; unit tests with fake fetcher (hydration counts, merge idempotency). 3. **M3 – Lookups + actions**: lookup API methods + DTOs, `get_sync_info`, `add_devices_to_topology`, `sync_devices`. -4. **M4 – Change set + writes**: `changeset.py`, direct write sugar, commit lifecycle incl. conflict check + targeted refresh; unit tests with mocked API (payload-shape assertions against the four updateTopology fixtures; conflict scenarios; refresh-hook calls). +4. **M4 – Transaction + writes**: `transaction.py`, direct write sugar, commit lifecycle incl. conflict check + targeted refresh; unit tests with mocked API (payload-shape assertions against the four updateTopology fixtures; conflict scenarios; refresh-hook calls). 5. **M5 – E2E suite** (below) + getting-started doc page (`docs/getting-started-guide/0X_Inspect.md`) + [models.md](./models.md)/README sync. ## 6. Testing @@ -237,7 +237,7 @@ Add `InspectDevice.is_hydrated` / `snapshot.fetched_at(...)` for introspection. - **Fixture contract tests** (`tests/inspect/test_models.py`): every fixture in `tests/inspect/fixtures/2025.4.9/` parses into its DTO; write-payload builders reproduce the fixture requests byte-for-byte (devices/vertices edit forms, edge form, no-op). - **Snapshot unit tests** (`tests/inspect/test_snapshot.py`): fake fetcher returning fixture payloads — skeleton indexes, exactly-one hydration fetch per device, section laziness, preload fan-out, post-commit hooks (removal drops indexes; refresh replaces records; stale section reloads). -- **Changeset unit tests** (`tests/inspect/test_changeset.py`): staging→baseline capture, intent application, conflict detection (mutated baseline → error with field diffs), `check_conflicts=False` bypass, three-flag result evaluation incl. the captured failure fixtures, affected-set derivation. +- **Transaction unit tests** (`tests/inspect/test_transaction.py`): staging→baseline capture, intent application, conflict detection (mutated baseline → error with field diffs), `check_conflicts=False` bypass, three-flag result evaluation incl. the captured failure fixtures, affected-set derivation. - **Query tests**: encoding, 414 length guard, projection constants stability. ### 6.2 E2E — live instance, topology replica (ADR-0005) diff --git a/src/videoipath_automation_tool/apps/inspect/__init__.py b/src/videoipath_automation_tool/apps/inspect/__init__.py index 57ca1c8..1969abb 100644 --- a/src/videoipath_automation_tool/apps/inspect/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/__init__.py @@ -1,9 +1,9 @@ from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy as ConflictStrategy -from videoipath_automation_tool.apps.inspect.changeset import CommitResult as CommitResult -from videoipath_automation_tool.apps.inspect.changeset import InspectTransaction as InspectTransaction +from videoipath_automation_tool.apps.inspect.transaction import CommitResult as CommitResult +from videoipath_automation_tool.apps.inspect.transaction import InspectTransaction as InspectTransaction from videoipath_automation_tool.apps.inspect.domain import * from videoipath_automation_tool.apps.inspect.errors import * -from videoipath_automation_tool.apps.inspect.inspect_app import InspectApp as InspectApp +from videoipath_automation_tool.apps.inspect.app import InspectApp as InspectApp from videoipath_automation_tool.apps.inspect.model import * # InspectSnapshot is an internal implementation detail of InspectApp ([ADR-0007]); it is not part of diff --git a/src/videoipath_automation_tool/apps/inspect/api/__init__.py b/src/videoipath_automation_tool/apps/inspect/api/__init__.py new file mode 100644 index 0000000..2ee039a --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/api/__init__.py @@ -0,0 +1,4 @@ +from . import queries +from .inspect_api import InspectAPI + +__all__ = ["InspectAPI", "queries"] diff --git a/src/videoipath_automation_tool/apps/inspect/inspect_api.py b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py similarity index 99% rename from src/videoipath_automation_tool/apps/inspect/inspect_api.py rename to src/videoipath_automation_tool/apps/inspect/api/inspect_api.py index 93cd286..b238f4c 100644 --- a/src/videoipath_automation_tool/apps/inspect/inspect_api.py +++ b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py @@ -10,7 +10,7 @@ import logging from typing import Any, Optional -from videoipath_automation_tool.apps.inspect import queries +from . import queries from videoipath_automation_tool.apps.inspect.model.actions import ( InspectApiAddDevicesItem, InspectApiAddDevicesRequest, diff --git a/src/videoipath_automation_tool/apps/inspect/queries.py b/src/videoipath_automation_tool/apps/inspect/api/queries.py similarity index 100% rename from src/videoipath_automation_tool/apps/inspect/queries.py rename to src/videoipath_automation_tool/apps/inspect/api/queries.py diff --git a/src/videoipath_automation_tool/apps/inspect/app/__init__.py b/src/videoipath_automation_tool/apps/inspect/app/__init__.py index e69de29..7adf215 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/app/__init__.py @@ -0,0 +1,3 @@ +from videoipath_automation_tool.apps.inspect.app.app import InspectApp + +__all__ = ["InspectApp"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/actions.py b/src/videoipath_automation_tool/apps/inspect/app/actions.py index 7464ba4..32b8bf9 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/actions.py +++ b/src/videoipath_automation_tool/apps/inspect/app/actions.py @@ -2,7 +2,7 @@ These wrap the ``actions/status/network/*`` and ``lookupSyncInfo`` endpoints used by the Inspect device-onboarding-into-topology workflows. Placement and connections themselves go through the -change set ([ADR-0006]); these actions handle bringing a device's driver-reported topology into +transaction ([ADR-0006]); these actions handle bringing a device's driver-reported topology into the graph and keeping it in sync. """ @@ -10,13 +10,14 @@ import logging from enum import IntEnum -from typing import Iterable, Protocol, Union +from typing import Iterable, Optional, Protocol, Union -from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.model.actions import ( InspectApiAddDevicesItem, InspectApiLookupSyncInfoItem, ) +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot class ConflictStrategy(IntEnum): @@ -34,11 +35,13 @@ class ConflictStrategy(IntEnum): class _HasInspectApi(Protocol): _inspect_api: InspectAPI _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] class InspectActionsMixin: _inspect_api: InspectAPI _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] def get_sync_info(self: _HasInspectApi, device_ids: list[str]) -> dict[str, InspectApiLookupSyncInfoItem]: """Per-device sync differences (what would be added/removed/updated on the next sync).""" @@ -59,7 +62,9 @@ def add_devices_to_topology(self: _HasInspectApi, devices: Iterable[AddDeviceSpe response = self._inspect_api.add_devices(items) if not response.data.ok: self._logger.warning(f"addDevices reported failure: {response.data.msg}") - return response.data.ok + return False + self._refresh_after_network_action([item.id for item in items]) + return True def sync_devices( self: _HasInspectApi, @@ -84,7 +89,17 @@ def sync_devices( ) if not response.data.ok: self._logger.warning(f"syncDevices reported failure: {response.data.msg}") - return response.data.ok + return False + self._refresh_after_network_action(device_ids) + return True + + def _refresh_after_network_action(self: _HasInspectApi, device_ids: list[str]) -> None: + """Update the internal snapshot for the affected devices after a successful network action. + + Only refreshes when a snapshot is already loaded, so a pure-action workflow never triggers + an unnecessary topology read (mirrors the write path; [ADR-0010]).""" + if self._snapshot is not None: + self._snapshot.apply_network_refresh(device_ids) def _to_add_item(spec: AddDeviceSpec) -> InspectApiAddDevicesItem: diff --git a/src/videoipath_automation_tool/apps/inspect/inspect_app.py b/src/videoipath_automation_tool/apps/inspect/app/app.py similarity index 89% rename from src/videoipath_automation_tool/apps/inspect/inspect_app.py rename to src/videoipath_automation_tool/apps/inspect/app/app.py index 00cc0b6..4eee4f9 100644 --- a/src/videoipath_automation_tool/apps/inspect/inspect_app.py +++ b/src/videoipath_automation_tool/apps/inspect/app/app.py @@ -9,13 +9,14 @@ import logging from typing import Optional -from videoipath_automation_tool.apps.inspect.app.actions import InspectActionsMixin -from videoipath_automation_tool.apps.inspect.app.read import InspectReadMixin, LoadMode -from videoipath_automation_tool.apps.inspect.app.write import InspectWriteMixin -from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot from videoipath_automation_tool.connector.vip_connector import VideoIPathConnector +from .actions import InspectActionsMixin +from .read import InspectReadMixin, LoadMode +from .write import InspectWriteMixin + # First VideoIPath version the Inspect collector surface was verified against. _MIN_VERIFIED_VERSION = (2025, 4) diff --git a/src/videoipath_automation_tool/apps/inspect/app/read.py b/src/videoipath_automation_tool/apps/inspect/app/read.py index 47bdac7..3cd0be2 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/read.py +++ b/src/videoipath_automation_tool/apps/inspect/app/read.py @@ -12,7 +12,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Literal, Optional, Protocol -from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot if TYPE_CHECKING: diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py index cc39602..2cc4d4c 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/write.py +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -14,8 +14,8 @@ import logging from typing import Any, Optional, Protocol -from videoipath_automation_tool.apps.inspect.changeset import CommitResult, InspectTransaction -from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.transaction import CommitResult, InspectTransaction +from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot @@ -31,7 +31,7 @@ class InspectWriteMixin: _snapshot: Optional[InspectSnapshot] def transaction(self: _HasInspectState) -> InspectTransaction: - """Open a batched, atomic change set bound to the app's internal snapshot.""" + """Open a batched, atomic transaction bound to the app's internal snapshot.""" return InspectTransaction(self._inspect_api, snapshot=self._snapshot, logger=self._logger) def place_device(self: _HasInspectState, device_id: str, x: float, y: float) -> CommitResult: diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index 8109e87..d336fb5 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -7,12 +7,13 @@ carries its own fetch timestamp. ``refresh()`` builds a *new* snapshot; state is never reused across snapshots. -After a successful commit the change set calls the post-commit hooks here to update only the +After a successful commit the transaction calls the post-commit hooks here to update only the touched entities via targeted scoped re-reads ([ADR-0010]) instead of a full reload. """ from __future__ import annotations +import logging import threading from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone @@ -38,10 +39,12 @@ from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService - from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI + from videoipath_automation_tool.apps.inspect.api import InspectAPI _PRELOAD_WORKERS = 8 +_logger = logging.getLogger("videoipath_automation_tool_inspect_snapshot") + class HydrationLevel(str, Enum): SKELETON = "skeleton" @@ -123,6 +126,10 @@ def __init__( self._edge_cache: dict[str, "InspectEdge"] = {} self._service_cache: dict[str, "InspectService"] = {} + # Entities whose post-write re-fetch failed; re-fetched lazily on next access ([ADR-0010]). + self._stale_devices: set[str] = set() + self._stale_pairs: set[str] = set() + for node in device_items or []: self._index_device(node, device_level) for pair in edge_items or []: @@ -171,6 +178,7 @@ def is_device_hydrated(self, device_id: str) -> bool: # --- Device reads --- def get_device(self, device_id: str) -> Optional["InspectDevice"]: + self._reconcile_stale_device(device_id) if device_id not in self._devices_by_id: return None return self._wrap_device(device_id) @@ -199,6 +207,7 @@ def get_devices(self, detail: bool = False) -> list["InspectDevice"]: def get_device_record(self, device_id: str) -> Optional[_DeviceRecord]: """Internal: return the (possibly hydrated) record for a device; used by domain objects.""" + self._reconcile_stale_device(device_id) return self._devices_by_id.get(device_id) # --- Port reads (trigger hydration) --- @@ -220,6 +229,7 @@ def find_port_by_id(self, port_id: str) -> Optional["InspectPort"]: @property def edges(self) -> list["InspectEdge"]: + self._reconcile_stale_pairs() seen: set[str] = set() result: list["InspectEdge"] = [] for indexed_edges in self._edges_by_device_id.values(): @@ -234,6 +244,7 @@ def get_edges(self) -> list["InspectEdge"]: return self.edges def get_edges_for_device(self, device_id: str) -> list["InspectEdge"]: + self._reconcile_stale_pairs() seen: set[str] = set() result: list["InspectEdge"] = [] for indexed in self._edges_by_device_id.get(device_id, []): @@ -244,10 +255,12 @@ def get_edges_for_device(self, device_id: str) -> list["InspectEdge"]: return result def get_edge_for_port(self, device_id: str, port_id: str) -> Optional["InspectEdge"]: + self._reconcile_stale_pairs() indexed = self._edge_by_port_key.get((device_id, port_id)) return self._wrap_edge(indexed) if indexed else None def get_linked_devices(self, device_id: str) -> list["InspectDevice"]: + self._reconcile_stale_pairs() linked: set[str] = set() for indexed in self._edges_by_device_id.get(device_id, []): for candidate in (indexed.from_device_id, indexed.to_device_id): @@ -314,23 +327,41 @@ def apply_post_commit( mark_paths_stale: bool = True, ) -> None: """Targeted refresh after a successful commit: drop removed entities locally, re-fetch the - affected devices and edge pairs, and mark the services section stale.""" + affected devices and edge pairs, and mark the services section stale. + + Never raises: a failed re-fetch marks the entity stale (re-fetched lazily on next access) + and logs, so a post-commit hook cannot lose the caller's already-successful commit result. + """ self._apply_removals(removed_ids or []) if self._fetcher is not None: for device_id in device_ids or []: if device_id in self._devices_by_id: - self._refresh_device(device_id) + self._try_refresh_device(device_id) for pair_id in pair_ids or []: - self._refresh_edge_pair(pair_id) + self._try_refresh_edge_pair(pair_id) if mark_paths_stale: - with self._lock: - self._section_loaded["paths"] = False - self._paths_by_booking_id.clear() - self._services_by_device_id.clear() + self._mark_paths_stale() + + def apply_network_refresh(self, device_ids: list[str]) -> None: + """Targeted refresh after a network action (addDevices / syncDevices): upsert the named + devices (new or restructured) and reconcile the edge pairs touching them, then mark the + services section stale. Never raises (same contract as :meth:`apply_post_commit`). + + Unlike a commit, a network action does not report the exact touched entities and can create + pairs to previously-unconnected devices, so edges are reconciled from one cheap edge-skeleton + read scoped to pairs touching an affected device (per-device detail stays targeted).""" + if self._fetcher is None or not device_ids: + return + affected = set(device_ids) + for device_id in device_ids: + self._try_refresh_device(device_id) + self._reconcile_pairs_for_devices(affected) + self._mark_paths_stale() # --- Internal: hydration --- def _ensure_device_detail(self, device_id: str) -> None: + self._reconcile_stale_device(device_id) record = self._devices_by_id.get(device_id) if record is None or record.level is HydrationLevel.FULL or self._fetcher is None: return @@ -349,23 +380,23 @@ def _ensure_device_detail(self, device_id: str) -> None: current = self._devices_by_id.get(device_id) if current is None or current.level is HydrationLevel.FULL: return - self._devices_by_id[device_id] = _DeviceRecord(device_id=device_id, node=detail, level=HydrationLevel.FULL) - self._device_cache.pop(device_id, None) - self._rebuild_device_ports(device_id, detail) + self._upsert_device(device_id, detail) def _refresh_device(self, device_id: str) -> None: + """Re-fetch one device's full detail and upsert it (adds it if newly present). May raise.""" if self._fetcher is None: return record = self._devices_by_id.get(device_id) detail = self._fetcher.get_device_detail(record.node.id if record else device_id) if detail is None: + # Keep any existing record untouched (e.g. detail-less virtual devices); just clear stale. + self._stale_devices.discard(device_id) return with self._lock: - self._devices_by_id[device_id] = _DeviceRecord(device_id=device_id, node=detail, level=HydrationLevel.FULL) - self._device_cache.pop(device_id, None) - self._rebuild_device_ports(device_id, detail) + self._upsert_device(device_id, detail) def _refresh_edge_pair(self, pair_id: str) -> None: + """Re-fetch and re-index one external-edge device pair. May raise.""" if self._fetcher is None: return pair = self._fetcher.get_edge_pair(pair_id) @@ -373,6 +404,93 @@ def _refresh_edge_pair(self, pair_id: str) -> None: self._drop_edge_pair(pair_id) if pair is not None: self._index_edge_pair(pair) + self._stale_pairs.discard(pair_id) + + # --- Internal: resilient refresh + lazy-stale self-heal (ADR-0010) --- + + def _try_refresh_device(self, device_id: str) -> None: + """Re-fetch a device; on failure mark it stale (lazy self-heal on next access) and log.""" + try: + self._refresh_device(device_id) + except Exception as exc: + self._stale_devices.add(device_id) + _logger.warning("Inspect snapshot: post-write re-fetch of device '%s' failed: %s", device_id, exc) + + def _try_refresh_edge_pair(self, pair_id: str) -> None: + """Re-fetch an edge pair; on failure mark it stale (lazy self-heal on next access) and log.""" + try: + self._refresh_edge_pair(pair_id) + except Exception as exc: + self._stale_pairs.add(pair_id) + _logger.warning("Inspect snapshot: post-write re-fetch of edge pair '%s' failed: %s", pair_id, exc) + + def _reconcile_stale_device(self, device_id: str) -> None: + if device_id not in self._stale_devices: + return + try: + self._refresh_device(device_id) + self._stale_devices.discard(device_id) + except Exception as exc: + _logger.warning("Inspect snapshot: lazy re-fetch of stale device '%s' failed: %s", device_id, exc) + + def _reconcile_stale_pairs(self) -> None: + for pair_id in list(self._stale_pairs): + try: + self._refresh_edge_pair(pair_id) + except Exception as exc: + _logger.warning("Inspect snapshot: lazy re-fetch of stale edge pair '%s' failed: %s", pair_id, exc) + + def _reconcile_pairs_for_devices(self, device_ids: set[str]) -> None: + """Reconcile every edge pair touching an affected device from one fresh edge-skeleton read.""" + if self._fetcher is None or not device_ids: + return + try: + pairs = self._fetcher.get_edge_skeleton() + except Exception as exc: + self._stale_pairs.update( + indexed.pair_id for d in device_ids for indexed in self._edges_by_device_id.get(d, []) + ) + _logger.warning("Inspect snapshot: edge reconcile after network action failed: %s", exc) + return + with self._lock: + for pair_id in {indexed.pair_id for d in device_ids for indexed in self._edges_by_device_id.get(d, [])}: + self._drop_edge_pair(pair_id) + for pair in pairs: + if self._pair_touches(pair, device_ids): + self._drop_edge_pair(pair.id) + self._index_edge_pair(pair) + + def _pair_touches(self, pair: InspectApiExternalEdgesByDeviceKeyItem, device_ids: set[str]) -> bool: + primary = self._resolve_device_id(pair.primary.devicePid) + secondary = self._resolve_device_id(pair.secondary.devicePid) + return primary in device_ids or secondary in device_ids + + def _mark_paths_stale(self) -> None: + with self._lock: + self._section_loaded["paths"] = False + self._paths_by_booking_id.clear() + self._services_by_device_id.clear() + + def _upsert_device(self, device_id: str, detail: InspectApiNodeStatusItem) -> None: + """Insert or replace a device record (FULL), keeping the label index and caches consistent.""" + old = self._devices_by_id.get(device_id) + old_label = old.label if old is not None else None + record = _DeviceRecord(device_id=device_id, node=detail, level=HydrationLevel.FULL) + new_label = record.label + if old_label and old_label != new_label: + remaining = [d for d in self._devices_by_label.get(old_label, []) if d != device_id] + if remaining: + self._devices_by_label[old_label] = remaining + else: + self._devices_by_label.pop(old_label, None) + self._devices_by_id[device_id] = record + if new_label: + ids = self._devices_by_label.setdefault(new_label, []) + if device_id not in ids: + ids.append(device_id) + self._device_cache.pop(device_id, None) + self._rebuild_device_ports(device_id, detail) + self._stale_devices.discard(device_id) def _ensure_section_paths(self) -> None: if self._section_loaded.get("paths") or self._fetcher is None: diff --git a/src/videoipath_automation_tool/apps/inspect/changeset.py b/src/videoipath_automation_tool/apps/inspect/transaction.py similarity index 99% rename from src/videoipath_automation_tool/apps/inspect/changeset.py rename to src/videoipath_automation_tool/apps/inspect/transaction.py index 6cc52b2..ea6f9ad 100644 --- a/src/videoipath_automation_tool/apps/inspect/changeset.py +++ b/src/videoipath_automation_tool/apps/inspect/transaction.py @@ -1,4 +1,4 @@ -"""Change set / transaction for Inspect topology writes ([ADR-0006]/[ADR-0009]/[ADR-0010]). +"""Transaction for Inspect topology writes ([ADR-0006]/[ADR-0009]/[ADR-0010]). A transaction stages topology changes, captures a per-entity baseline via the lookup endpoints (the lookup forms *are* the ``updateTopology`` write shapes — [ADR-0009]), and applies them @@ -46,7 +46,7 @@ ) if TYPE_CHECKING: - from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI + from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot # Staged-entry kinds. diff --git a/src/videoipath_automation_tool/apps/videoipath_app.py b/src/videoipath_automation_tool/apps/videoipath_app.py index 1a48590..6c13121 100644 --- a/src/videoipath_automation_tool/apps/videoipath_app.py +++ b/src/videoipath_automation_tool/apps/videoipath_app.py @@ -1,7 +1,7 @@ import logging from typing import Literal, Optional -from videoipath_automation_tool.apps.inspect.inspect_app import InspectApp +from videoipath_automation_tool.apps.inspect.app import InspectApp from videoipath_automation_tool.apps.inventory import InventoryApp from videoipath_automation_tool.apps.inventory.model.drivers import AVAILABLE_SCHEMA_VERSIONS, SELECTED_SCHEMA_VERSION from videoipath_automation_tool.apps.preferences.preferences_app import PreferencesApp diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 24a308c..7d94fdc 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -23,7 +23,7 @@ import pytest -from videoipath_automation_tool.apps.inspect.inspect_app import _MIN_VERIFIED_VERSION, _parse_version +from videoipath_automation_tool.apps.inspect.app.app import _MIN_VERIFIED_VERSION, _parse_version from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp from vipat_cli_scripts.project_env import load_project_env diff --git a/tests/inspect/test_actions.py b/tests/inspect/test_actions.py index 0f4894d..ada7c58 100644 --- a/tests/inspect/test_actions.py +++ b/tests/inspect/test_actions.py @@ -5,7 +5,7 @@ import pytest from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy, InspectActionsMixin -from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.api import InspectAPI def _ok_header(): @@ -34,12 +34,23 @@ def post(self, url_path, body, **kwargs): return SimpleNamespace(data=self._post_data, header=_ok_header()) +class _RecordingSnapshot: + """Stand-in that records apply_network_refresh calls (see snapshot.apply_network_refresh).""" + + def __init__(self): + self.network_refresh_calls = [] + + def apply_network_refresh(self, device_ids): + self.network_refresh_calls.append(list(device_ids)) + + class _App(InspectActionsMixin): - def __init__(self, post_data): + def __init__(self, post_data, snapshot=None): import logging self._logger = logging.getLogger("test") self._inspect_api = InspectAPI(SimpleNamespace(rest=FakeRest(post_data)), self._logger) + self._snapshot = snapshot def test_conflict_strategy_values(): @@ -73,3 +84,33 @@ def test_empty_lists_rejected(): app.sync_devices([]) with pytest.raises(ValueError): app.get_sync_info([]) + + +# --- Post-action snapshot refresh (ADR-0010) --- + + +def test_add_devices_refreshes_snapshot_when_loaded(): + snap = _RecordingSnapshot() + app = _App({"msg": [], "ok": True}, snapshot=snap) + assert app.add_devices_to_topology([("device12", 1, 2), "device13"]) is True + assert snap.network_refresh_calls == [["device12", "device13"]] + + +def test_sync_devices_refreshes_snapshot_when_loaded(): + snap = _RecordingSnapshot() + app = _App({"msg": [], "ok": True}, snapshot=snap) + assert app.sync_devices(["device12", "device13"]) is True + assert snap.network_refresh_calls == [["device12", "device13"]] + + +def test_network_action_without_snapshot_does_not_build_one(): + # _snapshot is None: a pure-action workflow must not trigger a topology read. + app = _App({"msg": [], "ok": True}) + assert app.add_devices_to_topology(["device12"]) is True + + +def test_failed_action_does_not_refresh(): + snap = _RecordingSnapshot() + app = _App({"msg": ["nope"], "ok": False}, snapshot=snap) + assert app.sync_devices(["device12"]) is False + assert snap.network_refresh_calls == [] diff --git a/tests/inspect/test_api.py b/tests/inspect/test_api.py index efd4b87..b3412d3 100644 --- a/tests/inspect/test_api.py +++ b/tests/inspect/test_api.py @@ -3,7 +3,7 @@ from types import SimpleNamespace -from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyData diff --git a/tests/inspect/test_queries.py b/tests/inspect/test_queries.py index b0b3dd2..c21a211 100644 --- a/tests/inspect/test_queries.py +++ b/tests/inspect/test_queries.py @@ -2,7 +2,7 @@ import pytest -from videoipath_automation_tool.apps.inspect import queries +from videoipath_automation_tool.apps.inspect.api import queries from videoipath_automation_tool.apps.inspect.errors import InspectQueryTooLongError diff --git a/tests/inspect/test_snapshot.py b/tests/inspect/test_snapshot.py index d009e5d..e1e23ec 100644 --- a/tests/inspect/test_snapshot.py +++ b/tests/inspect/test_snapshot.py @@ -241,6 +241,47 @@ def test_post_commit_marks_paths_stale(snapshot): assert fetcher.section_calls == 2 +def test_post_commit_reindexes_changed_label(snapshot): + # Latent-bug guard: a committed label change must re-point the label index (ADR-0010). + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _detail_node("leaf-a", "LEAF-A-RENAMED", ["leaf-a.dev.0.up1"]) + snap.apply_post_commit(device_ids=["leaf-a"], mark_paths_stale=False) + assert snap.find_device_by_label("LEAF-A-RENAMED").id == "leaf-a" + assert snap.find_device_by_label("LEAF-A") is None + + +def test_post_commit_refetch_failure_does_not_raise_and_self_heals(snapshot): + snap, fetcher = snapshot + calls = {"n": 0} + real = fetcher.get_device_detail + + def flaky(device_id): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("network blip") + return real(device_id) + + fetcher.get_device_detail = flaky + # The refresh failure is swallowed (the commit result must survive); the device is marked stale. + snap.apply_post_commit(device_ids=["leaf-a"], mark_paths_stale=False) + # Next access re-fetches (self-heal) and succeeds. + assert snap.get_device("leaf-a").label == "LEAF-A" + assert calls["n"] == 2 + + +def test_apply_network_refresh_adds_new_device_and_edge(snapshot): + snap, fetcher = snapshot + fetcher._details["leaf-b"] = _detail_node("leaf-b", "LEAF-B", ["leaf-b.dev.0.up1"]) + fetcher.get_edge_skeleton = lambda: [ + _edge_pair("leaf-a", "spine-a", "leaf-a.dev.0.up1", "spine-a.dev.0.swp1"), + _edge_pair("leaf-b", "spine-a", "leaf-b.dev.0.up1", "spine-a.dev.0.swp2"), + ] + snap.apply_network_refresh(["leaf-b"]) + assert snap.get_device("leaf-b") is not None + assert snap.find_device_by_label("LEAF-B").id == "leaf-b" + assert {e.pair_id for e in snap.get_edges_for_device("leaf-b")} == {"leaf-b::spine-a"} + + def test_full_snapshot_is_hydrated_without_fetcher(): fetcher = FakeFetcher() # Build a full snapshot manually (device_level=FULL, path_items provided) diff --git a/tests/inspect/test_changeset.py b/tests/inspect/test_transaction.py similarity index 98% rename from tests/inspect/test_changeset.py rename to tests/inspect/test_transaction.py index f2f86d5..fca4e31 100644 --- a/tests/inspect/test_changeset.py +++ b/tests/inspect/test_transaction.py @@ -1,4 +1,4 @@ -"""Change-set / transaction unit tests with a fake API (offline). +"""Transaction unit tests with a fake API (offline). Covers staging + intent application, payload-shape assertions against the verified write forms, the three-flag commit result, conflict detection (compare-and-commit), the ``check_conflicts=False`` @@ -9,7 +9,7 @@ import pytest -from videoipath_automation_tool.apps.inspect.changeset import InspectTransaction +from videoipath_automation_tool.apps.inspect.transaction import InspectTransaction from videoipath_automation_tool.apps.inspect.errors import ( InspectCommitConflictError, InspectCommitError, From b81386ff0a6f63ac854f1b47f436fe7c5c5753f7 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:07:49 +0200 Subject: [PATCH 15/15] code style improvements --- .../apps/inspect/__init__.py | 2 + .../apps/inspect/api/__init__.py | 2 + .../apps/inspect/api/inspect_api.py | 35 +- .../apps/inspect/api/queries.py | 76 ++--- .../apps/inspect/app/__init__.py | 2 + .../apps/inspect/app/actions.py | 6 +- .../apps/inspect/app/app.py | 15 +- .../apps/inspect/app/read.py | 40 +-- .../apps/inspect/app/write.py | 6 +- .../apps/inspect/domain/__init__.py | 2 + .../apps/inspect/domain/device.py | 12 +- .../apps/inspect/model/__init__.py | 2 + .../apps/inspect/snapshot.py | 87 ++--- .../apps/inspect/transaction.py | 93 +++--- tests/e2e/inspect/test_e2e_inspect.py | 2 +- tests/e2e/test_e2e_workflow.py | 3 +- tests/inspect/conftest.py | 8 +- tests/inspect/test_actions.py | 103 +++--- tests/inspect/test_api.py | 110 ++++--- tests/inspect/test_models.py | 80 ++--- tests/inspect/test_queries.py | 14 +- tests/inspect/test_snapshot.py | 298 +++++++++--------- tests/inspect/test_transaction.py | 243 +++++++------- 23 files changed, 665 insertions(+), 576 deletions(-) diff --git a/src/videoipath_automation_tool/apps/inspect/__init__.py b/src/videoipath_automation_tool/apps/inspect/__init__.py index 1969abb..d547716 100644 --- a/src/videoipath_automation_tool/apps/inspect/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy as ConflictStrategy from videoipath_automation_tool.apps.inspect.transaction import CommitResult as CommitResult from videoipath_automation_tool.apps.inspect.transaction import InspectTransaction as InspectTransaction diff --git a/src/videoipath_automation_tool/apps/inspect/api/__init__.py b/src/videoipath_automation_tool/apps/inspect/api/__init__.py index 2ee039a..7d5e871 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/api/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from . import queries from .inspect_api import InspectAPI diff --git a/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py index b238f4c..519decc 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py +++ b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py @@ -43,23 +43,8 @@ from videoipath_automation_tool.utils.cross_app_utils import create_fallback_logger -def _extract_items(data: dict[str, Any], *path: str) -> list[dict[str, Any]]: - """Walk ``data`` down ``path`` and return the ``_items`` list (empty if any node is absent).""" - node: Any = data - for key in path: - if not isinstance(node, dict): - return [] - node = node.get(key) - if node is None: - return [] - if isinstance(node, dict): - items = node.get("_items", []) - return items if isinstance(items, list) else [] - return [] - - class InspectAPI: - def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging.Logger] = None): + def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging.Logger] = None) -> None: self._logger = logger or create_fallback_logger("videoipath_automation_tool_inspect_api") self.vip_connector = vip_connector self._logger.debug("Inspect API initialized.") @@ -149,6 +134,24 @@ def sync_devices( return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) +# --- Internal --- + + +def _extract_items(data: dict[str, Any], *path: str) -> list[dict[str, Any]]: + """Walk ``data`` down ``path`` and return the ``_items`` list (empty if any node is absent).""" + node: Any = data + for key in path: + if not isinstance(node, dict): + return [] + node = node.get(key) + if node is None: + return [] + if isinstance(node, dict): + items = node.get("_items", []) + return items if isinstance(items, list) else [] + return [] + + def _header_dict(response: Any) -> dict[str, Any]: header = getattr(response, "header", None) if header is None: diff --git a/src/videoipath_automation_tool/apps/inspect/api/queries.py b/src/videoipath_automation_tool/apps/inspect/api/queries.py index c095bac..8fb8205 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/queries.py +++ b/src/videoipath_automation_tool/apps/inspect/api/queries.py @@ -29,13 +29,48 @@ # the full UI projection (thousands of chars) triggers HTTP 414 behind the proxy. MAX_QUERY_LENGTH = 4000 + +def encode(path: str) -> str: + """Percent-encode a collector query path, preserving projection-grammar characters.""" + return urllib.parse.quote(path, safe=_SAFE) + + +def device_skeleton() -> str: + """GET path for the device skeleton (all devices, no module/port detail).""" + return _build(_DEVICE_SKELETON) + + +def device_detail(device_id: str) -> str: + """GET path for one device's full nodeStatus sub-tree (modules, ports, vertexInfo, ...).""" + return _build(f"/status/collector/inspect/nodeStatus/{device_id}/**") + + +def edge_skeleton() -> str: + """GET path for the lean edge skeleton (all device pairs, connectivity + status severities).""" + return _build(_EDGE_SKELETON) + + +def edge_pair(pair_id: str) -> str: + """GET path for a single external-edge device pair (targeted refresh, [ADR-0010]).""" + return _build(f"/status/collector/externalEdgesByDeviceKey/{pair_id}" + _EDGE_LEAN_TAIL) + + +def paths_section() -> str: + """GET path for the services/paths section.""" + return _build(_PATHS_SECTION) + + +def collector_full() -> str: + """GET path for the full collector aggregate (eager / fallback mode).""" + return _build(_COLLECTOR_FULL) + + +# --- Internal --- + # Characters that are meaningful in the projection grammar and must survive encoding. # Everything else (spaces, double quotes, ...) is percent-encoded. _SAFE = "/*,'=()" - -# --- Verified query fragments (path relative to /rest/v2/data) --- - # Device skeleton: identity + descriptor + meta (incl. coordinates) + status + syncSeverity # + tags, with the module sub-tree suppressed ("_noId"). ~200 char URL, ~30 KB / 30 devices. _DEVICE_SKELETON = ( @@ -77,11 +112,6 @@ _COLLECTOR_FULL = "/status/collector/**" -def encode(path: str) -> str: - """Percent-encode a collector query path, preserving projection-grammar characters.""" - return urllib.parse.quote(path, safe=_SAFE) - - def _build(path: str) -> str: encoded = encode(_DATA + path) if len(encoded) > MAX_QUERY_LENGTH: @@ -89,36 +119,6 @@ def _build(path: str) -> str: return encoded -def device_skeleton() -> str: - """GET path for the device skeleton (all devices, no module/port detail).""" - return _build(_DEVICE_SKELETON) - - -def device_detail(device_id: str) -> str: - """GET path for one device's full nodeStatus sub-tree (modules, ports, vertexInfo, ...).""" - return _build(f"/status/collector/inspect/nodeStatus/{device_id}/**") - - -def edge_skeleton() -> str: - """GET path for the lean edge skeleton (all device pairs, connectivity + status severities).""" - return _build(_EDGE_SKELETON) - - -def edge_pair(pair_id: str) -> str: - """GET path for a single external-edge device pair (targeted refresh, [ADR-0010]).""" - return _build(f"/status/collector/externalEdgesByDeviceKey/{pair_id}" + _EDGE_LEAN_TAIL) - - -def paths_section() -> str: - """GET path for the services/paths section.""" - return _build(_PATHS_SECTION) - - -def collector_full() -> str: - """GET path for the full collector aggregate (eager / fallback mode).""" - return _build(_COLLECTOR_FULL) - - __all__ = [ "MAX_QUERY_LENGTH", "encode", diff --git a/src/videoipath_automation_tool/apps/inspect/app/__init__.py b/src/videoipath_automation_tool/apps/inspect/app/__init__.py index 7adf215..250e9fb 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/app/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from videoipath_automation_tool.apps.inspect.app.app import InspectApp __all__ = ["InspectApp"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/actions.py b/src/videoipath_automation_tool/apps/inspect/app/actions.py index 32b8bf9..230db07 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/actions.py +++ b/src/videoipath_automation_tool/apps/inspect/app/actions.py @@ -10,14 +10,16 @@ import logging from enum import IntEnum -from typing import Iterable, Optional, Protocol, Union +from typing import TYPE_CHECKING, Iterable, Optional, Protocol, Union from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.model.actions import ( InspectApiAddDevicesItem, InspectApiLookupSyncInfoItem, ) -from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot class ConflictStrategy(IntEnum): diff --git a/src/videoipath_automation_tool/apps/inspect/app/app.py b/src/videoipath_automation_tool/apps/inspect/app/app.py index 4eee4f9..cf4d487 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/app.py +++ b/src/videoipath_automation_tool/apps/inspect/app/app.py @@ -7,19 +7,18 @@ from __future__ import annotations import logging -from typing import Optional +from typing import TYPE_CHECKING, Optional from videoipath_automation_tool.apps.inspect.api import InspectAPI -from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot from videoipath_automation_tool.connector.vip_connector import VideoIPathConnector +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + from .actions import InspectActionsMixin from .read import InspectReadMixin, LoadMode from .write import InspectWriteMixin -# First VideoIPath version the Inspect collector surface was verified against. -_MIN_VERIFIED_VERSION = (2025, 4) - class InspectApp(InspectReadMixin, InspectWriteMixin, InspectActionsMixin): def __init__( @@ -27,7 +26,7 @@ def __init__( vip_connector: VideoIPathConnector, logger: Optional[logging.Logger] = None, load: LoadMode = "skeleton", - ): + ) -> None: """Inspect App: read the topology/status and apply commit-style topology changes. The app keeps a single internal topology view that is loaded lazily on the first read and @@ -58,6 +57,10 @@ def _warn_if_version_unverified(self) -> None: ) +# First VideoIPath version the Inspect collector surface was verified against. +_MIN_VERIFIED_VERSION = (2025, 4) + + def _parse_version(version: str) -> Optional[tuple[int, int]]: parts = version.split(".") if len(parts) < 2: diff --git a/src/videoipath_automation_tool/apps/inspect/app/read.py b/src/videoipath_automation_tool/apps/inspect/app/read.py index 3cd0be2..58f3bb5 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/read.py +++ b/src/videoipath_automation_tool/apps/inspect/app/read.py @@ -36,26 +36,6 @@ class InspectReadMixin: _snapshot: Optional[InspectSnapshot] _load_mode: LoadMode - # --- Internal snapshot lifecycle --- - - def _get_snapshot(self: _HasInspectState) -> InspectSnapshot: - """Return the internal snapshot, building it on first access ([ADR-0007]).""" - if self._snapshot is None: - self._snapshot = self._load_snapshot(self._load_mode) - return self._snapshot - - def _load_snapshot(self: _HasInspectState, load: LoadMode) -> InspectSnapshot: - if load == "full": - self._logger.debug("Loading full (eager) Inspect snapshot.") - return InspectSnapshot.from_full_response(self._inspect_api.get_collector_full(), fetcher=self._inspect_api) - self._logger.debug("Loading skeleton Inspect snapshot (devices + edges in parallel).") - with ThreadPoolExecutor(max_workers=2) as pool: - devices_future = pool.submit(self._inspect_api.get_device_skeleton) - edges_future = pool.submit(self._inspect_api.get_edge_skeleton) - devices = devices_future.result() - edges = edges_future.result() - return InspectSnapshot(fetcher=self._inspect_api, device_items=devices, edge_items=edges) - def refresh(self: _HasInspectState, load: Optional[LoadMode] = None) -> None: """Reload the topology from the server, discarding the current internal view. @@ -128,5 +108,25 @@ def get_services_for_device(self: _HasInspectState, device_id: str) -> list["Ins """All services whose path traverses the given device.""" return self._get_snapshot().get_services_for_device(device_id) + # --- Internal snapshot lifecycle --- + + def _get_snapshot(self: _HasInspectState) -> InspectSnapshot: + """Return the internal snapshot, building it on first access ([ADR-0007]).""" + if self._snapshot is None: + self._snapshot = self._load_snapshot(self._load_mode) + return self._snapshot + + def _load_snapshot(self: _HasInspectState, load: LoadMode) -> InspectSnapshot: + if load == "full": + self._logger.debug("Loading full (eager) Inspect snapshot.") + return InspectSnapshot.from_full_response(self._inspect_api.get_collector_full(), fetcher=self._inspect_api) + self._logger.debug("Loading skeleton Inspect snapshot (devices + edges in parallel).") + with ThreadPoolExecutor(max_workers=2) as pool: + devices_future = pool.submit(self._inspect_api.get_device_skeleton) + edges_future = pool.submit(self._inspect_api.get_edge_skeleton) + devices = devices_future.result() + edges = edges_future.result() + return InspectSnapshot(fetcher=self._inspect_api, device_items=devices, edge_items=edges) + __all__ = ["InspectReadMixin", "LoadMode"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py index 2cc4d4c..054984b 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/write.py +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -12,11 +12,13 @@ from __future__ import annotations import logging -from typing import Any, Optional, Protocol +from typing import TYPE_CHECKING, Any, Optional, Protocol from videoipath_automation_tool.apps.inspect.transaction import CommitResult, InspectTransaction from videoipath_automation_tool.apps.inspect.api import InspectAPI -from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot class _HasInspectState(Protocol): diff --git a/src/videoipath_automation_tool/apps/inspect/domain/__init__.py b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py index b65cbc5..589625e 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.port import InspectPort diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index f440938..450e4ad 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -22,12 +22,6 @@ class InspectDevice(InspectFrozenModel): snapshot: InspectSnapshot id: str - def _record(self) -> "_DeviceRecord": - record = self.snapshot.get_device_record(self.id) - if record is None: - raise KeyError(f"Device '{self.id}' is no longer present in the snapshot.") - return record - @property def label(self) -> str | None: return self._record().label @@ -85,3 +79,9 @@ def services(self) -> list[InspectService]: @property def linked_devices(self) -> list[InspectDevice]: return self.snapshot.get_linked_devices(self.id) + + def _record(self) -> "_DeviceRecord": + record = self.snapshot.get_device_record(self.id) + if record is None: + raise KeyError(f"Device '{self.id}' is no longer present in the snapshot.") + return record diff --git a/src/videoipath_automation_tool/apps/inspect/model/__init__.py b/src/videoipath_automation_tool/apps/inspect/model/__init__.py index 8b773e0..cf78aaf 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/model/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from videoipath_automation_tool.apps.inspect.model.actions import * from videoipath_automation_tool.apps.inspect.model.collector import * from videoipath_automation_tool.apps.inspect.model.common import * diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index d336fb5..2021117 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -41,54 +41,12 @@ from videoipath_automation_tool.apps.inspect.domain.service import InspectService from videoipath_automation_tool.apps.inspect.api import InspectAPI -_PRELOAD_WORKERS = 8 - -_logger = logging.getLogger("videoipath_automation_tool_inspect_snapshot") - class HydrationLevel(str, Enum): SKELETON = "skeleton" FULL = "full" -def _now() -> datetime: - return datetime.now(timezone.utc) - - -class _DeviceRecord(InspectInternalModel): - device_id: str - node: InspectApiNodeStatusItem - level: HydrationLevel - fetched_at: datetime = Field(default_factory=_now) - - @property - def label(self) -> str | None: - return self.node.effective_label - - @property - def pid(self) -> str | None: - return self.node.pid or self.node.deviceId - - -class _IndexedPort(InspectFrozenModel): - device_id: str - module_id: str | None - port: InspectPortStatus - - -class _IndexedEdge(InspectFrozenModel): - edge_id: str - pair_id: str - edge: InspectApiExternalEdgeStatus - pair_status: InspectApiExternalEdgeLiveStatus | None - primary_device_id: str | None - secondary_device_id: str | None - from_device_id: str | None - from_port_id: str | None - to_device_id: str | None - to_port_id: str | None - - class InspectSnapshot: def __init__( self, @@ -702,6 +660,51 @@ def _wrap_service(self, path_item: InspectApiPathItem) -> "InspectService": return service +# --- Internal --- + +_PRELOAD_WORKERS = 8 + +_logger = logging.getLogger("videoipath_automation_tool_inspect_snapshot") + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class _DeviceRecord(InspectInternalModel): + device_id: str + node: InspectApiNodeStatusItem + level: HydrationLevel + fetched_at: datetime = Field(default_factory=_now) + + @property + def label(self) -> str | None: + return self.node.effective_label + + @property + def pid(self) -> str | None: + return self.node.pid or self.node.deviceId + + +class _IndexedPort(InspectFrozenModel): + device_id: str + module_id: str | None + port: InspectPortStatus + + +class _IndexedEdge(InspectFrozenModel): + edge_id: str + pair_id: str + edge: InspectApiExternalEdgeStatus + pair_status: InspectApiExternalEdgeLiveStatus | None + primary_device_id: str | None + secondary_device_id: str | None + from_device_id: str | None + from_port_id: str | None + to_device_id: str | None + to_port_id: str | None + + # --- Module-level helpers (kept stable for the domain layer) --- diff --git a/src/videoipath_automation_tool/apps/inspect/transaction.py b/src/videoipath_automation_tool/apps/inspect/transaction.py index ea6f9ad..953ea69 100644 --- a/src/videoipath_automation_tool/apps/inspect/transaction.py +++ b/src/videoipath_automation_tool/apps/inspect/transaction.py @@ -49,11 +49,6 @@ from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot -# Staged-entry kinds. -_DEVICE = "device" -_VERTEX = "vertex" -_EDGE = "edge" - class CommitResult(InspectFrozenModel): """Outcome of a successful commit ([ADR-0006]); a failed commit raises ``InspectCommitError``.""" @@ -71,46 +66,6 @@ def validation(self) -> Any: return self.response.data.validation -class _Staged(InspectInternalModel): - kind: str - entity_id: str - # The write-shape baseline (edit/edge form) as fetched at stage time; None for a raw remove. - baseline_form: Any | None = None - # JSON dump of the baseline at stage time, used for the compare-and-commit conflict check. - baseline_dump: dict[str, Any] | None = None - # Field-level intents (wire field names; dotted for one level of nesting, e.g. "descriptor.label"). - intents: dict[str, Any] = Field(default_factory=dict) - remove: bool = False - is_new: bool = False - - -def _device_of(entity_id: str) -> str: - """Owning device id of a vertex/port id (``device12.1.Ethernet1.out`` -> ``device12``).""" - return entity_id.split(".", 1)[0] - - -def _reverse_vertex(vertex_id: str) -> str: - """Flip the trailing direction of an IP vertex id (``.out`` <-> ``.in``).""" - if vertex_id.endswith(".out"): - return vertex_id[: -len(".out")] + ".in" - if vertex_id.endswith(".in"): - return vertex_id[: -len(".in")] + ".out" - return vertex_id - - -def _edge_key(from_id: str, to_id: str) -> str: - return f"{from_id}::{to_id}" - - -def _apply_intents(form: Any, intents: dict[str, Any]) -> None: - for key, value in intents.items(): - if "." in key: - head, tail = key.split(".", 1) - setattr(getattr(form, head), tail, value) - else: - setattr(form, key, value) - - class InspectTransaction: """Single-use, atomic batch of Inspect topology changes. @@ -528,6 +483,54 @@ def _refresh_snapshot(self) -> None: ) +# --- Internal --- + +# Staged-entry kinds. +_DEVICE = "device" +_VERTEX = "vertex" +_EDGE = "edge" + + +class _Staged(InspectInternalModel): + kind: str + entity_id: str + # The write-shape baseline (edit/edge form) as fetched at stage time; None for a raw remove. + baseline_form: Any | None = None + # JSON dump of the baseline at stage time, used for the compare-and-commit conflict check. + baseline_dump: dict[str, Any] | None = None + # Field-level intents (wire field names; dotted for one level of nesting, e.g. "descriptor.label"). + intents: dict[str, Any] = Field(default_factory=dict) + remove: bool = False + is_new: bool = False + + +def _device_of(entity_id: str) -> str: + """Owning device id of a vertex/port id (``device12.1.Ethernet1.out`` -> ``device12``).""" + return entity_id.split(".", 1)[0] + + +def _reverse_vertex(vertex_id: str) -> str: + """Flip the trailing direction of an IP vertex id (``.out`` <-> ``.in``).""" + if vertex_id.endswith(".out"): + return vertex_id[: -len(".out")] + ".in" + if vertex_id.endswith(".in"): + return vertex_id[: -len(".in")] + ".out" + return vertex_id + + +def _edge_key(from_id: str, to_id: str) -> str: + return f"{from_id}::{to_id}" + + +def _apply_intents(form: Any, intents: dict[str, Any]) -> None: + for key, value in intents.items(): + if "." in key: + head, tail = key.split(".", 1) + setattr(getattr(form, head), tail, value) + else: + setattr(form, key, value) + + def _checkable(entry: _Staged) -> bool: return not entry.remove and not entry.is_new and entry.baseline_dump is not None diff --git a/tests/e2e/inspect/test_e2e_inspect.py b/tests/e2e/inspect/test_e2e_inspect.py index e77b974..d299592 100644 --- a/tests/e2e/inspect/test_e2e_inspect.py +++ b/tests/e2e/inspect/test_e2e_inspect.py @@ -147,7 +147,7 @@ def test_full_vs_skeleton_equivalence(app: VideoIPathApp, topology_builder: Topo topology_builder.link(id_a, id_b) topology_builder.link(id_b, id_c) - def graph_view() -> dict: + def graph_view() -> dict[str, tuple[str | None, frozenset[str | None]]]: return { device_id: ( app.inspect.get_device(device_id).label, diff --git a/tests/e2e/test_e2e_workflow.py b/tests/e2e/test_e2e_workflow.py index 6709396..e463e3e 100644 --- a/tests/e2e/test_e2e_workflow.py +++ b/tests/e2e/test_e2e_workflow.py @@ -42,7 +42,8 @@ class LinkSpec(InspectFrozenModel): b: int # device index -# A full leaf-spine in its own map region (all coordinates shifted +3000 in y so the test data rather not conflict with other data). Port counts equal each device's link degree; devices with +# A full leaf-spine in its own map region (all coordinates shifted +3000 in y so the test data +# rather not conflict with other data). Port counts equal each device's link degree; devices with # no links sit at the region origin. Two device pairs — (2, 15) and (5, 16) — carry parallel links, # represented here as repeated ``LinkSpec`` entries. DEVICES = [ diff --git a/tests/inspect/conftest.py b/tests/inspect/conftest.py index c28dfec..5cfd95b 100644 --- a/tests/inspect/conftest.py +++ b/tests/inspect/conftest.py @@ -1,14 +1,18 @@ """Shared fixtures for offline Inspect tests.""" +from __future__ import annotations + import json +from collections.abc import Callable from pathlib import Path +from typing import Any import pytest FIXTURE_DIR = Path(__file__).parent / "fixtures" / "2025.4.9" -def load_fixture(name: str) -> dict: +def load_fixture(name: str) -> dict[str, Any]: with open(FIXTURE_DIR / name) as handle: return json.load(handle) @@ -19,5 +23,5 @@ def fixtures_dir() -> Path: @pytest.fixture -def load(): +def load() -> Callable[[str], dict[str, Any]]: return load_fixture diff --git a/tests/inspect/test_actions.py b/tests/inspect/test_actions.py index ada7c58..15cb6cb 100644 --- a/tests/inspect/test_actions.py +++ b/tests/inspect/test_actions.py @@ -1,6 +1,10 @@ """Network-action mixin tests with a fake connector.""" +from __future__ import annotations + +import logging from types import SimpleNamespace +from typing import Any import pytest @@ -8,77 +12,42 @@ from videoipath_automation_tool.apps.inspect.api import InspectAPI -def _ok_header(): - return SimpleNamespace( - model_dump=lambda mode="json": { - "auth": True, - "caption": "OK", - "code": "OK", - "errorCodes": [], - "errorDetails": [], - "id": "0", - "msg": [], - "ok": True, - "user": "api-user", - } - ) - - class FakeRest: - def __init__(self, post_data): + def __init__(self, post_data: dict[str, Any]) -> None: self._post_data = post_data - self.post_calls = [] + self.post_calls: list[tuple[str, dict[str, Any]]] = [] - def post(self, url_path, body, **kwargs): + def post(self, url_path: str, body: Any, **kwargs: Any) -> SimpleNamespace: self.post_calls.append((url_path, body.model_dump(mode="json", by_alias=True))) return SimpleNamespace(data=self._post_data, header=_ok_header()) -class _RecordingSnapshot: - """Stand-in that records apply_network_refresh calls (see snapshot.apply_network_refresh).""" - - def __init__(self): - self.network_refresh_calls = [] - - def apply_network_refresh(self, device_ids): - self.network_refresh_calls.append(list(device_ids)) - - -class _App(InspectActionsMixin): - def __init__(self, post_data, snapshot=None): - import logging - - self._logger = logging.getLogger("test") - self._inspect_api = InspectAPI(SimpleNamespace(rest=FakeRest(post_data)), self._logger) - self._snapshot = snapshot - - -def test_conflict_strategy_values(): +def test_conflict_strategy_values() -> None: assert int(ConflictStrategy.STRICT) == 0 assert int(ConflictStrategy.INVALIDATE_SERVICES) == 1 assert int(ConflictStrategy.CANCEL_SERVICES) == 2 -def test_add_devices_builds_placement_items(): +def test_add_devices_builds_placement_items() -> None: app = _App({"msg": [], "ok": True}) assert app.add_devices_to_topology([("device12", 100, 200), "device13"]) is True _, payload = app._inspect_api.vip_connector.rest.post_calls[0] assert payload["data"] == [{"id": "device12", "x": 100, "y": 200}, {"id": "device13", "x": 0, "y": 0}] -def test_sync_devices_passes_strategy(): +def test_sync_devices_passes_strategy() -> None: app = _App({"msg": [], "ok": True}) app.sync_devices(["device12"], add_only=True, conflict_strategy=ConflictStrategy.CANCEL_SERVICES) _, payload = app._inspect_api.vip_connector.rest.post_calls[0] assert payload["data"] == {"ids": ["device12"], "addOnly": True, "conflictStrategy": 2} -def test_sync_devices_reports_failure(): +def test_sync_devices_reports_failure() -> None: app = _App({"msg": ["No topology reported by the device"], "ok": False}) assert app.sync_devices(["device12"]) is False -def test_empty_lists_rejected(): +def test_empty_lists_rejected() -> None: app = _App({"msg": [], "ok": True}) with pytest.raises(ValueError): app.sync_devices([]) @@ -89,28 +58,68 @@ def test_empty_lists_rejected(): # --- Post-action snapshot refresh (ADR-0010) --- -def test_add_devices_refreshes_snapshot_when_loaded(): +def test_add_devices_refreshes_snapshot_when_loaded() -> None: snap = _RecordingSnapshot() app = _App({"msg": [], "ok": True}, snapshot=snap) assert app.add_devices_to_topology([("device12", 1, 2), "device13"]) is True assert snap.network_refresh_calls == [["device12", "device13"]] -def test_sync_devices_refreshes_snapshot_when_loaded(): +def test_sync_devices_refreshes_snapshot_when_loaded() -> None: snap = _RecordingSnapshot() app = _App({"msg": [], "ok": True}, snapshot=snap) assert app.sync_devices(["device12", "device13"]) is True assert snap.network_refresh_calls == [["device12", "device13"]] -def test_network_action_without_snapshot_does_not_build_one(): +def test_network_action_without_snapshot_does_not_build_one() -> None: # _snapshot is None: a pure-action workflow must not trigger a topology read. app = _App({"msg": [], "ok": True}) assert app.add_devices_to_topology(["device12"]) is True -def test_failed_action_does_not_refresh(): +def test_failed_action_does_not_refresh() -> None: snap = _RecordingSnapshot() app = _App({"msg": ["nope"], "ok": False}, snapshot=snap) assert app.sync_devices(["device12"]) is False assert snap.network_refresh_calls == [] + + +# --- Internal --- + + +def _ok_header() -> SimpleNamespace: + return SimpleNamespace( + model_dump=lambda mode="json": { + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", + } + ) + + +class _RecordingSnapshot: + """Stand-in that records apply_network_refresh calls (see snapshot.apply_network_refresh).""" + + def __init__(self) -> None: + self.network_refresh_calls: list[list[str]] = [] + + def apply_network_refresh(self, device_ids: list[str]) -> None: + self.network_refresh_calls.append(list(device_ids)) + + +class _App(InspectActionsMixin): + def __init__( + self, + post_data: dict[str, Any], + snapshot: _RecordingSnapshot | None = None, + ) -> None: + self._logger = logging.getLogger("test") + self._inspect_api = InspectAPI(SimpleNamespace(rest=FakeRest(post_data)), self._logger) + self._snapshot = snapshot diff --git a/tests/inspect/test_api.py b/tests/inspect/test_api.py index b3412d3..87d971a 100644 --- a/tests/inspect/test_api.py +++ b/tests/inspect/test_api.py @@ -1,64 +1,37 @@ """InspectAPI wiring tests with a fake REST connector: verifies each method hits the right endpoint, passes allow_projection for scoped reads, and parses responses into DTOs.""" +from __future__ import annotations + +from collections.abc import Callable from types import SimpleNamespace +from typing import Any from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyData class FakeRest: - def __init__(self, get_data=None, post_data=None): + def __init__( + self, + get_data: dict[str, Any] | None = None, + post_data: dict[str, Any] | None = None, + ) -> None: self._get_data = get_data or {} self._post_data = post_data or {} - self.get_calls = [] - self.post_calls = [] + self.get_calls: list[tuple[str, bool]] = [] + self.post_calls: list[tuple[str, Any]] = [] - def get(self, url_path, allow_projection=False, **kwargs): + def get(self, url_path: str, allow_projection: bool = False, **kwargs: Any) -> SimpleNamespace: self.get_calls.append((url_path, allow_projection)) return SimpleNamespace(data=self._get_data, header=_ok_header()) - def post(self, url_path, body, **kwargs): + def post(self, url_path: str, body: Any, **kwargs: Any) -> SimpleNamespace: self.post_calls.append((url_path, body)) return SimpleNamespace(data=self._post_data, header=_ok_header()) -def _ok_header(): - return SimpleNamespace( - model_dump=lambda mode="json": { - "auth": True, - "caption": "OK", - "code": "OK", - "errorCodes": [], - "errorDetails": [], - "id": "0", - "msg": [], - "ok": True, - "user": "api-user", - } - ) - - -def _connector(get_data=None, post_data=None): - rest = FakeRest(get_data=get_data, post_data=post_data) - return SimpleNamespace(rest=rest), rest - - -def _collector(node_items=None, edge_items=None, path_items=None): - return { - "status": { - "collector": { - "inspect": { - "nodeStatus": {"_items": node_items or []}, - "paths": {"_items": path_items or []}, - }, - "externalEdgesByDeviceKey": {"_items": edge_items or []}, - } - } - } - - -def test_device_skeleton_uses_projection_and_parses(load): +def test_device_skeleton_uses_projection_and_parses(load: Callable[[str], dict[str, Any]]) -> None: node_items = load("skeleton_nodestatus_short.json")["data"]["status"]["collector"]["inspect"]["nodeStatus"][ "_items" ] @@ -69,7 +42,7 @@ def test_device_skeleton_uses_projection_and_parses(load): assert rest.get_calls[0][1] is True # allow_projection -def test_edge_skeleton_parses(load): +def test_edge_skeleton_parses(load: Callable[[str], dict[str, Any]]) -> None: edge_items = load("edge_skeleton.json")["data"]["status"]["collector"]["externalEdgesByDeviceKey"]["_items"] conn, rest = _connector(get_data=_collector(edge_items=edge_items)) api = InspectAPI(conn) @@ -77,13 +50,13 @@ def test_edge_skeleton_parses(load): assert len(edges) == len(edge_items) -def test_device_detail_returns_none_when_absent(): +def test_device_detail_returns_none_when_absent() -> None: conn, rest = _connector(get_data=_collector(node_items=[])) api = InspectAPI(conn) assert api.get_device_detail("deviceX") is None -def test_lookup_edges_hits_correct_endpoint(load): +def test_lookup_edges_hits_correct_endpoint(load: Callable[[str], dict[str, Any]]) -> None: conn, rest = _connector(post_data=load("lookup_inspect_edges_by_ids.json")["data"]) api = InspectAPI(conn) resp = api.lookup_edges(["a::b"]) @@ -91,7 +64,7 @@ def test_lookup_edges_hits_correct_endpoint(load): assert resp.data -def test_update_topology_posts_delta(): +def test_update_topology_posts_delta() -> None: conn, rest = _connector( post_data={ "items": [], @@ -105,10 +78,55 @@ def test_update_topology_posts_delta(): assert resp.committed is True -def test_add_and_sync_devices_endpoints(): +def test_add_and_sync_devices_endpoints() -> None: conn, rest = _connector(post_data={"msg": [], "ok": True}) api = InspectAPI(conn) api.add_devices([]) api.sync_devices([], add_only=True, conflict_strategy=0) assert rest.post_calls[0][0].endswith("/network/addDevices") assert rest.post_calls[1][0].endswith("/network/syncDevices") + + +# --- Internal --- + + +def _ok_header() -> SimpleNamespace: + return SimpleNamespace( + model_dump=lambda mode="json": { + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", + } + ) + + +def _connector( + get_data: dict[str, Any] | None = None, + post_data: dict[str, Any] | None = None, +) -> tuple[SimpleNamespace, FakeRest]: + rest = FakeRest(get_data=get_data, post_data=post_data) + return SimpleNamespace(rest=rest), rest + + +def _collector( + node_items: list[dict[str, Any]] | None = None, + edge_items: list[dict[str, Any]] | None = None, + path_items: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + return { + "status": { + "collector": { + "inspect": { + "nodeStatus": {"_items": node_items or []}, + "paths": {"_items": path_items or []}, + }, + "externalEdgesByDeviceKey": {"_items": edge_items or []}, + } + } + } diff --git a/tests/inspect/test_models.py b/tests/inspect/test_models.py index 47ccf89..0134e52 100644 --- a/tests/inspect/test_models.py +++ b/tests/inspect/test_models.py @@ -1,6 +1,11 @@ """Contract tests: every fixture parses into its DTO, and write-payload builders reproduce the verified request shapes byte-for-byte.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + from videoipath_automation_tool.apps.inspect.model.actions import ( InspectApiLookupEdgesResponse, InspectApiLookupInspectDeviceResponse, @@ -18,11 +23,7 @@ ) -def _node_items(payload): - return payload["data"]["status"]["collector"]["inspect"]["nodeStatus"]["_items"] - - -def test_device_skeleton_items_parse_and_expose_effective_label(load): +def test_device_skeleton_items_parse_and_expose_effective_label(load: Callable[[str], dict[str, Any]]) -> None: items = _node_items(load("skeleton_nodestatus_short.json")) assert items for raw in items: @@ -31,7 +32,7 @@ def test_device_skeleton_items_parse_and_expose_effective_label(load): assert node.effective_label # comes from descriptor.label, not a top-level label -def test_device_detail_parses_modules_and_ports(load): +def test_device_detail_parses_modules_and_ports(load: Callable[[str], dict[str, Any]]) -> None: items = _node_items(load("device_hydration_modules_ports.json")) node = InspectApiNodeStatusItem.model_validate(items[0]) assert node.modules @@ -39,7 +40,7 @@ def test_device_detail_parses_modules_and_ports(load): assert module.ports -def test_edge_skeleton_items_parse(load): +def test_edge_skeleton_items_parse(load: Callable[[str], dict[str, Any]]) -> None: items = load("edge_skeleton.json")["data"]["status"]["collector"]["externalEdgesByDeviceKey"]["_items"] assert len(items) > 0 edge = InspectApiExternalEdgesByDeviceKeyItem.model_validate(items[0]) @@ -47,14 +48,14 @@ def test_edge_skeleton_items_parse(load): assert edge.primary is not None -def test_paths_fixture_parses(load): +def test_paths_fixture_parses(load: Callable[[str], dict[str, Any]]) -> None: items = load("inspect_paths_limit5.json")["data"]["status"]["collector"]["inspect"]["paths"]["_items"] for raw in items: path = InspectApiPathItem.model_validate(raw) assert path.serviceFields.bid -def test_lookup_edges_returns_full_persisted_edge_form(load): +def test_lookup_edges_returns_full_persisted_edge_form(load: Callable[[str], dict[str, Any]]) -> None: resp = InspectApiLookupEdgesResponse.model_validate(load("lookup_inspect_edges_by_ids.json")) key = next(iter(resp.data)) edge = resp.data[key].edge @@ -62,53 +63,32 @@ def test_lookup_edges_returns_full_persisted_edge_form(load): assert edge.capacity == 65535 -def test_lookup_vertex_edit_form(load): +def test_lookup_vertex_edit_form(load: Callable[[str], dict[str, Any]]) -> None: resp = InspectApiLookupVertexResponse.model_validate(load("lookup_inspect_vertex_by_id.json")) assert resp.data.fields.typeFields is not None assert resp.data.vertexType in ("In", "Out", "Internal", None) -def test_lookup_device_fields(): +def test_lookup_device_fields() -> None: resp = InspectApiLookupInspectDeviceResponse.model_validate(_device_lookup_fixture()) assert resp.data.fields.coordinates is not None -def _device_lookup_fixture(): - # lookupInspectDevice example from endpoints.md (anonymized) - return { - "data": { - "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, - "fields": { - "coordinates": {"x": 500, "y": 8150}, - "descriptor": {"desc": "", "label": "Example Device A"}, - "iconSize": "medium", - "iconType": "gateway", - "localAssignedTags": [], - "sdpStrategy": "always", - "siteId": None, - "tags": [], - "virtualDeviceFields": None, - }, - }, - "header": {"auth": True, "caption": "OK", "code": "OK", "id": "0", "msg": [], "ok": True, "user": "api-user"}, - } - - -def test_replace_devices_payload_roundtrips_byte_for_byte(load): +def test_replace_devices_payload_roundtrips_byte_for_byte(load: Callable[[str], dict[str, Any]]) -> None: fixture = load("update_topology_replace_devices.json") data = InspectApiUpdateTopologyData.model_validate(fixture["request"]["data"]) built = data.model_dump(mode="json", by_alias=True) assert built["replaceDevices"] == fixture["request"]["data"]["replaceDevices"] -def test_replace_vertices_payload_roundtrips_byte_for_byte(load): +def test_replace_vertices_payload_roundtrips_byte_for_byte(load: Callable[[str], dict[str, Any]]) -> None: fixture = load("update_topology_replace_vertices.json") data = InspectApiUpdateTopologyData.model_validate(fixture["request"]["data"]) built = data.model_dump(mode="json", by_alias=True) assert built["replaceVertices"] == fixture["request"]["data"]["replaceVertices"] -def test_commit_success_and_failure_flags(load): +def test_commit_success_and_failure_flags(load: Callable[[str], dict[str, Any]]) -> None: ok = InspectApiUpdateTopologyResponse.model_validate(load("update_topology_success.json")) assert ok.committed is True @@ -120,7 +100,7 @@ def test_commit_success_and_failure_flags(load): assert fail_remove.committed is False -def test_port_assigned_tags_from_tags_info(): +def test_port_assigned_tags_from_tags_info() -> None: port = InspectPortStatus.model_validate( { "_id": "device-a.1.p1", @@ -129,3 +109,31 @@ def test_port_assigned_tags_from_tags_info(): ) assert port.assigned_tags == ["Video~~T"] assert InspectPortStatus.model_validate({"_id": "device-a.1.p2"}).assigned_tags == [] + + +# --- Internal --- + + +def _node_items(payload: dict[str, Any]) -> list[dict[str, Any]]: + return payload["data"]["status"]["collector"]["inspect"]["nodeStatus"]["_items"] + + +def _device_lookup_fixture() -> dict[str, Any]: + # lookupInspectDevice example from endpoints.md (anonymized) + return { + "data": { + "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, + "fields": { + "coordinates": {"x": 500, "y": 8150}, + "descriptor": {"desc": "", "label": "Example Device A"}, + "iconSize": "medium", + "iconType": "gateway", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": None, + "tags": [], + "virtualDeviceFields": None, + }, + }, + "header": {"auth": True, "caption": "OK", "code": "OK", "id": "0", "msg": [], "ok": True, "user": "api-user"}, + } diff --git a/tests/inspect/test_queries.py b/tests/inspect/test_queries.py index c21a211..a1fd31a 100644 --- a/tests/inspect/test_queries.py +++ b/tests/inspect/test_queries.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import urllib.parse import pytest @@ -6,14 +8,14 @@ from videoipath_automation_tool.apps.inspect.errors import InspectQueryTooLongError -def test_all_queries_within_length_limit(): +def test_all_queries_within_length_limit() -> None: for build in (queries.device_skeleton, queries.edge_skeleton, queries.paths_section, queries.collector_full): path = build() assert len(path) < queries.MAX_QUERY_LENGTH assert path.startswith("/rest/v2/data/status/collector/") -def test_device_skeleton_suppresses_modules_and_selects_skeleton_fields(): +def test_device_skeleton_suppresses_modules_and_selects_skeleton_fields() -> None: path = queries.device_skeleton() decoded = urllib.parse.unquote(path) assert 'modules/"_noId"' in decoded @@ -22,18 +24,18 @@ def test_device_skeleton_suppresses_modules_and_selects_skeleton_fields(): assert field in decoded -def test_device_detail_uses_direct_id_and_full_subtree(): +def test_device_detail_uses_direct_id_and_full_subtree() -> None: path = queries.device_detail("device12") assert path.endswith("/nodeStatus/device12/**") -def test_edge_pair_targets_single_pair(): +def test_edge_pair_targets_single_pair() -> None: path = queries.edge_pair("device12::device7") decoded = urllib.parse.unquote(path) assert "externalEdgesByDeviceKey/device12::device7" in decoded -def test_encode_preserves_grammar_characters_and_encodes_quotes_and_spaces(): +def test_encode_preserves_grammar_characters_and_encodes_quotes_and_spaces() -> None: encoded = queries.encode("/a b/*/x,y/'z'/\"q\"") assert "%20" in encoded # space encoded assert "%22" in encoded # double-quote encoded @@ -41,7 +43,7 @@ def test_encode_preserves_grammar_characters_and_encodes_quotes_and_spaces(): assert "'z'" in encoded # single-quote preserved -def test_query_too_long_raises(monkeypatch): +def test_query_too_long_raises(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(queries, "MAX_QUERY_LENGTH", 10) with pytest.raises(InspectQueryTooLongError): queries.device_skeleton() diff --git a/tests/inspect/test_snapshot.py b/tests/inspect/test_snapshot.py index e1e23ec..8aaf207 100644 --- a/tests/inspect/test_snapshot.py +++ b/tests/inspect/test_snapshot.py @@ -3,6 +3,8 @@ from __future__ import annotations +from collections.abc import Iterator + import pytest from videoipath_automation_tool.apps.inspect.model.collector import ( @@ -13,134 +15,7 @@ from videoipath_automation_tool.apps.inspect.snapshot import HydrationLevel, InspectSnapshot -# --- Test data builders (small synthetic leaf-spine-ish graph) --- - - -def _skeleton_node(device_id: str, label: str, x: float = 0.0, y: float = 0.0, sync: int = 0): - return InspectApiNodeStatusItem.model_validate( - { - "_id": device_id, - "_vid": device_id, - "deviceId": device_id, - "descriptor": {"desc": "", "label": label}, - "meta": {"coordinates": {"x": x, "y": y}, "isVirtual": True, "iconType": "switch"}, - "status": {"sa": 0, "severity": 0}, - "syncSeverity": sync, - "tags": ["#e2e"], - "modules": {}, - } - ) - - -def _detail_node(device_id: str, label: str, port_pids: list[str]): - ports = { - pid: { - "pid": pid, - "descriptor": {"label": pid.split(".")[-1]}, - "status": {"sa": 0, "severity": 0}, - "vertexInfo": {"type": "single", "id": pid.replace(".dev.", "."), "vertexType": "Out"}, - } - for pid in port_pids - } - return InspectApiNodeStatusItem.model_validate( - { - "_id": device_id, - "_vid": device_id, - "deviceId": device_id, - "descriptor": {"desc": "", "label": label}, - "meta": {"coordinates": {"x": 0, "y": 0}}, - "status": {"sa": 0, "severity": 0}, - "syncSeverity": 0, - "modules": {f"{device_id}.dev.0": {"pid": f"{device_id}.dev.0", "ports": ports}}, - } - ) - - -def _edge_pair(dev_a: str, dev_b: str, port_a: str, port_b: str): - edge_id = f"{port_a}::{port_b}" - return InspectApiExternalEdgesByDeviceKeyItem.model_validate( - { - "_id": f"{dev_a}::{dev_b}", - "_vid": f"{dev_a}::{dev_b}", - "primary": { - "devicePid": dev_a, - "label": dev_a, - "data": { - edge_id: { - "id": edge_id, - "fromStatus": {"context": {"devicePid": dev_a, "portPid": port_a}, "label": "out"}, - "toStatus": {"context": {"devicePid": dev_b, "portPid": port_b}, "label": "in"}, - } - }, - }, - "secondary": {"devicePid": dev_b, "label": dev_b, "data": {}}, - "status": {"alarm": 0, "bandwidth": 0, "maintenance": 0, "ptp": 0}, - } - ) - - -def _path_item(booking: str, dev_a: str, dev_b: str): - return InspectApiPathItem.model_validate( - { - "_id": f"{booking}::main", - "_vid": f"_:{booking}::main", - "serviceFields": {"bid": booking, "isMain": True, "fromLabel": "src", "toLabel": "dst"}, - "path": [ - {"bid": booking, "structure": {"deviceId": dev_a, "devicePid": dev_a}}, - {"bid": booking, "structure": {"deviceId": dev_b, "devicePid": dev_b}}, - ], - } - ) - - -class FakeFetcher: - """Records how many detail/section fetches occur so laziness can be asserted.""" - - def __init__(self): - self.device_detail_calls: list[str] = [] - self.edge_pair_calls: list[str] = [] - self.section_calls = 0 - self.skeleton_calls = 0 - self._details = { - "spine-a": _detail_node("spine-a", "SPINE-A", ["spine-a.dev.0.swp1", "spine-a.dev.0.swp2"]), - "leaf-a": _detail_node("leaf-a", "LEAF-A", ["leaf-a.dev.0.up1", "leaf-a.dev.0.host1"]), - } - self._paths = [_path_item("1001", "leaf-a", "spine-a")] - - def get_device_skeleton(self): - self.skeleton_calls += 1 - return [_skeleton_node("spine-a", "SPINE-A"), _skeleton_node("leaf-a", "LEAF-A")] - - def get_edge_skeleton(self): - return [_edge_pair("leaf-a", "spine-a", "leaf-a.dev.0.up1", "spine-a.dev.0.swp1")] - - def get_device_detail(self, device_id): - self.device_detail_calls.append(device_id) - return self._details.get(device_id) - - def get_edge_pair(self, pair_id): - self.edge_pair_calls.append(pair_id) - a, b = pair_id.split("::") - return _edge_pair(a, b, f"{a}.dev.0.up1", f"{b}.dev.0.swp1") - - def get_paths_section(self): - self.section_calls += 1 - return self._paths - - -@pytest.fixture -def snapshot(): - fetcher = FakeFetcher() - snap = InspectSnapshot( - fetcher=fetcher, - device_items=fetcher.get_device_skeleton(), - edge_items=fetcher.get_edge_skeleton(), - ) - fetcher.skeleton_calls = 0 # reset after construction - return snap, fetcher - - -def test_skeleton_indexes_devices_and_edges(snapshot): +def test_skeleton_indexes_devices_and_edges(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot assert {d.id for d in snap.devices} == {"spine-a", "leaf-a"} leaf = snap.get_device("leaf-a") @@ -151,12 +26,12 @@ def test_skeleton_indexes_devices_and_edges(snapshot): assert fetcher.device_detail_calls == [] # nothing hydrated yet -def test_find_by_label(snapshot): +def test_find_by_label(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, _ = snapshot assert snap.find_device_by_label("SPINE-A").id == "spine-a" -def test_ports_trigger_exactly_one_hydration(snapshot): +def test_ports_trigger_exactly_one_hydration(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot leaf = snap.get_device("leaf-a") assert leaf.is_hydrated is False @@ -170,7 +45,7 @@ def test_ports_trigger_exactly_one_hydration(snapshot): assert snap.get_device("spine-a").is_hydrated is False -def test_edges_do_not_trigger_hydration(snapshot): +def test_edges_do_not_trigger_hydration(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot edge = snap.edges[0] assert edge.from_device.id == "leaf-a" @@ -179,7 +54,7 @@ def test_edges_do_not_trigger_hydration(snapshot): assert fetcher.device_detail_calls == [] -def test_edge_from_port_triggers_owning_device_hydration(snapshot): +def test_edge_from_port_triggers_owning_device_hydration(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot edge = snap.edges[0] port = edge.from_port @@ -188,7 +63,7 @@ def test_edge_from_port_triggers_owning_device_hydration(snapshot): assert fetcher.device_detail_calls == ["leaf-a"] -def test_services_section_is_lazy(snapshot): +def test_services_section_is_lazy(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot assert fetcher.section_calls == 0 services = snap.services @@ -198,19 +73,19 @@ def test_services_section_is_lazy(snapshot): assert {d.id for d in snap.get_services_for_device("leaf-a")[0].path_devices} == {"leaf-a", "spine-a"} -def test_linked_devices_from_edges(snapshot): +def test_linked_devices_from_edges(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, _ = snapshot assert {d.id for d in snap.get_device("leaf-a").linked_devices} == {"spine-a"} -def test_preload_hydrates_all(snapshot): +def test_preload_hydrates_all(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot snap.preload() assert set(fetcher.device_detail_calls) == {"spine-a", "leaf-a"} assert snap.get_device("spine-a").is_hydrated -def test_refresh_returns_new_snapshot(snapshot): +def test_refresh_returns_new_snapshot(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot new = snap.refresh() assert new is not snap @@ -218,7 +93,7 @@ def test_refresh_returns_new_snapshot(snapshot): assert {d.id for d in new.devices} == {"spine-a", "leaf-a"} -def test_post_commit_removes_locally_and_refreshes_pair(snapshot): +def test_post_commit_removes_locally_and_refreshes_pair(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot # remove a device locally snap.apply_post_commit(removed_ids=["spine-a"], mark_paths_stale=False) @@ -226,13 +101,13 @@ def test_post_commit_removes_locally_and_refreshes_pair(snapshot): assert {d.id for d in snap.devices} == {"leaf-a"} -def test_post_commit_refreshes_edge_pair(snapshot): +def test_post_commit_refreshes_edge_pair(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot snap.apply_post_commit(pair_ids=["leaf-a::spine-a"]) assert "leaf-a::spine-a" in fetcher.edge_pair_calls -def test_post_commit_marks_paths_stale(snapshot): +def test_post_commit_marks_paths_stale(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot _ = snap.services # load section assert fetcher.section_calls == 1 @@ -241,7 +116,7 @@ def test_post_commit_marks_paths_stale(snapshot): assert fetcher.section_calls == 2 -def test_post_commit_reindexes_changed_label(snapshot): +def test_post_commit_reindexes_changed_label(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: # Latent-bug guard: a committed label change must re-point the label index (ADR-0010). snap, fetcher = snapshot fetcher._details["leaf-a"] = _detail_node("leaf-a", "LEAF-A-RENAMED", ["leaf-a.dev.0.up1"]) @@ -250,12 +125,14 @@ def test_post_commit_reindexes_changed_label(snapshot): assert snap.find_device_by_label("LEAF-A") is None -def test_post_commit_refetch_failure_does_not_raise_and_self_heals(snapshot): +def test_post_commit_refetch_failure_does_not_raise_and_self_heals( + snapshot: tuple[InspectSnapshot, FakeFetcher], +) -> None: snap, fetcher = snapshot calls = {"n": 0} real = fetcher.get_device_detail - def flaky(device_id): + def flaky(device_id: str) -> InspectApiNodeStatusItem | None: calls["n"] += 1 if calls["n"] == 1: raise RuntimeError("network blip") @@ -269,7 +146,7 @@ def flaky(device_id): assert calls["n"] == 2 -def test_apply_network_refresh_adds_new_device_and_edge(snapshot): +def test_apply_network_refresh_adds_new_device_and_edge(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot fetcher._details["leaf-b"] = _detail_node("leaf-b", "LEAF-B", ["leaf-b.dev.0.up1"]) fetcher.get_edge_skeleton = lambda: [ @@ -282,7 +159,7 @@ def test_apply_network_refresh_adds_new_device_and_edge(snapshot): assert {e.pair_id for e in snap.get_edges_for_device("leaf-b")} == {"leaf-b::spine-a"} -def test_full_snapshot_is_hydrated_without_fetcher(): +def test_full_snapshot_is_hydrated_without_fetcher() -> None: fetcher = FakeFetcher() # Build a full snapshot manually (device_level=FULL, path_items provided) snap = InspectSnapshot( @@ -300,3 +177,136 @@ def test_full_snapshot_is_hydrated_without_fetcher(): # refresh without a fetcher raises with pytest.raises(RuntimeError): snap.refresh() + + +# --- Internal --- + + +def _skeleton_node( + device_id: str, + label: str, + x: float = 0.0, + y: float = 0.0, + sync: int = 0, +) -> InspectApiNodeStatusItem: + return InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "_vid": device_id, + "deviceId": device_id, + "descriptor": {"desc": "", "label": label}, + "meta": {"coordinates": {"x": x, "y": y}, "isVirtual": True, "iconType": "switch"}, + "status": {"sa": 0, "severity": 0}, + "syncSeverity": sync, + "tags": ["#e2e"], + "modules": {}, + } + ) + + +def _detail_node(device_id: str, label: str, port_pids: list[str]) -> InspectApiNodeStatusItem: + ports = { + pid: { + "pid": pid, + "descriptor": {"label": pid.split(".")[-1]}, + "status": {"sa": 0, "severity": 0}, + "vertexInfo": {"type": "single", "id": pid.replace(".dev.", "."), "vertexType": "Out"}, + } + for pid in port_pids + } + return InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "_vid": device_id, + "deviceId": device_id, + "descriptor": {"desc": "", "label": label}, + "meta": {"coordinates": {"x": 0, "y": 0}}, + "status": {"sa": 0, "severity": 0}, + "syncSeverity": 0, + "modules": {f"{device_id}.dev.0": {"pid": f"{device_id}.dev.0", "ports": ports}}, + } + ) + + +def _edge_pair(dev_a: str, dev_b: str, port_a: str, port_b: str) -> InspectApiExternalEdgesByDeviceKeyItem: + edge_id = f"{port_a}::{port_b}" + return InspectApiExternalEdgesByDeviceKeyItem.model_validate( + { + "_id": f"{dev_a}::{dev_b}", + "_vid": f"{dev_a}::{dev_b}", + "primary": { + "devicePid": dev_a, + "label": dev_a, + "data": { + edge_id: { + "id": edge_id, + "fromStatus": {"context": {"devicePid": dev_a, "portPid": port_a}, "label": "out"}, + "toStatus": {"context": {"devicePid": dev_b, "portPid": port_b}, "label": "in"}, + } + }, + }, + "secondary": {"devicePid": dev_b, "label": dev_b, "data": {}}, + "status": {"alarm": 0, "bandwidth": 0, "maintenance": 0, "ptp": 0}, + } + ) + + +def _path_item(booking: str, dev_a: str, dev_b: str) -> InspectApiPathItem: + return InspectApiPathItem.model_validate( + { + "_id": f"{booking}::main", + "_vid": f"_:{booking}::main", + "serviceFields": {"bid": booking, "isMain": True, "fromLabel": "src", "toLabel": "dst"}, + "path": [ + {"bid": booking, "structure": {"deviceId": dev_a, "devicePid": dev_a}}, + {"bid": booking, "structure": {"deviceId": dev_b, "devicePid": dev_b}}, + ], + } + ) + + +class FakeFetcher: + """Records how many detail/section fetches occur so laziness can be asserted.""" + + def __init__(self) -> None: + self.device_detail_calls: list[str] = [] + self.edge_pair_calls: list[str] = [] + self.section_calls = 0 + self.skeleton_calls = 0 + self._details = { + "spine-a": _detail_node("spine-a", "SPINE-A", ["spine-a.dev.0.swp1", "spine-a.dev.0.swp2"]), + "leaf-a": _detail_node("leaf-a", "LEAF-A", ["leaf-a.dev.0.up1", "leaf-a.dev.0.host1"]), + } + self._paths = [_path_item("1001", "leaf-a", "spine-a")] + + def get_device_skeleton(self) -> list[InspectApiNodeStatusItem]: + self.skeleton_calls += 1 + return [_skeleton_node("spine-a", "SPINE-A"), _skeleton_node("leaf-a", "LEAF-A")] + + def get_edge_skeleton(self) -> list[InspectApiExternalEdgesByDeviceKeyItem]: + return [_edge_pair("leaf-a", "spine-a", "leaf-a.dev.0.up1", "spine-a.dev.0.swp1")] + + def get_device_detail(self, device_id: str) -> InspectApiNodeStatusItem | None: + self.device_detail_calls.append(device_id) + return self._details.get(device_id) + + def get_edge_pair(self, pair_id: str) -> InspectApiExternalEdgesByDeviceKeyItem: + self.edge_pair_calls.append(pair_id) + a, b = pair_id.split("::") + return _edge_pair(a, b, f"{a}.dev.0.up1", f"{b}.dev.0.swp1") + + def get_paths_section(self) -> list[InspectApiPathItem]: + self.section_calls += 1 + return self._paths + + +@pytest.fixture +def snapshot() -> Iterator[tuple[InspectSnapshot, FakeFetcher]]: + fetcher = FakeFetcher() + snap = InspectSnapshot( + fetcher=fetcher, + device_items=fetcher.get_device_skeleton(), + edge_items=fetcher.get_edge_skeleton(), + ) + fetcher.skeleton_calls = 0 # reset after construction + yield snap, fetcher diff --git a/tests/inspect/test_transaction.py b/tests/inspect/test_transaction.py index fca4e31..87790a9 100644 --- a/tests/inspect/test_transaction.py +++ b/tests/inspect/test_transaction.py @@ -5,7 +5,10 @@ bypass, ``rebase``, and the post-commit targeted-refresh hook derivation. """ +from __future__ import annotations + from types import SimpleNamespace +from typing import Any import pytest @@ -41,103 +44,10 @@ EDGE_ID = f"{A_OUT}::{B_IN}" -def _device_response(label="Dev A", coordinates=None, icon_type="switch", tags=None): - return InspectApiLookupInspectDeviceResponse.model_validate( - { - "data": { - "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, - "fields": { - "coordinates": coordinates or {"x": 0, "y": 0}, - "descriptor": {"desc": "", "label": label}, - "iconSize": "medium", - "iconType": icon_type, - "localAssignedTags": [], - "sdpStrategy": None, - "siteId": None, - "tags": tags or [], - "virtualDeviceFields": None, - }, - }, - "header": _OK_HEADER, - } - ) - - -def _vertex_data(): - fixture = load_fixture("lookup_inspect_vertex_by_id.json") - return InspectApiLookupVertexResponseData.model_validate(fixture["data"]) - - -def _edge_item(weight=1): - edge = { - "active": True, - "bandwidth": -1.0, - "capacity": 65535, - "conflictPri": 0, - "descriptor": {"desc": "", "label": ""}, - "excludeFormats": [], - "fDescriptor": {"desc": "", "label": ""}, - "fromId": A_OUT, - "includeFormats": [], - "redundancyMode": "Any", - "tags": [], - "toId": B_IN, - "weight": weight, - "weightFactors": {"bandwidth": {"weight": 0}, "service": {"max": 100, "weight": 0}}, - } - return InspectApiLookupEdgeResponseItem.model_validate( - {"edge": edge, "fromDevice": "device12", "toDevice": "device7"} - ) - - -class FakeAPI: - def __init__(self): - self.devices = {} - self.vertices = {} - self.edges = {} - self.update_response = InspectApiUpdateTopologyResponse.model_validate( - load_fixture("update_topology_success.json") - ) - self.update_calls = [] - self.lookup_device_calls = [] - self.lookup_vertices_calls = [] - self.lookup_edges_calls = [] - - def lookup_inspect_device(self, device_id): - self.lookup_device_calls.append(device_id) - if device_id not in self.devices: - raise KeyError(device_id) - return self.devices[device_id] - - def lookup_vertices(self, ids): - self.lookup_vertices_calls.append(list(ids)) - return SimpleNamespace(data={i: self.vertices[i] for i in ids if i in self.vertices}) - - def lookup_edges(self, ids): - self.lookup_edges_calls.append(list(ids)) - return SimpleNamespace(data={i: self.edges[i] for i in ids if i in self.edges}) - - def update_topology(self, delta): - self.update_calls.append(delta) - return self.update_response - - -class FakeSnapshot: - def __init__(self): - self.calls = [] - - def apply_post_commit(self, **kwargs): - self.calls.append(kwargs) - - -def _txn(api, snapshot=None): - return InspectTransaction(api, snapshot=snapshot) - - # --- Staging + payload shape --- -def test_connect_builds_edge_payload(): +def test_connect_builds_edge_payload() -> None: api = FakeAPI() with _txn(api) as tx: tx.connect(A_OUT, B_IN, bidirectional=False, weight=10) @@ -149,7 +59,7 @@ def test_connect_builds_edge_payload(): assert edge.weight == 10 and edge.capacity == 65535 and edge.redundancyMode == "Any" -def test_connect_bidirectional_stages_reverse_edge(): +def test_connect_bidirectional_stages_reverse_edge() -> None: api = FakeAPI() with _txn(api) as tx: tx.connect(A_OUT, B_IN, bidirectional=True) @@ -158,7 +68,7 @@ def test_connect_bidirectional_stages_reverse_edge(): assert keys == {EDGE_ID, "device7.0.swp1.out::device12.1.Ethernet1.in"} -def test_connect_existing_edge_requires_overwrite(): +def test_connect_existing_edge_requires_overwrite() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item() with _txn(api) as tx: @@ -166,7 +76,7 @@ def test_connect_existing_edge_requires_overwrite(): tx.connect(A_OUT, B_IN, bidirectional=False) -def test_update_edge_applies_intent(): +def test_update_edge_applies_intent() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item() with _txn(api) as tx: @@ -175,14 +85,14 @@ def test_update_edge_applies_intent(): assert api.update_calls[0].replaceEdges[EDGE_ID].weight == 42 -def test_update_edge_missing_raises_not_found(): +def test_update_edge_missing_raises_not_found() -> None: api = FakeAPI() with _txn(api) as tx: with pytest.raises(InspectEntityNotFoundError): tx.update_edge(EDGE_ID, weight=42) -def test_update_device_only_changes_label_when_set(): +def test_update_device_only_changes_label_when_set() -> None: api = FakeAPI() api.devices["device12"] = _device_response(label="Original", icon_type="switch") with _txn(api) as tx: @@ -193,7 +103,7 @@ def test_update_device_only_changes_label_when_set(): assert form.iconType == "router" -def test_update_device_sets_label(): +def test_update_device_sets_label() -> None: api = FakeAPI() api.devices["device12"] = _device_response(label="Original") with _txn(api) as tx: @@ -202,7 +112,7 @@ def test_update_device_sets_label(): assert api.update_calls[0].replaceDevices["device12"].descriptor.label == "BU-LEAF-A" -def test_place_device_sets_coordinates(): +def test_place_device_sets_coordinates() -> None: api = FakeAPI() api.devices["device12"] = _device_response() with _txn(api) as tx: @@ -211,7 +121,7 @@ def test_place_device_sets_coordinates(): assert api.update_calls[0].replaceDevices["device12"].coordinates == {"x": 1600, "y": 9050} -def test_update_vertex_sets_endpoint(): +def test_update_vertex_sets_endpoint() -> None: api = FakeAPI() api.vertices[A_OUT] = _vertex_data() with _txn(api) as tx: @@ -220,7 +130,7 @@ def test_update_vertex_sets_endpoint(): assert api.update_calls[0].replaceVertices[A_OUT].useAsEndpoint is True -def test_update_vertex_assigns_tags_as_local(): +def test_update_vertex_assigns_tags_as_local() -> None: # Port tag assignment goes to localAssignedTags, not the plain fields.tags list. api = FakeAPI() api.vertices[A_OUT] = _vertex_data() @@ -232,7 +142,7 @@ def test_update_vertex_assigns_tags_as_local(): assert form.tags == [] -def test_remove_appends_to_remove_list(): +def test_remove_appends_to_remove_list() -> None: api = FakeAPI() with _txn(api) as tx: tx.remove(EDGE_ID) @@ -240,7 +150,7 @@ def test_remove_appends_to_remove_list(): assert api.update_calls[0].remove == [EDGE_ID] -def test_disconnect_removes_both_directions(): +def test_disconnect_removes_both_directions() -> None: api = FakeAPI() with _txn(api) as tx: tx.disconnect(A_OUT, B_IN, bidirectional=True) @@ -251,7 +161,7 @@ def test_disconnect_removes_both_directions(): # --- Commit result / failure --- -def test_commit_success_returns_result(): +def test_commit_success_returns_result() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item() with _txn(api) as tx: @@ -261,7 +171,7 @@ def test_commit_success_returns_result(): assert result.applied_ids == [EDGE_ID] -def test_commit_failure_raises_commit_error(): +def test_commit_failure_raises_commit_error() -> None: api = FakeAPI() api.update_response = InspectApiUpdateTopologyResponse.model_validate( load_fixture("update_topology_fail_remove.json") @@ -273,7 +183,7 @@ def test_commit_failure_raises_commit_error(): assert "non-existent" in str(exc.value) -def test_empty_commit_rejected(): +def test_empty_commit_rejected() -> None: api = FakeAPI() tx = _txn(api) with pytest.raises(ValueError, match="Nothing staged"): @@ -283,7 +193,7 @@ def test_empty_commit_rejected(): # --- Conflict detection (ADR-0009) --- -def test_conflict_detected_on_baseline_change(): +def test_conflict_detected_on_baseline_change() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item(weight=1) tx = _txn(api) @@ -298,7 +208,7 @@ def test_conflict_detected_on_baseline_change(): assert not api.update_calls # nothing sent -def test_conflict_check_bypass(): +def test_conflict_check_bypass() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item(weight=1) tx = _txn(api) @@ -308,7 +218,7 @@ def test_conflict_check_bypass(): assert api.update_calls # sent despite the change -def test_conflict_when_entity_vanishes(): +def test_conflict_when_entity_vanishes() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item() tx = _txn(api) @@ -319,7 +229,7 @@ def test_conflict_when_entity_vanishes(): assert "__exists__" in exc.value.conflicts[0].field_diffs -def test_rebase_refetches_baseline(): +def test_rebase_refetches_baseline() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item(weight=1) tx = _txn(api) @@ -335,7 +245,7 @@ def test_rebase_refetches_baseline(): # --- Post-commit refresh hook derivation (ADR-0010) --- -def test_post_commit_refresh_derives_affected_ids(): +def test_post_commit_refresh_derives_affected_ids() -> None: api = FakeAPI() snapshot = FakeSnapshot() with _txn(api, snapshot=snapshot) as tx: @@ -347,7 +257,7 @@ def test_post_commit_refresh_derives_affected_ids(): assert call["mark_paths_stale"] is True -def test_post_commit_refresh_vertex_maps_to_device(): +def test_post_commit_refresh_vertex_maps_to_device() -> None: api = FakeAPI() api.vertices[A_OUT] = _vertex_data() snapshot = FakeSnapshot() @@ -360,7 +270,7 @@ def test_post_commit_refresh_vertex_maps_to_device(): # --- Lifecycle --- -def test_reuse_after_commit_rejected(): +def test_reuse_after_commit_rejected() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item() tx = _txn(api) @@ -370,10 +280,111 @@ def test_reuse_after_commit_rejected(): tx.update_edge(EDGE_ID, weight=6) -def test_context_exit_without_commit_discards(caplog): +def test_context_exit_without_commit_discards(caplog: pytest.LogCaptureFixture) -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item() with _txn(api) as tx: tx.update_edge(EDGE_ID, weight=5) assert not api.update_calls assert tx._discarded + + +# --- Internal --- + + +def _device_response( + label: str = "Dev A", + coordinates: dict[str, int] | None = None, + icon_type: str = "switch", + tags: list[str] | None = None, +) -> InspectApiLookupInspectDeviceResponse: + return InspectApiLookupInspectDeviceResponse.model_validate( + { + "data": { + "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, + "fields": { + "coordinates": coordinates or {"x": 0, "y": 0}, + "descriptor": {"desc": "", "label": label}, + "iconSize": "medium", + "iconType": icon_type, + "localAssignedTags": [], + "sdpStrategy": None, + "siteId": None, + "tags": tags or [], + "virtualDeviceFields": None, + }, + }, + "header": _OK_HEADER, + } + ) + + +def _vertex_data() -> InspectApiLookupVertexResponseData: + fixture = load_fixture("lookup_inspect_vertex_by_id.json") + return InspectApiLookupVertexResponseData.model_validate(fixture["data"]) + + +def _edge_item(weight: int = 1) -> InspectApiLookupEdgeResponseItem: + edge = { + "active": True, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": {"desc": "", "label": ""}, + "excludeFormats": [], + "fDescriptor": {"desc": "", "label": ""}, + "fromId": A_OUT, + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": B_IN, + "weight": weight, + "weightFactors": {"bandwidth": {"weight": 0}, "service": {"max": 100, "weight": 0}}, + } + return InspectApiLookupEdgeResponseItem.model_validate( + {"edge": edge, "fromDevice": "device12", "toDevice": "device7"} + ) + + +class FakeAPI: + def __init__(self) -> None: + self.devices: dict[str, InspectApiLookupInspectDeviceResponse] = {} + self.vertices: dict[str, InspectApiLookupVertexResponseData] = {} + self.edges: dict[str, InspectApiLookupEdgeResponseItem] = {} + self.update_response = InspectApiUpdateTopologyResponse.model_validate( + load_fixture("update_topology_success.json") + ) + self.update_calls: list[Any] = [] + self.lookup_device_calls: list[str] = [] + self.lookup_vertices_calls: list[list[str]] = [] + self.lookup_edges_calls: list[list[str]] = [] + + def lookup_inspect_device(self, device_id: str) -> InspectApiLookupInspectDeviceResponse: + self.lookup_device_calls.append(device_id) + if device_id not in self.devices: + raise KeyError(device_id) + return self.devices[device_id] + + def lookup_vertices(self, ids: list[str]) -> SimpleNamespace: + self.lookup_vertices_calls.append(list(ids)) + return SimpleNamespace(data={i: self.vertices[i] for i in ids if i in self.vertices}) + + def lookup_edges(self, ids: list[str]) -> SimpleNamespace: + self.lookup_edges_calls.append(list(ids)) + return SimpleNamespace(data={i: self.edges[i] for i in ids if i in self.edges}) + + def update_topology(self, delta: Any) -> InspectApiUpdateTopologyResponse: + self.update_calls.append(delta) + return self.update_response + + +class FakeSnapshot: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def apply_post_commit(self, **kwargs: Any) -> None: + self.calls.append(kwargs) + + +def _txn(api: FakeAPI, snapshot: FakeSnapshot | None = None) -> InspectTransaction: + return InspectTransaction(api, snapshot=snapshot)