lvi-api-client 0.2.4__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.
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/PKG-INFO +1 -1
- lvi_api_client-0.2.7/VERSION +1 -0
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/src/logicvein/lvi_api_client.py +157 -20
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/src/lvi_api_client.egg-info/PKG-INFO +1 -1
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/src/lvi_api_client.egg-info/SOURCES.txt +1 -0
- lvi_api_client-0.2.7/tests/test_check_version_bump.py +61 -0
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/tests/test_lvi_api_client.py +60 -28
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/tests/test_server_request_shapes.py +67 -0
- lvi_api_client-0.2.4/VERSION +0 -1
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/LICENSE +0 -0
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/README.md +0 -0
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/pyproject.toml +0 -0
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/setup.cfg +0 -0
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/src/logicvein/__init__.py +0 -0
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/src/lvi_api_client.egg-info/dependency_links.txt +0 -0
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/src/lvi_api_client.egg-info/requires.txt +0 -0
- {lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/src/lvi_api_client.egg-info/top_level.txt +0 -0
|
@@ -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", "
|
|
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
|
|
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
|
|
|
@@ -3003,6 +3007,7 @@ class LogicVeinAPIClient:
|
|
|
3003
3007
|
commands (str): CLI commands to run, one per line.
|
|
3004
3008
|
job_name (str): Display name for the job.
|
|
3005
3009
|
backup_on_completion (bool): If True, LVI backs up the device after the job runs.
|
|
3010
|
+
description (str): The job's description. Empty uses ``job_name``.
|
|
3006
3011
|
|
|
3007
3012
|
Returns:
|
|
3008
3013
|
Dict[str, Any]: The saved job record (contains ``jobId``), or None on failure.
|
|
@@ -3011,7 +3016,7 @@ class LogicVeinAPIClient:
|
|
|
3011
3016
|
{
|
|
3012
3017
|
"jobId": 0,
|
|
3013
3018
|
"jobName": job_name,
|
|
3014
|
-
"description": description or
|
|
3019
|
+
"description": description or job_name,
|
|
3015
3020
|
"managedNetworks": [network],
|
|
3016
3021
|
"jobType": "Script Tool Job",
|
|
3017
3022
|
"subType": "org.ziptie.tools.scripts.commandRunner",
|
|
@@ -3052,6 +3057,8 @@ class LogicVeinAPIClient:
|
|
|
3052
3057
|
network (str): LVI managed network the device belongs to.
|
|
3053
3058
|
commands (str): CLI commands to run, one per line.
|
|
3054
3059
|
job_name (str): Display name for the job in the LVI scheduler.
|
|
3060
|
+
backup_on_completion (bool): If True, LVI backs up the device after the job runs.
|
|
3061
|
+
description (str): The job's description. Empty uses ``job_name``.
|
|
3055
3062
|
|
|
3056
3063
|
Returns:
|
|
3057
3064
|
Dict[str, Any]: The job execution result dict from LVI (contains ``id``
|
|
@@ -3071,7 +3078,7 @@ class LogicVeinAPIClient:
|
|
|
3071
3078
|
"tool": "org.ziptie.tools.scripts.commandRunner",
|
|
3072
3079
|
"backupOnCompletion": "true" if backup_on_completion else "false",
|
|
3073
3080
|
},
|
|
3074
|
-
description=description or
|
|
3081
|
+
description=description or job_name,
|
|
3075
3082
|
)
|
|
3076
3083
|
|
|
3077
3084
|
# =====================================================
|
|
@@ -3645,6 +3652,8 @@ class LogicVeinAPIClient:
|
|
|
3645
3652
|
"""Get actions configured for an alert policy.
|
|
3646
3653
|
|
|
3647
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.
|
|
3648
3657
|
Each action has:
|
|
3649
3658
|
{
|
|
3650
3659
|
"actionId": int - Action ID,
|
|
@@ -3674,6 +3683,10 @@ class LogicVeinAPIClient:
|
|
|
3674
3683
|
Returns the saved actions with assigned actionIds (new actions use -1 as input id,
|
|
3675
3684
|
which is replaced with the assigned ID in the response).
|
|
3676
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
|
+
|
|
3677
3690
|
Each action object should have:
|
|
3678
3691
|
{
|
|
3679
3692
|
"actionId": int - Use -1 for new actions,
|
|
@@ -4285,6 +4298,33 @@ class LogicVeinAPIClient:
|
|
|
4285
4298
|
Returns:
|
|
4286
4299
|
List[Dict[str, Any]]: All matching alert policies.
|
|
4287
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
|
+
"""
|
|
4288
4328
|
all_policies: List[Dict[str, Any]] = []
|
|
4289
4329
|
offset = 0
|
|
4290
4330
|
|
|
@@ -4303,8 +4343,11 @@ class LogicVeinAPIClient:
|
|
|
4303
4343
|
}
|
|
4304
4344
|
response = self.call("Collection.searchAlertPolicies", **params)
|
|
4305
4345
|
|
|
4306
|
-
|
|
4307
|
-
|
|
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")
|
|
4308
4351
|
else:
|
|
4309
4352
|
break
|
|
4310
4353
|
|
|
@@ -4322,6 +4365,12 @@ class LogicVeinAPIClient:
|
|
|
4322
4365
|
|
|
4323
4366
|
Calls Collection.saveAlertPolicy. The response mirrors the input policy object.
|
|
4324
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.
|
|
4325
4374
|
|
|
4326
4375
|
The policy object has:
|
|
4327
4376
|
{
|
|
@@ -4344,6 +4393,89 @@ class LogicVeinAPIClient:
|
|
|
4344
4393
|
return response["result"]
|
|
4345
4394
|
return None
|
|
4346
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
|
+
|
|
4347
4479
|
# =====================================================
|
|
4348
4480
|
# Maps / Topology
|
|
4349
4481
|
# =====================================================
|
|
@@ -4620,27 +4752,32 @@ class LogicVeinAPIClient:
|
|
|
4620
4752
|
where ``recordId`` is the ``id`` field from a ``getExecutionDetails``
|
|
4621
4753
|
record.
|
|
4622
4754
|
|
|
4755
|
+
The server finds the output by ``recordId`` alone: a record of another
|
|
4756
|
+
execution comes back as long as the execution ID exists.
|
|
4757
|
+
|
|
4623
4758
|
Args:
|
|
4624
4759
|
execution_id: The execution ID (``executionId`` from the job result).
|
|
4625
4760
|
record_id: The per-device record ID (``id`` from ``getExecutionDetails``).
|
|
4626
4761
|
|
|
4627
4762
|
Returns:
|
|
4628
|
-
The raw output text
|
|
4763
|
+
The raw output text.
|
|
4764
|
+
|
|
4765
|
+
Raises:
|
|
4766
|
+
requests.HTTPError: If the server refuses the request, for example
|
|
4767
|
+
401 for a bad token, 404 for an unknown execution or 500 for a
|
|
4768
|
+
malformed ID.
|
|
4769
|
+
ValueError: If the server has no record with that ID. It answers
|
|
4770
|
+
200 with an empty body and no content type then, which is not
|
|
4771
|
+
an empty output: a Command Runner output holds at least the
|
|
4772
|
+
command and the prompt.
|
|
4629
4773
|
"""
|
|
4630
4774
|
url = f"https://{self.host}/servlet/pluginDetail"
|
|
4631
4775
|
params = {"executionId": execution_id, "recordId": record_id}
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
logger.warning(
|
|
4638
|
-
"get_execution_output failed for executionId=%s recordId=%s: %s",
|
|
4639
|
-
execution_id,
|
|
4640
|
-
record_id,
|
|
4641
|
-
exc,
|
|
4642
|
-
)
|
|
4643
|
-
return ""
|
|
4776
|
+
response = self.lvi_client.get(url, params=params, timeout=self.timeout)
|
|
4777
|
+
response.raise_for_status()
|
|
4778
|
+
if not response.content and not response.headers.get("Content-Type"):
|
|
4779
|
+
raise ValueError(f"No output record {record_id} in execution {execution_id}")
|
|
4780
|
+
return response.text
|
|
4644
4781
|
|
|
4645
4782
|
# =====================================================
|
|
4646
4783
|
# ZeroTouch
|
|
@@ -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
|
|
@@ -5048,7 +5048,7 @@ class TestCreateCommandRunnerJob:
|
|
|
5048
5048
|
|
|
5049
5049
|
|
|
5050
5050
|
class TestGetExecutionOutput:
|
|
5051
|
-
"""Tests for get_execution_output
|
|
5051
|
+
"""Tests for get_execution_output: the text on success, and an error for every failure."""
|
|
5052
5052
|
|
|
5053
5053
|
@pytest.fixture
|
|
5054
5054
|
def client(self, monkeypatch):
|
|
@@ -5057,39 +5057,50 @@ class TestGetExecutionOutput:
|
|
|
5057
5057
|
with patch("src.logicvein.lvi_api_client.requests.Session"):
|
|
5058
5058
|
return LogicVeinAPIClient()
|
|
5059
5059
|
|
|
5060
|
+
@staticmethod
|
|
5061
|
+
def answer(status, body=b"", content_type=None):
|
|
5062
|
+
"""A real requests.Response, shaped like the lab server's answers (issue #6)."""
|
|
5063
|
+
response = requests.Response()
|
|
5064
|
+
response.status_code = status
|
|
5065
|
+
response._content = body
|
|
5066
|
+
response.encoding = "utf-8"
|
|
5067
|
+
if content_type:
|
|
5068
|
+
response.headers["Content-Type"] = content_type
|
|
5069
|
+
response.url = "https://test.example.com/servlet/pluginDetail"
|
|
5070
|
+
return response
|
|
5071
|
+
|
|
5060
5072
|
def test_get_execution_output_success(self, client):
|
|
5061
|
-
"""get_execution_output returns
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
mock_get_response.text = "show version output\nCisco IOS Version 15.1"
|
|
5065
|
-
client.lvi_client.get = Mock(return_value=mock_get_response)
|
|
5073
|
+
"""get_execution_output returns the text, and asks for the execution and record."""
|
|
5074
|
+
output = b"show clock\r\n16:57:34.919 UTC Sat Nov 7 2026\r\nc1800#"
|
|
5075
|
+
client.lvi_client.get = Mock(return_value=self.answer(200, output, "text/plain;charset=utf-8"))
|
|
5066
5076
|
|
|
5067
5077
|
result = client.get_execution_output(execution_id=42, record_id=7)
|
|
5068
5078
|
|
|
5069
|
-
assert result ==
|
|
5070
|
-
client.lvi_client.get.
|
|
5071
|
-
call_args = client.lvi_client.get.call_args
|
|
5072
|
-
assert "executionId" in call_args.kwargs["params"]
|
|
5073
|
-
assert call_args.kwargs["params"]["executionId"] == 42
|
|
5074
|
-
assert call_args.kwargs["params"]["recordId"] == 7
|
|
5079
|
+
assert result == output.decode()
|
|
5080
|
+
assert client.lvi_client.get.call_args.kwargs["params"] == {"executionId": 42, "recordId": 7}
|
|
5075
5081
|
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
client.lvi_client.get = Mock(return_value=
|
|
5082
|
+
@pytest.mark.parametrize("status", [401, 404, 500])
|
|
5083
|
+
def test_get_execution_output_http_error_raises(self, client, status):
|
|
5084
|
+
"""A refused read raises instead of reading as an empty output (issue #6)."""
|
|
5085
|
+
page = b"<!doctype html><html></html>"
|
|
5086
|
+
client.lvi_client.get = Mock(return_value=self.answer(status, page, "text/html;charset=utf-8"))
|
|
5081
5087
|
|
|
5082
|
-
|
|
5088
|
+
with pytest.raises(requests.HTTPError):
|
|
5089
|
+
client.get_execution_output(execution_id=42, record_id=7)
|
|
5083
5090
|
|
|
5084
|
-
|
|
5091
|
+
def test_get_execution_output_unknown_record_raises(self, client):
|
|
5092
|
+
"""The server answers an unknown record with 200, no body and no content type (issue #6)."""
|
|
5093
|
+
client.lvi_client.get = Mock(return_value=self.answer(200))
|
|
5085
5094
|
|
|
5086
|
-
|
|
5087
|
-
|
|
5088
|
-
client.lvi_client.get = Mock(side_effect=requests.exceptions.ConnectionError("refused"))
|
|
5095
|
+
with pytest.raises(ValueError, match="No output record 7 in execution 42"):
|
|
5096
|
+
client.get_execution_output(execution_id=42, record_id=7)
|
|
5089
5097
|
|
|
5090
|
-
|
|
5098
|
+
def test_get_execution_output_connection_error_raises(self, client):
|
|
5099
|
+
"""A transport failure propagates instead of reading as an empty output (issue #6)."""
|
|
5100
|
+
client.lvi_client.get = Mock(side_effect=requests.exceptions.ConnectionError("refused"))
|
|
5091
5101
|
|
|
5092
|
-
|
|
5102
|
+
with pytest.raises(requests.exceptions.ConnectionError):
|
|
5103
|
+
client.get_execution_output(execution_id=99, record_id=1)
|
|
5093
5104
|
|
|
5094
5105
|
|
|
5095
5106
|
class TestRunCommandRunnerJob:
|
|
@@ -5121,6 +5132,27 @@ class TestRunCommandRunnerJob:
|
|
|
5121
5132
|
assert result["executionId"] == "exec-77"
|
|
5122
5133
|
client.lvi_client.post.assert_called_once()
|
|
5123
5134
|
|
|
5135
|
+
def test_run_command_runner_job_describes_the_job_by_its_name(self, client, mock_response):
|
|
5136
|
+
"""An empty description uses the job name, not "Job scheduled by ServiceNow" (issue #5)."""
|
|
5137
|
+
mock_response.json.return_value = {"jsonrpc": "2.0", "id": "t", "result": {"executionId": 1}}
|
|
5138
|
+
client.lvi_client.post = Mock(return_value=mock_response)
|
|
5139
|
+
|
|
5140
|
+
client.run_command_runner_job("10.1.1.1", "Default", "show version", job_name="NTP check")
|
|
5141
|
+
|
|
5142
|
+
body = json.loads(client.lvi_client.post.call_args.kwargs["data"])
|
|
5143
|
+
assert body["method"] == "Scheduler.runNow"
|
|
5144
|
+
assert body["params"]["jobData"]["description"] == "NTP check"
|
|
5145
|
+
|
|
5146
|
+
def test_run_command_runner_job_keeps_a_given_description(self, client, mock_response):
|
|
5147
|
+
"""A description the caller gives is sent as it is."""
|
|
5148
|
+
mock_response.json.return_value = {"jsonrpc": "2.0", "id": "t", "result": {"executionId": 1}}
|
|
5149
|
+
client.lvi_client.post = Mock(return_value=mock_response)
|
|
5150
|
+
|
|
5151
|
+
client.run_command_runner_job("10.1.1.1", "Default", "show version", description="Change 42")
|
|
5152
|
+
|
|
5153
|
+
body = json.loads(client.lvi_client.post.call_args.kwargs["data"])
|
|
5154
|
+
assert body["params"]["jobData"]["description"] == "Change 42"
|
|
5155
|
+
|
|
5124
5156
|
def test_run_command_runner_job_with_backup(self, client, mock_response):
|
|
5125
5157
|
"""run_command_runner_job passes backup_on_completion=True in job parameters."""
|
|
5126
5158
|
mock_response.json.return_value = {
|
|
@@ -5201,15 +5233,15 @@ class TestCreateCommandRunnerJobBackupAndDescription:
|
|
|
5201
5233
|
body = json.loads(client.lvi_client.post.call_args.kwargs["data"])
|
|
5202
5234
|
assert body["params"]["jobData"]["description"] == "My custom description"
|
|
5203
5235
|
|
|
5204
|
-
def
|
|
5205
|
-
"""create_command_runner_job
|
|
5236
|
+
def test_empty_description_defaults_to_the_job_name(self, client, mock_response):
|
|
5237
|
+
"""create_command_runner_job describes the job by its name, not by any one caller (issue #5)."""
|
|
5206
5238
|
mock_response.json.return_value = {"jsonrpc": "2.0", "id": "t", "result": {"jobId": 12}}
|
|
5207
5239
|
client.lvi_client.post = Mock(return_value=mock_response)
|
|
5208
5240
|
|
|
5209
|
-
client.create_command_runner_job("10.1.1.1", "Default", "show version", description="")
|
|
5241
|
+
client.create_command_runner_job("10.1.1.1", "Default", "show version", job_name="NTP check", description="")
|
|
5210
5242
|
|
|
5211
5243
|
body = json.loads(client.lvi_client.post.call_args.kwargs["data"])
|
|
5212
|
-
assert body["params"]["jobData"]["description"] == "
|
|
5244
|
+
assert body["params"]["jobData"]["description"] == "NTP check"
|
|
5213
5245
|
|
|
5214
5246
|
|
|
5215
5247
|
class TestRunJobNowSubTypeBranch:
|
|
@@ -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"])
|
lvi_api_client-0.2.4/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.2.4
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lvi_api_client-0.2.4 → lvi_api_client-0.2.7}/src/lvi_api_client.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|