8de3f91
#!/usr/bin/python3
8de3f91
# -*- coding: utf-8 -*-
8de3f91
# Copyright 2012, 2013 T.C. Hollingsworth <tchollingsworth@gmail.com>
8de3f91
# Copyright 2019 Jan Staněk <jstanek@redat.com>
8de3f91
#
8de3f91
# Permission is hereby granted, free of charge, to any person obtaining a copy
8de3f91
# of this software and associated documentation files (the "Software"), to
8de3f91
# deal in the Software without restriction, including without limitation the
8de3f91
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
8de3f91
# sell copies of the Software, and to permit persons to whom the Software is
8de3f91
# furnished to do so, subject to the following conditions:
8de3f91
#
8de3f91
# The above copyright notice and this permission notice shall be included in
8de3f91
# all copies or substantial portions of the Software.
8de3f91
#
8de3f91
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
8de3f91
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
8de3f91
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
8de3f91
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
8de3f91
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
8de3f91
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
8de3f91
# IN THE SOFTWARE.
8de3f91
8de3f91
""" Automatic dependency generator for Node.js libraries.
8de3f91
8de3f91
Metadata parsed from package.json.  See `man npm-json` for details.
8de3f91
"""
8de3f91
8de3f91
from __future__ import print_function, with_statement
8de3f91
8de3f91
import json
8de3f91
import operator
8de3f91
import os
8de3f91
import re
8de3f91
import sys
8de3f91
from collections import namedtuple
8de3f91
from itertools import chain
8de3f91
from itertools import takewhile
8de3f91
8de3f91
# Python version detection
8de3f91
_PY2 = sys.version_info[0] <= 2
8de3f91
_PY3 = sys.version_info[0] >= 3
8de3f91
8de3f91
if _PY2:
8de3f91
    from future_builtins import map, filter
8de3f91
8de3f91
8de3f91
#: Name format of the requirements
8de3f91
REQUIREMENT_NAME_TEMPLATE = "npm({name})"
8de3f91
8de3f91
#: ``simple`` product of the NPM semver grammar.
8de3f91
RANGE_SPECIFIER_SIMPLE = re.compile(
8de3f91
    r"""
8de3f91
    (?P<operator>
8de3f91
        <= | >= | < | > | =     # primitive
8de3f91
        | ~ | \^                # tilde/caret operators
8de3f91
    )?
8de3f91
    \s*(?P<version>\S+)\s*  # version specifier
8de3f91
    """,
8de3f91
    flags=re.VERBOSE,
8de3f91
)
8de3f91
8de3f91
8de3f91
class UnsupportedVersionToken(ValueError):
8de3f91
    """Version specifier contains token unsupported by the parser."""
8de3f91
8de3f91
8de3f91
class Version(tuple):
8de3f91
    """Normalized RPM/NPM version.
8de3f91
8de3f91
    The version has up to 3 components – major, minor, patch.
8de3f91
    Any part set to None is treated as unspecified.
8de3f91
8de3f91
    ::
8de3f91
8de3f91
        1.2.3 == Version(1, 2, 3)
8de3f91
        1.2   == Version(1, 2)
8de3f91
        1     == Version(1)
8de3f91
        *     == Version()
8de3f91
    """
8de3f91
8de3f91
    __slots__ = ()
8de3f91
8de3f91
    #: Version part meaning 'Any'
8de3f91
    #: ``xr`` in https://docs.npmjs.com/misc/semver#range-grammar
8de3f91
    _PART_ANY = re.compile(r"^[xX*]$")
8de3f91
    #: Numeric version part
8de3f91
    #: ``nr`` in https://docs.npmjs.com/misc/semver#range-grammar
8de3f91
    _PART_NUMERIC = re.compile(r"0|[1-9]\d*")
8de3f91
8de3f91
    def __new__(cls, *args):
