Blame SOURCES/nodejs.req

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