besapi 4.2.0__tar.gz → 4.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {besapi-4.2.0/src/besapi.egg-info → besapi-4.3.1}/PKG-INFO +3 -1
  2. {besapi-4.2.0 → besapi-4.3.1}/setup.cfg +15 -0
  3. {besapi-4.2.0 → besapi-4.3.1}/src/besapi/besapi.py +226 -83
  4. besapi-4.3.1/src/besapi/plugin_utilities.py +593 -0
  5. {besapi-4.2.0 → besapi-4.3.1/src/besapi.egg-info}/PKG-INFO +3 -1
  6. {besapi-4.2.0 → besapi-4.3.1}/src/besapi.egg-info/SOURCES.txt +8 -0
  7. {besapi-4.2.0 → besapi-4.3.1}/src/besapi.egg-info/requires.txt +3 -0
  8. {besapi-4.2.0 → besapi-4.3.1}/src/bescli/bescli.py +5 -5
  9. besapi-4.3.1/tests/test_besapi_coverage.py +748 -0
  10. besapi-4.3.1/tests/test_besapi_sdk.py +776 -0
  11. besapi-4.3.1/tests/test_bescli_coverage.py +448 -0
  12. besapi-4.3.1/tests/test_example_mqtt_homeassistant.py +250 -0
  13. besapi-4.3.1/tests/test_live_sample_plugin.py +83 -0
  14. besapi-4.3.1/tests/test_plugin_utilities_coverage.py +193 -0
  15. besapi-4.3.1/tests/test_plugin_utilities_sdk.py +538 -0
  16. besapi-4.3.1/tests/test_sample_plugin.py +161 -0
  17. besapi-4.2.0/src/besapi/plugin_utilities.py +0 -349
  18. {besapi-4.2.0 → besapi-4.3.1}/LICENSE.txt +0 -0
  19. {besapi-4.2.0 → besapi-4.3.1}/MANIFEST.in +0 -0
  20. {besapi-4.2.0 → besapi-4.3.1}/README.md +0 -0
  21. {besapi-4.2.0 → besapi-4.3.1}/pyproject.toml +0 -0
  22. {besapi-4.2.0 → besapi-4.3.1}/setup.py +0 -0
  23. {besapi-4.2.0 → besapi-4.3.1}/src/besapi/__init__.py +0 -0
  24. {besapi-4.2.0 → besapi-4.3.1}/src/besapi/__main__.py +0 -0
  25. {besapi-4.2.0 → besapi-4.3.1}/src/besapi/plugin_utilities_linux.py +0 -0
  26. {besapi-4.2.0 → besapi-4.3.1}/src/besapi/plugin_utilities_win.py +0 -0
  27. {besapi-4.2.0 → besapi-4.3.1}/src/besapi/schemas/BES.xsd +0 -0
  28. {besapi-4.2.0 → besapi-4.3.1}/src/besapi/schemas/BESAPI.xsd +0 -0
  29. {besapi-4.2.0 → besapi-4.3.1}/src/besapi/schemas/BESActionSettings.xsd +0 -0
  30. {besapi-4.2.0 → besapi-4.3.1}/src/besapi.egg-info/dependency_links.txt +0 -0
  31. {besapi-4.2.0 → besapi-4.3.1}/src/besapi.egg-info/top_level.txt +0 -0
  32. {besapi-4.2.0 → besapi-4.3.1}/src/bescli/__init__.py +0 -0
  33. {besapi-4.2.0 → besapi-4.3.1}/src/bescli/__main__.py +0 -0
  34. {besapi-4.2.0 → besapi-4.3.1}/tests/test_besapi.py +0 -0
  35. {besapi-4.2.0 → besapi-4.3.1}/tests/test_plugin_utilities.py +0 -0
  36. {besapi-4.2.0 → besapi-4.3.1}/tests/test_plugin_utilities_linux.py +0 -0
  37. {besapi-4.2.0 → besapi-4.3.1}/tests/test_plugin_utilities_logging.py +0 -0
  38. {besapi-4.2.0 → besapi-4.3.1}/tests/tests.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: besapi
3
- Version: 4.2.0
3
+ Version: 4.3.1
4
4
  Summary: Library for working with the BigFix REST API
5
5
  Home-page: https://github.com/jgstew/besapi