8de3f91
        """Create new version.
8de3f91
8de3f91
        Arguments:
8de3f91
            Version components in the order of "major", "minor", "patch".
8de3f91
            All parts are optional::
8de3f91
8de3f91
                >>> Version(1, 2, 3)
8de3f91
                Version(1, 2, 3)
8de3f91
                >>> Version(1)
8de3f91
                Version(1)
8de3f91
                >>> Version()
8de3f91
                Version()
8de3f91
8de3f91
        Returns:
8de3f91
            New Version.
8de3f91
        """
8de3f91
8de3f91
        if len(args) > 3:
8de3f91
            raise ValueError("Version has maximum of 3 components")
8de3f91
        return super(Version, cls).__new__(cls, map(int, args))
8de3f91
8de3f91
    def __repr__(self):
8de3f91
        """Pretty debugging format."""
8de3f91
8de3f91
        return "{0}({1})".format(self.__class__.__name__, ", ".join(map(str, self)))
8de3f91
8de3f91
    def __str__(self):
8de3f91
        """RPM version format."""
8de3f91
8de3f91
        return ".".join(format(part, "d") for part in self)
8de3f91
8de3f91
    @property
8de3f91
    def major(self):
8de3f91
        """Major version number, if any."""
8de3f91
        return self[0] if len(self) > 0 else None
8de3f91
8de3f91
    @property
8de3f91
    def minor(self):
8de3f91
        """Major version number, if any."""
8de3f91
        return self[1] if len(self) > 1 else None
8de3f91
8de3f91
    @property
8de3f91
    def patch(self):
8de3f91
        """Major version number, if any."""
8de3f91
        return self[2] if len(self) > 2 else None
8de3f91
8de3f91
    @property
8de3f91
    def empty(self):
8de3f91
        """True if the version contains nothing but zeroes."""
8de3f91
        return not any(self)
8de3f91
8de3f91
    @classmethod
8de3f91
    def parse(cls, version_string):
8de3f91
        """Parse individual version string (like ``1.2.3``) into Version.
8de3f91
8de3f91
        This is the ``partial`` production in the grammar:
8de3f91
        https://docs.npmjs.com/misc/semver#range-grammar
8de3f91
8de3f91
        Examples::
8de3f91
8de3f91
            >>> Version.parse("1.2.3")
8de3f91
            Version(1, 2, 3)
8de3f91
            >>> Version.parse("v2.x")
8de3f91
            Version(2)
8de3f91
            >>> Version.parse("")
8de3f91
            Version()
8de3f91
8de3f91
        Arguments:
8de3f91
            version_string (str): The version_string to parse.
8de3f91
8de3f91
        Returns:
8de3f91
            Version: Parsed result.
8de3f91
        """
8de3f91
8de3f91
        # Ignore leading ``v``, if any
8de3f91
        version_string = version_string.lstrip("v")
8de3f91
8de3f91
        part_list = version_string.split(".", 2)
8de3f91
        # Use only parts up to first "Any" indicator
8de3f91
        part_list = list(takewhile(lambda p: not cls._PART_ANY.match(p), part_list))
8de3f91
8de3f91
        if not part_list:
8de3f91
            return cls()
8de3f91
8de3f91
        # Strip off and discard any pre-release or build qualifiers at the end.
8de3f91
        # We can get away with this, because there is no sane way to represent
8de3f91
        # these kinds of version requirements in RPM, and we generally expect
8de3f91
        # the distro will only carry proper releases anyway.
8de3f91
        try:
8de3f91
            part_list[-1] = cls._PART_NUMERIC.match(part_list[-1]).group()
8de3f91
        except AttributeError:  # no match
8de3f91
            part_list.pop()
8de3f91
8de3f91
        # Extend with ``None``s at the end, if necessary
8de3f91
        return cls(*part_list)
8de3f91
8de3f91
    def incremented(self):
8de3f91
        """Increment the least significant part of the version::
8de3f91
8de3f91
            >>> Version(1, 2, 3).incremented()
8de3f91
            Version(1, 2, 4)
8de3f91
            >>> Version(1, 2).incremented()
8de3f91
            Version(1, 3)
8de3f91
            >>> Version(1).incremented()
8de3f91
            Version(2)
8de3f91
            >>> Version().incremented()
8de3f91
            Version()
8de3f91
8de3f91
        Returns:
8de3f91
            Version: New incremented Version.
8de3f91
        """
