Fri, 16 Jan 2015 18:13:44 +0100
Integrate suggestion from review to improve consistency with existing code.
michael@0 | 1 | # This Source Code Form is subject to the terms of the Mozilla Public |
michael@0 | 2 | # License, v. 2.0. If a copy of the MPL was not distributed with this |
michael@0 | 3 | # file, You can obtain one at http://mozilla.org/MPL/2.0/. |
michael@0 | 4 | |
michael@0 | 5 | import errno |
michael@0 | 6 | import os |
michael@0 | 7 | import platform |
michael@0 | 8 | import shutil |
michael@0 | 9 | import stat |
michael@0 | 10 | import subprocess |
michael@0 | 11 | import uuid |
michael@0 | 12 | import mozbuild.makeutil as makeutil |
michael@0 | 13 | from mozbuild.preprocessor import Preprocessor |
michael@0 | 14 | from mozbuild.util import FileAvoidWrite |
michael@0 | 15 | from mozpack.executables import ( |
michael@0 | 16 | is_executable, |
michael@0 | 17 | may_strip, |
michael@0 | 18 | strip, |
michael@0 | 19 | may_elfhack, |
michael@0 | 20 | elfhack, |
michael@0 | 21 | ) |
michael@0 | 22 | from mozpack.chrome.manifest import ManifestEntry |
michael@0 | 23 | from io import BytesIO |
michael@0 | 24 | from mozpack.errors import ( |
michael@0 | 25 | ErrorMessage, |
michael@0 | 26 | errors, |
michael@0 | 27 | ) |
michael@0 | 28 | from mozpack.mozjar import JarReader |
michael@0 | 29 | import mozpack.path |
michael@0 | 30 | from collections import OrderedDict |
michael@0 | 31 | from jsmin import JavascriptMinify |
michael@0 | 32 | from tempfile import ( |
michael@0 | 33 | mkstemp, |
michael@0 | 34 | NamedTemporaryFile, |
michael@0 | 35 | ) |
michael@0 | 36 | |
michael@0 | 37 | |
michael@0 | 38 | class Dest(object): |
michael@0 | 39 | ''' |
michael@0 | 40 | Helper interface for BaseFile.copy. The interface works as follows: |
michael@0 | 41 | - read() and write() can be used to sequentially read/write from the |
michael@0 | 42 | underlying file. |
michael@0 | 43 | - a call to read() after a write() will re-open the underlying file and |
michael@0 | 44 | read from it. |
michael@0 | 45 | - a call to write() after a read() will re-open the underlying file, |
michael@0 | 46 | emptying it, and write to it. |
michael@0 | 47 | ''' |
michael@0 | 48 | def __init__(self, path): |
michael@0 | 49 | self.path = path |
michael@0 | 50 | self.mode = None |
michael@0 | 51 | |
michael@0 | 52 | @property |
michael@0 | 53 | def name(self): |
michael@0 | 54 | return self.path |
michael@0 | 55 | |
michael@0 | 56 | def read(self, length=-1): |
michael@0 | 57 | if self.mode != 'r': |
michael@0 | 58 | self.file = open(self.path, 'rb') |
michael@0 | 59 | self.mode = 'r' |
michael@0 | 60 | return self.file.read(length) |
michael@0 | 61 | |
michael@0 | 62 | def write(self, data): |
michael@0 | 63 | if self.mode != 'w': |
michael@0 | 64 | self.file = open(self.path, 'wb') |
michael@0 | 65 | self.mode = 'w' |
michael@0 | 66 | return self.file.write(data) |
michael@0 | 67 | |
michael@0 | 68 | def exists(self): |
michael@0 | 69 | return os.path.exists(self.path) |
michael@0 | 70 | |
michael@0 | 71 | def close(self): |
michael@0 | 72 | if self.mode: |
michael@0 | 73 | self.mode = None |
michael@0 | 74 | self.file.close() |
michael@0 | 75 | |
michael@0 | 76 | |
michael@0 | 77 | class BaseFile(object): |
michael@0 | 78 | ''' |
michael@0 | 79 | Base interface and helper for file copying. Derived class may implement |
michael@0 | 80 | their own copy function, or rely on BaseFile.copy using the open() member |
michael@0 | 81 | function and/or the path property. |
michael@0 | 82 | ''' |
michael@0 | 83 | @staticmethod |
michael@0 | 84 | def is_older(first, second): |
michael@0 | 85 | ''' |
michael@0 | 86 | Compares the modification time of two files, and returns whether the |
michael@0 | 87 | ``first`` file is older than the ``second`` file. |
michael@0 | 88 | ''' |
michael@0 | 89 | # os.path.getmtime returns a result in seconds with precision up to |
michael@0 | 90 | # the microsecond. But microsecond is too precise because |
michael@0 | 91 | # shutil.copystat only copies milliseconds, and seconds is not |
michael@0 | 92 | # enough precision. |
michael@0 | 93 | return int(os.path.getmtime(first) * 1000) \ |
michael@0 | 94 | <= int(os.path.getmtime(second) * 1000) |
michael@0 | 95 | |
michael@0 | 96 | @staticmethod |
michael@0 | 97 | def any_newer(dest, inputs): |
michael@0 | 98 | ''' |
michael@0 | 99 | Compares the modification time of ``dest`` to multiple input files, and |
michael@0 | 100 | returns whether any of the ``inputs`` is newer (has a later mtime) than |
michael@0 | 101 | ``dest``. |
michael@0 | 102 | ''' |
michael@0 | 103 | # os.path.getmtime returns a result in seconds with precision up to |
michael@0 | 104 | # the microsecond. But microsecond is too precise because |
michael@0 | 105 | # shutil.copystat only copies milliseconds, and seconds is not |
michael@0 | 106 | # enough precision. |
michael@0 | 107 | dest_mtime = int(os.path.getmtime(dest) * 1000) |
michael@0 | 108 | for input in inputs: |
michael@0 | 109 | if dest_mtime < int(os.path.getmtime(input) * 1000): |
michael@0 | 110 | return True |
michael@0 | 111 | return False |
michael@0 | 112 | |
michael@0 | 113 | def copy(self, dest, skip_if_older=True): |
michael@0 | 114 | ''' |
michael@0 | 115 | Copy the BaseFile content to the destination given as a string or a |
michael@0 | 116 | Dest instance. Avoids replacing existing files if the BaseFile content |
michael@0 | 117 | matches that of the destination, or in case of plain files, if the |
michael@0 | 118 | destination is newer than the original file. This latter behaviour is |
michael@0 | 119 | disabled when skip_if_older is False. |
michael@0 | 120 | Returns whether a copy was actually performed (True) or not (False). |
michael@0 | 121 | ''' |
michael@0 | 122 | if isinstance(dest, basestring): |
michael@0 | 123 | dest = Dest(dest) |
michael@0 | 124 | else: |
michael@0 | 125 | assert isinstance(dest, Dest) |
michael@0 | 126 | |
michael@0 | 127 | can_skip_content_check = False |
michael@0 | 128 | if not dest.exists(): |
michael@0 | 129 | can_skip_content_check = True |
michael@0 | 130 | elif getattr(self, 'path', None) and getattr(dest, 'path', None): |
michael@0 | 131 | if skip_if_older and BaseFile.is_older(self.path, dest.path): |
michael@0 | 132 | return False |
michael@0 | 133 | elif os.path.getsize(self.path) != os.path.getsize(dest.path): |
michael@0 | 134 | can_skip_content_check = True |
michael@0 | 135 | |
michael@0 | 136 | if can_skip_content_check: |
michael@0 | 137 | if getattr(self, 'path', None) and getattr(dest, 'path', None): |
michael@0 | 138 | shutil.copy2(self.path, dest.path) |
michael@0 | 139 | else: |
michael@0 | 140 | # Ensure the file is always created |
michael@0 | 141 | if not dest.exists(): |
michael@0 | 142 | dest.write('') |
michael@0 | 143 | shutil.copyfileobj(self.open(), dest) |
michael@0 | 144 | return True |
michael@0 | 145 | |
michael@0 | 146 | src = self.open() |
michael@0 | 147 | copy_content = '' |
michael@0 | 148 | while True: |
michael@0 | 149 | dest_content = dest.read(32768) |
michael@0 | 150 | src_content = src.read(32768) |
michael@0 | 151 | copy_content += src_content |
michael@0 | 152 | if len(dest_content) == len(src_content) == 0: |
michael@0 | 153 | break |
michael@0 | 154 | # If the read content differs between origin and destination, |
michael@0 | 155 | # write what was read up to now, and copy the remainder. |
michael@0 | 156 | if dest_content != src_content: |
michael@0 | 157 | dest.write(copy_content) |
michael@0 | 158 | shutil.copyfileobj(src, dest) |
michael@0 | 159 | break |
michael@0 | 160 | if hasattr(self, 'path') and hasattr(dest, 'path'): |
michael@0 | 161 | shutil.copystat(self.path, dest.path) |
michael@0 | 162 | return True |
michael@0 | 163 | |
michael@0 | 164 | def open(self): |
michael@0 | 165 | ''' |
michael@0 | 166 | Return a file-like object allowing to read() the content of the |
michael@0 | 167 | associated file. This is meant to be overloaded in subclasses to return |
michael@0 | 168 | a custom file-like object. |
michael@0 | 169 | ''' |
michael@0 | 170 | assert self.path is not None |
michael@0 | 171 | return open(self.path, 'rb') |
michael@0 | 172 | |
michael@0 | 173 | @property |
michael@0 | 174 | def mode(self): |
michael@0 | 175 | ''' |
michael@0 | 176 | Return the file's unix mode, or None if it has no meaning. |
michael@0 | 177 | ''' |
michael@0 | 178 | return None |
michael@0 | 179 | |
michael@0 | 180 | |
michael@0 | 181 | class File(BaseFile): |
michael@0 | 182 | ''' |
michael@0 | 183 | File class for plain files. |
michael@0 | 184 | ''' |
michael@0 | 185 | def __init__(self, path): |
michael@0 | 186 | self.path = path |
michael@0 | 187 | |
michael@0 | 188 | @property |
michael@0 | 189 | def mode(self): |
michael@0 | 190 | ''' |
michael@0 | 191 | Return the file's unix mode, as returned by os.stat().st_mode. |
michael@0 | 192 | ''' |
michael@0 | 193 | if platform.system() == 'Windows': |
michael@0 | 194 | return None |
michael@0 | 195 | assert self.path is not None |
michael@0 | 196 | return os.stat(self.path).st_mode |
michael@0 | 197 | |
michael@0 | 198 | class ExecutableFile(File): |
michael@0 | 199 | ''' |
michael@0 | 200 | File class for executable and library files on OS/2, OS/X and ELF systems. |
michael@0 | 201 | (see mozpack.executables.is_executable documentation). |
michael@0 | 202 | ''' |
michael@0 | 203 | def copy(self, dest, skip_if_older=True): |
michael@0 | 204 | real_dest = dest |
michael@0 | 205 | if not isinstance(dest, basestring): |
michael@0 | 206 | fd, dest = mkstemp() |
michael@0 | 207 | os.close(fd) |
michael@0 | 208 | os.remove(dest) |
michael@0 | 209 | assert isinstance(dest, basestring) |
michael@0 | 210 | # If File.copy didn't actually copy because dest is newer, check the |
michael@0 | 211 | # file sizes. If dest is smaller, it means it is already stripped and |
michael@0 | 212 | # elfhacked, so we can skip. |
michael@0 | 213 | if not File.copy(self, dest, skip_if_older) and \ |
michael@0 | 214 | os.path.getsize(self.path) > os.path.getsize(dest): |
michael@0 | 215 | return False |
michael@0 | 216 | try: |
michael@0 | 217 | if may_strip(dest): |
michael@0 | 218 | strip(dest) |
michael@0 | 219 | if may_elfhack(dest): |
michael@0 | 220 | elfhack(dest) |
michael@0 | 221 | except ErrorMessage: |
michael@0 | 222 | os.remove(dest) |
michael@0 | 223 | raise |
michael@0 | 224 | |
michael@0 | 225 | if real_dest != dest: |
michael@0 | 226 | f = File(dest) |
michael@0 | 227 | ret = f.copy(real_dest, skip_if_older) |
michael@0 | 228 | os.remove(dest) |
michael@0 | 229 | return ret |
michael@0 | 230 | return True |
michael@0 | 231 | |
michael@0 | 232 | |
michael@0 | 233 | class AbsoluteSymlinkFile(File): |
michael@0 | 234 | '''File class that is copied by symlinking (if available). |
michael@0 | 235 | |
michael@0 | 236 | This class only works if the target path is absolute. |
michael@0 | 237 | ''' |
michael@0 | 238 | |
michael@0 | 239 | def __init__(self, path): |
michael@0 | 240 | if not os.path.isabs(path): |
michael@0 | 241 | raise ValueError('Symlink target not absolute: %s' % path) |
michael@0 | 242 | |
michael@0 | 243 | File.__init__(self, path) |
michael@0 | 244 | |
michael@0 | 245 | def copy(self, dest, skip_if_older=True): |
michael@0 | 246 | assert isinstance(dest, basestring) |
michael@0 | 247 | |
michael@0 | 248 | # The logic in this function is complicated by the fact that symlinks |
michael@0 | 249 | # aren't universally supported. So, where symlinks aren't supported, we |
michael@0 | 250 | # fall back to file copying. Keep in mind that symlink support is |
michael@0 | 251 | # per-filesystem, not per-OS. |
michael@0 | 252 | |
michael@0 | 253 | # Handle the simple case where symlinks are definitely not supported by |
michael@0 | 254 | # falling back to file copy. |
michael@0 | 255 | if not hasattr(os, 'symlink'): |
michael@0 | 256 | return File.copy(self, dest, skip_if_older=skip_if_older) |
michael@0 | 257 | |
michael@0 | 258 | # Always verify the symlink target path exists. |
michael@0 | 259 | if not os.path.exists(self.path): |
michael@0 | 260 | raise ErrorMessage('Symlink target path does not exist: %s' % self.path) |
michael@0 | 261 | |
michael@0 | 262 | st = None |
michael@0 | 263 | |
michael@0 | 264 | try: |
michael@0 | 265 | st = os.lstat(dest) |
michael@0 | 266 | except OSError as ose: |
michael@0 | 267 | if ose.errno != errno.ENOENT: |
michael@0 | 268 | raise |
michael@0 | 269 | |
michael@0 | 270 | # If the dest is a symlink pointing to us, we have nothing to do. |
michael@0 | 271 | # If it's the wrong symlink, the filesystem must support symlinks, |
michael@0 | 272 | # so we replace with a proper symlink. |
michael@0 | 273 | if st and stat.S_ISLNK(st.st_mode): |
michael@0 | 274 | link = os.readlink(dest) |
michael@0 | 275 | if link == self.path: |
michael@0 | 276 | return False |
michael@0 | 277 | |
michael@0 | 278 | os.remove(dest) |
michael@0 | 279 | os.symlink(self.path, dest) |
michael@0 | 280 | return True |
michael@0 | 281 | |
michael@0 | 282 | # If the destination doesn't exist, we try to create a symlink. If that |
michael@0 | 283 | # fails, we fall back to copy code. |
michael@0 | 284 | if not st: |
michael@0 | 285 | try: |
michael@0 | 286 | os.symlink(self.path, dest) |
michael@0 | 287 | return True |
michael@0 | 288 | except OSError: |
michael@0 | 289 | return File.copy(self, dest, skip_if_older=skip_if_older) |
michael@0 | 290 | |
michael@0 | 291 | # Now the complicated part. If the destination exists, we could be |
michael@0 | 292 | # replacing a file with a symlink. Or, the filesystem may not support |
michael@0 | 293 | # symlinks. We want to minimize I/O overhead for performance reasons, |
michael@0 | 294 | # so we keep the existing destination file around as long as possible. |
michael@0 | 295 | # A lot of the system calls would be eliminated if we cached whether |
michael@0 | 296 | # symlinks are supported. However, even if we performed a single |
michael@0 | 297 | # up-front test of whether the root of the destination directory |
michael@0 | 298 | # supports symlinks, there's no guarantee that all operations for that |
michael@0 | 299 | # dest (or source) would be on the same filesystem and would support |
michael@0 | 300 | # symlinks. |
michael@0 | 301 | # |
michael@0 | 302 | # Our strategy is to attempt to create a new symlink with a random |
michael@0 | 303 | # name. If that fails, we fall back to copy mode. If that works, we |
michael@0 | 304 | # remove the old destination and move the newly-created symlink into |
michael@0 | 305 | # its place. |
michael@0 | 306 | |
michael@0 | 307 | temp_dest = os.path.join(os.path.dirname(dest), str(uuid.uuid4())) |
michael@0 | 308 | try: |
michael@0 | 309 | os.symlink(self.path, temp_dest) |
michael@0 | 310 | # TODO Figure out exactly how symlink creation fails and only trap |
michael@0 | 311 | # that. |
michael@0 | 312 | except EnvironmentError: |
michael@0 | 313 | return File.copy(self, dest, skip_if_older=skip_if_older) |
michael@0 | 314 | |
michael@0 | 315 | # If removing the original file fails, don't forget to clean up the |
michael@0 | 316 | # temporary symlink. |
michael@0 | 317 | try: |
michael@0 | 318 | os.remove(dest) |
michael@0 | 319 | except EnvironmentError: |
michael@0 | 320 | os.remove(temp_dest) |
michael@0 | 321 | raise |
michael@0 | 322 | |
michael@0 | 323 | os.rename(temp_dest, dest) |
michael@0 | 324 | return True |
michael@0 | 325 | |
michael@0 | 326 | |
michael@0 | 327 | class ExistingFile(BaseFile): |
michael@0 | 328 | ''' |
michael@0 | 329 | File class that represents a file that may exist but whose content comes |
michael@0 | 330 | from elsewhere. |
michael@0 | 331 | |
michael@0 | 332 | This purpose of this class is to account for files that are installed via |
michael@0 | 333 | external means. It is typically only used in manifests or in registries to |
michael@0 | 334 | account for files. |
michael@0 | 335 | |
michael@0 | 336 | When asked to copy, this class does nothing because nothing is known about |
michael@0 | 337 | the source file/data. |
michael@0 | 338 | |
michael@0 | 339 | Instances of this class come in two flavors: required and optional. If an |
michael@0 | 340 | existing file is required, it must exist during copy() or an error is |
michael@0 | 341 | raised. |
michael@0 | 342 | ''' |
michael@0 | 343 | def __init__(self, required): |
michael@0 | 344 | self.required = required |
michael@0 | 345 | |
michael@0 | 346 | def copy(self, dest, skip_if_older=True): |
michael@0 | 347 | if isinstance(dest, basestring): |
michael@0 | 348 | dest = Dest(dest) |
michael@0 | 349 | else: |
michael@0 | 350 | assert isinstance(dest, Dest) |
michael@0 | 351 | |
michael@0 | 352 | if not self.required: |
michael@0 | 353 | return |
michael@0 | 354 | |
michael@0 | 355 | if not dest.exists(): |
michael@0 | 356 | errors.fatal("Required existing file doesn't exist: %s" % |
michael@0 | 357 | dest.path) |
michael@0 | 358 | |
michael@0 | 359 | |
michael@0 | 360 | class PreprocessedFile(BaseFile): |
michael@0 | 361 | ''' |
michael@0 | 362 | File class for a file that is preprocessed. PreprocessedFile.copy() runs |
michael@0 | 363 | the preprocessor on the file to create the output. |
michael@0 | 364 | ''' |
michael@0 | 365 | def __init__(self, path, depfile_path, marker, defines, extra_depends=None): |
michael@0 | 366 | self.path = path |
michael@0 | 367 | self.depfile = depfile_path |
michael@0 | 368 | self.marker = marker |
michael@0 | 369 | self.defines = defines |
michael@0 | 370 | self.extra_depends = list(extra_depends or []) |
michael@0 | 371 | |
michael@0 | 372 | def copy(self, dest, skip_if_older=True): |
michael@0 | 373 | ''' |
michael@0 | 374 | Invokes the preprocessor to create the destination file. |
michael@0 | 375 | ''' |
michael@0 | 376 | if isinstance(dest, basestring): |
michael@0 | 377 | dest = Dest(dest) |
michael@0 | 378 | else: |
michael@0 | 379 | assert isinstance(dest, Dest) |
michael@0 | 380 | |
michael@0 | 381 | # We have to account for the case where the destination exists and is a |
michael@0 | 382 | # symlink to something. Since we know the preprocessor is certainly not |
michael@0 | 383 | # going to create a symlink, we can just remove the existing one. If the |
michael@0 | 384 | # destination is not a symlink, we leave it alone, since we're going to |
michael@0 | 385 | # overwrite its contents anyway. |
michael@0 | 386 | # If symlinks aren't supported at all, we can skip this step. |
michael@0 | 387 | if hasattr(os, 'symlink'): |
michael@0 | 388 | if os.path.islink(dest.path): |
michael@0 | 389 | os.remove(dest.path) |
michael@0 | 390 | |
michael@0 | 391 | pp_deps = set(self.extra_depends) |
michael@0 | 392 | |
michael@0 | 393 | # If a dependency file was specified, and it exists, add any |
michael@0 | 394 | # dependencies from that file to our list. |
michael@0 | 395 | if self.depfile and os.path.exists(self.depfile): |
michael@0 | 396 | target = mozpack.path.normpath(dest.name) |
michael@0 | 397 | with open(self.depfile, 'rb') as fileobj: |
michael@0 | 398 | for rule in makeutil.read_dep_makefile(fileobj): |
michael@0 | 399 | if target in rule.targets(): |
michael@0 | 400 | pp_deps.update(rule.dependencies()) |
michael@0 | 401 | |
michael@0 | 402 | skip = False |
michael@0 | 403 | if dest.exists() and skip_if_older: |
michael@0 | 404 | # If a dependency file was specified, and it doesn't exist, |
michael@0 | 405 | # assume that the preprocessor needs to be rerun. That will |
michael@0 | 406 | # regenerate the dependency file. |
michael@0 | 407 | if self.depfile and not os.path.exists(self.depfile): |
michael@0 | 408 | skip = False |
michael@0 | 409 | else: |
michael@0 | 410 | skip = not BaseFile.any_newer(dest.path, pp_deps) |
michael@0 | 411 | |
michael@0 | 412 | if skip: |
michael@0 | 413 | return False |
michael@0 | 414 | |
michael@0 | 415 | deps_out = None |
michael@0 | 416 | if self.depfile: |
michael@0 | 417 | deps_out = FileAvoidWrite(self.depfile) |
michael@0 | 418 | pp = Preprocessor(defines=self.defines, marker=self.marker) |
michael@0 | 419 | |
michael@0 | 420 | with open(self.path, 'rU') as input: |
michael@0 | 421 | pp.processFile(input=input, output=dest, depfile=deps_out) |
michael@0 | 422 | |
michael@0 | 423 | dest.close() |
michael@0 | 424 | if self.depfile: |
michael@0 | 425 | deps_out.close() |
michael@0 | 426 | |
michael@0 | 427 | return True |
michael@0 | 428 | |
michael@0 | 429 | |
michael@0 | 430 | class GeneratedFile(BaseFile): |
michael@0 | 431 | ''' |
michael@0 | 432 | File class for content with no previous existence on the filesystem. |
michael@0 | 433 | ''' |
michael@0 | 434 | def __init__(self, content): |
michael@0 | 435 | self.content = content |
michael@0 | 436 | |
michael@0 | 437 | def open(self): |
michael@0 | 438 | return BytesIO(self.content) |
michael@0 | 439 | |
michael@0 | 440 | |
michael@0 | 441 | class DeflatedFile(BaseFile): |
michael@0 | 442 | ''' |
michael@0 | 443 | File class for members of a jar archive. DeflatedFile.copy() effectively |
michael@0 | 444 | extracts the file from the jar archive. |
michael@0 | 445 | ''' |
michael@0 | 446 | def __init__(self, file): |
michael@0 | 447 | from mozpack.mozjar import JarFileReader |
michael@0 | 448 | assert isinstance(file, JarFileReader) |
michael@0 | 449 | self.file = file |
michael@0 | 450 | |
michael@0 | 451 | def open(self): |
michael@0 | 452 | self.file.seek(0) |
michael@0 | 453 | return self.file |
michael@0 | 454 | |
michael@0 | 455 | |
michael@0 | 456 | class XPTFile(GeneratedFile): |
michael@0 | 457 | ''' |
michael@0 | 458 | File class for a linked XPT file. It takes several XPT files as input |
michael@0 | 459 | (using the add() and remove() member functions), and links them at copy() |
michael@0 | 460 | time. |
michael@0 | 461 | ''' |
michael@0 | 462 | def __init__(self): |
michael@0 | 463 | self._files = set() |
michael@0 | 464 | |
michael@0 | 465 | def add(self, xpt): |
michael@0 | 466 | ''' |
michael@0 | 467 | Add the given XPT file (as a BaseFile instance) to the list of XPTs |
michael@0 | 468 | to link. |
michael@0 | 469 | ''' |
michael@0 | 470 | assert isinstance(xpt, BaseFile) |
michael@0 | 471 | self._files.add(xpt) |
michael@0 | 472 | |
michael@0 | 473 | def remove(self, xpt): |
michael@0 | 474 | ''' |
michael@0 | 475 | Remove the given XPT file (as a BaseFile instance) from the list of |
michael@0 | 476 | XPTs to link. |
michael@0 | 477 | ''' |
michael@0 | 478 | assert isinstance(xpt, BaseFile) |
michael@0 | 479 | self._files.remove(xpt) |
michael@0 | 480 | |
michael@0 | 481 | def copy(self, dest, skip_if_older=True): |
michael@0 | 482 | ''' |
michael@0 | 483 | Link the registered XPTs and place the resulting linked XPT at the |
michael@0 | 484 | destination given as a string or a Dest instance. Avoids an expensive |
michael@0 | 485 | XPT linking if the interfaces in an existing destination match those of |
michael@0 | 486 | the individual XPTs to link. |
michael@0 | 487 | skip_if_older is ignored. |
michael@0 | 488 | ''' |
michael@0 | 489 | if isinstance(dest, basestring): |
michael@0 | 490 | dest = Dest(dest) |
michael@0 | 491 | assert isinstance(dest, Dest) |
michael@0 | 492 | |
michael@0 | 493 | from xpt import xpt_link, Typelib, Interface |
michael@0 | 494 | all_typelibs = [Typelib.read(f.open()) for f in self._files] |
michael@0 | 495 | if dest.exists(): |
michael@0 | 496 | # Typelib.read() needs to seek(), so use a BytesIO for dest |
michael@0 | 497 | # content. |
michael@0 | 498 | dest_interfaces = \ |
michael@0 | 499 | dict((i.name, i) |
michael@0 | 500 | for i in Typelib.read(BytesIO(dest.read())).interfaces |
michael@0 | 501 | if i.iid != Interface.UNRESOLVED_IID) |
michael@0 | 502 | identical = True |
michael@0 | 503 | for f in self._files: |
michael@0 | 504 | typelib = Typelib.read(f.open()) |
michael@0 | 505 | for i in typelib.interfaces: |
michael@0 | 506 | if i.iid != Interface.UNRESOLVED_IID and \ |
michael@0 | 507 | not (i.name in dest_interfaces and |
michael@0 | 508 | i == dest_interfaces[i.name]): |
michael@0 | 509 | identical = False |
michael@0 | 510 | break |
michael@0 | 511 | if identical: |
michael@0 | 512 | return False |
michael@0 | 513 | s = BytesIO() |
michael@0 | 514 | xpt_link(all_typelibs).write(s) |
michael@0 | 515 | dest.write(s.getvalue()) |
michael@0 | 516 | return True |
michael@0 | 517 | |
michael@0 | 518 | def open(self): |
michael@0 | 519 | raise RuntimeError("Unsupported") |
michael@0 | 520 | |
michael@0 | 521 | def isempty(self): |
michael@0 | 522 | ''' |
michael@0 | 523 | Return whether there are XPT files to link. |
michael@0 | 524 | ''' |
michael@0 | 525 | return len(self._files) == 0 |
michael@0 | 526 | |
michael@0 | 527 | |
michael@0 | 528 | class ManifestFile(BaseFile): |
michael@0 | 529 | ''' |
michael@0 | 530 | File class for a manifest file. It takes individual manifest entries (using |
michael@0 | 531 | the add() and remove() member functions), and adjusts them to be relative |
michael@0 | 532 | to the base path for the manifest, given at creation. |
michael@0 | 533 | Example: |
michael@0 | 534 | There is a manifest entry "content webapprt webapprt/content/" relative |
michael@0 | 535 | to "webapprt/chrome". When packaging, the entry will be stored in |
michael@0 | 536 | jar:webapprt/omni.ja!/chrome/chrome.manifest, which means the entry |
michael@0 | 537 | will have to be relative to "chrome" instead of "webapprt/chrome". This |
michael@0 | 538 | doesn't really matter when serializing the entry, since this base path |
michael@0 | 539 | is not written out, but it matters when moving the entry at the same |
michael@0 | 540 | time, e.g. to jar:webapprt/omni.ja!/chrome.manifest, which we don't do |
michael@0 | 541 | currently but could in the future. |
michael@0 | 542 | ''' |
michael@0 | 543 | def __init__(self, base, entries=None): |
michael@0 | 544 | self._entries = entries if entries else [] |
michael@0 | 545 | self._base = base |
michael@0 | 546 | |
michael@0 | 547 | def add(self, entry): |
michael@0 | 548 | ''' |
michael@0 | 549 | Add the given entry to the manifest. Entries are rebased at open() time |
michael@0 | 550 | instead of add() time so that they can be more easily remove()d. |
michael@0 | 551 | ''' |
michael@0 | 552 | assert isinstance(entry, ManifestEntry) |
michael@0 | 553 | self._entries.append(entry) |
michael@0 | 554 | |
michael@0 | 555 | def remove(self, entry): |
michael@0 | 556 | ''' |
michael@0 | 557 | Remove the given entry from the manifest. |
michael@0 | 558 | ''' |
michael@0 | 559 | assert isinstance(entry, ManifestEntry) |
michael@0 | 560 | self._entries.remove(entry) |
michael@0 | 561 | |
michael@0 | 562 | def open(self): |
michael@0 | 563 | ''' |
michael@0 | 564 | Return a file-like object allowing to read() the serialized content of |
michael@0 | 565 | the manifest. |
michael@0 | 566 | ''' |
michael@0 | 567 | return BytesIO(''.join('%s\n' % e.rebase(self._base) |
michael@0 | 568 | for e in self._entries)) |
michael@0 | 569 | |
michael@0 | 570 | def __iter__(self): |
michael@0 | 571 | ''' |
michael@0 | 572 | Iterate over entries in the manifest file. |
michael@0 | 573 | ''' |
michael@0 | 574 | return iter(self._entries) |
michael@0 | 575 | |
michael@0 | 576 | def isempty(self): |
michael@0 | 577 | ''' |
michael@0 | 578 | Return whether there are manifest entries to write |
michael@0 | 579 | ''' |
michael@0 | 580 | return len(self._entries) == 0 |
michael@0 | 581 | |
michael@0 | 582 | |
michael@0 | 583 | class MinifiedProperties(BaseFile): |
michael@0 | 584 | ''' |
michael@0 | 585 | File class for minified properties. This wraps around a BaseFile instance, |
michael@0 | 586 | and removes lines starting with a # from its content. |
michael@0 | 587 | ''' |
michael@0 | 588 | def __init__(self, file): |
michael@0 | 589 | assert isinstance(file, BaseFile) |
michael@0 | 590 | self._file = file |
michael@0 | 591 | |
michael@0 | 592 | def open(self): |
michael@0 | 593 | ''' |
michael@0 | 594 | Return a file-like object allowing to read() the minified content of |
michael@0 | 595 | the properties file. |
michael@0 | 596 | ''' |
michael@0 | 597 | return BytesIO(''.join(l for l in self._file.open().readlines() |
michael@0 | 598 | if not l.startswith('#'))) |
michael@0 | 599 | |
michael@0 | 600 | |
michael@0 | 601 | class MinifiedJavaScript(BaseFile): |
michael@0 | 602 | ''' |
michael@0 | 603 | File class for minifying JavaScript files. |
michael@0 | 604 | ''' |
michael@0 | 605 | def __init__(self, file, verify_command=None): |
michael@0 | 606 | assert isinstance(file, BaseFile) |
michael@0 | 607 | self._file = file |
michael@0 | 608 | self._verify_command = verify_command |
michael@0 | 609 | |
michael@0 | 610 | def open(self): |
michael@0 | 611 | output = BytesIO() |
michael@0 | 612 | minify = JavascriptMinify(self._file.open(), output) |
michael@0 | 613 | minify.minify() |
michael@0 | 614 | output.seek(0) |
michael@0 | 615 | |
michael@0 | 616 | if not self._verify_command: |
michael@0 | 617 | return output |
michael@0 | 618 | |
michael@0 | 619 | input_source = self._file.open().read() |
michael@0 | 620 | output_source = output.getvalue() |
michael@0 | 621 | |
michael@0 | 622 | with NamedTemporaryFile() as fh1, NamedTemporaryFile() as fh2: |
michael@0 | 623 | fh1.write(input_source) |
michael@0 | 624 | fh2.write(output_source) |
michael@0 | 625 | fh1.flush() |
michael@0 | 626 | fh2.flush() |
michael@0 | 627 | |
michael@0 | 628 | try: |
michael@0 | 629 | args = list(self._verify_command) |
michael@0 | 630 | args.extend([fh1.name, fh2.name]) |
michael@0 | 631 | subprocess.check_output(args, stderr=subprocess.STDOUT) |
michael@0 | 632 | except subprocess.CalledProcessError as e: |
michael@0 | 633 | errors.warn('JS minification verification failed for %s:' % |
michael@0 | 634 | (getattr(self._file, 'path', '<unknown>'))) |
michael@0 | 635 | # Prefix each line with "Warning:" so mozharness doesn't |
michael@0 | 636 | # think these error messages are real errors. |
michael@0 | 637 | for line in e.output.splitlines(): |
michael@0 | 638 | errors.warn(line) |
michael@0 | 639 | |
michael@0 | 640 | return self._file.open() |
michael@0 | 641 | |
michael@0 | 642 | return output |
michael@0 | 643 | |
michael@0 | 644 | |
michael@0 | 645 | class BaseFinder(object): |
michael@0 | 646 | def __init__(self, base, minify=False, minify_js=False, |
michael@0 | 647 | minify_js_verify_command=None): |
michael@0 | 648 | ''' |
michael@0 | 649 | Initializes the instance with a reference base directory. |
michael@0 | 650 | |
michael@0 | 651 | The optional minify argument specifies whether minification of code |
michael@0 | 652 | should occur. minify_js is an additional option to control minification |
michael@0 | 653 | of JavaScript. It requires minify to be True. |
michael@0 | 654 | |
michael@0 | 655 | minify_js_verify_command can be used to optionally verify the results |
michael@0 | 656 | of JavaScript minification. If defined, it is expected to be an iterable |
michael@0 | 657 | that will constitute the first arguments to a called process which will |
michael@0 | 658 | receive the filenames of the original and minified JavaScript files. |
michael@0 | 659 | The invoked process can then verify the results. If minification is |
michael@0 | 660 | rejected, the process exits with a non-0 exit code and the original |
michael@0 | 661 | JavaScript source is used. An example value for this argument is |
michael@0 | 662 | ('/path/to/js', '/path/to/verify/script.js'). |
michael@0 | 663 | ''' |
michael@0 | 664 | if minify_js and not minify: |
michael@0 | 665 | raise ValueError('minify_js requires minify.') |
michael@0 | 666 | |
michael@0 | 667 | self.base = base |
michael@0 | 668 | self._minify = minify |
michael@0 | 669 | self._minify_js = minify_js |
michael@0 | 670 | self._minify_js_verify_command = minify_js_verify_command |
michael@0 | 671 | |
michael@0 | 672 | def find(self, pattern): |
michael@0 | 673 | ''' |
michael@0 | 674 | Yield path, BaseFile_instance pairs for all files under the base |
michael@0 | 675 | directory and its subdirectories that match the given pattern. See the |
michael@0 | 676 | mozpack.path.match documentation for a description of the handled |
michael@0 | 677 | patterns. |
michael@0 | 678 | ''' |
michael@0 | 679 | while pattern.startswith('/'): |
michael@0 | 680 | pattern = pattern[1:] |
michael@0 | 681 | for p, f in self._find(pattern): |
michael@0 | 682 | yield p, self._minify_file(p, f) |
michael@0 | 683 | |
michael@0 | 684 | def __iter__(self): |
michael@0 | 685 | ''' |
michael@0 | 686 | Iterates over all files under the base directory (excluding files |
michael@0 | 687 | starting with a '.' and files at any level under a directory starting |
michael@0 | 688 | with a '.'). |
michael@0 | 689 | for path, file in finder: |
michael@0 | 690 | ... |
michael@0 | 691 | ''' |
michael@0 | 692 | return self.find('') |
michael@0 | 693 | |
michael@0 | 694 | def __contains__(self, pattern): |
michael@0 | 695 | raise RuntimeError("'in' operator forbidden for %s. Use contains()." % |
michael@0 | 696 | self.__class__.__name__) |
michael@0 | 697 | |
michael@0 | 698 | def contains(self, pattern): |
michael@0 | 699 | ''' |
michael@0 | 700 | Return whether some files under the base directory match the given |
michael@0 | 701 | pattern. See the mozpack.path.match documentation for a description of |
michael@0 | 702 | the handled patterns. |
michael@0 | 703 | ''' |
michael@0 | 704 | return any(self.find(pattern)) |
michael@0 | 705 | |
michael@0 | 706 | def _minify_file(self, path, file): |
michael@0 | 707 | ''' |
michael@0 | 708 | Return an appropriate MinifiedSomething wrapper for the given BaseFile |
michael@0 | 709 | instance (file), according to the file type (determined by the given |
michael@0 | 710 | path), if the FileFinder was created with minification enabled. |
michael@0 | 711 | Otherwise, just return the given BaseFile instance. |
michael@0 | 712 | ''' |
michael@0 | 713 | if not self._minify or isinstance(file, ExecutableFile): |
michael@0 | 714 | return file |
michael@0 | 715 | |
michael@0 | 716 | if path.endswith('.properties'): |
michael@0 | 717 | return MinifiedProperties(file) |
michael@0 | 718 | |
michael@0 | 719 | if self._minify_js and path.endswith(('.js', '.jsm')): |
michael@0 | 720 | return MinifiedJavaScript(file, self._minify_js_verify_command) |
michael@0 | 721 | |
michael@0 | 722 | return file |
michael@0 | 723 | |
michael@0 | 724 | |
michael@0 | 725 | class FileFinder(BaseFinder): |
michael@0 | 726 | ''' |
michael@0 | 727 | Helper to get appropriate BaseFile instances from the file system. |
michael@0 | 728 | ''' |
michael@0 | 729 | def __init__(self, base, find_executables=True, ignore=(), **kargs): |
michael@0 | 730 | ''' |
michael@0 | 731 | Create a FileFinder for files under the given base directory. |
michael@0 | 732 | |
michael@0 | 733 | The find_executables argument determines whether the finder needs to |
michael@0 | 734 | try to guess whether files are executables. Disabling this guessing |
michael@0 | 735 | when not necessary can speed up the finder significantly. |
michael@0 | 736 | |
michael@0 | 737 | ``ignore`` accepts an iterable of patterns to ignore. Entries are |
michael@0 | 738 | strings that match paths relative to ``base`` using |
michael@0 | 739 | ``mozpack.path.match()``. This means if an entry corresponds |
michael@0 | 740 | to a directory, all files under that directory will be ignored. If |
michael@0 | 741 | an entry corresponds to a file, that particular file will be ignored. |
michael@0 | 742 | ''' |
michael@0 | 743 | BaseFinder.__init__(self, base, **kargs) |
michael@0 | 744 | self.find_executables = find_executables |
michael@0 | 745 | self.ignore = ignore |
michael@0 | 746 | |
michael@0 | 747 | def _find(self, pattern): |
michael@0 | 748 | ''' |
michael@0 | 749 | Actual implementation of FileFinder.find(), dispatching to specialized |
michael@0 | 750 | member functions depending on what kind of pattern was given. |
michael@0 | 751 | Note all files with a name starting with a '.' are ignored when |
michael@0 | 752 | scanning directories, but are not ignored when explicitely requested. |
michael@0 | 753 | ''' |
michael@0 | 754 | if '*' in pattern: |
michael@0 | 755 | return self._find_glob('', mozpack.path.split(pattern)) |
michael@0 | 756 | elif os.path.isdir(os.path.join(self.base, pattern)): |
michael@0 | 757 | return self._find_dir(pattern) |
michael@0 | 758 | else: |
michael@0 | 759 | return self._find_file(pattern) |
michael@0 | 760 | |
michael@0 | 761 | def _find_dir(self, path): |
michael@0 | 762 | ''' |
michael@0 | 763 | Actual implementation of FileFinder.find() when the given pattern |
michael@0 | 764 | corresponds to an existing directory under the base directory. |
michael@0 | 765 | Ignores file names starting with a '.' under the given path. If the |
michael@0 | 766 | path itself has leafs starting with a '.', they are not ignored. |
michael@0 | 767 | ''' |
michael@0 | 768 | for p in self.ignore: |
michael@0 | 769 | if mozpack.path.match(path, p): |
michael@0 | 770 | return |
michael@0 | 771 | |
michael@0 | 772 | # The sorted makes the output idempotent. Otherwise, we are |
michael@0 | 773 | # likely dependent on filesystem implementation details, such as |
michael@0 | 774 | # inode ordering. |
michael@0 | 775 | for p in sorted(os.listdir(os.path.join(self.base, path))): |
michael@0 | 776 | if p.startswith('.'): |
michael@0 | 777 | continue |
michael@0 | 778 | for p_, f in self._find(mozpack.path.join(path, p)): |
michael@0 | 779 | yield p_, f |
michael@0 | 780 | |
michael@0 | 781 | def _find_file(self, path): |
michael@0 | 782 | ''' |
michael@0 | 783 | Actual implementation of FileFinder.find() when the given pattern |
michael@0 | 784 | corresponds to an existing file under the base directory. |
michael@0 | 785 | ''' |
michael@0 | 786 | srcpath = os.path.join(self.base, path) |
michael@0 | 787 | if not os.path.exists(srcpath): |
michael@0 | 788 | return |
michael@0 | 789 | |
michael@0 | 790 | for p in self.ignore: |
michael@0 | 791 | if mozpack.path.match(path, p): |
michael@0 | 792 | return |
michael@0 | 793 | |
michael@0 | 794 | if self.find_executables and is_executable(srcpath): |
michael@0 | 795 | yield path, ExecutableFile(srcpath) |
michael@0 | 796 | else: |
michael@0 | 797 | yield path, File(srcpath) |
michael@0 | 798 | |
michael@0 | 799 | def _find_glob(self, base, pattern): |
michael@0 | 800 | ''' |
michael@0 | 801 | Actual implementation of FileFinder.find() when the given pattern |
michael@0 | 802 | contains globbing patterns ('*' or '**'). This is meant to be an |
michael@0 | 803 | equivalent of: |
michael@0 | 804 | for p, f in self: |
michael@0 | 805 | if mozpack.path.match(p, pattern): |
michael@0 | 806 | yield p, f |
michael@0 | 807 | but avoids scanning the entire tree. |
michael@0 | 808 | ''' |
michael@0 | 809 | if not pattern: |
michael@0 | 810 | for p, f in self._find(base): |
michael@0 | 811 | yield p, f |
michael@0 | 812 | elif pattern[0] == '**': |
michael@0 | 813 | for p, f in self._find(base): |
michael@0 | 814 | if mozpack.path.match(p, mozpack.path.join(*pattern)): |
michael@0 | 815 | yield p, f |
michael@0 | 816 | elif '*' in pattern[0]: |
michael@0 | 817 | if not os.path.exists(os.path.join(self.base, base)): |
michael@0 | 818 | return |
michael@0 | 819 | |
michael@0 | 820 | for p in self.ignore: |
michael@0 | 821 | if mozpack.path.match(base, p): |
michael@0 | 822 | return |
michael@0 | 823 | |
michael@0 | 824 | # See above comment w.r.t. sorted() and idempotent behavior. |
michael@0 | 825 | for p in sorted(os.listdir(os.path.join(self.base, base))): |
michael@0 | 826 | if p.startswith('.') and not pattern[0].startswith('.'): |
michael@0 | 827 | continue |
michael@0 | 828 | if mozpack.path.match(p, pattern[0]): |
michael@0 | 829 | for p_, f in self._find_glob(mozpack.path.join(base, p), |
michael@0 | 830 | pattern[1:]): |
michael@0 | 831 | yield p_, f |
michael@0 | 832 | else: |
michael@0 | 833 | for p, f in self._find_glob(mozpack.path.join(base, pattern[0]), |
michael@0 | 834 | pattern[1:]): |
michael@0 | 835 | yield p, f |
michael@0 | 836 | |
michael@0 | 837 | |
michael@0 | 838 | class JarFinder(BaseFinder): |
michael@0 | 839 | ''' |
michael@0 | 840 | Helper to get appropriate DeflatedFile instances from a JarReader. |
michael@0 | 841 | ''' |
michael@0 | 842 | def __init__(self, base, reader, **kargs): |
michael@0 | 843 | ''' |
michael@0 | 844 | Create a JarFinder for files in the given JarReader. The base argument |
michael@0 | 845 | is used as an indication of the Jar file location. |
michael@0 | 846 | ''' |
michael@0 | 847 | assert isinstance(reader, JarReader) |
michael@0 | 848 | BaseFinder.__init__(self, base, **kargs) |
michael@0 | 849 | self._files = OrderedDict((f.filename, f) for f in reader) |
michael@0 | 850 | |
michael@0 | 851 | def _find(self, pattern): |
michael@0 | 852 | ''' |
michael@0 | 853 | Actual implementation of JarFinder.find(), dispatching to specialized |
michael@0 | 854 | member functions depending on what kind of pattern was given. |
michael@0 | 855 | ''' |
michael@0 | 856 | if '*' in pattern: |
michael@0 | 857 | for p in self._files: |
michael@0 | 858 | if mozpack.path.match(p, pattern): |
michael@0 | 859 | yield p, DeflatedFile(self._files[p]) |
michael@0 | 860 | elif pattern == '': |
michael@0 | 861 | for p in self._files: |
michael@0 | 862 | yield p, DeflatedFile(self._files[p]) |
michael@0 | 863 | elif pattern in self._files: |
michael@0 | 864 | yield pattern, DeflatedFile(self._files[pattern]) |
michael@0 | 865 | else: |
michael@0 | 866 | for p in self._files: |
michael@0 | 867 | if mozpack.path.basedir(p, [pattern]) == pattern: |
michael@0 | 868 | yield p, DeflatedFile(self._files[p]) |