6
6
  Author: Matt Hansen, James Stewart
@@ -26,6 +26,8 @@ Requires-Dist: requests
26
26
  Requires-Dist: setuptools
27
27
  Requires-Dist: pywin32; platform_system == "Windows"
28
28
  Requires-Dist: urllib3>=2.2.3
29
+ Provides-Extra: plugins
30
+ Requires-Dist: ruamel.yaml; extra == "plugins"
29
31
  Dynamic: license-file
30
32
 
31
33
  # besapi
@@ -36,12 +36,27 @@ install_requires =
36
36
  pywin32; platform_system == "Windows"
37
37
  urllib3 >= 2.2.3
38
38
 
39
+ [options.extras_require]
40
+ plugins =
41
+ ruamel.yaml
42
+
39
43
  [options.package_data]
40
44
  besapi = schemas/*.xsd
41
45
 
42
46
  [options.packages.find]
43
47
  where = src
44
48
 
49
+ [coverage:run]
50
+ source =
51
+ src/besapi
52
+ src/bescli
53
+ branch = True
54
+
55
+ [coverage:report]
56
+ show_missing = True
57
+ skip_empty = True
58
+ fail_under = 75
59
+
45
60
  [egg_info]
46
61
  tag_build =
47
62
  tag_date = 0
@@ -22,17 +22,23 @@ import random
22
22
  import site
23
23
  import string
24
24
  import sys
25
+ import threading
25
26
  import urllib.parse
27
+ from xml.sax.saxutils import escape as xml_escape
26
28
 
27
29
  import lxml.etree
28
30
  import lxml.objectify
29
31
  import requests
30
32
  import urllib3.poolmanager
31
33
 
32
- __version__ = "4.2.0"
34
+ __version__ = "4.3.1"
33
35
 
34
36
  besapi_logger = logging.getLogger("besapi")
35
37
 
38
+ # default requests timeout for each REST API request: (connect, read) seconds
39
+ # NOTE: long read timeout since some REST API calls, like exports, are slow
40
+ DEFAULT_TIMEOUT = (90, 600)
41
+
36
42
  # pylint: disable=consider-using-f-string
37
43
 
38
44
 
@@ -65,6 +71,41 @@ def sanitize_txt(*args):
65
71
  return tuple(sani_args)
66
72
 
67
73
 
74
+ def relevance_string_escape(value: str) -> str:
75
+ """Escape text to embed inside a relevance "string literal".
76
+
77
+ Relevance string literals use `%XX` escapes, so `%` itself must be
78
+ escaped, as must `"` which would end the literal. Control characters
79
+ (tab, newline, ...) are escaped so the relevance stays on one line.
80
+
81
+ NOTE: `%XX` escapes are single bytes, so non-ASCII text is passed through
82
+ unchanged. Relevance only accepts characters in the FXF character set,
83
+ such as `é`; others, such as `✓`, are rejected by the evaluator however
84
+ they are written.
85
+
86
+ Verified with the BigFix client QnA, for example:
87
+ length of "100%25" = 4, "a%22b" = a"b
88
+
89
+ Example:
90
+ f'bes sites whose(name of it = "{relevance_string_escape(site_name)}")'
91
+ """
92
+ escaped = []
93
+ for char in value:
94
+ if char in ("%", '"') or ord(char) < 0x20 or ord(char) == 0x7F:
95
+ escaped.append(f"%{ord(char):02X}")
96
+ else:
97
+ escaped.append(char)
98
+ return "".join(escaped)
99
+
100
+
101
+ def cdata_escape(text: str) -> str:
102
+ """Make text safe to place inside `<![CDATA[ ... ]]>`.
103
+
104
+ A literal `]]>` would end the CDATA section, so it is split across two.
105
+ """
106
+ return str(text).replace("]]>", "]]]]><![CDATA[>")
107
+
108
+
68
109
  def elem2dict(node):
69
110
  """
70
111
  Convert an lxml.etree node tree into a dict.
@@ -182,7 +223,7 @@ def get_target_xml(targets=None):
182
223
  # return "<AllComputers>false</AllComputers>"
183
224
  return "<AllComputers>true</AllComputers>"
184
225
  # treat as custom relevance:
185
- return f"<CustomRelevance><![CDATA[{targets}]]></CustomRelevance>"
226
+ return f"<CustomRelevance><![CDATA[{cdata_escape(targets)}]]></CustomRelevance>"
186
227
 
187
228
  # if targets is array:
188
229
  if isinstance(targets, list):
@@ -198,7 +239,7 @@ def get_target_xml(targets=None):
198
239
  # array of computer names
199
240
  return (
200
241
  "<ComputerName>"
201
- + "</ComputerName><ComputerName>".join(targets)
242
+ + "</ComputerName><ComputerName>".join(map(xml_escape, targets))
202
243
  + "</ComputerName>"
203
244
  )
204
245
 
@@ -208,30 +249,46 @@ def get_target_xml(targets=None):
208
249
  return "<CustomRelevance>False</CustomRelevance>"
209
250
 
210
251
 
211
- def validate_xsd(doc):
212
- """Validate results using XML XSDs."""
213
- try:
214
- xmldoc = lxml.etree.fromstring(doc)
215
- except BaseException: # pylint: disable=broad-except
216
- return False
252
+ # compiled XSD schemas, cached per thread:
253
+ _xsd_schemas_cache = threading.local()
254
+
217
255
 
256
+ def _get_xsd_schemas():
257
+ """Load and compile the BES XML schemas, once per thread.
258
+
259
+ NOTE: compiling them takes a few ms, and every REST API result is validated.
260
+ Cached per thread since sharing lxml schema objects across threads is not
261
+ guaranteed to be safe, and besapi is used from threads.
262
+ """
263
+ cached = getattr(_xsd_schemas_cache, "schemas", None)
264
+ if cached is not None:
265
+ return cached
266
+
267
+ schemas = []
218
268
  for xsd in ["BES.xsd", "BESAPI.xsd", "BESActionSettings.xsd"]:
219
269
  schema_path = importlib.resources.files(__package__) / f"schemas/{xsd}"
220
270
  with schema_path.open("r") as xsd_file:
221
271
  xmlschema_doc = lxml.etree.parse(xsd_file)
222
-
223
- # one schema may throw an error while another will validate
224
272
  try:
225
- xmlschema = lxml.etree.XMLSchema(xmlschema_doc)
273
+ schemas.append(lxml.etree.XMLSchema(xmlschema_doc))
226
274
  except lxml.etree.XMLSchemaParseError as err:
227
275
  # this should only error if the XSD itself is malformed
228
276
  besapi_logger.error("ERROR with `%s`: %s", xsd, err)
229
277
  raise err
230
278
 
231
- if xmlschema.validate(xmldoc):
232
- return True
279
+ _xsd_schemas_cache.schemas = tuple(schemas)
280
+ return _xsd_schemas_cache.schemas
233
281
 
234
- return False
282
+
283
+ def validate_xsd(doc):
284
+ """Validate results using XML XSDs."""
285
+ try:
286
+ xmldoc = lxml.etree.fromstring(doc)
287
+ except BaseException: # pylint: disable=broad-except
288
+ return False
289
+
290
+ # one schema may fail while another will validate
291
+ return any(xmlschema.validate(xmldoc) for xmlschema in _get_xsd_schemas())
235
292
 
236
293
 
237
294
  def validate_xml_bes_file(file_path):
@@ -321,7 +378,7 @@ def action_xml_from_bes_file(file_path, targets="<AllComputers>"):
321
378
  else:
322
379
  custom_relevance = tree.xpath(f"//BES/{bes_type}/SuccessCriteria/text()")[0]
323
380
 
324
- custom_relevance_xml = f"<![CDATA[{custom_relevance}]]>"
381
+ custom_relevance_xml = f"<![CDATA[{cdata_escape(custom_relevance)}]]>"
325
382
 
326
383
  logging.debug("success_criteria: %s", success_criteria)
327
384
 
@@ -351,10 +408,10 @@ def action_xml_from_bes_file(file_path, targets="<AllComputers>"):
351
408
  action_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
352
409
  <BES xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="BES.xsd">
353
410
  <SingleAction>
354
- <Title>{title}</Title>
355
- <Relevance><![CDATA[{relevance_clauses_combined}]]></Relevance>
411
+ <Title>{xml_escape(title)}</Title>
412
+ <Relevance><![CDATA[{cdata_escape(relevance_clauses_combined)}]]></Relevance>
356
413
  <ActionScript MIMEType="application/x-Fixlet-Windows-Shell"><![CDATA[// Start:
357
- {actionscript}
414
+ {cdata_escape(actionscript)}
358
415
  // End]]></ActionScript>
359
416
  <SuccessCriteria Option="{success_criteria}">{custom_relevance_xml}</SuccessCriteria>{settings_xml_string}
360
417
  <Target>
@@ -545,11 +602,33 @@ class HTTPAdapterBlocksize(requests.adapters.HTTPAdapter):
545
602
  class BESConnection:
546
603
  """BigFix RESTAPI connection abstraction class."""
547
604
 
548
- def __init__(self, username, password, rootserver, verify=False):
605
+ def __init__(
606
+ self,
607
+ username,
608
+ password,
609
+ rootserver,
610
+ verify=False,
611
+ timeout=DEFAULT_TIMEOUT,
612
+ raise_for_status=False,
613
+ ):
614
+ """Create a connection to the BigFix REST API.
615
+
616
+ Arguments:
617
+ timeout: default `timeout` for each request, as used by
618
+ `requests`, such as 60 or (connect, read). A timeout passed to a
619
+ request wins. Defaults to DEFAULT_TIMEOUT, None means requests
620
+ wait indefinitely.
621
+ raise_for_status: if True, raise requests.HTTPError for 4xx / 5xx
622
+ responses instead of returning them. 403 always raises
623
+ PermissionError. NOTE: session relevance errors are returned
624
+ with HTTP 200, see session_relevance_array(raise_errors=True).
625
+ """
549
626
  if not verify:
550
627
  # disable SSL warnings
551
628
  requests.packages.urllib3.disable_warnings() # pylint: disable=no-member
552
629
  self.verify = verify
630
+ self.timeout = timeout
631
+ self.raise_for_status = raise_for_status
553
632
  self.last_connected = None
554
633
 
555
634
  self.username = username
@@ -623,15 +702,25 @@ class BESConnection:
623
702
 
624
703
  return url
625
704
 
705
+ def _to_result(self, response):
706
+ """Wrap a response as a RESTResult, raising for errors if configured."""
707
+ # NOTE: RESTResult raises PermissionError for 403 first
708
+ result = RESTResult(response)
709
+ if self.raise_for_status:
710
+ response.raise_for_status()
711
+ return result
712
+
626
713
  def get(self, path="help", **kwargs):
627
714
  """HTTP GET request."""
715
+ kwargs.setdefault("timeout", self.timeout)
628
716
  self.last_connected = datetime.datetime.now()
629
- return RESTResult(
717
+ return self._to_result(
630
718
  self.session.get(self.url(path), verify=self.verify, **kwargs)
631
719
  )
632
720
 
633
721
  def post(self, path, data, validate_xml=None, **kwargs):
634
722
  """HTTP POST request."""
723
+ kwargs.setdefault("timeout", self.timeout)
635
724
 
636
725
  # if validate_xml is true, data must validate to xml schema
637
726
  # if validate_xml is false, no schema check will be made
@@ -646,12 +735,13 @@ class BESConnection:
646
735
  besapi_logger.warning(err_msg)
647
736
 
648
737
  self.last_connected = datetime.datetime.now()
649
- return RESTResult(
738
+ return self._to_result(
650
739
  self.session.post(self.url(path), data=data, verify=self.verify, **kwargs)
651
740
  )
652
741
 
653
742
  def put(self, path, data, validate_xml=None, **kwargs):
654
743
  """HTTP PUT request."""
744
+ kwargs.setdefault("timeout", self.timeout)
655
745
  self.last_connected = datetime.datetime.now()
656
746
 
657
747
  # if validate_xml is true, data must validate to xml schema
@@ -666,14 +756,15 @@ class BESConnection:
666
756
  # this is intended it validate_xml is None, but not used currently
667
757
  besapi_logger.warning(err_msg)
668
758
 
669
- return RESTResult(
759
+ return self._to_result(
670
760
  self.session.put(self.url(path), data=data, verify=self.verify, **kwargs)
671
761
  )
672
762
 
673
763
  def delete(self, path, **kwargs):
674
764
  """HTTP DELETE request."""
765
+ kwargs.setdefault("timeout", self.timeout)
675
766
  self.last_connected = datetime.datetime.now()
676
- return RESTResult(
767
+ return self._to_result(
677
768
  self.session.delete(self.url(path), verify=self.verify, **kwargs)
678
769
  )
679
770
 
@@ -699,15 +790,7 @@ class BESConnection:
699
790
  """
700
791
  session_relevance = urllib.parse.quote(relevance, safe=":+")
701
792
  rel_data = {"output": "json", "relevance": session_relevance}
702
- self.last_connected = datetime.datetime.now()
703
- result = RESTResult(
704
- self.session.post(
705
- self.url("query"),
706
- data=rel_data,
707
- verify=self.verify,
708
- **kwargs,
709
- )
710
- )
793
+ result = self.post("query", data=rel_data, **kwargs)
711
794
  return json.loads(result.text)
712
795
 
713
796
  def session_relevance_json_array(self, relevance, **kwargs):
@@ -731,18 +814,24 @@ class BESConnection:
731
814
 
732
815
  def session_relevance_xml(self, relevance, **kwargs):
733
816
  """Get Session Relevance Results XML."""
734
- self.last_connected = datetime.datetime.now()
735
- return RESTResult(
736
- self.session.post(
737
- self.url("query"),
738
- data=f"relevance={urllib.parse.quote(relevance, safe=':')}",
739
- verify=self.verify,
740
- **kwargs,
741
- )
817
+ # NOTE: the server URL decodes the query twice, so quote here and let
818
+ # requests form encode it again, same as session_relevance_json.
819
+ # Otherwise relevance escapes such as %22 are decoded too early.
820
+ return self.post(
821
+ "query",
822
+ data={"relevance": urllib.parse.quote(relevance, safe=":+")},
823
+ **kwargs,
742
824
  )
743
825
 
744
- def session_relevance_array(self, relevance, **kwargs):
745
- """Get Session Relevance Results array."""
826
+ def session_relevance_array(self, relevance, raise_errors=False, **kwargs):
827
+ """Get Session Relevance Results array.
828
+
829
+ Arguments:
830
+ raise_errors: if True, raise ValueError with the server's message
831
+ when the relevance has an error (such as a syntax error),
832
+ instead of returning ["ERROR: <message>"].
833
+ NOTE: the server returns these errors with HTTP 200.
834
+ """
746
835
  rel_result = self.session_relevance_xml(relevance, **kwargs)
747
836
  # print(rel_result)
748
837
  result = []
@@ -753,7 +842,7 @@ class BESConnection:
753
842
  # print(err)
754
843
  if "no such child: Answer" in str(err):
755
844
  try:
756
- result.append("ERROR: " + rel_result.besobj.Query.Error.text)
845
+ error_text = rel_result.besobj.Query.Error.text
757
846
  except AttributeError as err2:
758
847
  if "no such child: Error" in str(err2):
759
848
  result.append("<Nothing> Nothing returned, but no error.")
@@ -762,16 +851,28 @@ class BESConnection:
762
851
  besapi_logger.error("%s\n%s", err2, rel_result.text)
763
852
  result.append("ERROR: " + rel_result.text)
764
853
  raise
854
+ else:
855
+ if raise_errors:
856
+ raise ValueError(
857
+ f"Session relevance error: {error_text}"
858
+ ) from err
859
+ result.append("ERROR: " + error_text)
765
860
  else:
766
861
  besapi_logger.error("%s\n%s", err, rel_result.text)
767
862
  result.append("ERROR: " + rel_result.text)
768
863
  raise
769
864
  return result
770
865
 
771
- def session_relevance_string(self, relevance, **kwargs):
772
- """Get Session Relevance Results string."""
866
+ def session_relevance_string(self, relevance, raise_errors=False, **kwargs):
867
+ """Get Session Relevance Results string.
868
+
869
+ Arguments:
870
+ raise_errors: see session_relevance_array()
871
+ """
773
872
  rel_result_array = self.session_relevance_array(
774
- "(it as string) of ( " + relevance + " )", **kwargs
873
+ "(it as string) of ( " + relevance + " )",
874
+ raise_errors=raise_errors,
875
+ **kwargs,
775
876
  )
776
877
  return "\n".join(rel_result_array)
777
878
 
@@ -795,6 +896,9 @@ class BESConnection:
795
896
  if not bool(self.last_connected):
796
897
  result_login = self.get("login", timeout=timeout)
797
898
  if not result_login.request.status_code == 200:
899
+ # NOTE: get() sets last_connected before the request, clear it
900
+ # so a failed login is not treated as connected on the next call
901
+ self.last_connected = None
798
902
  result_login.request.raise_for_status()
799
903
  if result_login.request.status_code == 200:
800
904
  # set time of connection
@@ -821,10 +925,10 @@ class BESConnection:
821
925
 
822
926
  dash_var_xml = f"""<BESAPI xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="BESAPI.xsd">
823
927
  <DashboardData>
824
- <Dashboard>{dashboard_name}</Dashboard>
825
- <Name>{var_name}</Name>
928
+ <Dashboard>{xml_escape(str(dashboard_name))}</Dashboard>
929
+ <Name>{xml_escape(str(var_name))}</Name>
826
930
  <IsPrivate>{str(private).lower()}</IsPrivate>
827
- <Value>{var_value}</Value>
931
+ <Value>{xml_escape(str(var_value))}</Value>
828
932
  </DashboardData>
829
933
  </BESAPI>
830
934
  """
@@ -917,21 +1021,33 @@ class BESConnection:
917
1021
  besapi_logger.error("%s is not readable", bes_file_path)
918
1022
  raise FileNotFoundError(f"{bes_file_path} is not readable")
919
1023
 
1024
+ with open(bes_file_path, "rb") as f:
1025
+ content = f.read()
1026
+
1027
+ return self.import_bes_xml_to_site(content, site_path)
1028
+
1029
+ def import_bes_xml_to_site(self, bes_xml, site_path=None):
1030
+ """Import BES XML content (str or bytes) to site.
1031
+
1032
+ This avoids writing a temporary .bes file for generated content.
1033
+
1034
+ Returns:
1035
+ The RESTResult, or None if the BES XML is not valid.
1036
+ """
1037
+ if isinstance(bes_xml, str):
1038
+ bes_xml = bes_xml.encode("utf-8")
1039
+
920
1040
  site_path = self.get_current_site_path(site_path)
921
1041
 
922
1042
  self.validate_site_path(site_path, False, True)
923
1043
 
924
- with open(bes_file_path, "rb") as f:
925
- content = f.read()
926
-
927
- # validate BES File contents:
928
- if not validate_xsd(content):
929
- besapi_logger.error("%s is not valid", bes_file_path)
930
- return None
1044
+ # validate BES XML contents:
1045
+ if not validate_xsd(bes_xml):
1046
+ besapi_logger.error("BES XML to import to %s is not valid", site_path)
1047
+ return None
931
1048
 
932
- # https://developer.bigfix.com/rest-api/api/import.html
933
- result = self.post(f"import/{site_path}", content)
934
- return result
1049
+ # https://developer.bigfix.com/rest-api/api/import.html
1050
+ return self.post(f"import/{site_path}", bes_xml)
935
1051
 
936
1052
  def create_site_from_file(self, bes_file_path, site_type="custom"):
937
1053
  """Create new site."""
@@ -1010,7 +1126,7 @@ class BESConnection:
1010
1126
 
1011
1127
  besapi_logger.debug("group creation result:\n%s", create_group_result)
1012
1128
 
1013
- return self.get_computergroup(site_path, new_group_name)
1129
+ return self.get_computergroup(new_group_name, site_path)
1014
1130
 
1015
1131
  def get_upload(self, file_name, file_hash):
1016
1132
  """
@@ -1045,8 +1161,8 @@ class BESConnection:
1045
1161
  https://developer.bigfix.com/rest-api/api/upload.html
1046
1162
  """
1047
1163
  if not os.access(file_path, os.R_OK):
1048
- besapi_logger.error(file_path, "is not readable")
1049
- raise FileNotFoundError
1164
+ besapi_logger.error("%s is not readable", file_path)
1165
+ raise FileNotFoundError(f"{file_path} is not readable")
1050
1166
 
1051
1167
  # if file_name not specified, then get it from tail of file_path
1052
1168
  if not file_name:
@@ -1134,7 +1250,14 @@ class BESConnection:
1134
1250
  return content
1135
1251
 
1136
1252
  def update_item_from_file(self, file_path, site_path=None):
1137
- """Update an item by name and last modified."""
1253
+ """Update an item by name and last modified.
1254
+
1255
+ NOTE: not implemented yet.
1256
+ """
1257
+ raise NotImplementedError("besapi.update_item_from_file() is not implemented")
1258
+
1259
+ # pylint: disable=unreachable
1260
+ # NOTE: work in progress, kept for later, never runs because of the raise
1138
1261
  site_path = self.get_current_site_path(site_path)
1139
1262
  bes_tree = lxml.etree.parse(file_path)
1140
1263
 
@@ -1338,8 +1461,15 @@ class BESConnection:
1338
1461
  )
1339
1462
 
1340
1463
  __call__ = login
1341
- # https://stackoverflow.com/q/40536821/861745
1342
- __enter__ = login
1464
+
1465
+ def __enter__(self):
1466
+ """Log in and return this connection, for use with `with`."""
1467
+ self.login()
1468
+ return self
1469
+
1470
+ def __exit__(self, exc_type, exc_value, traceback):
1471
+ """Log out when leaving a `with` block."""
1472
+ self.logout()
1343
1473
 
1344
1474
 
1345
1475
  class RESTResult:
@@ -1348,7 +1478,8 @@ class RESTResult:
1348
1478
  def __init__(self, request):
1349
1479
  self.request = request
1350
1480
  self.text = request.text
1351
- self.valid = None
1481
+ # computed on first access of `valid`, see the property:
1482
+ self._valid = None
1352
1483
  self._besxml = None
1353
1484
  self._besobj = None
1354
1485
  self._besdict = None
@@ -1370,23 +1501,35 @@ class RESTResult:
1370
1501
  except AttributeError as err:
1371
1502
  besapi_logger.warning("Error (expected during tests) %s", err)
1372
1503
 
1373
- if (
1374
- "content-type" in request.headers
1375
- and request.headers["content-type"] == "application/xml"
1376
- ):
1377
- self.valid = True
1378
- elif type(request.text) is str and self.validate_xsd(
1379
- request.text.encode("utf-8")
1380
- ):
1381
- self.valid = True
1382
- else:
1383
- if self.validate_xsd(request.text):
1384
- self.valid = True
1504
+ @property
1505
+ def valid(self):
1506
+ """Whether the result is BES XML, validated on first access.
1507
+
1508
+ NOTE: this is lazy since XSD validation of every result is costly,
1509
+ and many results (such as JSON queries) are never used as XML.
1510
+ """
1511
+ if self._valid is None:
1512
+ headers = self.request.headers
1513
+ # NOTE: membership test, headers may not be a dict in all callers
1514
+ content_type = headers["content-type"] if "content-type" in headers else ""
1515
+ if content_type == "application/xml":
1516
+ self._valid = True
1517
+ elif content_type.startswith("application/json"):
1518
+ self._valid = False
1519
+ elif isinstance(self.text, str):
1520
+ self._valid = validate_xsd(self.text.encode("utf-8"))
1385
1521
  else:
1522
+ self._valid = validate_xsd(self.text)
1523
+
1524
+ if not self._valid:
1386
1525
  besapi_logger.debug(
1387
1526
  "INFO: REST API Result does not appear to be XML, this could be expected."
1388
1527
  )
1389
- self.valid = False
1528
+ return self._valid
1529
+
1530
+ @valid.setter
1531
+ def valid(self, value):
1532
+ self._valid = value
1390
1533
 
1391
1534
  def __str__(self):
1392
1535
  if self.valid:
@@ -1438,9 +1581,9 @@ class RESTResult:
1438
1581
 
1439
1582
  def validate_xsd(self, doc):
1440
1583
  """Validate results using XML XSDs."""
1441
- # return self.valid if already set
1442
- if self.valid is not None and isinstance(self.valid, bool):
1443
- return self.valid
1584
+ # return self._valid if already set
1585
+ if isinstance(self._valid, bool):
1586
+ return self._valid
1444
1587
  return validate_xsd(doc)
1445
1588
 
1446
1589
  def xmlparse_text(self, text):