From 913a677b2a7a51e9f69bc66eae756b0e4eb8ce63 Mon Sep 17 00:00:00 2001
From: Jan Staněk <jstanek@redhat.com>
Date: Jun 20 2019 15:09:56 +0000
Subject: [PATCH 1/3] Refactor nodejs.req


- Python 2/3 compatible
- Functions with docstrings and doctests
- Passes with unmodified test suite (including original typos)

---

diff --git a/nodejs.req b/nodejs.req
index 52b33f7..0111dd3 100755
--- a/nodejs.req
+++ b/nodejs.req
@@ -1,12 +1,7 @@
-#!/usr/bin/python3
-
-"""
-Automatic dependency generator for Node.js libraries.
-
-Parsed from package.json.  See `man npm-json` for details.
-"""
-
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
 # Copyright 2012, 2013 T.C. Hollingsworth <tchollingsworth@gmail.com>
+# Copyright 2019 Jan Staněk <jstanek@redat.com>
 #
 # Permission is hereby granted, free of charge, to any person obtaining a copy
 # of this software and associated documentation files (the "Software"), to
@@ -26,270 +21,679 @@ Parsed from package.json.  See `man npm-json` for details.
 # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
 # IN THE SOFTWARE.
 
-from __future__ import unicode_literals, print_function
+""" Automatic dependency generator for Node.js libraries.
+
+Metadata parsed from package.json.  See `man npm-json` for details.
+"""
+
+from __future__ import print_function, with_statement
+
 import json
+import operator
 import os
 import re
 import sys
+from collections import namedtuple
+from itertools import chain
+from itertools import takewhile
 
-def has_all_bundled(path):
-    # remove 'package.json'
-    path = os.path.dirname(path)
-    node_modules_dir_candidate = os.path.join(path, "node_modules")
-    if os.path.isdir(node_modules_dir_candidate):
-        modules_abs_path = map(lambda x: os.path.join(node_modules_dir_candidate, x),
-                               os.listdir(node_modules_dir_candidate))
-        any_link = any([os.path.islink(x) for x in modules_abs_path])
-        return not any_link
+# Python version detection
+_PY2 = sys.version_info[0] <= 2
+_PY3 = sys.version_info[0] >= 3
 
+if _PY2:
+    from future_builtins import map, filter
 
-def main():
-    #npm2rpm uses functions here to write BuildRequires so don't print anything
-    #until the very end
-    deps = []
 
-    #it's highly unlikely that we'll ever get more than one file but we handle
-    #this like all RPM automatic dependency generation scripts anyway
-    paths = [path.rstrip() for path in sys.stdin.readlines()]
+#: Name format of the requirements
+REQUIREMENT_NAME_TEMPLATE = "npm({name})"
 
-    for path in paths:
-        if not path.endswith('package.json'):
-            continue
+#: ``simple`` product of the NPM semver grammar.
+RANGE_SPECIFIER_SIMPLE = re.compile(
+    r"""
+    (?P<operator>
+        <= | >= | < | > | =     # primitive
+        | ~ | \^                # tilde/caret operators
+    )?
+    \s*(?P<version>\S+)\s*  # version specifier
+    """,
+    flags=re.VERBOSE,
+)
 
-        if has_all_bundled(path):
-            continue
 
-        fh = open(path)
-        metadata = json.load(fh)
-        fh.close()
+class UnsupportedVersionToken(ValueError):
+    """Version specifier contains token unsupported by the parser."""
 
-        if '--optional' in sys.argv:
-            deptype = 'optionalDependencies'
-        else:
-            deptype = 'dependencies'
-
-        if deptype == 'dependencies':
-            #write out the node.js interpreter dependency
-            req = 'nodejs(engine)'
-
-            if 'engines' in metadata and isinstance(metadata['engines'], dict) \
-                                                and 'node' in metadata['engines']:
-                deps.append(process_dep(req, metadata['engines']['node']))
-            else:
-                deps.append(req)
-
-        if deptype in metadata:
-            if isinstance(metadata[deptype], dict):
-                for name, version in metadata[deptype].items():
-                    req = 'npm(' + name + ')'
-                    deps.append(process_dep(req, version))
-            elif isinstance(metadata[deptype], list):
-                for name in metadata[deptype]:
-                    req = 'npm(' + name + ')'
-                    deps.append(req)
-            elif isinstance(metadata[deptype], str):
-                req = 'npm(' + metadata[deptype] + ')'
-                deps.append(req)
-            else:
-                raise TypeError('invalid package.json: dependencies not a valid type')
-
-    print('\n'.join(deps))
-
-def process_dep(req, version):
-    """Converts an individual npm dependency into RPM dependencies"""
-
-    #there's no way RPM can do anything like an OR dependency
-    if '||' in version:
-        sys.stderr.write("WARNING: The {0} dependency contains an ".format(req) +
-            "OR (||) dependency: '{0}.\nPlease manually include ".format(version) +
-            "a versioned dependency in your spec file if necessary")
-        return req
-
-    if ' - ' in version:
-        version = expand_hyphen_range(version)
-
-    deps = convert_dep(req, version)
-
-    if len(deps) > 1:
-        dep = "(" + " with ".join(deps) + ")"
-    else:
-        dep = deps[0]
 