8de3f91
8de3f91
        if len(self) == 0:
8de3f91
            return self.__class__()
8de3f91
        else:
8de3f91
            args = self[:-1] + (self[-1] + 1,)
8de3f91
            return self.__class__(*args)
8de3f91
8de3f91
8de3f91
class VersionBoundary(namedtuple("VersionBoundary", ("version", "operator"))):
8de3f91
    """Normalized version range boundary."""
8de3f91
8de3f91
    __slots__ = ()
8de3f91
8de3f91
    #: Ordering of primitive operators.
8de3f91
    #: Operators not listed here are handled specially; see __compare below.
8de3f91
    #: Convention: Lower boundary < 0, Upper boundary > 0
8de3f91
    _OPERATOR_ORDER = {"<": 2, "<=": 1, ">=": -1, ">": -2}
8de3f91
8de3f91
    def __str__(self):
8de3f91
        """Pretty-print the boundary"""
8de3f91
8de3f91
        return "{0.operator}{0.version}".format(self)
8de3f91
8de3f91
    def __compare(self, other, operator):
8de3f91
        """Compare two boundaries with provided operator.
8de3f91
8de3f91
        Boundaries compare same as (version, operator_order) tuple.
8de3f91
        In case the boundary operator is not listed in _OPERATOR_ORDER,
8de3f91
        it's order is treated as 0.
8de3f91
8de3f91
        Arguments:
8de3f91
            other (VersionBoundary): The other boundary to compare with.
8de3f91
            operator (Callable[[VersionBoundary, VersionBoundary], bool]):
8de3f91
                Comparison operator to delegate to.
8de3f91
8de3f91
        Returns:
8de3f91
            bool: The result of the operator's comparison.
8de3f91
        """
8de3f91
8de3f91
        ORDER = self._OPERATOR_ORDER
8de3f91
8de3f91
        lhs = self.version, ORDER.get(self.operator, 0)
8de3f91
        rhs = other.version, ORDER.get(other.operator, 0)
8de3f91
        return operator(lhs, rhs)
8de3f91
8de3f91
    def __eq__(self, other):
8de3f91
        return self.__compare(other, operator.eq)
8de3f91
8de3f91
    def __lt__(self, other):
8de3f91
        return self.__compare(other, operator.lt)
8de3f91
8de3f91
    def __le__(self, other):
8de3f91
        return self.__compare(other, operator.le)
8de3f91
8de3f91
    def __gt__(self, other):
8de3f91
        return self.__compare(other, operator.gt)
8de3f91
8de3f91
    def __ge__(self, other):
8de3f91
        return self.__compare(other, operator.ge)
8de3f91
8de3f91
    @property
8de3f91
    def upper(self):
8de3f91
        """True if self is upper boundary."""
8de3f91
        return self._OPERATOR_ORDER.get(self.operator, 0) > 0
8de3f91
8de3f91
    @property
8de3f91
    def lower(self):
8de3f91
        """True if self is lower boundary."""
8de3f91
        return self._OPERATOR_ORDER.get(self.operator, 0) < 0
8de3f91
8de3f91
    @classmethod
8de3f91
    def equal(cls, version):
8de3f91
        """Normalize single samp:`={version}` into equivalent x-range::
8de3f91
8de3f91
            >>> empty = VersionBoundary.equal(Version()); tuple(map(str, empty))
8de3f91
            ()
8de3f91
            >>> patch = VersionBoundary.equal(Version(1, 2, 3)); tuple(map(str, patch))
8de3f91
            ('>=1.2.3', '<1.2.4')
8de3f91
            >>> minor = VersionBoundary.equal(Version(1, 2)); tuple(map(str, minor))
8de3f91
            ('>=1.2', '<1.3')
8de3f91
            >>> major = VersionBoundary.equal(Version(1)); tuple(map(str, major))
8de3f91
            ('>=1', '<2')
8de3f91
8de3f91
        See `X-Ranges <https://docs.npmjs.com/misc/semver#x-ranges-12x-1x-12->`_
8de3f91
        for details.
8de3f91
8de3f91
        Arguments:
8de3f91
            version (Version): The version the x-range should be equal to.
8de3f91
8de3f91
        Returns:
8de3f91
            (VersionBoundary, VersionBoundary):
8de3f91
                Lower and upper bound of the x-range.
8de3f91
            (): Empty tuple in case version is empty (any version matches).
8de3f91
        """
