From a89a9187f88aa45dea5fadf7c9636bda5afb8cb9 Mon Sep 17 00:00:00 2001 From: Kelly Brazil Date: Mon, 23 Oct 2023 07:53:16 -0700 Subject: [PATCH 01/13] version bump --- CHANGELOG | 3 +++ jc/lib.py | 2 +- setup.py | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index d2aecd5e2..3594b6364 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,8 @@ jc changelog +20231023 v1.23.6 +- Fix XML parser for older library versions + 20231021 v1.23.5 - Add `host` command parser - Add `nsd-control` command parser diff --git a/jc/lib.py b/jc/lib.py index acccd3840..4fc15615b 100644 --- a/jc/lib.py +++ b/jc/lib.py @@ -9,7 +9,7 @@ from jc import appdirs -__version__ = '1.23.5' +__version__ = '1.23.6' parsers: List[str] = [ 'acpi', diff --git a/setup.py b/setup.py index cade05ee5..f92fbe61c 100755 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name='jc', - version='1.23.5', + version='1.23.6', author='Kelly Brazil', author_email='kellyjonbrazil@gmail.com', description='Converts the output of popular command-line tools and file-types to JSON.', From 3161c48939284bcda22c10b2d64a5f2b1f9bf4ce Mon Sep 17 00:00:00 2001 From: Kelly Brazil Date: Mon, 23 Oct 2023 07:53:39 -0700 Subject: [PATCH 02/13] fix for older xmltodict library versions --- jc/parsers/xml.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/jc/parsers/xml.py b/jc/parsers/xml.py index d8393a680..5adb088c6 100644 --- a/jc/parsers/xml.py +++ b/jc/parsers/xml.py @@ -81,7 +81,7 @@ class info(): """Provides parser metadata (version, author, etc.)""" - version = '1.8' + version = '1.9' description = 'XML file parser' author = 'Kelly Brazil' author_email = 'kellyjonbrazil@gmail.com' @@ -113,7 +113,7 @@ def _process(proc_data, has_data=False): proc_output = xmltodict.parse(proc_data, dict_constructor=dict, process_comments=True) - except ValueError: + except (ValueError, TypeError): proc_output = xmltodict.parse(proc_data, dict_constructor=dict) return proc_output @@ -149,7 +149,7 @@ def parse(data, raw=False, quiet=False): dict_constructor=dict, process_comments=True, attr_prefix='_') - except ValueError: + except (ValueError, TypeError): raw_output = xmltodict.parse(data, dict_constructor=dict, attr_prefix='_') From 46a897874094986cfecb586907a109bb353cc46d Mon Sep 17 00:00:00 2001 From: Kelly Brazil Date: Mon, 23 Oct 2023 07:54:14 -0700 Subject: [PATCH 03/13] doc update --- docs/parsers/xml.md | 2 +- man/jc.1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/parsers/xml.md b/docs/parsers/xml.md index e02d5e15b..20d522f38 100644 --- a/docs/parsers/xml.md +++ b/docs/parsers/xml.md @@ -98,4 +98,4 @@ Returns: ### Parser Information Compatibility: linux, darwin, cygwin, win32, aix, freebsd -Version 1.8 by Kelly Brazil (kellyjonbrazil@gmail.com) +Version 1.9 by Kelly Brazil (kellyjonbrazil@gmail.com) diff --git a/man/jc.1 b/man/jc.1 index fe86eb59a..1b2c24e0d 100644 --- a/man/jc.1 +++ b/man/jc.1 @@ -1,4 +1,4 @@ -.TH jc 1 2023-10-21 1.23.5 "JSON Convert" +.TH jc 1 2023-10-23 1.23.6 "JSON Convert" .SH NAME \fBjc\fP \- JSON Convert JSONifies the output of many CLI tools, file-types, and strings From 3cd2dce496fff1c45e888cd6b971fc1cbc15f755 Mon Sep 17 00:00:00 2001 From: Kelly Brazil Date: Mon, 23 Oct 2023 08:01:50 -0700 Subject: [PATCH 04/13] formatting --- jc/parsers/xml.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/jc/parsers/xml.py b/jc/parsers/xml.py index 5adb088c6..903d96803 100644 --- a/jc/parsers/xml.py +++ b/jc/parsers/xml.py @@ -146,13 +146,13 @@ def parse(data, raw=False, quiet=False): # modified output with _ prefix for attributes try: raw_output = xmltodict.parse(data, - dict_constructor=dict, - process_comments=True, - attr_prefix='_') + dict_constructor=dict, + process_comments=True, + attr_prefix='_') except (ValueError, TypeError): raw_output = xmltodict.parse(data, - dict_constructor=dict, - attr_prefix='_') + dict_constructor=dict, + attr_prefix='_') return raw_output From a77bb4165afe46253e8afd418a8ebe40b070c18f Mon Sep 17 00:00:00 2001 From: Kelly Brazil Date: Mon, 23 Oct 2023 12:49:06 -0700 Subject: [PATCH 05/13] fix tests for different xmltodict versions --- tests/_vendor/__init__.py | 0 tests/_vendor/packaging/LICENSE.APACHE | 177 ++++++++ tests/_vendor/packaging/LICENSE.BSD | 23 + tests/_vendor/packaging/__init__.py | 0 tests/_vendor/packaging/_structures.py | 68 +++ tests/_vendor/packaging/version.py | 393 ++++++++++++++++++ tests/_vendor/vendored.txt | 1 + .../fixtures/generic/xml-nmap-nocomment.json | 1 + .../generic/xml-nmap-raw-nocomment.json | 1 + tests/test_xml.py | 27 +- 10 files changed, 689 insertions(+), 2 deletions(-) create mode 100644 tests/_vendor/__init__.py create mode 100644 tests/_vendor/packaging/LICENSE.APACHE create mode 100644 tests/_vendor/packaging/LICENSE.BSD create mode 100644 tests/_vendor/packaging/__init__.py create mode 100644 tests/_vendor/packaging/_structures.py create mode 100644 tests/_vendor/packaging/version.py create mode 100644 tests/_vendor/vendored.txt create mode 100644 tests/fixtures/generic/xml-nmap-nocomment.json create mode 100644 tests/fixtures/generic/xml-nmap-raw-nocomment.json diff --git a/tests/_vendor/__init__.py b/tests/_vendor/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/_vendor/packaging/LICENSE.APACHE b/tests/_vendor/packaging/LICENSE.APACHE new file mode 100644 index 000000000..4947287f7 --- /dev/null +++ b/tests/_vendor/packaging/LICENSE.APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS \ No newline at end of file diff --git a/tests/_vendor/packaging/LICENSE.BSD b/tests/_vendor/packaging/LICENSE.BSD new file mode 100644 index 000000000..1144d7127 --- /dev/null +++ b/tests/_vendor/packaging/LICENSE.BSD @@ -0,0 +1,23 @@ +Copyright (c) Donald Stufft and individual contributors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/tests/_vendor/packaging/__init__.py b/tests/_vendor/packaging/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/_vendor/packaging/_structures.py b/tests/_vendor/packaging/_structures.py new file mode 100644 index 000000000..4f567600b --- /dev/null +++ b/tests/_vendor/packaging/_structures.py @@ -0,0 +1,68 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. +from __future__ import absolute_import, division, print_function + + +class Infinity(object): + + def __repr__(self): + return "Infinity" + + def __hash__(self): + return hash(repr(self)) + + def __lt__(self, other): + return False + + def __le__(self, other): + return False + + def __eq__(self, other): + return isinstance(other, self.__class__) + + def __ne__(self, other): + return not isinstance(other, self.__class__) + + def __gt__(self, other): + return True + + def __ge__(self, other): + return True + + def __neg__(self): + return NegativeInfinity + +Infinity = Infinity() + + +class NegativeInfinity(object): + + def __repr__(self): + return "-Infinity" + + def __hash__(self): + return hash(repr(self)) + + def __lt__(self, other): + return True + + def __le__(self, other): + return True + + def __eq__(self, other): + return isinstance(other, self.__class__) + + def __ne__(self, other): + return not isinstance(other, self.__class__) + + def __gt__(self, other): + return False + + def __ge__(self, other): + return False + + def __neg__(self): + return Infinity + +NegativeInfinity = NegativeInfinity() \ No newline at end of file diff --git a/tests/_vendor/packaging/version.py b/tests/_vendor/packaging/version.py new file mode 100644 index 000000000..05535072a --- /dev/null +++ b/tests/_vendor/packaging/version.py @@ -0,0 +1,393 @@ +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the BSD License. See the LICENSE file in the root of this repository +# for complete details. +from __future__ import absolute_import, division, print_function + +import collections +import itertools +import re + +from ._structures import Infinity + + +__all__ = [ + "parse", "Version", "LegacyVersion", "InvalidVersion", "VERSION_PATTERN" +] + + +_Version = collections.namedtuple( + "_Version", + ["epoch", "release", "dev", "pre", "post", "local"], +) + + +def parse(version): + """ + Parse the given version string and return either a :class:`Version` object + or a :class:`LegacyVersion` object depending on if the given version is + a valid PEP 440 version or a legacy version. + """ + try: + return Version(version) + except InvalidVersion: + return LegacyVersion(version) + + +class InvalidVersion(ValueError): + """ + An invalid version was found, users should refer to PEP 440. + """ + + +class _BaseVersion(object): + + def __hash__(self): + return hash(self._key) + + def __lt__(self, other): + return self._compare(other, lambda s, o: s < o) + + def __le__(self, other): + return self._compare(other, lambda s, o: s <= o) + + def __eq__(self, other): + return self._compare(other, lambda s, o: s == o) + + def __ge__(self, other): + return self._compare(other, lambda s, o: s >= o) + + def __gt__(self, other): + return self._compare(other, lambda s, o: s > o) + + def __ne__(self, other): + return self._compare(other, lambda s, o: s != o) + + def _compare(self, other, method): + if not isinstance(other, _BaseVersion): + return NotImplemented + + return method(self._key, other._key) + + +class LegacyVersion(_BaseVersion): + + def __init__(self, version): + self._version = str(version) + self._key = _legacy_cmpkey(self._version) + + def __str__(self): + return self._version + + def __repr__(self): + return "".format(repr(str(self))) + + @property + def public(self): + return self._version + + @property + def base_version(self): + return self._version + + @property + def local(self): + return None + + @property + def is_prerelease(self): + return False + + @property + def is_postrelease(self): + return False + + +_legacy_version_component_re = re.compile( + r"(\d+ | [a-z]+ | \.| -)", re.VERBOSE, +) + +_legacy_version_replacement_map = { + "pre": "c", "preview": "c", "-": "final-", "rc": "c", "dev": "@", +} + + +def _parse_version_parts(s): + for part in _legacy_version_component_re.split(s): + part = _legacy_version_replacement_map.get(part, part) + + if not part or part == ".": + continue + + if part[:1] in "0123456789": + # pad for numeric comparison + yield part.zfill(8) + else: + yield "*" + part + + # ensure that alpha/beta/candidate are before final + yield "*final" + + +def _legacy_cmpkey(version): + # We hardcode an epoch of -1 here. A PEP 440 version can only have a epoch + # greater than or equal to 0. This will effectively put the LegacyVersion, + # which uses the defacto standard originally implemented by setuptools, + # as before all PEP 440 versions. + epoch = -1 + + # This scheme is taken from pkg_resources.parse_version setuptools prior to + # it's adoption of the packaging library. + parts = [] + for part in _parse_version_parts(version.lower()): + if part.startswith("*"): + # remove "-" before a prerelease tag + if part < "*final": + while parts and parts[-1] == "*final-": + parts.pop() + + # remove trailing zeros from each series of numeric parts + while parts and parts[-1] == "00000000": + parts.pop() + + parts.append(part) + parts = tuple(parts) + + return epoch, parts + +# Deliberately not anchored to the start and end of the string, to make it +# easier for 3rd party code to reuse +VERSION_PATTERN = r""" + v? + (?: + (?:(?P[0-9]+)!)? # epoch + (?P[0-9]+(?:\.[0-9]+)*) # release segment + (?P
                                          # pre-release
