packaging.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471
  1. # This Source Code Form is subject to the terms of the Mozilla Public
  2. # License, v. 2.0. If a copy of the MPL was not distributed with this
  3. # file, You can obtain one at http://mozilla.org/MPL/2.0/.
  4. import os
  5. import sys
  6. import re
  7. import copy
  8. import simplejson as json
  9. from cuddlefish.bunch import Bunch
  10. MANIFEST_NAME = 'package.json'
  11. DEFAULT_LOADER = 'addon-sdk'
  12. # Is different from root_dir when running tests
  13. env_root = os.environ.get('CUDDLEFISH_ROOT')
  14. DEFAULT_PROGRAM_MODULE = 'main'
  15. DEFAULT_ICON = 'icon.png'
  16. DEFAULT_ICON64 = 'icon64.png'
  17. METADATA_PROPS = ['name', 'description', 'keywords', 'author', 'version',
  18. 'translators', 'contributors', 'license', 'homepage', 'icon',
  19. 'icon64', 'main', 'directories', 'permissions']
  20. RESOURCE_HOSTNAME_RE = re.compile(r'^[a-z0-9_\-]+$')
  21. class Error(Exception):
  22. pass
  23. class MalformedPackageError(Error):
  24. pass
  25. class MalformedJsonFileError(Error):
  26. pass
  27. class DuplicatePackageError(Error):
  28. pass
  29. class PackageNotFoundError(Error):
  30. def __init__(self, missing_package, reason):
  31. self.missing_package = missing_package
  32. self.reason = reason
  33. def __str__(self):
  34. return "%s (%s)" % (self.missing_package, self.reason)
  35. class BadChromeMarkerError(Error):
  36. pass
  37. def validate_resource_hostname(name):
  38. """
  39. Validates the given hostname for a resource: URI.
  40. For more information, see:
  41. https://bugzilla.mozilla.org/show_bug.cgi?id=566812#c13
  42. Examples:
  43. >>> validate_resource_hostname('blarg')
  44. >>> validate_resource_hostname('bl arg')
  45. Traceback (most recent call last):
  46. ...
  47. ValueError: Error: the name of your package contains an invalid character.
  48. Package names can contain only lower-case letters, numbers, underscores, and dashes.
  49. Current package name: bl arg
  50. >>> validate_resource_hostname('BLARG')
  51. Traceback (most recent call last):
  52. ...
  53. ValueError: Error: the name of your package contains upper-case letters.
  54. Package names can contain only lower-case letters, numbers, underscores, and dashes.
  55. Current package name: BLARG
  56. >>> validate_resource_hostname('foo@bar')
  57. Traceback (most recent call last):
  58. ...
  59. ValueError: Error: the name of your package contains an invalid character.
  60. Package names can contain only lower-case letters, numbers, underscores, and dashes.
  61. Current package name: foo@bar
  62. """
  63. # See https://bugzilla.mozilla.org/show_bug.cgi?id=568131 for details.
  64. if not name.islower():
  65. raise ValueError("""Error: the name of your package contains upper-case letters.
  66. Package names can contain only lower-case letters, numbers, underscores, and dashes.
  67. Current package name: %s""" % name)
  68. if not RESOURCE_HOSTNAME_RE.match(name):
  69. raise ValueError("""Error: the name of your package contains an invalid character.
  70. Package names can contain only lower-case letters, numbers, underscores, and dashes.
  71. Current package name: %s""" % name)
  72. def find_packages_with_module(pkg_cfg, name):
  73. # TODO: Make this support more than just top-level modules.
  74. filename = "%s.js" % name
  75. packages = []
  76. for cfg in pkg_cfg.packages.itervalues():
  77. if 'lib' in cfg:
  78. matches = [dirname for dirname in resolve_dirs(cfg, cfg.lib)
  79. if os.path.exists(os.path.join(dirname, filename))]
  80. if matches:
  81. packages.append(cfg.name)
  82. return packages
  83. def resolve_dirs(pkg_cfg, dirnames):
  84. for dirname in dirnames:
  85. yield resolve_dir(pkg_cfg, dirname)
  86. def resolve_dir(pkg_cfg, dirname):
  87. return os.path.join(pkg_cfg.root_dir, dirname)
  88. def validate_permissions(perms):
  89. if (perms.get('cross-domain-content') and
  90. not isinstance(perms.get('cross-domain-content'), list)):
  91. raise ValueError("Error: `cross-domain-content` permissions in \
  92. package.json file must be an array of strings:\n %s" % perms)
  93. def get_metadata(pkg_cfg, deps):
  94. metadata = Bunch()
  95. for pkg_name in deps:
  96. cfg = pkg_cfg.packages[pkg_name]
  97. metadata[pkg_name] = Bunch()
  98. for prop in METADATA_PROPS:
  99. if cfg.get(prop):
  100. if prop == 'permissions':
  101. validate_permissions(cfg[prop])
  102. metadata[pkg_name][prop] = cfg[prop]
  103. return metadata
  104. def set_section_dir(base_json, name, base_path, dirnames, allow_root=False):
  105. resolved = compute_section_dir(base_json, base_path, dirnames, allow_root)
  106. if resolved:
  107. base_json[name] = os.path.abspath(resolved)
  108. def compute_section_dir(base_json, base_path, dirnames, allow_root):
  109. # PACKAGE_JSON.lib is highest priority
  110. # then PACKAGE_JSON.directories.lib
  111. # then lib/ (if it exists)
  112. # then . (but only if allow_root=True)
  113. for dirname in dirnames:
  114. if base_json.get(dirname):
  115. return os.path.join(base_path, base_json[dirname])
  116. if "directories" in base_json:
  117. for dirname in dirnames:
  118. if dirname in base_json.directories:
  119. return os.path.join(base_path, base_json.directories[dirname])
  120. for dirname in dirnames:
  121. if os.path.isdir(os.path.join(base_path, dirname)):
  122. return os.path.join(base_path, dirname)
  123. if allow_root:
  124. return os.path.abspath(base_path)
  125. return None
  126. def normalize_string_or_array(base_json, key):
  127. if base_json.get(key):
  128. if isinstance(base_json[key], basestring):
  129. base_json[key] = [base_json[key]]
  130. def load_json_file(path):
  131. data = open(path, 'r').read()
  132. try:
  133. return Bunch(json.loads(data))
  134. except ValueError, e:
  135. raise MalformedJsonFileError('%s when reading "%s"' % (str(e),
  136. path))
  137. def get_config_in_dir(path):
  138. package_json = os.path.join(path, MANIFEST_NAME)
  139. if not (os.path.exists(package_json) and
  140. os.path.isfile(package_json)):
  141. raise MalformedPackageError('%s not found in "%s"' % (MANIFEST_NAME,
  142. path))
  143. base_json = load_json_file(package_json)
  144. if 'name' not in base_json:
  145. base_json.name = os.path.basename(path)
  146. # later processing steps will expect to see the following keys in the
  147. # base_json that we return:
  148. #
  149. # name: name of the package
  150. # lib: list of directories with .js files
  151. # test: list of directories with test-*.js files
  152. # doc: list of directories with documentation .md files
  153. # data: list of directories with bundled arbitrary data files
  154. # packages: ?
  155. if (not base_json.get('tests') and
  156. os.path.isdir(os.path.join(path, 'test'))):
  157. base_json['tests'] = 'test'
  158. set_section_dir(base_json, 'lib', path, ['lib'], True)
  159. set_section_dir(base_json, 'tests', path, ['test', 'tests'], False)
  160. set_section_dir(base_json, 'doc', path, ['doc', 'docs'])
  161. set_section_dir(base_json, 'data', path, ['data'])
  162. set_section_dir(base_json, 'packages', path, ['packages'])
  163. set_section_dir(base_json, 'locale', path, ['locale'])
  164. if (not base_json.get('icon') and
  165. os.path.isfile(os.path.join(path, DEFAULT_ICON))):
  166. base_json['icon'] = DEFAULT_ICON
  167. if (not base_json.get('icon64') and
  168. os.path.isfile(os.path.join(path, DEFAULT_ICON64))):
  169. base_json['icon64'] = DEFAULT_ICON64
  170. for key in ['lib', 'tests', 'dependencies', 'packages']:
  171. # TODO: lib/tests can be an array?? consider interaction with
  172. # compute_section_dir above
  173. normalize_string_or_array(base_json, key)
  174. if 'main' not in base_json and 'lib' in base_json:
  175. for dirname in base_json['lib']:
  176. program = os.path.join(path, dirname,
  177. '%s.js' % DEFAULT_PROGRAM_MODULE)
  178. if os.path.exists(program):
  179. base_json['main'] = DEFAULT_PROGRAM_MODULE
  180. break
  181. base_json.root_dir = path
  182. if "dependencies" in base_json:
  183. deps = base_json["dependencies"]
  184. deps = [x for x in deps if x not in ["addon-kit", "api-utils"]]
  185. deps.append("addon-sdk")
  186. base_json["dependencies"] = deps
  187. return base_json
  188. def _is_same_file(a, b):
  189. if hasattr(os.path, 'samefile'):
  190. return os.path.samefile(a, b)
  191. return a == b
  192. def build_config(root_dir, target_cfg, packagepath=[]):
  193. dirs_to_scan = [env_root] # root is addon-sdk dir, diff from root_dir in tests
  194. def add_packages_from_config(pkgconfig):
  195. if 'packages' in pkgconfig:
  196. for package_dir in resolve_dirs(pkgconfig, pkgconfig.packages):
  197. dirs_to_scan.append(package_dir)
  198. add_packages_from_config(target_cfg)
  199. packages_dir = os.path.join(root_dir, 'packages')
  200. if os.path.exists(packages_dir) and os.path.isdir(packages_dir):
  201. dirs_to_scan.append(packages_dir)
  202. dirs_to_scan.extend(packagepath)
  203. packages = Bunch({target_cfg.name: target_cfg})
  204. while dirs_to_scan:
  205. packages_dir = dirs_to_scan.pop()
  206. if os.path.exists(os.path.join(packages_dir, "package.json")):
  207. package_paths = [packages_dir]
  208. else:
  209. package_paths = [os.path.join(packages_dir, dirname)
  210. for dirname in os.listdir(packages_dir)
  211. if not dirname.startswith('.')]
  212. package_paths = [dirname for dirname in package_paths
  213. if os.path.isdir(dirname)]
  214. for path in package_paths:
  215. pkgconfig = get_config_in_dir(path)
  216. if pkgconfig.name in packages:
  217. otherpkg = packages[pkgconfig.name]
  218. if not _is_same_file(otherpkg.root_dir, path):
  219. raise DuplicatePackageError(path, otherpkg.root_dir)
  220. else:
  221. packages[pkgconfig.name] = pkgconfig
  222. add_packages_from_config(pkgconfig)
  223. return Bunch(packages=packages)
  224. def get_deps_for_targets(pkg_cfg, targets):
  225. visited = []
  226. deps_left = [[dep, None] for dep in list(targets)]
  227. while deps_left:
  228. [dep, required_by] = deps_left.pop()
  229. if dep not in visited:
  230. visited.append(dep)
  231. if dep not in pkg_cfg.packages:
  232. required_reason = ("required by '%s'" % (required_by)) \
  233. if required_by is not None \
  234. else "specified as target"
  235. raise PackageNotFoundError(dep, required_reason)
  236. dep_cfg = pkg_cfg.packages[dep]
  237. deps_left.extend([[i, dep] for i in dep_cfg.get('dependencies', [])])
  238. deps_left.extend([[i, dep] for i in dep_cfg.get('extra_dependencies', [])])
  239. return visited
  240. def generate_build_for_target(pkg_cfg, target, deps,
  241. include_tests=True,
  242. include_dep_tests=False,
  243. is_running_tests=False,
  244. default_loader=DEFAULT_LOADER):
  245. build = Bunch(# Contains section directories for all packages:
  246. packages=Bunch(),
  247. locale=Bunch()
  248. )
  249. def add_section_to_build(cfg, section, is_code=False,
  250. is_data=False):
  251. if section in cfg:
  252. dirnames = cfg[section]
  253. if isinstance(dirnames, basestring):
  254. # This is just for internal consistency within this
  255. # function, it has nothing to do w/ a non-canonical
  256. # configuration dict.
  257. dirnames = [dirnames]
  258. for dirname in resolve_dirs(cfg, dirnames):
  259. # ensure that package name is valid
  260. try:
  261. validate_resource_hostname(cfg.name)
  262. except ValueError, err:
  263. print err
  264. sys.exit(1)
  265. # ensure that this package has an entry
  266. if not cfg.name in build.packages:
  267. build.packages[cfg.name] = Bunch()
  268. # detect duplicated sections
  269. if section in build.packages[cfg.name]:
  270. raise KeyError("package's section already defined",
  271. cfg.name, section)
  272. # Register this section (lib, data, tests)
  273. build.packages[cfg.name][section] = dirname
  274. def add_locale_to_build(cfg):
  275. # Bug 730776: Ignore locales for addon-kit, that are only for unit tests
  276. if not is_running_tests and cfg.name == "addon-sdk":
  277. return
  278. path = resolve_dir(cfg, cfg['locale'])
  279. files = os.listdir(path)
  280. for filename in files:
  281. fullpath = os.path.join(path, filename)
  282. if os.path.isfile(fullpath) and filename.endswith('.properties'):
  283. language = filename[:-len('.properties')]
  284. from property_parser import parse_file, MalformedLocaleFileError
  285. try:
  286. content = parse_file(fullpath)
  287. except MalformedLocaleFileError, msg:
  288. print msg[0]
  289. sys.exit(1)
  290. # Merge current locales into global locale hashtable.
  291. # Locale files only contains one big JSON object
  292. # that act as an hastable of:
  293. # "keys to translate" => "translated keys"
  294. if language in build.locale:
  295. merge = (build.locale[language].items() +
  296. content.items())
  297. build.locale[language] = Bunch(merge)
  298. else:
  299. build.locale[language] = content
  300. def add_dep_to_build(dep):
  301. dep_cfg = pkg_cfg.packages[dep]
  302. add_section_to_build(dep_cfg, "lib", is_code=True)
  303. add_section_to_build(dep_cfg, "data", is_data=True)
  304. if include_tests and include_dep_tests:
  305. add_section_to_build(dep_cfg, "tests", is_code=True)
  306. if 'locale' in dep_cfg:
  307. add_locale_to_build(dep_cfg)
  308. if ("loader" in dep_cfg) and ("loader" not in build):
  309. build.loader = "%s/%s" % (dep,
  310. dep_cfg.loader)
  311. target_cfg = pkg_cfg.packages[target]
  312. if include_tests and not include_dep_tests:
  313. add_section_to_build(target_cfg, "tests", is_code=True)
  314. for dep in deps:
  315. add_dep_to_build(dep)
  316. if 'loader' not in build:
  317. add_dep_to_build(DEFAULT_LOADER)
  318. if 'icon' in target_cfg:
  319. build['icon'] = os.path.join(target_cfg.root_dir, target_cfg.icon)
  320. del target_cfg['icon']
  321. if 'icon64' in target_cfg:
  322. build['icon64'] = os.path.join(target_cfg.root_dir, target_cfg.icon64)
  323. del target_cfg['icon64']
  324. if ('preferences' in target_cfg):
  325. build['preferences'] = target_cfg.preferences
  326. if 'id' in target_cfg:
  327. # NOTE: logic duplicated from buildJID()
  328. jid = target_cfg['id']
  329. if not ('@' in jid or jid.startswith('{')):
  330. jid += '@jetpack'
  331. build['preferencesBranch'] = jid
  332. if 'preferences-branch' in target_cfg:
  333. # check it's a non-empty, valid branch name
  334. preferencesBranch = target_cfg['preferences-branch']
  335. if re.match('^[\w{@}-]+$', preferencesBranch):
  336. build['preferencesBranch'] = preferencesBranch
  337. elif not is_running_tests:
  338. print >>sys.stderr, "IGNORING preferences-branch (not a valid branch name)"
  339. return build
  340. def _get_files_in_dir(path):
  341. data = {}
  342. files = os.listdir(path)
  343. for filename in files:
  344. fullpath = os.path.join(path, filename)
  345. if os.path.isdir(fullpath):
  346. data[filename] = _get_files_in_dir(fullpath)
  347. else:
  348. try:
  349. info = os.stat(fullpath)
  350. data[filename] = ("file", dict(size=info.st_size))
  351. except OSError:
  352. pass
  353. return ("directory", data)
  354. def build_pkg_index(pkg_cfg):
  355. pkg_cfg = copy.deepcopy(pkg_cfg)
  356. for pkg in pkg_cfg.packages:
  357. root_dir = pkg_cfg.packages[pkg].root_dir
  358. files = _get_files_in_dir(root_dir)
  359. pkg_cfg.packages[pkg].files = files
  360. try:
  361. readme = open(root_dir + '/README.md').read()
  362. pkg_cfg.packages[pkg].readme = readme
  363. except IOError:
  364. pass
  365. del pkg_cfg.packages[pkg].root_dir
  366. return pkg_cfg.packages
  367. def build_pkg_cfg(root):
  368. pkg_cfg = build_config(root, Bunch(name='dummy'))
  369. del pkg_cfg.packages['dummy']
  370. return pkg_cfg
  371. def call_plugins(pkg_cfg, deps):
  372. for dep in deps:
  373. dep_cfg = pkg_cfg.packages[dep]
  374. dirnames = dep_cfg.get('python-lib', [])
  375. for dirname in resolve_dirs(dep_cfg, dirnames):
  376. sys.path.append(dirname)
  377. module_names = dep_cfg.get('python-plugins', [])
  378. for module_name in module_names:
  379. module = __import__(module_name)
  380. module.init(root_dir=dep_cfg.root_dir)
  381. def call_cmdline_tool(env_root, pkg_name):
  382. pkg_cfg = build_config(env_root, Bunch(name='dummy'))
  383. if pkg_name not in pkg_cfg.packages:
  384. print "This tool requires the '%s' package." % pkg_name
  385. sys.exit(1)
  386. cfg = pkg_cfg.packages[pkg_name]
  387. for dirname in resolve_dirs(cfg, cfg['python-lib']):
  388. sys.path.append(dirname)
  389. module_name = cfg.get('python-cmdline-tool')
  390. module = __import__(module_name)
  391. module.run()