8de3f91
8de3f91
        if version:
8de3f91
            return (
8de3f91
                cls(version=version, operator=">="),
8de3f91
                cls(version=version.incremented(), operator="<"),
8de3f91
            )
8de3f91
        else:
8de3f91
            return ()
8de3f91
8de3f91
    @classmethod
8de3f91
    def tilde(cls, version):
8de3f91
        """Normalize :samp:`~{version}` into equivalent range.
8de3f91
8de3f91
        Tilde allows patch-level changes if a minor version is specified.
8de3f91
        Allows minor-level changes if not::
8de3f91
8de3f91
            >>> with_minor = VersionBoundary.tilde(Version(1, 2, 3)); tuple(map(str, with_minor))
8de3f91
            ('>=1.2.3', '<1.3')
8de3f91
            >>> no_minor = VersionBoundary.tilde(Version(1)); tuple(map(str, no_minor))
8de3f91
            ('>=1', '<2')
8de3f91
8de3f91
        Arguments:
8de3f91
            version (Version): The version to tilde-expand.
8de3f91
8de3f91
        Returns:
8de3f91
            (VersionBoundary, VersionBoundary):
8de3f91
                The lower and upper boundary of the tilde range.
8de3f91
        """
8de3f91
8de3f91
        # Fail on ``~*`` or similar nonsense specifier
8de3f91
        assert version.major is not None, "Nonsense '~*' specifier"
8de3f91
8de3f91
        lower_boundary = cls(version=version, operator=">=")
8de3f91
8de3f91
        if version.minor is None:
8de3f91
            upper_boundary = cls(version=Version(version.major + 1), operator="<")
8de3f91
        else:
8de3f91
            upper_boundary = cls(
8de3f91
                version=Version(version.major, version.minor + 1), operator="<"
8de3f91
            )
8de3f91
8de3f91
        return lower_boundary, upper_boundary
8de3f91
8de3f91
    @classmethod
8de3f91
    def caret(cls, version):
8de3f91
        """Normalize :samp:`^{version}` into equivalent range.
8de3f91
8de3f91
        Caret allows changes that do not modify the left-most non-zero digit
8de3f91
        in the ``(major, minor, patch)`` tuple.
8de3f91
        In other words, this allows
8de3f91
        patch and minor updates for versions 1.0.0 and above,
8de3f91
        patch updates for versions 0.X >=0.1.0,
8de3f91
        and no updates for versions 0.0.X::
8de3f91
8de3f91
            >>> major = VersionBoundary.caret(Version(1, 2, 3)); tuple(map(str, major))
8de3f91
            ('>=1.2.3', '<2')
8de3f91
            >>> minor = VersionBoundary.caret(Version(0, 2, 3)); tuple(map(str, minor))
8de3f91
            ('>=0.2.3', '<0.3')
8de3f91
            >>> patch = VersionBoundary.caret(Version(0, 0, 3)); tuple(map(str, patch))
8de3f91
            ('>=0.0.3', '<0.0.4')
8de3f91
8de3f91
        When parsing caret ranges, a missing patch value desugars to the number 0,
8de3f91
        but will allow flexibility within that value,
8de3f91
        even if the major and minor versions are both 0::
8de3f91
8de3f91
            >>> rel = VersionBoundary.caret(Version(1, 2)); tuple(map(str, rel))
8de3f91
            ('>=1.2', '<2')
8de3f91
            >>> pre = VersionBoundary.caret(Version(0, 0)); tuple(map(str, pre))
8de3f91
            ('>=0.0', '<0.1')
8de3f91
8de3f91
        A missing minor and patch values will desugar to zero,
8de3f91
        but also allow flexibility within those values,
8de3f91
        even if the major version is zero::
8de3f91
8de3f91
            >>> rel = VersionBoundary.caret(Version(1)); tuple(map(str, rel))
8de3f91
            ('>=1', '<2')
8de3f91
            >>> pre = VersionBoundary.caret(Version(0)); tuple(map(str, pre))
8de3f91
            ('>=0', '<1')
8de3f91
8de3f91
        Arguments:
8de3f91
            version (Version): The version to range-expand.
8de3f91
8de3f91
        Returns:
8de3f91
            (VersionBoundary, VersionBoundary):
8de3f91
                The lower and upper boundary of caret-range.
8de3f91
        """