+            [-_\.]?
+            (?P(a|b|c|rc|alpha|beta|pre|preview))
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+        (?P                                         # post release
+            (?:-(?P[0-9]+))
+            |
+            (?:
+                [-_\.]?
+                (?Ppost|rev|r)
+                [-_\.]?
+                (?P[0-9]+)?
+            )
+        )?
+        (?P                                          # dev release
+            [-_\.]?
+            (?Pdev)
+            [-_\.]?
+            (?P[0-9]+)?
+        )?
+    )
+    (?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))?       # local version
+"""
+
+
+class Version(_BaseVersion):
+
+    _regex = re.compile(
+        r"^\s*" + VERSION_PATTERN + r"\s*$",
+        re.VERBOSE | re.IGNORECASE,
+    )
+
+    def __init__(self, version):
+        # Validate the version and parse it into pieces
+        match = self._regex.search(version)
+        if not match:
+            raise InvalidVersion("Invalid version: '{0}'".format(version))
+
+        # Store the parsed out pieces of the version
+        self._version = _Version(
+            epoch=int(match.group("epoch")) if match.group("epoch") else 0,
+            release=tuple(int(i) for i in match.group("release").split(".")),
+            pre=_parse_letter_version(
+                match.group("pre_l"),
+                match.group("pre_n"),
+            ),
+            post=_parse_letter_version(
+                match.group("post_l"),
+                match.group("post_n1") or match.group("post_n2"),
+            ),
+            dev=_parse_letter_version(
+                match.group("dev_l"),
+                match.group("dev_n"),
+            ),
+            local=_parse_local_version(match.group("local")),
+        )
+
+        # Generate a key which will be used for sorting
+        self._key = _cmpkey(
+            self._version.epoch,
+            self._version.release,
+            self._version.pre,
+            self._version.post,
+            self._version.dev,
+            self._version.local,
+        )
+
+    def __repr__(self):
+        return "".format(repr(str(self)))
+
+    def __str__(self):
+        parts = []
+
+        # Epoch
+        if self._version.epoch != 0:
+            parts.append("{0}!".format(self._version.epoch))
+
+        # Release segment
+        parts.append(".".join(str(x) for x in self._version.release))
+
+        # Pre-release
+        if self._version.pre is not None:
+            parts.append("".join(str(x) for x in self._version.pre))
+
+        # Post-release
+        if self._version.post is not None:
+            parts.append(".post{0}".format(self._version.post[1]))
+
+        # Development release
+        if self._version.dev is not None:
+            parts.append(".dev{0}".format(self._version.dev[1]))
+
+        # Local version segment
+        if self._version.local is not None:
+            parts.append(
+                "+{0}".format(".".join(str(x) for x in self._version.local))
+            )
+
+        return "".join(parts)
+
+    @property
+    def public(self):
+        return str(self).split("+", 1)[0]
+
+    @property
+    def base_version(self):
+        parts = []
+
+        # Epoch
+        if self._version.epoch != 0:
+            parts.append("{0}!".format(self._version.epoch))
+
+        # Release segment
+        parts.append(".".join(str(x) for x in self._version.release))
+
+        return "".join(parts)
+
+    @property
+    def local(self):
+        version_string = str(self)
+        if "+" in version_string:
+            return version_string.split("+", 1)[1]
+
+    @property
+    def is_prerelease(self):
+        return bool(self._version.dev or self._version.pre)
+
+    @property
+    def is_postrelease(self):
+        return bool(self._version.post)
+
+
+def _parse_letter_version(letter, number):
+    if letter:
+        # We consider there to be an implicit 0 in a pre-release if there is
+        # not a numeral associated with it.
+        if number is None:
+            number = 0
+
+        # We normalize any letters to their lower case form
+        letter = letter.lower()
+
+        # We consider some words to be alternate spellings of other words and
+        # in those cases we want to normalize the spellings to our preferred
+        # spelling.
+        if letter == "alpha":
+            letter = "a"
+        elif letter == "beta":
+            letter = "b"
+        elif letter in ["c", "pre", "preview"]:
+            letter = "rc"
+        elif letter in ["rev", "r"]:
+            letter = "post"
+
+        return letter, int(number)
+    if not letter and number:
+        # We assume if we are given a number, but we are not given a letter
+        # then this is using the implicit post release syntax (e.g. 1.0-1)
+        letter = "post"
+
+        return letter, int(number)
+
+
+_local_version_seperators = re.compile(r"[\._-]")
+
+
+def _parse_local_version(local):
+    """
+    Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
+    """
+    if local is not None:
+        return tuple(
+            part.lower() if not part.isdigit() else int(part)
+            for part in _local_version_seperators.split(local)
+        )
+
+
+def _cmpkey(epoch, release, pre, post, dev, local):
+    # When we compare a release version, we want to compare it with all of the
+    # trailing zeros removed. So we'll use a reverse the list, drop all the now
+    # leading zeros until we come to something non zero, then take the rest
+    # re-reverse it back into the correct order and make it a tuple and use
+    # that for our sorting key.
+    release = tuple(
+        reversed(list(
+            itertools.dropwhile(
+                lambda x: x == 0,
+                reversed(release),
+            )
+        ))
+    )
+
+    # We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
+    # We'll do this by abusing the pre segment, but we _only_ want to do this
+    # if there is not a pre or a post segment. If we have one of those then
+    # the normal sorting rules will handle this case correctly.
+    if pre is None and post is None and dev is not None:
+        pre = -Infinity
+    # Versions without a pre-release (except as noted above) should sort after
+    # those with one.
+    elif pre is None:
+        pre = Infinity
+
+    # Versions without a post segment should sort before those with one.
+    if post is None:
+        post = -Infinity
+
+    # Versions without a development segment should sort after those with one.
+    if dev is None:
+        dev = Infinity
+
+    if local is None:
+        # Versions without a local segment should sort before those with one.
+        local = -Infinity
+    else:
+        # Versions with a local segment need that segment parsed to implement
+        # the sorting rules in PEP440.
+        # - Alpha numeric segments sort before numeric segments
+        # - Alpha numeric segments sort lexicographically
+        # - Numeric segments sort numerically
+        # - Shorter versions sort before longer versions when the prefixes
+        #   match exactly
+        local = tuple(
+            (i, "") if isinstance(i, int) else (-Infinity, i)
+            for i in local
+        )
+
+    return epoch, release, pre, post, dev, local
\ No newline at end of file
diff --git a/tests/_vendor/vendored.txt b/tests/_vendor/vendored.txt
new file mode 100644
index 000000000..40b6ae75c
--- /dev/null
+++ b/tests/_vendor/vendored.txt
@@ -0,0 +1 @@
+packaging==16.8
\ No newline at end of file
diff --git a/tests/fixtures/generic/xml-nmap-nocomment.json b/tests/fixtures/generic/xml-nmap-nocomment.json
new file mode 100644
index 000000000..849ea04ae
--- /dev/null
+++ b/tests/fixtures/generic/xml-nmap-nocomment.json
@@ -0,0 +1 @@
+{"nmaprun":{"@scanner":"nmap","@args":"nmap -oX - -p 443 galaxy.ansible.com","@start":"1666781498","@startstr":"Wed Oct 26 11:51:38 2022","@version":"7.92","@xmloutputversion":"1.05","scaninfo":{"@type":"connect","@protocol":"tcp","@numservices":"1","@services":"443"},"verbose":{"@level":"0"},"debugging":{"@level":"0"},"hosthint":{"status":{"@state":"up","@reason":"unknown-response","@reason_ttl":"0"},"address":{"@addr":"172.67.68.251","@addrtype":"ipv4"},"hostnames":{"hostname":{"@name":"galaxy.ansible.com","@type":"user"}}},"host":{"@starttime":"1666781498","@endtime":"1666781498","status":{"@state":"up","@reason":"syn-ack","@reason_ttl":"0"},"address":{"@addr":"172.67.68.251","@addrtype":"ipv4"},"hostnames":{"hostname":[{"@name":"galaxy.ansible.com","@type":"user"},{"@name":"galaxy.ansible.com","@type":"PTR"}]},"ports":{"port":{"@protocol":"tcp","@portid":"443","state":{"@state":"open","@reason":"syn-ack","@reason_ttl":"0"},"service":{"@name":"https","@method":"table","@conf":"3"}}},"times":{"@srtt":"12260","@rttvar":"9678","@to":"100000"}},"runstats":{"finished":{"@time":"1666781498","@timestr":"Wed Oct 26 11:51:38 2022","@summary":"Nmap done at Wed Oct 26 11:51:38 2022; 1 IP address (1 host up) scanned in 0.10 seconds","@elapsed":"0.10","@exit":"success"},"hosts":{"@up":"1","@down":"0","@total":"1"}}}}
diff --git a/tests/fixtures/generic/xml-nmap-raw-nocomment.json b/tests/fixtures/generic/xml-nmap-raw-nocomment.json
new file mode 100644
index 000000000..accbe22f9
--- /dev/null
+++ b/tests/fixtures/generic/xml-nmap-raw-nocomment.json
@@ -0,0 +1 @@
+{"nmaprun":{"_scanner":"nmap","_args":"nmap -oX - -p 443 galaxy.ansible.com","_start":"1666781498","_startstr":"Wed Oct 26 11:51:38 2022","_version":"7.92","_xmloutputversion":"1.05","scaninfo":{"_type":"connect","_protocol":"tcp","_numservices":"1","_services":"443"},"verbose":{"_level":"0"},"debugging":{"_level":"0"},"hosthint":{"status":{"_state":"up","_reason":"unknown-response","_reason_ttl":"0"},"address":{"_addr":"172.67.68.251","_addrtype":"ipv4"},"hostnames":{"hostname":{"_name":"galaxy.ansible.com","_type":"user"}}},"host":{"_starttime":"1666781498","_endtime":"1666781498","status":{"_state":"up","_reason":"syn-ack","_reason_ttl":"0"},"address":{"_addr":"172.67.68.251","_addrtype":"ipv4"},"hostnames":{"hostname":[{"_name":"galaxy.ansible.com","_type":"user"},{"_name":"galaxy.ansible.com","_type":"PTR"}]},"ports":{"port":{"_protocol":"tcp","_portid":"443","state":{"_state":"open","_reason":"syn-ack","_reason_ttl":"0"},"service":{"_name":"https","_method":"table","_conf":"3"}}},"times":{"_srtt":"12260","_rttvar":"9678","_to":"100000"}},"runstats":{"finished":{"_time":"1666781498","_timestr":"Wed Oct 26 11:51:38 2022","_summary":"Nmap done at Wed Oct 26 11:51:38 2022; 1 IP address (1 host up) scanned in 0.10 seconds","_elapsed":"0.10","_exit":"success"},"hosts":{"_up":"1","_down":"0","_total":"1"}}}}
diff --git a/tests/test_xml.py b/tests/test_xml.py
index 5b0b4bc4f..4a685a408 100644
--- a/tests/test_xml.py
+++ b/tests/test_xml.py
@@ -2,8 +2,19 @@
 import unittest
 import json
 import jc.parsers.xml
+import xmltodict
+
+# fix for whether tests are run directly or via runtests.sh
+try:
+    from ._vendor.packaging import version
+except:
+    from _vendor.packaging import version  # type: ignore
 
 THIS_DIR = os.path.dirname(os.path.abspath(__file__))
+XMLTODICT_0_13_0_OR_HIGHER = version.parse(xmltodict.__version__) >= version.parse('0.13.0')
+
+if not XMLTODICT_0_13_0_OR_HIGHER:
+    print('\n### Using older version of xmltodict library. Testing without comment support. ###\n')
 
 
 class MyTests(unittest.TestCase):
@@ -28,9 +39,15 @@ class MyTests(unittest.TestCase):
     with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/xml-nmap.json'), 'r', encoding='utf-8') as f:
         generic_xml_nmap_json = json.loads(f.read())
 
+    with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/xml-nmap-nocomment.json'), 'r', encoding='utf-8') as f:
+        generic_xml_nmap_nocomment_json = json.loads(f.read())
+
     with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/xml-nmap-raw.json'), 'r', encoding='utf-8') as f:
         generic_xml_nmap_r_json = json.loads(f.read())
 
+    with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/xml-nmap-raw-nocomment.json'), 'r', encoding='utf-8') as f:
+        generic_xml_nmap_r_nocomment_json = json.loads(f.read())
+
 
     def test_xml_nodata(self):
         """
