diff --git a/CI_REQUIREMENTS.txt b/CI_REQUIREMENTS.txt index e989460..2d0cfe9 100644 --- a/CI_REQUIREMENTS.txt +++ b/CI_REQUIREMENTS.txt @@ -1,2 +1,4 @@ -r requirements.txt -lxml +lxml!=3.7.0 +PyYAML>=3.12 +ruamel.yaml diff --git a/README.rst b/README.rst index c8594e0..6a19c93 100644 --- a/README.rst +++ b/README.rst @@ -223,7 +223,7 @@ Execution result object has a set of useful properties: * `stdout_json` - STDOUT decoded as JSON. -* `stdout_yaml` - STDOUT decoded as YAML. +* `stdout_yaml` - STDOUT decoded as YAML. Accessible only if PyYAML or ruamel.YAML library installed. * `stdout_xml` - STDOUT decoded as XML to `ElementTree` using `defusedxml` library. diff --git a/doc/source/ExecResult.rst b/doc/source/ExecResult.rst index 46ddf2a..50eb223 100644 --- a/doc/source/ExecResult.rst +++ b/doc/source/ExecResult.rst @@ -146,6 +146,7 @@ API: ExecResult :rtype: ``typing.Any`` :raises DeserializeValueError: STDOUT can not be deserialized as YAML + :raises AttributeError: no any yaml parser installed .. py:attribute:: stdout_xml diff --git a/exec_helpers/exec_result.py b/exec_helpers/exec_result.py index e294188..ee5d95d 100644 --- a/exec_helpers/exec_result.py +++ b/exec_helpers/exec_result.py @@ -28,13 +28,23 @@ # External Dependencies import defusedxml.ElementTree # type: ignore -import yaml # Exec-Helpers Implementation from exec_helpers import exceptions from exec_helpers import proc_enums try: + # noinspection PyPackageRequirements + import yaml +except ImportError: + yaml = None # type:ignore # pylint: disable=invalid-name +try: + import ruamel.yaml as ruamel_yaml # type: ignore +except ImportError: + ruamel_yaml = None # pylint: disable=invalid-name + +try: + # noinspection PyPackageRequirements import lxml.etree # type: ignore # nosec except ImportError: lxml = None # pylint: disable=invalid-name @@ -496,9 +506,11 @@ def __deserialize(self, fmt: str) -> typing.Any: if fmt == "json": return json.loads(self.stdout_str, encoding="utf-8") if fmt == "yaml": - if yaml.__with_libyaml__: - return yaml.load(self.stdout_str, Loader=yaml.CSafeLoader) # nosec # Safe - return yaml.safe_load(self.stdout_str) + if yaml is not None: + if yaml.__with_libyaml__: + return yaml.load(self.stdout_str, Loader=yaml.CSafeLoader) # nosec # Safe + return yaml.safe_load(self.stdout_str) + return ruamel_yaml.YAML(typ="safe").load(self.stdout_str) # nosec # Safe if fmt == "xml": return defusedxml.ElementTree.fromstring(bytes(self.stdout_bin)) if fmt == "lxml": @@ -531,7 +543,10 @@ def stdout_yaml(self) -> typing.Any: :rtype: typing.Any :raises DeserializeValueError: STDOUT can not be deserialized as YAML + :raises AttributeError: no any yaml parser installed """ + if yaml is None and ruamel_yaml is None: + raise AttributeError("no any yaml parser installed -> attribute is not functional.") with self.stdout_lock: return self.__deserialize(fmt="yaml") @@ -576,10 +591,11 @@ def __dir__(self) -> typing.List[str]: "stdout_lines", "stderr_lines", "stdout_json", - "stdout_yaml", "stdout_xml", "lock", ] + if yaml is not None or ruamel_yaml is not None: + content.append("stdout_yaml") if lxml is not None: content.append("stdout_lxml") return content diff --git a/requirements.txt b/requirements.txt index ec5a37d..348e598 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ paramiko>=2.4 # LGPLv2.1+ tenacity>=4.4.0 # Apache-2.0 six>=1.10.0 # MIT threaded>=2.0 # Apache-2.0 -PyYAML>=3.12 # MIT advanced-descriptors>=1.0 # Apache-2.0 typing >= 3.6 ; python_version < "3.7" # PSF psutil >= 5.0 # BSD diff --git a/setup.cfg b/setup.cfg index 43c9ac2..2f6e3bd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -66,6 +66,7 @@ ignore = # 1 blank line required before class docstring D213 # Multi-line docstring summary should start at the second line +match = (?!_version|test_)*.py [doc8] max-line-length = 150 diff --git a/setup.py b/setup.py index 295581e..1e39587 100644 --- a/setup.py +++ b/setup.py @@ -259,7 +259,8 @@ def get_simple_vars_from_src(src): use_scm_version={'write_to': 'exec_helpers/_version.py'}, install_requires=REQUIRED, extras_require={ - "lxml": ["lxml!=3.7.0"] + "lxml": ["lxml!=3.7.0"], + "yaml": ["PyYAML>=3.12"], }, package_data={"exec_helpers": ["py.typed"]}, ) diff --git a/test/test_exec_result.py b/test/test_exec_result.py index 6a72d33..8e82738 100644 --- a/test/test_exec_result.py +++ b/test/test_exec_result.py @@ -26,6 +26,14 @@ import exec_helpers from exec_helpers import proc_enums +try: + import yaml +except ImportError: + yaml = None +try: + import ruamel.yaml as ruamel_yaml +except ImportError: + ruamel_yaml = None try: import lxml.etree except ImportError: @@ -79,7 +87,6 @@ def test_create_minimal(self, logger): # noinspection PyStatementEffect result["stdout_json"] # pylint: disable=pointless-statement logger.assert_has_calls((mock.call.exception(f"{cmd} stdout is not valid json:\n{result.stdout_str!r}\n"),)) - self.assertIsNone(result["stdout_yaml"]) self.assertEqual(hash(result), hash((exec_helpers.ExecResult, cmd, None, (), (), proc_enums.INVALID))) @@ -149,7 +156,6 @@ def test_wrong_result(self, logger): # noinspection PyStatementEffect result.stdout_json # pylint: disable=pointless-statement logger.assert_has_calls((mock.call.exception(f"{cmd} stdout is not valid json:\n{result.stdout_str!r}\n"),)) - self.assertIsNone(result["stdout_yaml"]) def test_not_equal(self): """Exec result equality is validated by all fields.""" @@ -294,3 +300,35 @@ def test_stdout_lxml(self): self.assertEqual( lxml.etree.tostring(expect), lxml.etree.tostring(result.stdout_lxml) ) + + @unittest.skipUnless(yaml is not None, "PyYAML parser should be installed") + def test_stdout_yaml_pyyaml(self): + result = exec_helpers.ExecResult( + "test", + stdout=[ + b"{test: data}\n" + ] + ) + expect = {"test": "data"} + self.assertEqual(expect, result.stdout_yaml) + + +# noinspection PyTypeChecker +class TestExecResultRuamelYaml(unittest.TestCase): + def setUp(self) -> None: + self._orig_yaml, exec_helpers.exec_result.yaml = exec_helpers.exec_result.yaml, None + + def tearDown(self) -> None: + exec_helpers.exec_result.yaml = self._orig_yaml + + @unittest.skipUnless(ruamel_yaml is not None, "Ruamel.YAML parser should be installed") + def test_stdout_yaml_ruamel(self): + result = exec_helpers.ExecResult( + "test", + stdout=[ + b"{test: data}\n" + ] + ) + expect = {"test": "data"} + result = result.stdout_yaml + self.assertEqual(expect, result) diff --git a/tox.ini b/tox.ini index 8e963f1..2b0e2d5 100644 --- a/tox.ini +++ b/tox.ini @@ -108,7 +108,6 @@ commands = pipdeptree [testenv:mypy] deps = mypy>=0.700 - lxml -r{toxinidir}/CI_REQUIREMENTS.txt commands = python setup.py --version