8de3f91
8de3f91
        # Fail on ^* or similar nonsense specifier
8de3f91
        assert len(version) != 0, "Nonsense '^*' specifier"
8de3f91
8de3f91
        lower_boundary = cls(version=version, operator=">=")
8de3f91
8de3f91
        # Increment left-most non-zero part
8de3f91
        for idx, part in enumerate(version):
8de3f91
            if part != 0:
8de3f91
                upper_version = Version(*(version[:idx] + (part + 1,)))
8de3f91
                break
8de3f91
        else:  # No non-zero found; increment last specified part
8de3f91
            upper_version = version.incremented()
8de3f91
8de3f91
        upper_boundary = cls(version=upper_version, operator="<")
8de3f91
8de3f91
        return lower_boundary, upper_boundary
8de3f91
8de3f91
    @classmethod
8de3f91
    def hyphen(cls, lower_version, upper_version):
8de3f91
        """Construct hyphen range (inclusive set)::
8de3f91
8de3f91
            >>> full = VersionBoundary.hyphen(Version(1, 2, 3), Version(2, 3, 4)); tuple(map(str, full))
8de3f91
            ('>=1.2.3', '<=2.3.4')
8de3f91
8de3f91
        If a partial version is provided as the first version in the inclusive range,
8de3f91
        then the missing pieces are treated as zeroes::
8de3f91
8de3f91
            >>> part = VersionBoundary.hyphen(Version(1, 2), Version(2, 3, 4)); tuple(map(str, part))
8de3f91
            ('>=1.2', '<=2.3.4')
8de3f91
8de3f91
        If a partial version is provided as the second version in the inclusive range,
8de3f91
        then all versions that start with the supplied parts of the tuple are accepted,
8de3f91
        but nothing that would be greater than the provided tuple parts::
8de3f91
8de3f91
            >>> part = VersionBoundary.hyphen(Version(1, 2, 3), Version(2, 3)); tuple(map(str, part))
8de3f91
            ('>=1.2.3', '<2.4')
8de3f91
            >>> part = VersionBoundary.hyphen(Version(1, 2, 3), Version(2)); tuple(map(str, part))
8de3f91
            ('>=1.2.3', '<3')
8de3f91
8de3f91
        Arguments:
8de3f91
            lower_version (Version): Version on the lower range boundary.
8de3f91
            upper_version (Version): Version on the upper range boundary.
8de3f91
8de3f91
        Returns:
8de3f91
            (VersionBoundary, VersionBoundary):
8de3f91
                Lower and upper boundaries of the hyphen range.
8de3f91
        """
8de3f91
8de3f91
        lower_boundary = cls(version=lower_version, operator=">=")
8de3f91
8de3f91
        if len(upper_version) < 3:
8de3f91
            upper_boundary = cls(version=upper_version.incremented(), operator="<")
8de3f91
        else:
8de3f91
            upper_boundary = cls(version=upper_version, operator="<=")
8de3f91
8de3f91
        return lower_boundary, upper_boundary