@@ -60,13 +77,19 @@ def test_xml_nmap(self):
         """
         Test nmap xml output
         """
-        self.assertEqual(jc.parsers.xml.parse(self.generic_xml_nmap, quiet=True), self.generic_xml_nmap_json)
+        if XMLTODICT_0_13_0_OR_HIGHER:
+            self.assertEqual(jc.parsers.xml.parse(self.generic_xml_nmap, quiet=True), self.generic_xml_nmap_json)
+        else:
+            self.assertEqual(jc.parsers.xml.parse(self.generic_xml_nmap, quiet=True), self.generic_xml_nmap_nocomment_json)
 
     def test_xml_nmap_r(self):
         """
         Test nmap xml raw output
         """
-        self.assertEqual(jc.parsers.xml.parse(self.generic_xml_nmap, raw=True, quiet=True), self.generic_xml_nmap_r_json)
+        if XMLTODICT_0_13_0_OR_HIGHER:
+            self.assertEqual(jc.parsers.xml.parse(self.generic_xml_nmap, raw=True, quiet=True), self.generic_xml_nmap_r_json)
+        else:
+            self.assertEqual(jc.parsers.xml.parse(self.generic_xml_nmap, raw=True, quiet=True), self.generic_xml_nmap_r_nocomment_json)
 
 
 if __name__ == '__main__':