-    return dep
+class Version(tuple):
+    """Normalized RPM/NPM version.
 
-def parse_version(v):
-    """
-    Parse an individual version number like 1.2.3 into a tuple.
-        '1.2.3' -> (1, 2, 3)
-        '1.2'   -> (1, 2)
-        '1'     -> (1,)
-        '*'     -> ()
-    This is the "partial" production in the grammar:
-    https://docs.npmjs.com/misc/semver#range-grammar
+    The version has up to 3 components – major, minor, patch.
+    Any part set to None is treated as unspecified.
+
+    ::
+
+        1.2.3 == Version(1, 2, 3)
+        1.2   == Version(1, 2)
+        1     == Version(1)
+        *     == Version()
     """
-    # Ignore leading 'v'
-    v = v.lstrip('v')
-    parts = v.split('.', 3)
-    if parts[0] in ['', 'x', 'X', '*']:
-        return ()
-    if len(parts) < 2 or parts[1] in ['', 'x', 'X', '*']:
-        return (int(parts[0]),)
-    if len(parts) < 3 or parts[2] in ['', 'x', 'X', '*']:
-        return (int(parts[0]), int(parts[1]))
-    # Strip off and discard any pre-release or build qualifiers at the end.
-    # We can get away with this, because there is no sane way to represent
-    # these kinds of version requirements in RPM, and we generally expect
-    # the distro will only carry proper releases anyway.
-    return (int(parts[0]),
-            int(parts[1]),
-            int(''.join(c for c in parts[2] if c.isdigit())))
-
-def incremented(v):
+
+    __slots__ = ()
+
+    #: Version part meaning 'Any'
+    #: ``xr`` in https://docs.npmjs.com/misc/semver#range-grammar
+    _PART_ANY = re.compile(r"^[xX*]$")
+    #: Numeric version part
+    #: ``nr`` in https://docs.npmjs.com/misc/semver#range-grammar
+    _PART_NUMERIC = re.compile(r"0|[1-9]\d*")
+
+    def __new__(cls, *args):
+        """Create new version.
+
+        Arguments:
+            Version components in the order of "major", "minor", "patch".
+            All parts are optional::
+
+                >>> Version(1, 2, 3)
+                Version(1, 2, 3)
+                >>> Version(1)
+                Version(1)
+                >>> Version()
+                Version()
+
+        Returns:
+            New Version.
+        """
+
+        if len(args) > 3:
+            raise ValueError("Version has maximum of 3 components")
+        return super(Version, cls).__new__(cls, map(int, args))
+
+    def __repr__(self):
+        """Pretty debugging format."""
+
+        return "{0}({1})".format(self.__class__.__name__, ", ".join(map(str, self)))
+
+    def __str__(self):
+        """RPM version format."""
+
+        return ".".join(format(part, "d") for part in self)
+
+    @property
+    def major(self):
+        """Major version number, if any."""
+        return self[0] if len(self) > 0 else None
+
+    @property
+    def minor(self):
+        """Major version number, if any."""
+        return self[1] if len(self) > 1 else None
+
+    @property
+    def patch(self):
+        """Major version number, if any."""
+        return self[2] if len(self) > 2 else None
+
+    @property
+    def empty(self):
+        """True if the version contains nothing but zeroes."""
+        return not any(self)
+
+    @classmethod
+    def parse(cls, version_string):
+        """Parse individual version string (like ``1.2.3``) into Version.
+
+        This is the ``partial`` production in the grammar:
+        https://docs.npmjs.com/misc/semver#range-grammar
+
+        Examples::
+
+            >>> Version.parse("1.2.3")
+            Version(1, 2, 3)
+            >>> Version.parse("v2.x")
+            Version(2)
+            >>> Version.parse("")
+            Version()
+
+        Arguments:
+            version_string (str): The version_string to parse.
+
+        Returns:
+            Version: Parsed result.
+        """
+
+        # Ignore leading ``v``, if any
+        version_string = version_string.lstrip("v")
+
+        part_list = version_string.split(".", 2)
+        # Use only parts up to first "Any" indicator
+        part_list = list(takewhile(lambda p: not cls._PART_ANY.match(p), part_list))
+
+        if not part_list:
+            return cls()
+
+        # Strip off and discard any pre-release or build qualifiers at the end.
+        # We can get away with this, because there is no sane way to represent
+        # these kinds of version requirements in RPM, and we generally expect
+        # the distro will only carry proper releases anyway.
+        try:
+            part_list[-1] = cls._PART_NUMERIC.match(part_list[-1]).group()
+        except AttributeError:  # no match
+            part_list.pop()
+
+        # Extend with ``None``s at the end, if necessary
+        return cls(*part_list)
+
+    def incremented(self):
+        """Increment the least significant part of the version::
+
+            >>> Version(1, 2, 3).incremented()
+            Version(1, 2, 4)
+            >>> Version(1, 2).incremented()
+            Version(1, 3)
+            >>> Version(1).incremented()
+            Version(2)
+            >>> Version().incremented()
+            Version()
+
+        Returns:
+            Version: New incremented Version.
+        """
+
+        if len(self) == 0:
+            return self.__class__()
+        else:
+            args = self[:-1] + (self[-1] + 1,)
+            return self.__class__(*args)
+
+
+class VersionBoundary(namedtuple("VersionBoundary", ("version", "operator"))):
+    """Normalized version range boundary."""
+
+    __slots__ = ()
+
+    #: Ordering of primitive operators.
+    #: Operators not listed here are handled specially; see __compare below.
+    #: Convention: Lower boundary < 0, Upper boundary > 0
+    _OPERATOR_ORDER = {"<": 2, "<=": 1, ">=": -1, ">": -2}
+
+    def __str__(self):
+        """Pretty-print the boundary"""
+
+        return "{0.operator}{0.version}".format(self)
+
+    def __compare(self, other, operator):
+        """Compare two boundaries with provided operator.
+
+        Boundaries compare same as (version, operator_order) tuple.
+        In case the boundary operator is not listed in _OPERATOR_ORDER,
+        it's order is treated as 0.
+
+        Arguments:
+            other (VersionBoundary): The other boundary to compare with.
+            operator (Callable[[VersionBoundary, VersionBoundary], bool]):
+                Comparison operator to delegate to.
+
+        Returns:
+            bool: The result of the operator's comparison.
+        """
+
+        ORDER = self._OPERATOR_ORDER
+
+        lhs = self.version, ORDER.get(self.operator, 0)
+        rhs = other.version, ORDER.get(other.operator, 0)
+        return operator(lhs, rhs)
+
+    def __eq__(self, other):
+        return self.__compare(other, operator.eq)
+
+    def __lt__(self, other):
+        return self.__compare(other, operator.lt)
+
+    def __le__(self, other):
+        return self.__compare(other, operator.le)
+
+    def __gt__(self, other):
+        return self.__compare(other, operator.gt)
+
+    def __ge__(self, other):
+        return self.__compare(other, operator.ge)
+
+    @property
+    def upper(self):
+        """True if self is upper boundary."""
+        return self._OPERATOR_ORDER.get(self.operator, 0) > 0
+
+    @property
+    def lower(self):
+        """True if self is lower boundary."""
+        return self._OPERATOR_ORDER.get(self.operator, 0) < 0
+
+    @classmethod
+    def equal(cls, version):
+        """Normalize single samp:`={version}` into equivalent x-range::
+
+            >>> empty = VersionBoundary.equal(Version()); tuple(map(str, empty))
+            ()
+            >>> patch = VersionBoundary.equal(Version(1, 2, 3)); tuple(map(str, patch))
+            ('>=1.2.3', '<1.2.4')
+            >>> minor = VersionBoundary.equal(Version(1, 2)); tuple(map(str, minor))
+            ('>=1.2', '<1.3')
+            >>> major = VersionBoundary.equal(Version(1)); tuple(map(str, major))
+            ('>=1', '<2')
+
+        See `X-Ranges <https://docs.npmjs.com/misc/semver#x-ranges-12x-1x-12->`_
+        for details.
+
+        Arguments:
+            version (Version): The version the x-range should be equal to.
+
+        Returns:
+            (VersionBoundary, VersionBoundary):
+                Lower and upper bound of the x-range.
+            (): Empty tuple in case version is empty (any version matches).
+        """
+
+        if version:
+            return (
+                cls(version=version, operator=">="),
+                cls(version=version.incremented(), operator="<"),
+            )
+        else:
+            return ()
+
+    @classmethod
+    def tilde(cls, version):
+        """Normalize :samp:`~{version}` into equivalent range.
+
+        Tilde allows patch-level changes if a minor version is specified.
+        Allows minor-level changes if not::
+
+            >>> with_minor = VersionBoundary.tilde(Version(1, 2, 3)); tuple(map(str, with_minor))
+            ('>=1.2.3', '<1.3')
+            >>> no_minor = VersionBoundary.tilde(Version(1)); tuple(map(str, no_minor))
+            ('>=1', '<2')
+
+        Arguments:
+            version (Version): The version to tilde-expand.
+
+        Returns:
+            (VersionBoundary, VersionBoundary):
+                The lower and upper boundary of the tilde range.
+        """
+
+        # Fail on ``~*`` or similar nonsense specifier
+        assert version.major is not None, "Nonsense '~*' specifier"
+
+        lower_boundary = cls(version=version, operator=">=")
+
+        if version.minor is None:
+            upper_boundary = cls(version=Version(version.major + 1), operator="<")
+        else:
+            upper_boundary = cls(
+                version=Version(version.major, version.minor + 1), operator="<"
+            )
+
+        return lower_boundary, upper_boundary
+
+    @classmethod
+    def caret(cls, version):
+        """Normalize :samp:`^{version}` into equivalent range.
+
+        Caret allows changes that do not modify the left-most non-zero digit
+        in the ``(major, minor, patch)`` tuple.
+        In other words, this allows
+        patch and minor updates for versions 1.0.0 and above,
+        patch updates for versions 0.X >=0.1.0,
+        and no updates for versions 0.0.X::
+
+            >>> major = VersionBoundary.caret(Version(1, 2, 3)); tuple(map(str, major))
+            ('>=1.2.3', '<2')
+            >>> minor = VersionBoundary.caret(Version(0, 2, 3)); tuple(map(str, minor))
+            ('>=0.2.3', '<0.3')
+            >>> patch = VersionBoundary.caret(Version(0, 0, 3)); tuple(map(str, patch))
+            ('>=0.0.3', '<0.0.4')
+
+        When parsing caret ranges, a missing patch value desugars to the number 0,
+        but will allow flexibility within that value,
+        even if the major and minor versions are both 0::
+
+            >>> rel = VersionBoundary.caret(Version(1, 2)); tuple(map(str, rel))
+            ('>=1.2', '<2')
+            >>> pre = VersionBoundary.caret(Version(0, 0)); tuple(map(str, pre))
+            ('>=0.0', '<0.1')
+
+        A missing minor and patch values will desugar to zero,
+        but also allow flexibility within those values,
+        even if the major version is zero::
+
+            >>> rel = VersionBoundary.caret(Version(1)); tuple(map(str, rel))
+            ('>=1', '<2')
+            >>> pre = VersionBoundary.caret(Version(0)); tuple(map(str, pre))
+            ('>=0', '<1')
+
+        Arguments:
+            version (Version): The version to range-expand.
+
+        Returns:
+            (VersionBoundary, VersionBoundary):
+                The lower and upper boundary of caret-range.
+        """
+
+        # Fail on ^* or similar nonsense specifier
+        assert len(version) != 0, "Nonsense '^*' specifier"
+
+        lower_boundary = cls(version=version, operator=">=")
+
+        # Increment left-most non-zero part
+        for idx, part in enumerate(version):
+            if part != 0:
+                upper_version = Version(*(version[:idx] + (part + 1,)))
+                break
+        else:  # No non-zero found; increment last specified part
+            upper_version = version.incremented()
+
+        upper_boundary = cls(version=upper_version, operator="<")
+
+        return lower_boundary, upper_boundary
+
+    @classmethod
+    def hyphen(cls, lower_version, upper_version):
+        """Construct hyphen range (inclusive set)::
+
+            >>> full = VersionBoundary.hyphen(Version(1, 2, 3), Version(2, 3, 4)); tuple(map(str, full))
+            ('>=1.2.3', '<=2.3.4')
+
+        If a partial version is provided as the first version in the inclusive range,
+        then the missing pieces are treated as zeroes::
+
+            >>> part = VersionBoundary.hyphen(Version(1, 2), Version(2, 3, 4)); tuple(map(str, part))
+            ('>=1.2', '<=2.3.4')
+
+        If a partial version is provided as the second version in the inclusive range,
+        then all versions that start with the supplied parts of the tuple are accepted,
+        but nothing that would be greater than the provided tuple parts::
+
+            >>> part = VersionBoundary.hyphen(Version(1, 2, 3), Version(2, 3)); tuple(map(str, part))
+            ('>=1.2.3', '<2.4')
+            >>> part = VersionBoundary.hyphen(Version(1, 2, 3), Version(2)); tuple(map(str, part))
+            ('>=1.2.3', '<3')
+
+        Arguments:
+            lower_version (Version): Version on the lower range boundary.
+            upper_version (Version): Version on the upper range boundary.
+
+        Returns:
+            (VersionBoundary, VersionBoundary):
+                Lower and upper boundaries of the hyphen range.
+        """
+
+        lower_boundary = cls(version=lower_version, operator=">=")
+
+        if len(upper_version) < 3:
+            upper_boundary = cls(version=upper_version.incremented(), operator="<")
+        else:
+            upper_boundary = cls(version=upper_version, operator="<=")
+
+        return lower_boundary, upper_boundary
+
+
+def parse_simple_seq(specifier_string):
+    """Parse all specifiers from a space-separated string::
+
+        >>> single = parse_simple_seq(">=1.2.3"); list(map(str, single))
+        ['>=1.2.3']
+        >>> multi = parse_simple_seq("~1.2.0 <1.2.5"); list(map(str, multi))
+        ['>=1.2.0', '<1.3', '<1.2.5']
+
+    This method implements the ``simple (' ' simple)*`` part of the grammar:
+    https://docs.npmjs.com/misc/semver#range-grammar.
+
+    Arguments:
+        specifier_string (str): Space-separated string of simple version specifiers.
+
+    Yields:
+        VersionBoundary: Parsed boundaries.
     """
-    Returns the given version tuple with the last part incremented.
-        (1, 2, 3) -> (1, 2, 4)
-        (1, 2)    -> (1, 3)
-        (1,)      -> (2,)
-        ()        -> ()
+
+    # Per-operator dispatch table
+    # API: Callable[[Version], Iterable[VersionBoundary]]
+    handler = {
+        ">": lambda v: [VersionBoundary(version=v, operator=">")],
+        ">=": lambda v: [VersionBoundary(version=v, operator=">=")],
+        "<=": lambda v: [VersionBoundary(version=v, operator="<=")],
+        "<": lambda v: [VersionBoundary(version=v, operator="<")],
+        "=": VersionBoundary.equal,
+        "~": VersionBoundary.tilde,
+        "^": VersionBoundary.caret,
+        None: VersionBoundary.equal,
+    }
+
+    for match in RANGE_SPECIFIER_SIMPLE.finditer(specifier_string):
+        operator, version_string = match.group("operator", "version")
+
+        for boundary in handler[operator](Version.parse(version_string)):
+            yield boundary
+
+
+def parse_range(range_string):
+    """Parse full NPM version range specification::
+
+        >>> empty = parse_range(""); list(map(str, empty))
+        []
+        >>> simple = parse_range("^1.0"); list(map(str, simple))
+        ['>=1.0', '<2']
+        >>> hyphen = parse_range("1.0 - 2.0"); list(map(str, hyphen))
+        ['>=1.0', '<2.1']
+
+    This method implements the ``range`` part of the grammar:
+    https://docs.npmjs.com/misc/semver#range-grammar.
+
+    Arguments:
+        range_string (str): The range specification to parse.
+
+    Returns:
+        Iterable[VersionBoundary]: Parsed boundaries.
+
+    Raises:
+        UnsupportedVersionToken: ``||`` is present in range_string.
     """
-    if len(v) == 3:
-        return (v[0], v[1], v[2] + 1)
-    if len(v) == 2:
-        return (v[0], v[1] + 1)
-    if len(v) == 1:
-        return (v[0] + 1,)
-    if len(v) == 0:
-        return ()
-
-def expand_hyphen_range(version):
+
+    HYPHEN = " - "
+
+    # FIXME: rpm should be able to process OR in dependencies
+    # This error reporting kept for backward compatibility
+    if "||" in range_string:
+        raise UnsupportedVersionToken(range_string)
+
+    if HYPHEN in range_string:
+        version_pair = map(Version.parse, range_string.split(HYPHEN, 2))
+        return VersionBoundary.hyphen(*version_pair)
+
+    elif range_string != "":
+        return parse_simple_seq(range_string)
+
+    else:
+        return []
+
+
+def unify_range(boundary_iter):
+    """Calculate largest allowed continuous version range from a set of boundaries::
+
+        >>> unify_range([])
+        ()
+        >>> _ = unify_range(parse_range("=1.2.3 <2")); tuple(map(str, _))
+        ('>=1.2.3', '<1.2.4')
+        >>> _ = unify_range(parse_range("~1.2 <1.2.5")); tuple(map(str, _))
+        ('>=1.2', '<1.2.5')
+
+    Arguments:
+        boundary_iter (Iterable[VersionBoundary]): The version boundaries to unify.
+
+    Returns:
+        (VersionBoundary, VersionBoundary):
+            Lower and upper boundary of the unified range.
     """
-    Converts a hyphen range into its equivalent comparator set.
-        '1.2.3 - 2.3.4' -> '>=1.2.3 <=2.3.4'
 
-    https://docs.npmjs.com/misc/semver#hyphen-ranges-xyz---abc
+    # Drop boundaries with empty version
+    boundary_iter = (
+        boundary for boundary in boundary_iter if not boundary.version.empty
+    )
+
+    # Split input sequence into upper/lower boundaries
+    lower_list, upper_list = [], []
+    for boundary in boundary_iter:
+        if boundary.lower:
+            lower_list.append(boundary)
+        elif boundary.upper:
+            upper_list.append(boundary)
+        else:
+            msg = "Unsupported boundary for unify_range: {0}".format(boundary)
+            raise ValueError(msg)
+
+    # Select maximum from lower boundaries and minimum from upper boundaries
+    intermediate = (
+        max(lower_list) if lower_list else None,
+        min(upper_list) if upper_list else None,
+    )
+
+    return tuple(filter(None, intermediate))
+
+
+def rpm_format(requirement, version_spec="*"):
+    """Format requirement as RPM boolean dependency::
+
+        >>> rpm_format("nodejs(engine)")
+        'nodejs(engine)'
+        >>> rpm_format("npm(foo)", ">=1.0.0")
+        'npm(foo) >= 1.0.0'
+        >>> rpm_format("npm(bar)", "~1.2")
+        '(npm(bar) >= 1.2 with npm(bar) < 1.3)'
+
+    Arguments:
+        requirement (str): The name of the requirement.
+        version_spec (str): The NPM version specification for the requirement.
+
+    Returns:
+        str: Formatted requirement.
     """
-    lower, upper = version.split(' - ', 1)
-    upper_parts = parse_version(upper)
-    if len(upper_parts) == 3:
-        return '>={} <={}'.format(lower, upper)
-    # Special behaviour if the upper bound is partial:
-    if len(upper_parts) == 2:
-        return '>={} <{}.{}'.format(lower, upper_parts[0], upper_parts[1] + 1)
-    if len(upper_parts) == 1:
-        return '>={} <{}'.format(lower, upper_parts[0] + 1)
-    if len(upper_parts) == 0:
-        return '>={}'.format(lower)
-
-def convert_dep(req, version):
+
+    TEMPLATE = "{name} {boundary.operator} {boundary.version!s}"
+
+    try:
+        boundary_tuple = unify_range(parse_range(version_spec))
+
+    except UnsupportedVersionToken:
+        # FIXME: Typos and print behavior kept for backward compatibility
+        warning_lines = [
+            "WARNING: The {requirement} dependency contains an OR (||) dependency: '{version_spec}.",
+            "Please manually include a versioned dependency in your spec file if necessary",
+        ]
+        warning = "\n".join(warning_lines).format(
+            requirement=requirement, version_spec=version_spec
+        )
+        print(warning, end="", file=sys.stderr)
+
+        return requirement
+
+    formatted = [
+        TEMPLATE.format(name=requirement, boundary=boundary)
+        for boundary in boundary_tuple
+    ]
+
+    if len(formatted) > 1:
+        return "({0})".format(" with ".join(formatted))
+    elif len(formatted) == 1:
+        return formatted[0]
+    else:
+        return requirement
+
+
+def has_only_bundled_dependencies(module_dir_path):
+    """Determines if the module contains only bundled dependencies.
+
+    Arguments:
+        module_dir_path (str):
+            Path to the module directory (directory with ``package.json``).
+
+    Returns:
+        bool: True if all dependencies are bundled, False otherwise.
     """
-    Converts an NPM requirement to an equivalent list of RPM requirements.
+
+    module_root_path = os.path.abspath(module_dir_path)
+    dependency_root_path = os.path.join(module_root_path, "node_modules")
+
+    try:
+        dependency_path_iter = (
+            os.path.join(dependency_root_path, basename)
+            for basename in os.listdir(dependency_root_path)
+        )
+
+        return not any(os.path.islink(path) for path in dependency_path_iter)
+    except OSError:  # node_modules does not exist
+        return False
+
+
+def extract_dependencies(metadata_path, optional=False):
+    """Extract all dependencies in RPM format from package metadata.
+
+    Arguments:
+        metadata_path (str): Path to package metadata (``package.json``).
+        optional (bool):
+            If True, extract ``optionalDependencies``
+            instead of ``dependencies``.
+
+    Yields:
+        RPM-formatted dependencies.
+
+    Raises:
+        TypeError: Invalid dependency data type.
     """
-    # The version is a space-separated set of one or more comparators.
-    # There can be any number of comparators (even more than two) using all the
-    # various shortcut operators, but ultimately the comparator set is
-    # equivalent to a continuous range of version numbers, with an upper and
-    # lower bound (possibly inclusive or exclusive at each end).
-
-    # Start by defining the range as infinite.
-    lower_bound = ()
-    lower_bound_inclusive = True
-    upper_bound = ()
-    upper_bound_inclusive = False
-
-    # Helper function to narrow the lower bound to the given version, if it's
-    # *higher* than what we have now.
-    def narrow_lower(parts, inclusive):
-        nonlocal lower_bound, lower_bound_inclusive
-        if parts > lower_bound:
-            lower_bound = parts
-            lower_bound_inclusive = inclusive
-        elif parts == lower_bound:
-            if not inclusive and lower_bound_inclusive:
-                lower_bound_inclusive = False
-    # Same for the upper bound.
-    def narrow_upper(parts, inclusive):
-        nonlocal upper_bound, upper_bound_inclusive
-        if parts == ():
-            return
-        if upper_bound == () or parts < upper_bound:
-            upper_bound = parts
-            upper_bound_inclusive = inclusive
-        elif parts == upper_bound:
-            if not inclusive and upper_bound_inclusive:
-                upper_bound_inclusive = False
-
-    # For each comparator in the set, narrow the range to match it,
-    # using the two helper functions.
-    for operator, v in re.findall(r'(<=|>=|<|>|=|\^|~)?\s*(\S+)\s*', version):
-        if not operator:
-            operator = '='
-        parts = parse_version(v)
-
-        if operator == '>':
-            narrow_lower(parts, False)
-
-        elif operator == '>=':
-            narrow_lower(parts, True)
-
-        elif operator == '<':
-            narrow_upper(parts, False)
-
-        elif operator == '<=':
-            narrow_upper(parts, True)
-
-        elif operator == '=':
-            narrow_lower(parts, True)
-            narrow_upper(incremented(parts), False)
-
-        elif operator == '~':
-            narrow_lower(parts, True)
-            if len(parts) == 0:
-                pass
-            elif len(parts) == 1:
-                narrow_upper((parts[0] + 1,), False)
-            else:
-                narrow_upper((parts[0], parts[1] + 1), False)
-
-        elif operator == '^':
-            narrow_lower(parts, True)
-            if len(parts) == 0:
-                pass
-            elif len(parts) == 1:
-                narrow_upper((parts[0] + 1,), False)
-            elif len(parts) == 2:
-                if parts[0] == 0:
-                    narrow_upper((0, parts[1] + 1), False)
-                else:
-                    narrow_upper((parts[0] + 1,), False)
-            elif len(parts) == 3:
-                if parts[0] == 0 and parts[1] == 0:
-                    narrow_upper((0, 0, parts[2] + 1), False)
-                elif parts[0] == 0:
-                    narrow_upper((0, parts[1] + 1), False)
-                else:
-                    narrow_upper((parts[0] + 1,), False)
-
-    # At the end, we have an upper and lower bound which satisfies all the
-    # comparators in the set. This is what will become our RPM version
-    # requirements.
-
-    # Special case: no effective bounds.
-    if not lower_bound and not upper_bound:
-        return [req]
-
-    # Otherwise, produce RPM requirements for the upper and lower bounds.
-    deps = []
-    if lower_bound not in [(), (0,), (0, 0), (0, 0, 0)]:
-        deps.append('{} {} {}'.format(
-                req,
-                '>=' if lower_bound_inclusive else '>',
-                '.'.join(str(part) for part in lower_bound)))
-    if upper_bound != ():
-        deps.append('{} {} {}'.format(
-                req,
-                '<=' if upper_bound_inclusive else '<',
-                '.'.join(str(part) for part in upper_bound)))
-    return deps
-
-if __name__ == '__main__':
-    main()
+
+    if has_only_bundled_dependencies(os.path.dirname(metadata_path)):
+        return  # skip
+
+    # Read metadata
+    try:
+        with open(metadata_path, mode="r") as metadata_file:
+            metadata = json.load(metadata_file)
+    except OSError:  # Invalid metadata file
+        return  # skip
+
+    # Report required NodeJS version with required dependencies
+    if not optional:
+        try:
+            yield rpm_format("nodejs(engine)", metadata["engines"]["node"])
+        except KeyError:  # NodeJS engine version unspecified
+            yield rpm_format("nodejs(engine)")
+
+    # Report listed dependencies
+    kind = "optionalDependencies" if optional else "dependencies"
+    container = metadata.get(kind, {})
+
+    if isinstance(container, dict):
+        for name, version_spec in container.items():
+            yield rpm_format(REQUIREMENT_NAME_TEMPLATE.format(name=name), version_spec)
+
+    elif isinstance(container, list):
+        for name in container:
+            yield rpm_format(REQUIREMENT_NAME_TEMPLATE.format(name=name))
+
+    elif isinstance(container, str):
+        yield rpm_format(REQUIREMENT_NAME_TEMPLATE.format(name=name))
+
+    else:
+        raise TypeError("invalid package.json: dependencies not a valid type")
+
+
+if __name__ == "__main__":
+    nested = (
+        extract_dependencies(path.strip(), optional="--optional" in sys.argv)
+        for path in sys.stdin
+    )
+    flat = chain.from_iterable(nested)
+    # Ignore parentheses around the requirements when sorting
+    ordered = sorted(flat, key=lambda s: s.strip("()"))
+
+    print(*ordered, sep="\n")

From b480bbd8c1548984c805c1e03295efaa98c6caf8 Mon Sep 17 00:00:00 2001
From: Jan Staněk <jstanek@redhat.com>
Date: Jun 20 2019 15:12:32 +0000
Subject: [PATCH 2/3] Run doctests in requirement generator


---

diff --git a/.gitignore b/.gitignore
index 0572610..b867f40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,8 @@
+__pycache__/
+*.pyc
+
+nodejs_req.py
+
 test/*/package.json
 test/*/nodejs.prov.err
 test/*/nodejs.prov.out
diff --git a/test/run b/test/run
index cc2af26..6afb356 100755
--- a/test/run
+++ b/test/run
@@ -1,5 +1,9 @@
 #!/bin/sh
 
+ln -sf nodejs.req nodejs_req.py
+"$(command -v python2 || echo :)" -m doctest nodejs_req.py || exit 1
+"$(command -v python3 || echo :)" -m doctest nodejs_req.py || exit 1
+
 for test in unbundled bundled
 do
     sed -e "s|//.*$||" < test/$test/package.json.in > test/$test/package.json

From 254684f541158789e816057a9d8d72ea041e3f08 Mon Sep 17 00:00:00 2001
From: Jan Staněk <jstanek@redhat.com>
Date: Jun 21 2019 08:56:32 +0000
Subject: [PATCH 3/3] fixup! Refactor nodejs.req


---

diff --git a/nodejs.req b/nodejs.req
index 0111dd3..0a36187 100755
--- a/nodejs.req
+++ b/nodejs.req
@@ -1,4 +1,4 @@
-#!/usr/bin/python
+#!/usr/bin/python3
 # -*- coding: utf-8 -*-
 # Copyright 2012, 2013 T.C. Hollingsworth <tchollingsworth@gmail.com>
 # Copyright 2019 Jan Staněk <jstanek@redat.com>