8de3f91
8de3f91
8de3f91
def parse_simple_seq(specifier_string):
8de3f91
    """Parse all specifiers from a space-separated string::
8de3f91
8de3f91
        >>> single = parse_simple_seq(">=1.2.3"); list(map(str, single))
8de3f91
        ['>=1.2.3']
8de3f91
        >>> multi = parse_simple_seq("~1.2.0 <1.2.5"); list(map(str, multi))
8de3f91
        ['>=1.2.0', '<1.3', '<1.2.5']
8de3f91
8de3f91
    This method implements the ``simple (' ' simple)*`` part of the grammar:
8de3f91
    https://docs.npmjs.com/misc/semver#range-grammar.
8de3f91
8de3f91
    Arguments:
8de3f91
        specifier_string (str): Space-separated string of simple version specifiers.
8de3f91
8de3f91
    Yields:
8de3f91
        VersionBoundary: Parsed boundaries.
8de3f91
    """
8de3f91
8de3f91
    # Per-operator dispatch table
8de3f91
    # API: Callable[[Version], Iterable[VersionBoundary]]
8de3f91
    handler = {
8de3f91
        ">": lambda v: [VersionBoundary(version=v, operator=">")],
8de3f91
        ">=": lambda v: [VersionBoundary(version=v, operator=">=")],
8de3f91
        "<=": lambda v: [VersionBoundary(version=v, operator="<=")],
8de3f91
        "<": lambda v: [VersionBoundary(version=v, operator="<")],
8de3f91
        "=": VersionBoundary.equal,
8de3f91
        "~": VersionBoundary.tilde,
8de3f91
        "^": VersionBoundary.caret,
8de3f91
        None: VersionBoundary.equal,
8de3f91
    }
8de3f91
8de3f91
    for match in RANGE_SPECIFIER_SIMPLE.finditer(specifier_string):
8de3f91
        operator, version_string = match.group("operator", "version")
8de3f91
8de3f91
        for boundary in handler[operator](Version.parse(version_string)):
8de3f91
            yield boundary
8de3f91
8de3f91
8de3f91
def parse_range(range_string):
8de3f91
    """Parse full NPM version range specification::
8de3f91
8de3f91
        >>> empty = parse_range(""); list(map(str, empty))
8de3f91
        []
8de3f91
        >>> simple = parse_range("^1.0"); list(map(str, simple))
8de3f91
        ['>=1.0', '<2']
8de3f91
        >>> hyphen = parse_range("1.0 - 2.0"); list(map(str, hyphen))
8de3f91
        ['>=1.0', '<2.1']
8de3f91
8de3f91
    This method implements the ``range`` part of the grammar:
8de3f91
    https://docs.npmjs.com/misc/semver#range-grammar.
8de3f91
8de3f91
    Arguments:
8de3f91
        range_string (str): The range specification to parse.
8de3f91
8de3f91
    Returns:
8de3f91
        Iterable[VersionBoundary]: Parsed boundaries.
8de3f91
8de3f91
    Raises:
8de3f91
        UnsupportedVersionToken: ``||`` is present in range_string.
8de3f91
    """
8de3f91
8de3f91
    HYPHEN = " - "
8de3f91
8de3f91
    # FIXME: rpm should be able to process OR in dependencies
8de3f91
    # This error reporting kept for backward compatibility
8de3f91
    if "||" in range_string:
8de3f91
        raise UnsupportedVersionToken(range_string)
8de3f91
8de3f91
    if HYPHEN in range_string:
8de3f91
        version_pair = map(Version.parse, range_string.split(HYPHEN, 2))
8de3f91
        return VersionBoundary.hyphen(*version_pair)
8de3f91
8de3f91
    elif range_string != "":
8de3f91
        return parse_simple_seq(range_string)
8de3f91
8de3f91
    else:
8de3f91
        return []
8de3f91
8de3f91
8de3f91
def unify_range(boundary_iter):
8de3f91
    """Calculate largest allowed continuous version range from a set of boundaries::
8de3f91
8de3f91
        >>> unify_range([])
8de3f91
        ()
8de3f91
        >>> _ = unify_range(parse_range("=1.2.3 <2")); tuple(map(str, _))
8de3f91
        ('>=1.2.3', '<1.2.4')
8de3f91
        >>> _ = unify_range(parse_range("~1.2 <1.2.5")); tuple(map(str, _))
8de3f91
        ('>=1.2', '<1.2.5')
8de3f91
8de3f91
    Arguments:
8de3f91
        boundary_iter (Iterable[VersionBoundary]): The version boundaries to unify.
8de3f91
8de3f91
    Returns:
8de3f91
        (VersionBoundary, VersionBoundary):
8de3f91
            Lower and upper boundary of the unified range.
8de3f91
    """