From c4e1068895247e2323ab67257990bdf3e8821ed3 Mon Sep 17 00:00:00 2001
From: Kelly Brazil 
Date: Mon, 23 Oct 2023 14:06:04 -0700
Subject: [PATCH 06/13] move print statements

---
 tests/test_xml.py | 5 ++---
 1 file changed, 2 insertions(+), 3 deletions(-)

diff --git a/tests/test_xml.py b/tests/test_xml.py
index 4a685a408..bbe269392 100644
--- a/tests/test_xml.py
+++ b/tests/test_xml.py
@@ -13,9 +13,6 @@
 THIS_DIR = os.path.dirname(os.path.abspath(__file__))
 XMLTODICT_0_13_0_OR_HIGHER = version.parse(xmltodict.__version__) >= version.parse('0.13.0')
 
-if not XMLTODICT_0_13_0_OR_HIGHER:
-    print('\n### Using older version of xmltodict library. Testing without comment support. ###\n')
-
 
 class MyTests(unittest.TestCase):
 
@@ -80,6 +77,7 @@ def test_xml_nmap(self):
         if XMLTODICT_0_13_0_OR_HIGHER:
             self.assertEqual(jc.parsers.xml.parse(self.generic_xml_nmap, quiet=True), self.generic_xml_nmap_json)
         else:
