addon-sdk/source/python-lib/cuddlefish/packaging.py

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/addon-sdk/source/python-lib/cuddlefish/packaging.py	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,471 @@
     1.4 +# This Source Code Form is subject to the terms of the Mozilla Public
     1.5 +# License, v. 2.0. If a copy of the MPL was not distributed with this
     1.6 +# file, You can obtain one at http://mozilla.org/MPL/2.0/.
     1.7 +
     1.8 +import os
     1.9 +import sys
    1.10 +import re
    1.11 +import copy
    1.12 +
    1.13 +import simplejson as json
    1.14 +from cuddlefish.bunch import Bunch
    1.15 +
    1.16 +MANIFEST_NAME = 'package.json'
    1.17 +DEFAULT_LOADER = 'addon-sdk'
    1.18 +
    1.19 +# Is different from root_dir when running tests
    1.20 +env_root = os.environ.get('CUDDLEFISH_ROOT')
    1.21 +
    1.22 +DEFAULT_PROGRAM_MODULE = 'main'
    1.23 +
    1.24 +DEFAULT_ICON = 'icon.png'
    1.25 +DEFAULT_ICON64 = 'icon64.png'
    1.26 +
    1.27 +METADATA_PROPS = ['name', 'description', 'keywords', 'author', 'version',
    1.28 +                  'translators', 'contributors', 'license', 'homepage', 'icon',
    1.29 +                  'icon64', 'main', 'directories', 'permissions']
    1.30 +
    1.31 +RESOURCE_HOSTNAME_RE = re.compile(r'^[a-z0-9_\-]+$')
    1.32 +
    1.33 +class Error(Exception):
    1.34 +    pass
    1.35 +
    1.36 +class MalformedPackageError(Error):
    1.37 +    pass
    1.38 +
    1.39 +class MalformedJsonFileError(Error):
    1.40 +    pass
    1.41 +
    1.42 +class DuplicatePackageError(Error):
    1.43 +    pass
    1.44 +
    1.45 +class PackageNotFoundError(Error):
    1.46 +    def __init__(self, missing_package, reason):
    1.47 +        self.missing_package = missing_package
    1.48 +        self.reason = reason
    1.49 +    def __str__(self):
    1.50 +        return "%s (%s)" % (self.missing_package, self.reason)
    1.51 +
    1.52 +class BadChromeMarkerError(Error):
    1.53 +    pass
    1.54 +
    1.55 +def validate_resource_hostname(name):
    1.56 +    """
    1.57 +    Validates the given hostname for a resource: URI.
    1.58 +
    1.59 +    For more information, see:
    1.60 +
    1.61 +      https://bugzilla.mozilla.org/show_bug.cgi?id=566812#c13
    1.62 +
    1.63 +    Examples:
    1.64 +
    1.65 +      >>> validate_resource_hostname('blarg')
    1.66 +
    1.67 +      >>> validate_resource_hostname('bl arg')
    1.68 +      Traceback (most recent call last):
    1.69 +      ...
    1.70 +      ValueError: Error: the name of your package contains an invalid character.
    1.71 +      Package names can contain only lower-case letters, numbers, underscores, and dashes.
    1.72 +      Current package name: bl arg
    1.73 +
    1.74 +      >>> validate_resource_hostname('BLARG')
    1.75 +      Traceback (most recent call last):
    1.76 +      ...
    1.77 +      ValueError: Error: the name of your package contains upper-case letters.
    1.78 +      Package names can contain only lower-case letters, numbers, underscores, and dashes.
    1.79 +      Current package name: BLARG
    1.80 +
    1.81 +      >>> validate_resource_hostname('foo@bar')
    1.82 +      Traceback (most recent call last):
    1.83 +      ...
    1.84 +      ValueError: Error: the name of your package contains an invalid character.
    1.85 +      Package names can contain only lower-case letters, numbers, underscores, and dashes.
    1.86 +      Current package name: foo@bar
    1.87 +    """
    1.88 +
    1.89 +    # See https://bugzilla.mozilla.org/show_bug.cgi?id=568131 for details.
    1.90 +    if not name.islower():
    1.91 +        raise ValueError("""Error: the name of your package contains upper-case letters.
    1.92 +Package names can contain only lower-case letters, numbers, underscores, and dashes.
    1.93 +Current package name: %s""" % name)
    1.94 +
    1.95 +    if not RESOURCE_HOSTNAME_RE.match(name):
    1.96 +        raise ValueError("""Error: the name of your package contains an invalid character.
    1.97 +Package names can contain only lower-case letters, numbers, underscores, and dashes.
    1.98 +Current package name: %s""" % name)
    1.99 +
   1.100 +def find_packages_with_module(pkg_cfg, name):
   1.101 +    # TODO: Make this support more than just top-level modules.
   1.102 +    filename = "%s.js" % name
   1.103 +    packages = []
   1.104 +    for cfg in pkg_cfg.packages.itervalues():
   1.105 +        if 'lib' in cfg:
   1.106 +            matches = [dirname for dirname in resolve_dirs(cfg, cfg.lib)
   1.107 +                       if os.path.exists(os.path.join(dirname, filename))]
   1.108 +            if matches:
   1.109 +                packages.append(cfg.name)
   1.110 +    return packages
   1.111 +
   1.112 +def resolve_dirs(pkg_cfg, dirnames):
   1.113 +    for dirname in dirnames:
   1.114 +        yield resolve_dir(pkg_cfg, dirname)
   1.115 +
   1.116 +def resolve_dir(pkg_cfg, dirname):
   1.117 +    return os.path.join(pkg_cfg.root_dir, dirname)
   1.118 +
   1.119 +def validate_permissions(perms):
   1.120 +    if (perms.get('cross-domain-content') and
   1.121 +        not isinstance(perms.get('cross-domain-content'), list)):
   1.122 +        raise ValueError("Error: `cross-domain-content` permissions in \
   1.123 + package.json file must be an array of strings:\n  %s" % perms)
   1.124 +
   1.125 +def get_metadata(pkg_cfg, deps):
   1.126 +    metadata = Bunch()
   1.127 +    for pkg_name in deps:
   1.128 +        cfg = pkg_cfg.packages[pkg_name]
   1.129 +        metadata[pkg_name] = Bunch()
   1.130 +        for prop in METADATA_PROPS:
   1.131 +            if cfg.get(prop):
   1.132 +                if prop == 'permissions':
   1.133 +                    validate_permissions(cfg[prop])
   1.134 +                metadata[pkg_name][prop] = cfg[prop]
   1.135 +    return metadata
   1.136 +
   1.137 +def set_section_dir(base_json, name, base_path, dirnames, allow_root=False):
   1.138 +    resolved = compute_section_dir(base_json, base_path, dirnames, allow_root)
   1.139 +    if resolved:
   1.140 +        base_json[name] = os.path.abspath(resolved)
   1.141 +
   1.142 +def compute_section_dir(base_json, base_path, dirnames, allow_root):
   1.143 +    # PACKAGE_JSON.lib is highest priority
   1.144 +    # then PACKAGE_JSON.directories.lib
   1.145 +    # then lib/ (if it exists)
   1.146 +    # then . (but only if allow_root=True)
   1.147 +    for dirname in dirnames:
   1.148 +        if base_json.get(dirname):
   1.149 +            return os.path.join(base_path, base_json[dirname])
   1.150 +    if "directories" in base_json:
   1.151 +        for dirname in dirnames:
   1.152 +            if dirname in base_json.directories:
   1.153 +                return os.path.join(base_path, base_json.directories[dirname])
   1.154 +    for dirname in dirnames:
   1.155 +        if os.path.isdir(os.path.join(base_path, dirname)):
   1.156 +            return os.path.join(base_path, dirname)
   1.157 +    if allow_root:
   1.158 +        return os.path.abspath(base_path)
   1.159 +    return None
   1.160 +
   1.161 +def normalize_string_or_array(base_json, key):
   1.162 +    if base_json.get(key):
   1.163 +        if isinstance(base_json[key], basestring):
   1.164 +            base_json[key] = [base_json[key]]
   1.165 +
   1.166 +def load_json_file(path):
   1.167 +    data = open(path, 'r').read()
   1.168 +    try:
   1.169 +        return Bunch(json.loads(data))
   1.170 +    except ValueError, e:
   1.171 +        raise MalformedJsonFileError('%s when reading "%s"' % (str(e),
   1.172 +                                                               path))
   1.173 +
   1.174 +def get_config_in_dir(path):
   1.175 +    package_json = os.path.join(path, MANIFEST_NAME)
   1.176 +    if not (os.path.exists(package_json) and
   1.177 +            os.path.isfile(package_json)):
   1.178 +        raise MalformedPackageError('%s not found in "%s"' % (MANIFEST_NAME,
   1.179 +                                                              path))
   1.180 +    base_json = load_json_file(package_json)
   1.181 +
   1.182 +    if 'name' not in base_json:
   1.183 +        base_json.name = os.path.basename(path)
   1.184 +
   1.185 +    # later processing steps will expect to see the following keys in the
   1.186 +    # base_json that we return:
   1.187 +    #
   1.188 +    #  name: name of the package
   1.189 +    #  lib: list of directories with .js files
   1.190 +    #  test: list of directories with test-*.js files
   1.191 +    #  doc: list of directories with documentation .md files
   1.192 +    #  data: list of directories with bundled arbitrary data files
   1.193 +    #  packages: ?
   1.194 +
   1.195 +    if (not base_json.get('tests') and
   1.196 +        os.path.isdir(os.path.join(path, 'test'))):
   1.197 +        base_json['tests'] = 'test'
   1.198 +
   1.199 +    set_section_dir(base_json, 'lib', path, ['lib'], True)
   1.200 +    set_section_dir(base_json, 'tests', path, ['test', 'tests'], False)
   1.201 +    set_section_dir(base_json, 'doc', path, ['doc', 'docs'])
   1.202 +    set_section_dir(base_json, 'data', path, ['data'])
   1.203 +    set_section_dir(base_json, 'packages', path, ['packages'])
   1.204 +    set_section_dir(base_json, 'locale', path, ['locale'])
   1.205 +
   1.206 +    if (not base_json.get('icon') and
   1.207 +        os.path.isfile(os.path.join(path, DEFAULT_ICON))):
   1.208 +        base_json['icon'] = DEFAULT_ICON
   1.209 +
   1.210 +    if (not base_json.get('icon64') and
   1.211 +        os.path.isfile(os.path.join(path, DEFAULT_ICON64))):
   1.212 +        base_json['icon64'] = DEFAULT_ICON64
   1.213 +
   1.214 +    for key in ['lib', 'tests', 'dependencies', 'packages']:
   1.215 +        # TODO: lib/tests can be an array?? consider interaction with
   1.216 +        # compute_section_dir above
   1.217 +        normalize_string_or_array(base_json, key)
   1.218 +
   1.219 +    if 'main' not in base_json and 'lib' in base_json:
   1.220 +        for dirname in base_json['lib']:
   1.221 +            program = os.path.join(path, dirname,
   1.222 +                                   '%s.js' % DEFAULT_PROGRAM_MODULE)
   1.223 +            if os.path.exists(program):
   1.224 +                base_json['main'] = DEFAULT_PROGRAM_MODULE
   1.225 +                break
   1.226 +
   1.227 +    base_json.root_dir = path
   1.228 +
   1.229 +    if "dependencies" in base_json:
   1.230 +      deps = base_json["dependencies"]
   1.231 +      deps = [x for x in deps if x not in ["addon-kit", "api-utils"]]
   1.232 +      deps.append("addon-sdk")
   1.233 +      base_json["dependencies"] = deps
   1.234 +
   1.235 +    return base_json
   1.236 +
   1.237 +def _is_same_file(a, b):
   1.238 +    if hasattr(os.path, 'samefile'):
   1.239 +        return os.path.samefile(a, b)
   1.240 +    return a == b
   1.241 +
   1.242 +def build_config(root_dir, target_cfg, packagepath=[]):
   1.243 +    dirs_to_scan = [env_root] # root is addon-sdk dir, diff from root_dir in tests
   1.244 +
   1.245 +    def add_packages_from_config(pkgconfig):
   1.246 +        if 'packages' in pkgconfig:
   1.247 +            for package_dir in resolve_dirs(pkgconfig, pkgconfig.packages):
   1.248 +                dirs_to_scan.append(package_dir)
   1.249 +
   1.250 +    add_packages_from_config(target_cfg)
   1.251 +
   1.252 +    packages_dir = os.path.join(root_dir, 'packages')
   1.253 +    if os.path.exists(packages_dir) and os.path.isdir(packages_dir):
   1.254 +        dirs_to_scan.append(packages_dir)
   1.255 +    dirs_to_scan.extend(packagepath)
   1.256 +
   1.257 +    packages = Bunch({target_cfg.name: target_cfg})
   1.258 +
   1.259 +    while dirs_to_scan:
   1.260 +        packages_dir = dirs_to_scan.pop()
   1.261 +        if os.path.exists(os.path.join(packages_dir, "package.json")):
   1.262 +            package_paths = [packages_dir]
   1.263 +        else:
   1.264 +            package_paths = [os.path.join(packages_dir, dirname)
   1.265 +                             for dirname in os.listdir(packages_dir)
   1.266 +                             if not dirname.startswith('.')]
   1.267 +            package_paths = [dirname for dirname in package_paths
   1.268 +                             if os.path.isdir(dirname)]
   1.269 +
   1.270 +        for path in package_paths:
   1.271 +            pkgconfig = get_config_in_dir(path)
   1.272 +            if pkgconfig.name in packages:
   1.273 +                otherpkg = packages[pkgconfig.name]
   1.274 +                if not _is_same_file(otherpkg.root_dir, path):
   1.275 +                    raise DuplicatePackageError(path, otherpkg.root_dir)
   1.276 +            else:
   1.277 +                packages[pkgconfig.name] = pkgconfig
   1.278 +                add_packages_from_config(pkgconfig)
   1.279 +
   1.280 +    return Bunch(packages=packages)
   1.281 +
   1.282 +def get_deps_for_targets(pkg_cfg, targets):
   1.283 +    visited = []
   1.284 +    deps_left = [[dep, None] for dep in list(targets)]
   1.285 +
   1.286 +    while deps_left:
   1.287 +        [dep, required_by] = deps_left.pop()
   1.288 +        if dep not in visited:
   1.289 +            visited.append(dep)
   1.290 +            if dep not in pkg_cfg.packages:
   1.291 +                required_reason = ("required by '%s'" % (required_by)) \
   1.292 +                                    if required_by is not None \
   1.293 +                                    else "specified as target"
   1.294 +                raise PackageNotFoundError(dep, required_reason)
   1.295 +            dep_cfg = pkg_cfg.packages[dep]
   1.296 +            deps_left.extend([[i, dep] for i in dep_cfg.get('dependencies', [])])
   1.297 +            deps_left.extend([[i, dep] for i in dep_cfg.get('extra_dependencies', [])])
   1.298 +
   1.299 +    return visited
   1.300 +
   1.301 +def generate_build_for_target(pkg_cfg, target, deps,
   1.302 +                              include_tests=True,
   1.303 +                              include_dep_tests=False,
   1.304 +                              is_running_tests=False,
   1.305 +                              default_loader=DEFAULT_LOADER):
   1.306 +
   1.307 +    build = Bunch(# Contains section directories for all packages:
   1.308 +                  packages=Bunch(),
   1.309 +                  locale=Bunch()
   1.310 +                  )
   1.311 +
   1.312 +    def add_section_to_build(cfg, section, is_code=False,
   1.313 +                             is_data=False):
   1.314 +        if section in cfg:
   1.315 +            dirnames = cfg[section]
   1.316 +            if isinstance(dirnames, basestring):
   1.317 +                # This is just for internal consistency within this
   1.318 +                # function, it has nothing to do w/ a non-canonical
   1.319 +                # configuration dict.
   1.320 +                dirnames = [dirnames]
   1.321 +            for dirname in resolve_dirs(cfg, dirnames):
   1.322 +                # ensure that package name is valid
   1.323 +                try:
   1.324 +                    validate_resource_hostname(cfg.name)
   1.325 +                except ValueError, err:
   1.326 +                    print err
   1.327 +                    sys.exit(1)
   1.328 +                # ensure that this package has an entry
   1.329 +                if not cfg.name in build.packages:
   1.330 +                    build.packages[cfg.name] = Bunch()
   1.331 +                # detect duplicated sections
   1.332 +                if section in build.packages[cfg.name]:
   1.333 +                    raise KeyError("package's section already defined",
   1.334 +                                   cfg.name, section)
   1.335 +                # Register this section (lib, data, tests)
   1.336 +                build.packages[cfg.name][section] = dirname
   1.337 +
   1.338 +    def add_locale_to_build(cfg):
   1.339 +        # Bug 730776: Ignore locales for addon-kit, that are only for unit tests
   1.340 +        if not is_running_tests and cfg.name == "addon-sdk":
   1.341 +            return
   1.342 +
   1.343 +        path = resolve_dir(cfg, cfg['locale'])
   1.344 +        files = os.listdir(path)
   1.345 +        for filename in files:
   1.346 +            fullpath = os.path.join(path, filename)
   1.347 +            if os.path.isfile(fullpath) and filename.endswith('.properties'):
   1.348 +                language = filename[:-len('.properties')]
   1.349 +
   1.350 +                from property_parser import parse_file, MalformedLocaleFileError
   1.351 +                try:
   1.352 +                    content = parse_file(fullpath)
   1.353 +                except MalformedLocaleFileError, msg:
   1.354 +                    print msg[0]
   1.355 +                    sys.exit(1)
   1.356 +
   1.357 +                # Merge current locales into global locale hashtable.
   1.358 +                # Locale files only contains one big JSON object
   1.359 +                # that act as an hastable of:
   1.360 +                # "keys to translate" => "translated keys"
   1.361 +                if language in build.locale:
   1.362 +                    merge = (build.locale[language].items() +
   1.363 +                             content.items())
   1.364 +                    build.locale[language] = Bunch(merge)
   1.365 +                else:
   1.366 +                    build.locale[language] = content
   1.367 +
   1.368 +    def add_dep_to_build(dep):
   1.369 +        dep_cfg = pkg_cfg.packages[dep]
   1.370 +        add_section_to_build(dep_cfg, "lib", is_code=True)
   1.371 +        add_section_to_build(dep_cfg, "data", is_data=True)
   1.372 +        if include_tests and include_dep_tests:
   1.373 +            add_section_to_build(dep_cfg, "tests", is_code=True)
   1.374 +        if 'locale' in dep_cfg:
   1.375 +            add_locale_to_build(dep_cfg)
   1.376 +        if ("loader" in dep_cfg) and ("loader" not in build):
   1.377 +            build.loader = "%s/%s" % (dep,
   1.378 +                                                 dep_cfg.loader)
   1.379 +
   1.380 +    target_cfg = pkg_cfg.packages[target]
   1.381 +
   1.382 +    if include_tests and not include_dep_tests:
   1.383 +        add_section_to_build(target_cfg, "tests", is_code=True)
   1.384 +
   1.385 +    for dep in deps:
   1.386 +        add_dep_to_build(dep)
   1.387 +
   1.388 +    if 'loader' not in build:
   1.389 +        add_dep_to_build(DEFAULT_LOADER)
   1.390 +
   1.391 +    if 'icon' in target_cfg:
   1.392 +        build['icon'] = os.path.join(target_cfg.root_dir, target_cfg.icon)
   1.393 +        del target_cfg['icon']
   1.394 +
   1.395 +    if 'icon64' in target_cfg:
   1.396 +        build['icon64'] = os.path.join(target_cfg.root_dir, target_cfg.icon64)
   1.397 +        del target_cfg['icon64']
   1.398 +
   1.399 +    if ('preferences' in target_cfg):
   1.400 +        build['preferences'] = target_cfg.preferences
   1.401 +
   1.402 +    if 'id' in target_cfg:
   1.403 +        # NOTE: logic duplicated from buildJID()
   1.404 +        jid = target_cfg['id']
   1.405 +        if not ('@' in jid or jid.startswith('{')):
   1.406 +            jid += '@jetpack'
   1.407 +        build['preferencesBranch'] = jid
   1.408 +
   1.409 +    if 'preferences-branch' in target_cfg:
   1.410 +        # check it's a non-empty, valid branch name
   1.411 +        preferencesBranch = target_cfg['preferences-branch']
   1.412 +        if re.match('^[\w{@}-]+$', preferencesBranch):
   1.413 +            build['preferencesBranch'] = preferencesBranch
   1.414 +        elif not is_running_tests:
   1.415 +            print >>sys.stderr, "IGNORING preferences-branch (not a valid branch name)"
   1.416 +
   1.417 +    return build
   1.418 +
   1.419 +def _get_files_in_dir(path):
   1.420 +    data = {}
   1.421 +    files = os.listdir(path)
   1.422 +    for filename in files:
   1.423 +        fullpath = os.path.join(path, filename)
   1.424 +        if os.path.isdir(fullpath):
   1.425 +            data[filename] = _get_files_in_dir(fullpath)
   1.426 +        else:
   1.427 +            try:
   1.428 +                info = os.stat(fullpath)
   1.429 +                data[filename] = ("file", dict(size=info.st_size))
   1.430 +            except OSError:
   1.431 +                pass
   1.432 +    return ("directory", data)
   1.433 +
   1.434 +def build_pkg_index(pkg_cfg):
   1.435 +    pkg_cfg = copy.deepcopy(pkg_cfg)
   1.436 +    for pkg in pkg_cfg.packages:
   1.437 +        root_dir = pkg_cfg.packages[pkg].root_dir
   1.438 +        files = _get_files_in_dir(root_dir)
   1.439 +        pkg_cfg.packages[pkg].files = files
   1.440 +        try:
   1.441 +            readme = open(root_dir + '/README.md').read()
   1.442 +            pkg_cfg.packages[pkg].readme = readme
   1.443 +        except IOError:
   1.444 +            pass
   1.445 +        del pkg_cfg.packages[pkg].root_dir
   1.446 +    return pkg_cfg.packages
   1.447 +
   1.448 +def build_pkg_cfg(root):
   1.449 +    pkg_cfg = build_config(root, Bunch(name='dummy'))
   1.450 +    del pkg_cfg.packages['dummy']
   1.451 +    return pkg_cfg
   1.452 +
   1.453 +def call_plugins(pkg_cfg, deps):
   1.454 +    for dep in deps:
   1.455 +        dep_cfg = pkg_cfg.packages[dep]
   1.456 +        dirnames = dep_cfg.get('python-lib', [])
   1.457 +        for dirname in resolve_dirs(dep_cfg, dirnames):
   1.458 +            sys.path.append(dirname)
   1.459 +        module_names = dep_cfg.get('python-plugins', [])
   1.460 +        for module_name in module_names:
   1.461 +            module = __import__(module_name)
   1.462 +            module.init(root_dir=dep_cfg.root_dir)
   1.463 +
   1.464 +def call_cmdline_tool(env_root, pkg_name):
   1.465 +    pkg_cfg = build_config(env_root, Bunch(name='dummy'))
   1.466 +    if pkg_name not in pkg_cfg.packages:
   1.467 +        print "This tool requires the '%s' package." % pkg_name
   1.468 +        sys.exit(1)
   1.469 +    cfg = pkg_cfg.packages[pkg_name]
   1.470 +    for dirname in resolve_dirs(cfg, cfg['python-lib']):
   1.471 +        sys.path.append(dirname)
   1.472 +    module_name = cfg.get('python-cmdline-tool')
   1.473 +    module = __import__(module_name)
   1.474 +    module.run()

mercurial