lvi-api-client 0.2.4__tar.gz → 0.2.6__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.4
3
+ Version: 0.2.6
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.6
@@ -3003,6 +3003,7 @@ class LogicVeinAPIClient:
3003
3003
  commands (str): CLI commands to run, one per line.
3004
3004
  job_name (str): Display name for the job.
3005
3005
  backup_on_completion (bool): If True, LVI backs up the device after the job runs.
3006
+ description (str): The job's description. Empty uses ``job_name``.
3006
3007
 
3007
3008
  Returns:
3008
3009
  Dict[str, Any]: The saved job record (contains ``jobId``), or None on failure.
@@ -3011,7 +3012,7 @@ class LogicVeinAPIClient:
3011
3012
  {
3012
3013
  "jobId": 0,
3013
3014
  "jobName": job_name,
3014
- "description": description or "Job scheduled by ServiceNow",
3015
+ "description": description or job_name,
3015
3016
  "managedNetworks": [network],
3016
3017
  "jobType": "Script Tool Job",
3017
3018
  "subType": "org.ziptie.tools.scripts.commandRunner",
@@ -3052,6 +3053,8 @@ class LogicVeinAPIClient:
3052
3053
  network (str): LVI managed network the device belongs to.
3053
3054
  commands (str): CLI commands to run, one per line.
3054
3055
  job_name (str): Display name for the job in the LVI scheduler.
3056
+ backup_on_completion (bool): If True, LVI backs up the device after the job runs.
3057
+ description (str): The job's description. Empty uses ``job_name``.
3055
3058
 
3056
3059
  Returns:
3057
3060
  Dict[str, Any]: The job execution result dict from LVI (contains ``id``
@@ -3071,7 +3074,7 @@ class LogicVeinAPIClient:
3071
3074
  "tool": "org.ziptie.tools.scripts.commandRunner",
3072
3075
  "backupOnCompletion": "true" if backup_on_completion else "false",
3073
3076
  },
3074
- description=description or "Job scheduled by ServiceNow",
3077
+ description=description or job_name,
3075
3078
  )
3076
3079
 
3077
3080
  # =====================================================
@@ -4620,27 +4623,32 @@ class LogicVeinAPIClient:
4620
4623
  where ``recordId`` is the ``id`` field from a ``getExecutionDetails``
4621
4624
  record.
4622
4625
 
4626
+ The server finds the output by ``recordId`` alone: a record of another
4627
+ execution comes back as long as the execution ID exists.
4628
+
4623
4629
  Args:
4624
4630
  execution_id: The execution ID (``executionId`` from the job result).
4625
4631
  record_id: The per-device record ID (``id`` from ``getExecutionDetails``).
4626
4632
 
4627
4633
  Returns:
4628
- The raw output text, or an empty string if the request fails.
4634
+ The raw output text.
4635
+
4636
+ Raises:
4637
+ requests.HTTPError: If the server refuses the request, for example
4638
+ 401 for a bad token, 404 for an unknown execution or 500 for a
4639
+ malformed ID.
4640
+ ValueError: If the server has no record with that ID. It answers
4641
+ 200 with an empty body and no content type then, which is not
4642
+ an empty output: a Command Runner output holds at least the
4643
+ command and the prompt.
4629
4644
  """
4630
4645
  url = f"https://{self.host}/servlet/pluginDetail"
4631
4646
  params = {"executionId": execution_id, "recordId": record_id}
4632
- try:
4633
- response = self.lvi_client.get(url, params=params, timeout=self.timeout)
4634
- response.raise_for_status()
4635
- return response.text
4636
- except Exception as exc:
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 ""
4647
+ response = self.lvi_client.get(url, params=params, timeout=self.timeout)
4648
+ response.raise_for_status()
4649
+ if not response.content and not response.headers.get("Content-Type"):
4650
+ raise ValueError(f"No output record {record_id} in execution {execution_id}")
4651
+ return response.text
4644
4652
 
4645
4653
  # =====================================================
4646
4654
  # ZeroTouch
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lvi-api-client
3
- Version: 0.2.4
3
+ Version: 0.2.6
4
4
  Summary: LogicVein API Client Library
5
5
  Requires-Python: >=3.8
6
6
  Description-Content-Type: text/markdown
@@ -5048,7 +5048,7 @@ class TestCreateCommandRunnerJob:
5048
5048
 
5049
5049
 
5050
5050
  class TestGetExecutionOutput:
5051
- """Tests for get_execution_output (lines 3641-3674): success and exception paths."""
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 response text on success."""
5062
- mock_get_response = Mock()
5063
- mock_get_response.raise_for_status = Mock()
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 == "show version output\nCisco IOS Version 15.1"
5070
- client.lvi_client.get.assert_called_once()
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
- def test_get_execution_output_http_error_returns_empty_string(self, client):
5077
- """get_execution_output returns '' when raise_for_status raises."""
5078
- mock_get_response = Mock()
5079
- mock_get_response.raise_for_status.side_effect = requests.HTTPError("403 Forbidden")
5080
- client.lvi_client.get = Mock(return_value=mock_get_response)
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
- result = client.get_execution_output(execution_id=42, record_id=7)
5088
+ with pytest.raises(requests.HTTPError):
5089
+ client.get_execution_output(execution_id=42, record_id=7)
5083
5090
 
5084
- assert result == ""
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
- def test_get_execution_output_connection_error_returns_empty_string(self, client):
5087
- """get_execution_output returns '' when a connection exception is raised."""
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
- result = client.get_execution_output(execution_id=99, record_id=1)
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
- assert result == ""
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 test_empty_description_defaults_to_service_now_string(self, client, mock_response):
5205
- """create_command_runner_job defaults description when empty string passed."""
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"] == "Job scheduled by ServiceNow"
5244
+ assert body["params"]["jobData"]["description"] == "NTP check"
5213
5245
 
5214
5246
 
5215
5247
  class TestRunJobNowSubTypeBranch:
@@ -1 +0,0 @@
1
- 0.2.4
File without changes
File without changes
File without changes