+            print('\n### Using older version of xmltodict library. Testing without comment support. ### ... ')
             self.assertEqual(jc.parsers.xml.parse(self.generic_xml_nmap, quiet=True), self.generic_xml_nmap_nocomment_json)
 
     def test_xml_nmap_r(self):
@@ -89,6 +87,7 @@ def test_xml_nmap_r(self):
         if XMLTODICT_0_13_0_OR_HIGHER:
             self.assertEqual(jc.parsers.xml.parse(self.generic_xml_nmap, raw=True, quiet=True), self.generic_xml_nmap_r_json)
         else:
+            print('\n### Using older version of xmltodict library. Testing without comment support. ### ... ')
             self.assertEqual(jc.parsers.xml.parse(self.generic_xml_nmap, raw=True, quiet=True), self.generic_xml_nmap_r_nocomment_json)
 
 

From 81f721f1ab75a0d21967bdc45ead693ba7fbcbe7 Mon Sep 17 00:00:00 2001
From: Kelly Brazil 
Date: Mon, 23 Oct 2023 14:33:40 -0700
Subject: [PATCH 07/13] doc update

---
 CHANGELOG | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/CHANGELOG b/CHANGELOG
index 3594b6364..5f3baa628 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,7 +1,7 @@
 jc changelog
 
 20231023 v1.23.6
