Blob Blame History Raw
#!/usr/bin/python

"""
Automatic dependency generator for Node.js libraries.

Parsed from package.json.  See `man npm-json` for details.
"""

# Copyright 2012, 2013 T.C. Hollingsworth <tchollingsworth@gmail.com>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# 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
import json
import os
import re
import sys

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


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()]

    for path in paths:
        if not path.endswith('package.json'):
            continue

        if has_all_bundled(path):
            continue

        fh = open(path)
        metadata = json.load(fh)
        fh.close()

        if '--optional' in sys.argv:
            deptype = 'optionalDependencies'
        else:
            deptype = 'dependencies'

        if deptype == 'dependencies':
            #write out the node.js interpreter dependency
            req = 'rh-nodejs10-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 = 'rh-nodejs10-npm(' + name + ')'
                    deps.append(process_dep(req, version))
            elif isinstance(metadata[deptype], list):
                for name in metadata[deptype]:
                    req = 'rh-nodejs10-npm(' + name + ')'
                    deps.append(req)
            elif isinstance(metadata[deptype], str):
                req = 'rh-nodejs10-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

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
    """
    # 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):
    """
    Returns the given version tuple with the last part incremented.
        (1, 2, 3) -> (1, 2, 4)
        (1, 2)    -> (1, 3)
        (1,)      -> (2,)
        ()        -> ()
    """
    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):
    """
    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
    """
    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):
    """
    Converts an NPM requirement to an equivalent list of RPM requirements.
    """
    # 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()