8de3f91
8de3f91
    # Drop boundaries with empty version
8de3f91
    boundary_iter = (
8de3f91
        boundary for boundary in boundary_iter if not boundary.version.empty
8de3f91
    )
8de3f91
8de3f91
    # Split input sequence into upper/lower boundaries
8de3f91
    lower_list, upper_list = [], []
8de3f91
    for boundary in boundary_iter:
8de3f91
        if boundary.lower:
8de3f91
            lower_list.append(boundary)
8de3f91
        elif boundary.upper:
8de3f91
            upper_list.append(boundary)
8de3f91
        else:
8de3f91
            msg = "Unsupported boundary for unify_range: {0}".format(boundary)
8de3f91
            raise ValueError(msg)
8de3f91
8de3f91
    # Select maximum from lower boundaries and minimum from upper boundaries
8de3f91
    intermediate = (
8de3f91
        max(lower_list) if lower_list else None,
8de3f91
        min(upper_list) if upper_list else None,
8de3f91
    )
8de3f91
8de3f91
    return tuple(filter(None, intermediate))
8de3f91
8de3f91
8de3f91
def rpm_format(requirement, version_spec="*"):
8de3f91
    """Format requirement as RPM boolean dependency::
8de3f91
8de3f91
        >>> rpm_format("nodejs(engine)")
8de3f91
        'nodejs(engine)'
8de3f91
        >>> rpm_format("npm(foo)", ">=1.0.0")
8de3f91
        'npm(foo) >= 1.0.0'
8de3f91
        >>> rpm_format("npm(bar)", "~1.2")
8de3f91
        '(npm(bar) >= 1.2 with npm(bar) < 1.3)'
8de3f91
8de3f91
    Arguments:
8de3f91
        requirement (str): The name of the requirement.
8de3f91
        version_spec (str): The NPM version specification for the requirement.
8de3f91
8de3f91
    Returns:
8de3f91
        str: Formatted requirement.
8de3f91
    """
8de3f91
8de3f91
    TEMPLATE = "{name} {boundary.operator} {boundary.version!s}"
8de3f91
8de3f91
    try:
8de3f91
        boundary_tuple = unify_range(parse_range(version_spec))
8de3f91
8de3f91
    except UnsupportedVersionToken:
8de3f91
        # FIXME: Typos and print behavior kept for backward compatibility
8de3f91
        warning_lines = [
8de3f91
            "WARNING: The {requirement} dependency contains an OR (||) dependency: '{version_spec}.",
8de3f91
            "Please manually include a versioned dependency in your spec file if necessary",
8de3f91
        ]
8de3f91
        warning = "\n".join(warning_lines).format(
8de3f91
            requirement=requirement, version_spec=version_spec
8de3f91
        )
8de3f91
        print(warning, end="", file=sys.stderr)
8de3f91
8de3f91
        return requirement
8de3f91
8de3f91
    formatted = [
8de3f91
        TEMPLATE.format(name=requirement, boundary=boundary)
8de3f91
        for boundary in boundary_tuple
8de3f91
    ]
8de3f91
8de3f91
    if len(formatted) > 1:
8de3f91
        return "({0})".format(" with ".join(formatted))
8de3f91
    elif len(formatted) == 1:
8de3f91
        return formatted[0]
8de3f91
    else:
8de3f91
        return requirement