-- Fix XML parser for older library versions
+- Fix XML parser for xmltodict library versions < 0.13.0
 
 20231021 v1.23.5
 - Add `host` command parser

From 47d4335890fc84a82502cc207b682a3731c7be0d Mon Sep 17 00:00:00 2001
From: Kelly Brazil 
Date: Mon, 23 Oct 2023 15:14:28 -0700
Subject: [PATCH 08/13] fix for multi-word remote

---
 jc/parsers/who.py | 6 ++++++
 1 file changed, 6 insertions(+)

diff --git a/jc/parsers/who.py b/jc/parsers/who.py
index be8c74e75..753c53b9d 100644
--- a/jc/parsers/who.py
+++ b/jc/parsers/who.py
@@ -272,6 +272,12 @@ def parse(data, raw=False, quiet=False):
                 output_line['time'] = ' '.join([linedata.pop(0),
                                                 linedata.pop(0)])
 
+            # if the rest of the data is within parens, then it's the remote IP or console
+            if len(linedata) > 1 and ' '.join(linedata).startswith('(') and ' '.join(linedata).endswith(')'):
+                output_line['from'] = ' '.join(linedata)[1:-1]
+                raw_output.append(output_line)
+                continue
+
             # if just one more field, then it's the remote IP
             if len(linedata) == 1:
                 output_line['from'] = linedata[0].replace('(', '').replace(')', '')

