lvi-api-client 0.2.6__tar.gz → 0.2.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lvi-api-client
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: LogicVein API Client Library
5
5
  Requires-Python: >=3.8
6
6
  Description-Content-Type: text/markdown
@@ -0,0 +1 @@
1
+ 0.2.7
@@ -1840,7 +1840,8 @@ class LogicVeinAPIClient:
1840
1840
  "summary": str - Incident summary,
1841
1841
  "severity": str - "INFO", "WARNING", "ERROR", or "CRITICAL",
1842
1842
  "priority": str - "LOW", "MEDIUM", "HIGH", or "CRITICAL",
1843
- "status": str - "OPEN", "WORKING", or "CLOSED",
1843
+ "status": str - "OPEN", "RESOLVED" or "DEFERRED" seen on a server; whether
1844
+ "WORKING" or "CLOSED" exist has not been measured,
1844
1845
  "resolution": str - "PENDING" or "RESOLVED",
1845
1846
  "assignee": str - Assigned username (empty string if unassigned),
1846
1847
  "labels": list[str] - Label tags,
@@ -1875,14 +1876,15 @@ class LogicVeinAPIClient:
1875
1876
  # Incidents
1876
1877
  # =====================================================
1877
1878
 
1878
- def get_incident_by_id(self, incident_id: int) -> Dict[str, Any]:
1879
+ def get_incident_by_id(self, incident_id: int) -> Optional[Dict[str, Any]]:
1879
1880
  """Get an incident by its ID from the LogicVein server.
1880
1881
 
1881
1882
  Args:
1882
1883
  incident_id (int): The ID of the incident.
1883
1884
 
1884
1885
  Returns:
1885
- Dict[str, Any]: The incident if it exists, otherwise an empty dictionary.
1886
+ Dict[str, Any]: The incident, or None for an unknown ID, which the server answers with
1887
+ a null result. An empty dictionary only when the server gives no result at all.
1886
1888
  """
1887
1889
  response = self.call("Incidents.getIncidentById", incidentId=incident_id)
1888
1890
 
@@ -2549,6 +2551,8 @@ class LogicVeinAPIClient:
2549
2551
  def delete_managed_network(self, network_name: str) -> bool:
2550
2552
  """Delete a managed network from the LogicVein server.
2551
2553
 
2554
+ An alert policy whose only network this is goes with it.
2555
+
2552
2556
  Args:
2553
2557
  network_name (str): The name of the managed network to delete.
2554
2558
 
@@ -3648,6 +3652,8 @@ class LogicVeinAPIClient:
3648
3652
  """Get actions configured for an alert policy.
3649
3653
 
3650
3654
  Calls Collection.getActionsForPolicy. Returns the result list directly (not paginated).
3655
+ Settings come back as stored, secrets included: an exec action's ``password`` and a
3656
+ trap action's ``community`` are not masked, and the web UI sends them back unchanged.
3651
3657
  Each action has:
3652
3658
  {
3653
3659
  "actionId": int - Action ID,
@@ -3677,6 +3683,10 @@ class LogicVeinAPIClient:
3677
3683
  Returns the saved actions with assigned actionIds (new actions use -1 as input id,
3678
3684
  which is replaced with the assigned ID in the response).
3679
3685
 
3686
+ The saved actions **replace** all of the policy's actions; they are not added to them.
3687
+ An empty list does nothing, so it cannot clear a policy's actions. ``username`` is
3688
+ filled in with the caller's when left out.
3689
+
3680
3690
  Each action object should have:
3681
3691
  {
3682
3692
  "actionId": int - Use -1 for new actions,
@@ -4288,6 +4298,33 @@ class LogicVeinAPIClient:
4288
4298
  Returns:
4289
4299
  List[Dict[str, Any]]: All matching alert policies.
4290
4300
  """
4301
+ return self._search_alert_policies(managed_networks, queries, sort_column, descending, page_size, strict=False)
4302
+
4303
+ def _search_alert_policies(
4304
+ self,
4305
+ managed_networks: List[str],
4306
+ queries: List[str],
4307
+ sort_column: str,
4308
+ descending: bool,
4309
+ page_size: int,
4310
+ strict: bool,
4311
+ ) -> List[Dict[str, Any]]:
4312
+ """Read every page of alert policies.
4313
+
4314
+ Args:
4315
+ managed_networks (List[str]): Networks to search. Empty for all.
4316
+ queries (List[str]): Query filters.
4317
+ sort_column (str): Column to sort by.
4318
+ descending (bool): Sort descending if True.
4319
+ page_size (int): Records per API call.
4320
+ strict (bool): Raise on an answer with no policies list, rather than stopping there.
4321
+
4322
+ Returns:
4323
+ List[Dict[str, Any]]: All matching alert policies.
4324
+
4325
+ Raises:
4326
+ ValueError: If ``strict`` and an answer has no policies list.
4327
+ """
4291
4328
  all_policies: List[Dict[str, Any]] = []
4292
4329
  offset = 0
4293
4330
 
@@ -4306,8 +4343,11 @@ class LogicVeinAPIClient:
4306
4343
  }
4307
4344
  response = self.call("Collection.searchAlertPolicies", **params)
4308
4345
 
4309
- if response and isinstance(response, dict) and "result" in response and "policies" in response["result"]:
4310
- batch = response["result"]["policies"]
4346
+ result = response.get("result") if isinstance(response, dict) else None
4347
+ if isinstance(result, dict) and isinstance(result.get("policies"), list):
4348
+ batch = result["policies"]
4349
+ elif strict:
4350
+ raise ValueError("Collection.searchAlertPolicies did not answer with a list of policies")
4311
4351
  else:
4312
4352
  break
4313
4353
 
@@ -4325,6 +4365,12 @@ class LogicVeinAPIClient:
4325
4365
 
4326
4366
  Calls Collection.saveAlertPolicy. The response mirrors the input policy object.
4327
4367
  Use policyId=-1 (or omit) to create a new policy; provide an existing policyId to update.
4368
+ The result carries the new policy's ID.
4369
+
4370
+ Saving an existing policy **removes all its actions**, although ``actionTypes`` still
4371
+ lists them: save them again with :meth:`save_alert_actions`, as the web UI does. Saving
4372
+ a name that is already in use makes a second policy of that name; check with
4373
+ :meth:`is_alert_policy_name_unique` first.
4328
4374
 
4329
4375
  The policy object has:
4330
4376
  {
@@ -4347,6 +4393,89 @@ class LogicVeinAPIClient:
4347
4393
  return response["result"]
4348
4394
  return None
4349
4395
 
4396
+ def delete_alert_policy(self, policy_id: int, reassign_policy_id: int = -1) -> bool:
4397
+ """Delete an alert policy, with its actions.
4398
+
4399
+ Calls Collection.deleteAlertPolicy, with the parameters the web UI sends. The server
4400
+ refuses ``policyId`` alone. The UI asks first whether the policy is referenced
4401
+ (:meth:`is_alert_policy_referenced`); what the server does with the references of a
4402
+ referenced policy deleted with ``reassign_policy_id`` -1 has not been measured.
4403
+
4404
+ The server's answer is not relied on: the policies are read back afterwards. An answer
4405
+ that cannot show whether the policy is gone raises rather than reading as deleted, and
4406
+ errors from either call (``LogicVeinAPIError``, ``requests`` errors) propagate.
4407
+
4408
+ Args:
4409
+ policy_id (int): The ID of the policy to delete.
4410
+ reassign_policy_id (int): The policy to move its references to, or -1 for none.
4411
+
4412
+ Returns:
4413
+ bool: True if the policy is gone afterwards.
4414
+
4415
+ Raises:
4416
+ ValueError: If the read-back answer has no list of policies, or a policy without an ID.
4417
+ """
4418
+ self.call("Collection.deleteAlertPolicy", policyId=policy_id, reassignPolicyId=reassign_policy_id)
4419
+ remaining = self._search_alert_policies([], [], "policyName", False, 500, strict=True)
4420
+ ids = [policy.get("policyId") if isinstance(policy, dict) else None for policy in remaining]
4421
+ if not all(isinstance(found, int) for found in ids):
4422
+ raise ValueError("Collection.searchAlertPolicies answered with a policy that has no ID")
4423
+ return policy_id not in ids
4424
+
4425
+ def is_alert_policy_referenced(self, policy_id: int) -> bool:
4426
+ """Check whether anything refers to an alert policy.
4427
+
4428
+ Calls Collection.isAlertPolicyReferenced, which the web UI calls before deleting one.
4429
+ Monitor triggers name the policy they alert through, but they are not the only
4430
+ references counted: on the lab, a policy no trigger names also answered true. An
4431
+ unknown ID answers false.
4432
+
4433
+ Args:
4434
+ policy_id (int): The ID of the policy.
4435
+
4436
+ Returns:
4437
+ bool: True if the policy is referenced.
4438
+
4439
+ Raises:
4440
+ ValueError: If the server's answer is not true or false.
4441
+ """
4442
+ response = self.call("Collection.isAlertPolicyReferenced", policyId=policy_id)
4443
+ referenced = response.get("result") if isinstance(response, dict) else None
4444
+ if not isinstance(referenced, bool):
4445
+ raise ValueError(f"Collection.isAlertPolicyReferenced did not answer true or false for {policy_id}")
4446
+ return referenced
4447
+
4448
+ def is_alert_policy_name_unique(
4449
+ self, policy_name: str, managed_networks: List[str], ignore_policy_id: int = -1
4450
+ ) -> bool:
4451
+ """Check whether an alert policy name is free in some networks.
4452
+
4453
+ Calls Collection.isAlertPolicyNameUnique, with the parameters the web UI sends as a
4454
+ name is typed. :meth:`save_alert_policy` does not check: it saves a second policy of
4455
+ a name in use.
4456
+
4457
+ Args:
4458
+ policy_name (str): The name to check.
4459
+ managed_networks (List[str]): The networks to check it in.
4460
+ ignore_policy_id (int): A policy to leave out, such as the one being renamed. -1 for none.
4461
+
4462
+ Returns:
4463
+ bool: True if the name is free.
4464
+
4465
+ Raises:
4466
+ ValueError: If the server's answer is not true or false.
4467
+ """
4468
+ response = self.call(
4469
+ "Collection.isAlertPolicyNameUnique",
4470
+ policyName=policy_name,
4471
+ managedNetworks=managed_networks,
4472
+ ignorePolicyId=ignore_policy_id,
4473
+ )
4474
+ unique = response.get("result") if isinstance(response, dict) else None
4475
+ if not isinstance(unique, bool):
4476
+ raise ValueError(f"Collection.isAlertPolicyNameUnique did not answer true or false for {policy_name}")
4477
+ return unique
4478
+
4350
4479
  # =====================================================
4351
4480
  # Maps / Topology
4352
4481
  # =====================================================
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lvi-api-client
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: LogicVein API Client Library
5
5
  Requires-Python: >=3.8
6
6
  Description-Content-Type: text/markdown
@@ -9,5 +9,6 @@ src/lvi_api_client.egg-info/SOURCES.txt
9
9
  src/lvi_api_client.egg-info/dependency_links.txt
10
10
  src/lvi_api_client.egg-info/requires.txt
11
11
  src/lvi_api_client.egg-info/top_level.txt
12
+ tests/test_check_version_bump.py
12
13
  tests/test_lvi_api_client.py
13
14
  tests/test_server_request_shapes.py
@@ -0,0 +1,61 @@
1
+ """Tests for scripts/ci/check_version_bump.py."""
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ _PATH = Path(__file__).resolve().parent.parent / "scripts" / "ci" / "check_version_bump.py"
9
+ _SPEC = importlib.util.spec_from_file_location("check_version_bump", _PATH)
10
+ check = importlib.util.module_from_spec(_SPEC)
11
+ _SPEC.loader.exec_module(check)
12
+
13
+ LOG = "# Changelog\n\n## [0.2.7] - 2026-11-08\n\n### Fixed\n- Something.\n\n## [0.2.6] - 2026-11-07\n"
14
+
15
+
16
+ def test_a_heading_for_the_version_passes():
17
+ assert check.changelog_problem(LOG, "0.2.7") == ""
18
+
19
+
20
+ @pytest.mark.parametrize(
21
+ "changelog",
22
+ [
23
+ "## [Unreleased]\n\n### Fixed\n- Something.\n\n## [0.2.6] - 2026-11-07\n",
24
+ "Fixed in [0.2.7], see below.\n## [0.2.6]\n",
25
+ "## [0.2.70] - 2026-11-08\n",
26
+ ],
27
+ ids=["only unreleased", "not a heading", "another version"],
28
+ )
29
+ def test_no_heading_for_the_version_fails(changelog):
30
+ assert "no '## [0.2.7]' heading" in check.changelog_problem(changelog, "0.2.7")
31
+
32
+
33
+ def _run(monkeypatch, tmp_path, changed, version, changelog, base_version="0.2.6"):
34
+ (tmp_path / "VERSION").write_text(version + "\n")
35
+ (tmp_path / "CHANGELOG.md").write_text(changelog)
36
+ monkeypatch.setattr(check, "REPO_ROOT", tmp_path)
37
+
38
+ def git(*args):
39
+ return "\n".join(changed) if args[0] == "diff" else base_version + "\n"
40
+
41
+ monkeypatch.setattr(check, "_git", git)
42
+ monkeypatch.setattr("sys.argv", ["check_version_bump.py", "--base", "abc123"])
43
+ return check.main()
44
+
45
+
46
+ def test_a_bump_with_entries_left_under_unreleased_fails(monkeypatch, tmp_path, capsys):
47
+ changelog = "## [Unreleased]\n\n### Fixed\n- Something.\n"
48
+ assert _run(monkeypatch, tmp_path, ["src/x.py", "VERSION", "CHANGELOG.md"], "0.2.7", changelog) == 1
49
+ assert "no '## [0.2.7]' heading" in capsys.readouterr().out
50
+
51
+
52
+ def test_a_bump_without_a_src_change_still_needs_the_heading(monkeypatch, tmp_path):
53
+ assert _run(monkeypatch, tmp_path, ["VERSION"], "0.2.7", "## [0.2.6]\n") == 1
54
+
55
+
56
+ def test_a_bump_with_its_heading_passes(monkeypatch, tmp_path):
57
+ assert _run(monkeypatch, tmp_path, ["src/x.py", "VERSION", "CHANGELOG.md"], "0.2.7", LOG) == 0
58
+
59
+
60
+ def test_a_docs_change_needs_nothing(monkeypatch, tmp_path):
61
+ assert _run(monkeypatch, tmp_path, ["CHANGELOG.md", "README.md"], "0.2.6", "## [Unreleased]\n") == 0
@@ -620,3 +620,70 @@ class TestDeployingASet:
620
620
  with pytest.raises(ValueError):
621
621
  client.associate_monitor_set(2, queries)
622
622
  assert server.requests == []
623
+
624
+
625
+ class TestAlertPolicies:
626
+ """Deleting alert policies and the checks the web UI makes around saving and deleting them."""
627
+
628
+ def test_a_delete_sends_what_the_ui_sends_and_reads_the_policies_back(self, connect):
629
+ client, server = connect(
630
+ {
631
+ "Collection.deleteAlertPolicy": None,
632
+ "Collection.searchAlertPolicies": {"policies": [{"policyId": 1, "policyName": "kept"}]},
633
+ }
634
+ )
635
+ assert client.delete_alert_policy(65) is True
636
+ assert server.sent("Collection.deleteAlertPolicy") == {"policyId": 65, "reassignPolicyId": -1}
637
+ assert server.sent("Collection.searchAlertPolicies")["managedNetworks"] == []
638
+
639
+ def test_a_policy_still_listed_after_a_delete_is_not_deleted(self, connect):
640
+ client, _ = connect(
641
+ {
642
+ "Collection.deleteAlertPolicy": True,
643
+ "Collection.searchAlertPolicies": {"policies": [{"policyId": 65, "policyName": "p"}]},
644
+ }
645
+ )
646
+ assert client.delete_alert_policy(65, reassign_policy_id=1) is False
647
+
648
+ @pytest.mark.parametrize(
649
+ "answer", [None, {}, {"policies": None}, {"policies": [{}]}, {"policies": [{"policyId": None}, "x"]}]
650
+ )
651
+ def test_a_failed_read_back_raises_rather_than_reading_as_deleted(self, connect, answer):
652
+ client, _ = connect({"Collection.deleteAlertPolicy": None, "Collection.searchAlertPolicies": answer})
653
+ with pytest.raises(ValueError):
654
+ client.delete_alert_policy(65)
655
+
656
+ def test_a_failed_search_still_reads_as_no_policies(self, connect):
657
+ client, _ = connect({"Collection.searchAlertPolicies": None})
658
+ assert client.search_alert_policies() == []
659
+
660
+ @pytest.mark.parametrize("answer", [True, False])
661
+ def test_a_reference_check_sends_the_id_and_returns_the_answer(self, connect, answer):
662
+ client, server = connect({"Collection.isAlertPolicyReferenced": answer})
663
+ assert client.is_alert_policy_referenced(27) is answer
664
+ assert server.sent("Collection.isAlertPolicyReferenced") == {"policyId": 27}
665
+
666
+ def test_a_reference_check_with_no_answer_raises_rather_than_reading_as_unreferenced(self, connect):
667
+ client, _ = connect({"Collection.isAlertPolicyReferenced": None})
668
+ with pytest.raises(ValueError):
669
+ client.is_alert_policy_referenced(27)
670
+
671
+ @pytest.mark.parametrize("answer", [True, False])
672
+ def test_a_name_check_sends_what_the_ui_sends_and_returns_the_answer(self, connect, answer):
673
+ client, server = connect({"Collection.isAlertPolicyNameUnique": answer})
674
+ assert client.is_alert_policy_name_unique("p1", ["Default"], ignore_policy_id=1) is answer
675
+ assert server.sent("Collection.isAlertPolicyNameUnique") == {
676
+ "policyName": "p1",
677
+ "managedNetworks": ["Default"],
678
+ "ignorePolicyId": 1,
679
+ }
680
+
681
+ def test_a_name_check_ignores_no_policy_by_default(self, connect):
682
+ client, server = connect({"Collection.isAlertPolicyNameUnique": True})
683
+ client.is_alert_policy_name_unique("p1", ["Default"])
684
+ assert server.sent("Collection.isAlertPolicyNameUnique")["ignorePolicyId"] == -1
685
+
686
+ def test_a_name_check_with_no_answer_raises_rather_than_reading_as_taken(self, connect):
687
+ client, _ = connect({"Collection.isAlertPolicyNameUnique": None})
688
+ with pytest.raises(ValueError):
689
+ client.is_alert_policy_name_unique("p1", ["Default"])
@@ -1 +0,0 @@
1
- 0.2.6
File without changes
File without changes
File without changes