8de3f91
8de3f91
8de3f91
def has_only_bundled_dependencies(module_dir_path):
8de3f91
    """Determines if the module contains only bundled dependencies.
8de3f91
8de3f91
    Dependencies are considered un-bundled when they are symlinks
8de3f91
    pointing outside the root module's tree.
8de3f91
8de3f91
    Arguments:
8de3f91
        module_dir_path (str):
8de3f91
            Path to the module directory (directory with ``package.json``).
8de3f91
8de3f91
    Returns:
8de3f91
        bool: True if all dependencies are bundled, False otherwise.
8de3f91
    """
8de3f91
8de3f91
    module_root_path = os.path.abspath(module_dir_path)
8de3f91
    dependency_root_path = os.path.join(module_root_path, "node_modules")
8de3f91
8de3f91
    try:
8de3f91
        dependency_path_iter = (
8de3f91
            os.path.join(dependency_root_path, basename)
8de3f91
            for basename in os.listdir(dependency_root_path)
8de3f91
        )
8de3f91
        bundled_dependency_iter = (
8de3f91
            os.path.realpath(path)
8de3f91
            for path in dependency_path_iter
8de3f91
            if not os.path.islink(path) or path.startswith(module_root_path)
8de3f91
        )
8de3f91
8de3f91
        return any(bundled_dependency_iter)
8de3f91
    except OSError:  # node_modules does not exist
8de3f91
        return False
8de3f91
8de3f91
8de3f91
def extract_dependencies(metadata_path, optional=False):
8de3f91
    """Extract all dependencies in RPM format from package metadata.
8de3f91
8de3f91
    Arguments:
8de3f91
        metadata_path (str): Path to package metadata (``package.json``).
8de3f91
        optional (bool):
8de3f91
            If True, extract ``optionalDependencies``
8de3f91
            instead of ``dependencies``.
8de3f91
8de3f91
    Yields:
8de3f91
        RPM-formatted dependencies.
8de3f91
8de3f91
    Raises:
8de3f91
        TypeError: Invalid dependency data type.
8de3f91
    """
8de3f91
8de3f91
    if has_only_bundled_dependencies(os.path.dirname(metadata_path)):
8de3f91
        return  # skip
8de3f91
8de3f91
    # Read metadata
8de3f91
    try:
8de3f91
        with open(metadata_path, mode="r") as metadata_file:
8de3f91
            metadata = json.load(metadata_file)
8de3f91
    except OSError:  # Invalid metadata file
8de3f91
        return  # skip
8de3f91
8de3f91
    # Report required NodeJS version with required dependencies
8de3f91
    if not optional:
8de3f91
        try:
8de3f91
            yield rpm_format("nodejs(engine)", metadata["engines"]["node"])
8de3f91
        except KeyError:  # NodeJS engine version unspecified
8de3f91
            yield rpm_format("nodejs(engine)")
8de3f91
8de3f91
    # Report listed dependencies
8de3f91
    kind = "optionalDependencies" if optional else "dependencies"
8de3f91
    container = metadata.get(kind, {})
8de3f91
8de3f91
    if isinstance(container, dict):
8de3f91
        for name, version_spec in container.items():
8de3f91
            yield rpm_format(REQUIREMENT_NAME_TEMPLATE.format(name=name), version_spec)
8de3f91
8de3f91
    elif isinstance(container, list):
8de3f91
        for name in container:
8de3f91
            yield rpm_format(REQUIREMENT_NAME_TEMPLATE.format(name=name))
8de3f91
8de3f91
    elif isinstance(container, str):
8de3f91
        yield rpm_format(REQUIREMENT_NAME_TEMPLATE.format(name=name))
8de3f91
8de3f91
    else:
8de3f91
        raise TypeError("invalid package.json: dependencies not a valid type")
8de3f91
8de3f91
8de3f91
if __name__ == "__main__":
8de3f91
    nested = (
8de3f91
        extract_dependencies(path.strip(), optional="--optional" in sys.argv)
8de3f91
        for path in sys.stdin
8de3f91
    )
8de3f91
    flat = chain.from_iterable(nested)
8de3f91
    # Ignore parentheses around the requirements when sorting
8de3f91
    ordered = sorted(flat, key=lambda s: s.strip("()"))
8de3f91
8de3f91
    print(*ordered, sep="\n")