From 741b2d1c1d7bbd0ed047926df2c0b209bc6132c2 Mon Sep 17 00:00:00 2001
From: Kelly Brazil 
Date: Mon, 23 Oct 2023 15:15:07 -0700
Subject: [PATCH 09/13] version bump

---
 jc/parsers/who.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/jc/parsers/who.py b/jc/parsers/who.py
index 753c53b9d..47ebb2278 100644
--- a/jc/parsers/who.py
+++ b/jc/parsers/who.py
@@ -136,7 +136,7 @@
 
 class info():
     """Provides parser metadata (version, author, etc.)"""
-    version = '1.7'
+    version = '1.8'
     description = '`who` command parser'
     author = 'Kelly Brazil'
     author_email = 'kellyjonbrazil@gmail.com'

From 63c271b83701e59543b833ce96cb236bfb64ffde Mon Sep 17 00:00:00 2001
From: Kelly Brazil 
Date: Mon, 23 Oct 2023 15:39:13 -0700
Subject: [PATCH 10/13] add tests

---
 tests/fixtures/generic/who-login-screen.json |  1 +
 tests/fixtures/generic/who-login-screen.out  |  3 +++
 tests/test_who.py                            | 11 +++++++++++
 3 files changed, 15 insertions(+)
 create mode 100644 tests/fixtures/generic/who-login-screen.json
 create mode 100644 tests/fixtures/generic/who-login-screen.out

diff --git a/tests/fixtures/generic/who-login-screen.json b/tests/fixtures/generic/who-login-screen.json
new file mode 100644
index 000000000..e0862f754
--- /dev/null
+++ b/tests/fixtures/generic/who-login-screen.json
@@ -0,0 +1 @@
+[{"user":"atemu","tty":"seat0","time":"2023-10-21 20:06","from":"login screen","epoch":1697943960},{"user":"atemu","tty":":0","time":"2023-10-21 20:06","from":":0","epoch":1697943960},{"user":"atemu","tty":"pts/8","time":"2023-10-23 18:27","epoch":1698110820}]
diff --git a/tests/fixtures/generic/who-login-screen.out b/tests/fixtures/generic/who-login-screen.out
new file mode 100644
index 000000000..ce2dbf529
--- /dev/null
+++ b/tests/fixtures/generic/who-login-screen.out
@@ -0,0 +1,3 @@
+atemu    seat0        2023-10-21 20:06 (login screen)
+atemu    :0           2023-10-21 20:06 (:0)
+atemu    pts/8        2023-10-23 18:27
diff --git a/tests/test_who.py b/tests/test_who.py
index 9678932d6..fd93be995 100644
--- a/tests/test_who.py
+++ b/tests/test_who.py
@@ -34,6 +34,9 @@ class MyTests(unittest.TestCase):
     with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/osx-10.14.6/who-a.out'), 'r', encoding='utf-8') as f:
         osx_10_14_6_who_a = f.read()
 
+    with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/who-login-screen.out'), 'r', encoding='utf-8') as f:
+        generic_who_login_screen = f.read()
+
     # output
     with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/centos-7.7/who.json'), 'r', encoding='utf-8') as f:
         centos_7_7_who_json = json.loads(f.read())
@@ -53,6 +56,9 @@ class MyTests(unittest.TestCase):
     with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/osx-10.14.6/who-a.json'), 'r', encoding='utf-8') as f:
         osx_10_14_6_who_a_json = json.loads(f.read())
 
+    with open(os.path.join(THIS_DIR, os.pardir, 'tests/fixtures/generic/who-login-screen.json'), 'r', encoding='utf-8') as f:
+        generic_who_login_screen_json = json.loads(f.read())
+
 
     def test_who_nodata(self):
         """
@@ -96,6 +102,11 @@ def test_who_a_osx_10_14_6(self):
         """
         self.assertEqual(jc.parsers.who.parse(self.osx_10_14_6_who_a, quiet=True), self.osx_10_14_6_who_a_json)
 
+    def test_who_login_screen(self):
+        """
+        Test 'who' with (login screen) as remote
+        """
+        self.assertEqual(jc.parsers.who.parse(self.generic_who_login_screen, quiet=True), self.generic_who_login_screen_json)
 
 if __name__ == '__main__':
     unittest.main()

From 54def8ef497fa83aa1cfd11f360d318604c23c25 Mon Sep 17 00:00:00 2001
From: Kelly Brazil 
Date: Mon, 23 Oct 2023 15:41:11 -0700
Subject: [PATCH 11/13] doc update

---
 CHANGELOG           | 1 +
 docs/parsers/who.md | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG b/CHANGELOG
index 5f3baa628..1d64ef463 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -2,6 +2,7 @@ jc changelog
 
 20231023 v1.23.6
 - Fix XML parser for xmltodict library versions < 0.13.0
+- Fix `who` command parser for cases when the from field contains spaces
 
 20231021 v1.23.5
 - Add `host` command parser
diff --git a/docs/parsers/who.md b/docs/parsers/who.md
index 1abd1a0d4..0024d6385 100644
--- a/docs/parsers/who.md
+++ b/docs/parsers/who.md
@@ -158,4 +158,4 @@ Returns:
 ### Parser Information
 Compatibility:  linux, darwin, cygwin, aix, freebsd
 
-Version 1.7 by Kelly Brazil (kellyjonbrazil@gmail.com)
+Version 1.8 by Kelly Brazil (kellyjonbrazil@gmail.com)

From 264fcd40ad274714fa0642225f1a58b6a78d0668 Mon Sep 17 00:00:00 2001
From: Kelly Brazil 
Date: Mon, 23 Oct 2023 17:36:25 -0700
Subject: [PATCH 12/13] clear linedata if 'from' found

---
 jc/parsers/who.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/jc/parsers/who.py b/jc/parsers/who.py
index 47ebb2278..f459dab03 100644
--- a/jc/parsers/who.py
+++ b/jc/parsers/who.py
@@ -276,6 +276,7 @@ def parse(data, raw=False, quiet=False):
             if len(linedata) > 1 and ' '.join(linedata).startswith('(') and ' '.join(linedata).endswith(')'):
                 output_line['from'] = ' '.join(linedata)[1:-1]
                 raw_output.append(output_line)
+                linedata = []
                 continue
 
             # if just one more field, then it's the remote IP

From b0cf2e2d78f048c40e7c46b48269c6ff1da64c1a Mon Sep 17 00:00:00 2001
From: Kelly Brazil 
Date: Mon, 23 Oct 2023 17:37:53 -0700
Subject: [PATCH 13/13] clean up final return

---
 jc/parsers/who.py | 5 +----
 1 file changed, 1 insertion(+), 4 deletions(-)

diff --git a/jc/parsers/who.py b/jc/parsers/who.py
index f459dab03..6c8cc7840 100644
--- a/jc/parsers/who.py
+++ b/jc/parsers/who.py
@@ -303,7 +303,4 @@ def parse(data, raw=False, quiet=False):
 
             raw_output.append(output_line)
 
-    if raw:
-        return raw_output
-    else:
-        return _process(raw_output)
+    return raw_output if raw else _process(raw_output)