2 # -*- coding: utf-8 -*-
4 # Copyright 2004, 2005 Zuza Software Foundation
6 # This file is part of translate.
8 # translate is free software; you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 2 of the License, or
11 # (at your option) any later version.
13 # translate is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
18 # You should have received a copy of the GNU General Public License
19 # along with translate; if not, write to the Free Software
20 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
22 """module for accessing mozilla xpi packages"""
24 from __future__
import generators
27 from translate
import __version__
31 # we have some enhancements to zipfile in a file called zipfileext
32 # hopefully they will be included in a future version of python
33 from translate
.misc
import zipfileext
34 ZipFileBase
= zipfileext
.ZipFileExt
36 from translate
.misc
import wStringIO
37 # this is a fix to the StringIO in Python 2.3.3
38 # submitted as patch 951915 on sourceforge
39 class FixedStringIO(wStringIO
.StringIO
):
40 def truncate(self
, size
=None):
41 StringIO
.StringIO
.truncate(self
, size
)
42 self
.len = len(self
.buf
)
44 NamedStringInput
= wStringIO
.StringIO
45 NamedStringOutput
= wStringIO
.StringIO
47 def _commonprefix(itemlist
):
49 l
= min(len(a
), len(b
))
51 if a
[n
] != b
[n
]: return a
[:n
]
54 return reduce(cp
, itemlist
)
58 def rememberchanged(self
, method
):
59 def changed(*args
, **kwargs
):
61 method(*args
, **kwargs
)
64 class CatchPotentialOutput(NamedStringInput
, object):
65 """catches output if there has been, before closing"""
66 def __init__(self
, contents
, onclose
):
67 """Set up the output stream, and remember a method to call on closing"""
68 NamedStringInput
.__init
__(self
, contents
)
69 self
.onclose
= onclose
71 s
= super(CatchPotentialOutput
, self
)
72 self
.write
= rememberchanged(self
, s
.write
)
73 self
.writelines
= rememberchanged(self
, s
.writelines
)
74 self
.truncate
= rememberchanged(self
, s
.truncate
)
77 """wrap the underlying close method, to pass the value to onclose before it goes"""
79 value
= self
.getvalue()
81 NamedStringInput
.close(self
)
84 """zip files call flush, not close, on file-like objects"""
85 value
= self
.getvalue()
87 NamedStringInput
.flush(self
)
90 """use this method to force the closing of the stream if it isn't closed yet"""
94 class ZipFileCatcher(ZipFileBase
, object):
95 """a ZipFile that calls any methods its instructed to before closing (useful for catching stream output)"""
96 def __init__(self
, *args
, **kwargs
):
97 """initialize the ZipFileCatcher"""
98 # storing oldclose as attribute, since if close is called from __del__ it has no access to external variables
99 self
.oldclose
= super(ZipFileCatcher
, self
).close
100 super(ZipFileCatcher
, self
).__init
__(*args
, **kwargs
)
102 def addcatcher(self
, pendingsave
):
103 """remember to call the given method before closing"""
104 if hasattr(self
, "pendingsaves"):
105 if not pendingsave
in self
.pendingsaves
:
106 self
.pendingsaves
.append(pendingsave
)
108 self
.pendingsaves
= [pendingsave
]
111 """close the stream, remembering to call any addcatcher methods first"""
112 if hasattr(self
, "pendingsaves"):
113 for pendingsave
in self
.pendingsaves
:
115 # if close is called from __del__, it somehow can't see ZipFileCatcher, so we've cached oldclose...
116 if ZipFileCatcher
is None:
119 super(ZipFileCatcher
, self
).close()
121 def overwritestr(self
, zinfo_or_arcname
, bytes
):
122 """writes the string into the archive, overwriting the file if it exists..."""
123 if isinstance(zinfo_or_arcname
, zipfile
.ZipInfo
):
124 filename
= zinfo_or_arcname
.filename
126 filename
= zinfo_or_arcname
127 if filename
in self
.NameToInfo
:
128 self
.delete(filename
)
129 self
.writestr(zinfo_or_arcname
, bytes
)
132 class XpiFile(ZipFileCatcher
):
133 def __init__(self
, *args
, **kwargs
):
134 """sets up the xpi file"""
135 self
.includenonloc
= kwargs
.get("includenonloc", True)
136 if "includenonloc" in kwargs
:
137 del kwargs
["includenonloc"]
138 if "compression" not in kwargs
:
139 kwargs
["compression"] = zipfile
.ZIP_DEFLATED
140 self
.locale
= kwargs
.pop("locale", None)
141 self
.region
= kwargs
.pop("region", None)
142 super(XpiFile
, self
).__init
__(*args
, **kwargs
)
145 self
.jarprefixes
= self
.findjarprefixes()
146 self
.reverseprefixes
= dict([
147 (prefix
,jarfilename
) for jarfilename
, prefix
in self
.jarprefixes
.iteritems() if prefix
])
148 self
.reverseprefixes
["package/"] = None
151 """iterate through the jar files in the xpi as ZipFile objects"""
152 for filename
in self
.namelist():
153 if filename
.lower().endswith('.jar'):
154 if filename
not in self
.jarfiles
:
155 jarstream
= self
.openinputstream(None, filename
)
156 jarfile
= ZipFileCatcher(jarstream
, mode
=self
.mode
)
157 self
.jarfiles
[filename
] = jarfile
159 jarfile
= self
.jarfiles
[filename
]
160 yield filename
, jarfile
162 def islocfile(self
, filename
):
163 """returns whether the given file is needed for localization (basically .dtd and .properties)"""
164 base
, ext
= os
.path
.splitext(filename
)
165 return ext
in (os
.extsep
+ "dtd", os
.extsep
+ "properties")
167 def findlangreg(self
):
168 """finds the common prefix of all the files stored in the jar files"""
172 localematch
= re
.compile("^[a-z]{2,3}(-[a-zA-Z]{2,3}|)$")
173 regionmatch
= re
.compile("^[a-zA-Z]{2,3}$")
174 # exclude en-mac, en-win, en-unix for seamonkey
175 osmatch
= re
.compile("^[a-z]{2,3}-(mac|unix|win)$")
176 for jarfilename
, jarfile
in self
.iterjars():
177 jarname
= "".join(jarfilename
.split('/')[-1:]).replace(".jar", "", 1)
178 if localematch
.match(jarname
) and not osmatch
.match(jarname
):
181 elif locale
!= jarname
:
183 elif regionmatch
.match(jarname
):
186 elif region
!= jarname
:
188 for filename
in jarfile
.namelist():
189 if filename
.endswith('/'): continue
190 if not self
.islocfile(filename
) and not self
.includenonloc
: continue
191 parts
= filename
.split('/')[:-1]
192 treepoint
= dirstructure
193 for partnum
in range(len(parts
)):
194 part
= parts
[partnum
]
195 if part
in treepoint
:
196 treepoint
= treepoint
[part
]
199 treepoint
= treepoint
[part
]
201 if 'locale' in dirstructure
:
202 for dirname
in dirstructure
['locale']:
203 localeentries
[dirname
] = 1
204 if localematch
.match(dirname
) and not osmatch
.match(dirname
):
207 elif locale
!= dirname
:
208 print "locale dir mismatch - ", dirname
, "but locale is", locale
, "setting to 0"
210 elif regionmatch
.match(dirname
):
213 elif region
!= dirname
:
215 if locale
and locale
in localeentries
:
216 del localeentries
[locale
]
217 if region
and region
in localeentries
:
218 del localeentries
[region
]
219 if locale
and not region
:
221 region
= locale
.split("-", 1)[1]
224 self
.setlangreg(locale
, region
)
226 def setlangreg(self
, locale
, region
):
227 """set the locale and region of this xpi"""
228 if locale
== 0 or locale
is None:
229 raise ValueError("unable to determine locale")
233 if self
.locale
is not None:
234 self
.dirmap
[('locale', self
.locale
)] = ('lang-reg',)
236 self
.dirmap
[('locale', self
.region
)] = ('reg',)
238 def findjarprefixes(self
):
239 """checks the uniqueness of the jar files contents"""
242 for jarfilename
, jarfile
in self
.iterjars():
243 jarprefixes
[jarfilename
] = ""
244 for filename
in jarfile
.namelist():
245 if filename
.endswith('/'): continue
246 if filename
in uniquenames
:
247 jarprefixes
[jarfilename
] = True
248 jarprefixes
[uniquenames
[filename
]] = True
250 uniquenames
[filename
] = jarfilename
251 for jarfilename
, hasconflicts
in jarprefixes
.items():
253 shortjarfilename
= os
.path
.split(jarfilename
)[1]
254 shortjarfilename
= os
.path
.splitext(shortjarfilename
)[0]
255 jarprefixes
[jarfilename
] = shortjarfilename
+'/'
256 # this is a clever trick that will e.g. remove zu- from zu-win, zu-mac, zu-unix
257 commonjarprefix
= _commonprefix([prefix
for prefix
in jarprefixes
.itervalues() if prefix
])
259 for jarfilename
, prefix
in jarprefixes
.items():
261 jarprefixes
[jarfilename
] = prefix
.replace(commonjarprefix
, '', 1)
264 def ziptoospath(self
, zippath
):
265 """converts a zipfile filepath to an os-style filepath"""
266 return os
.path
.join(*zippath
.split('/'))
268 def ostozippath(self
, ospath
):
269 """converts an os-style filepath to a zipfile filepath"""
270 return '/'.join(ospath
.split(os
.sep
))
272 def mapfilename(self
, filename
):
273 """uses a map to simplify the directory structure"""
274 parts
= tuple(filename
.split('/'))
276 for prefix
, mapto
in self
.dirmap
.iteritems():
277 if parts
[:len(prefix
)] == prefix
:
278 if possiblematch
is None or len(possiblematch
[0]) < len(prefix
):
279 possiblematch
= prefix
, mapto
280 if possiblematch
is not None:
281 prefix
, mapto
= possiblematch
282 mapped
= mapto
+ parts
[len(prefix
):]
283 return '/'.join(mapped
)
286 def mapxpifilename(self
, filename
):
287 """uses a map to rename files that occur straight in the xpi"""
288 if filename
.startswith('bin/chrome/') and filename
.endswith(".manifest"):
289 return 'bin/chrome/lang-reg.manifest'
292 def reversemapfile(self
, filename
):
293 """unmaps the filename..."""
295 parts
= tuple(filename
.split('/'))
296 for prefix
, mapto
in self
.dirmap
.iteritems():
297 if parts
[:len(mapto
)] == mapto
:
298 if possiblematch
is None or len(possiblematch
[0]) < len(mapto
):
299 possiblematch
= (mapto
, prefix
)
300 if possiblematch
is None:
302 mapto
, prefix
= possiblematch
303 reversemapped
= prefix
+ parts
[len(mapto
):]
304 return '/'.join(reversemapped
)
306 def reversemapxpifilename(self
, filename
):
307 """uses a map to rename files that occur straight in the xpi"""
308 if filename
== 'bin/chrome/lang-reg.manifest':
310 return '/'.join(('bin', 'chrome', self
.locale
+ '.manifest'))
312 for otherfilename
in self
.namelist():
313 if otherfilename
.startswith("bin/chrome/") and otherfilename
.endswith(".manifest"):
317 def jartoospath(self
, jarfilename
, filename
):
318 """converts a filename from within a jarfile to an os-style filepath"""
320 jarprefix
= self
.jarprefixes
[jarfilename
]
321 return self
.ziptoospath(jarprefix
+self
.mapfilename(filename
))
323 return self
.ziptoospath(os
.path
.join("package", self
.mapxpifilename(filename
)))
325 def ostojarpath(self
, ospath
):
326 """converts an extracted os-style filepath to a jarfilename and filename"""
327 zipparts
= ospath
.split(os
.sep
)
328 prefix
= zipparts
[0] + '/'
329 if prefix
in self
.reverseprefixes
:
330 jarfilename
= self
.reverseprefixes
[prefix
]
331 filename
= self
.reversemapfile('/'.join(zipparts
[1:]))
332 if jarfilename
is None:
333 filename
= self
.reversemapxpifilename(filename
)
334 return jarfilename
, filename
336 filename
= self
.ostozippath(ospath
)
337 if filename
in self
.namelist():
338 return None, filename
339 filename
= self
.reversemapfile('/'.join(zipparts
))
340 possiblejarfilenames
= [jarfilename
for jarfilename
, prefix
in self
.jarprefixes
.iteritems() if not prefix
]
341 for jarfilename
in possiblejarfilenames
:
342 jarfile
= self
.jarfiles
[jarfilename
]
343 if filename
in jarfile
.namelist():
344 return jarfilename
, filename
345 raise IndexError("ospath not found in xpi file, could not guess location: %r" % ospath
)
347 def jarfileexists(self
, jarfilename
, filename
):
348 """checks whether the given file exists inside the xpi"""
349 if jarfilename
is None:
350 return filename
in self
.namelist()
352 jarfile
= self
.jarfiles
[jarfilename
]
353 return filename
in jarfile
.namelist()
355 def ospathexists(self
, ospath
):
356 """checks whether the given file exists inside the xpi"""
357 jarfilename
, filename
= self
.ostojarpath(ospath
)
358 if jarfilename
is None:
359 return filename
in self
.namelist()
361 jarfile
= self
.jarfiles
[jarfilename
]
362 return filename
in jarfile
.namelist()
364 def openinputstream(self
, jarfilename
, filename
):
365 """opens a file (possibly inside a jarfile as a StringIO"""
366 if jarfilename
is None:
367 contents
= self
.read(filename
)
368 def onclose(contents
):
369 if contents
!= self
.read(filename
):
370 self
.overwritestr(filename
, contents
)
371 inputstream
= CatchPotentialOutput(contents
, onclose
)
372 self
.addcatcher(inputstream
.slam
)
374 jarfile
= self
.jarfiles
[jarfilename
]
375 contents
= jarfile
.read(filename
)
376 inputstream
= NamedStringInput(contents
)
377 inputstream
.name
= self
.jartoospath(jarfilename
, filename
)
378 if hasattr(self
.fp
, 'name'):
379 inputstream
.name
= "%s:%s" % (self
.fp
.name
, inputstream
.name
)
382 def openoutputstream(self
, jarfilename
, filename
):
383 """opens a file for writing (possibly inside a jarfile as a StringIO"""
384 if jarfilename
is None:
385 def onclose(contents
):
386 self
.overwritestr(filename
, contents
)
388 if jarfilename
in self
.jarfiles
:
389 jarfile
= self
.jarfiles
[jarfilename
]
391 jarstream
= self
.openoutputstream(None, jarfilename
)
392 jarfile
= ZipFileCatcher(jarstream
, "w")
393 self
.jarfiles
[jarfilename
] = jarfile
394 self
.addcatcher(jarstream
.slam
)
395 def onclose(contents
):
396 jarfile
.overwritestr(filename
, contents
)
397 outputstream
= wStringIO
.CatchStringOutput(onclose
)
398 outputstream
.name
= "%s %s" % (jarfilename
, filename
)
399 if jarfilename
is None:
400 self
.addcatcher(outputstream
.slam
)
402 jarfile
.addcatcher(outputstream
.slam
)
406 """Close the file, and for mode "w" and "a" write the ending records."""
407 for jarfile
in self
.jarfiles
.itervalues():
409 super(XpiFile
, self
).close()
412 """test the xpi zipfile and all enclosed jar files..."""
413 for jarfile
in self
.jarfiles
.itervalues():
415 super(XpiFile
, self
).testzip()
417 def restructurejar(self
, origjarfilename
, newjarfilename
, otherxpi
, newlang
, newregion
):
418 """Create a new .jar file with the same contents as the given name, but rename directories, write to outputstream"""
419 jarfile
= self
.jarfiles
[origjarfilename
]
420 origlang
= self
.locale
[:self
.locale
.find("-")]
422 newlocale
= "%s-%s" % (newlang
, newregion
)
425 for filename
in jarfile
.namelist():
426 filenameparts
= filename
.split("/")
427 for i
in range(len(filenameparts
)):
428 part
= filenameparts
[i
]
430 filenameparts
[i
] = newlang
431 elif part
== self
.locale
:
432 filenameparts
[i
] = newlocale
433 elif part
== self
.region
:
434 filenameparts
[i
] = newregion
435 newfilename
= '/'.join(filenameparts
)
436 fileoutputstream
= otherxpi
.openoutputstream(newjarfilename
, newfilename
)
437 fileinputstream
= self
.openinputstream(origjarfilename
, filename
)
438 fileoutputstream
.write(fileinputstream
.read())
439 fileinputstream
.close()
440 fileoutputstream
.close()
442 def clone(self
, newfilename
, newmode
=None, newlang
=None, newregion
=None):
443 """Create a new .xpi file with the same contents as this one..."""
444 other
= XpiFile(newfilename
, "w", locale
=newlang
, region
=newregion
)
445 origlang
= self
.locale
[:self
.locale
.find("-")]
446 # TODO: check if this language replacement code is still neccessary
449 if newregion
is None:
450 newregion
= self
.region
452 newlocale
= "%s-%s" % (newlang
, newregion
)
455 for filename
in self
.namelist():
456 filenameparts
= filename
.split('/')
457 basename
= filenameparts
[-1]
458 if basename
.startswith(self
.locale
):
459 newbasename
= basename
.replace(self
.locale
, newlocale
)
460 elif basename
.startswith(origlang
):
461 newbasename
= basename
.replace(origlang
, newlang
)
462 elif basename
.startswith(self
.region
):
463 newbasename
= basename
.replace(self
.region
, newregion
)
465 newbasename
= basename
466 if newbasename
!= basename
:
467 filenameparts
[-1] = newbasename
468 renamefilename
= "/".join(filenameparts
)
469 print "cloning", filename
, "and renaming to", renamefilename
471 print "cloning", filename
472 renamefilename
= filename
473 if filename
.lower().endswith(".jar"):
474 self
.restructurejar(filename
, renamefilename
, other
, newlang
, newregion
)
476 inputstream
= self
.openinputstream(None, filename
)
477 outputstream
= other
.openoutputstream(None, renamefilename
)
478 outputstream
.write(inputstream
.read())
482 if newmode
is None: newmode
= self
.mode
483 if newmode
== "w": newmode
= "a"
484 other
= XpiFile(newfilename
, newmode
)
485 other
.setlangreg(newlocale
, newregion
)
488 def iterextractnames(self
, includenonjars
=False, includedirs
=False):
489 """iterates through all the localization files with the common prefix stripped and a jarfile name added if neccessary"""
491 for filename
in self
.namelist():
492 if filename
.endswith('/') and not includedirs
: continue
493 if not self
.islocfile(filename
) and not self
.includenonloc
: continue
494 if not filename
.lower().endswith(".jar"):
495 yield self
.jartoospath(None, filename
)
496 for jarfilename
, jarfile
in self
.iterjars():
497 for filename
in jarfile
.namelist():
498 if filename
.endswith('/'):
499 if not includedirs
: continue
500 if not self
.islocfile(filename
) and not self
.includenonloc
: continue
501 yield self
.jartoospath(jarfilename
, filename
)
503 # the following methods are required by translate.convert.ArchiveConvertOptionParser #
505 """iterates through all the files. this is the method use by the converters"""
506 for inputpath
in self
.iterextractnames(includenonjars
=True):
509 def __contains__(self
, fullpath
):
510 """returns whether the given pathname exists in the archive"""
512 jarfilename
, filename
= self
.ostojarpath(fullpath
)
515 return self
.jarfileexists(jarfilename
, filename
)
517 def openinputfile(self
, fullpath
):
518 """opens an input file given the full pathname"""
519 jarfilename
, filename
= self
.ostojarpath(fullpath
)
520 return self
.openinputstream(jarfilename
, filename
)
522 def openoutputfile(self
, fullpath
):
523 """opens an output file given the full pathname"""
525 jarfilename
, filename
= self
.ostojarpath(fullpath
)
528 return self
.openoutputstream(jarfilename
, filename
)
530 if __name__
== '__main__':
532 optparser
= optparse
.OptionParser(version
="%prog "+__version__
.ver
)
533 optparser
.usage
= "%prog [-l|-x] [options] file.xpi"
534 optparser
.add_option("-l", "--list", help="list files", \
535 action
="store_true", dest
="listfiles", default
=False)
536 optparser
.add_option("-p", "--prefix", help="show common prefix", \
537 action
="store_true", dest
="showprefix", default
=False)
538 optparser
.add_option("-x", "--extract", help="extract files", \
539 action
="store_true", dest
="extractfiles", default
=False)
540 optparser
.add_option("-d", "--extractdir", help="extract into EXTRACTDIR", \
541 default
=".", metavar
="EXTRACTDIR")
542 (options
, args
) = optparser
.parse_args()
544 optparser
.error("need at least one argument")
545 xpifile
= XpiFile(args
[0])
546 if options
.showprefix
:
547 for prefix
, mapto
in xpifile
.dirmap
.iteritems():
548 print "/".join(prefix
), "->", "/".join(mapto
)
549 if options
.listfiles
:
550 for name
in xpifile
.iterextractnames(includenonjars
=True, includedirs
=True):
551 print name
#, xpifile.ostojarpath(name)
552 if options
.extractfiles
:
553 if options
.extractdir
and not os
.path
.isdir(options
.extractdir
):
554 os
.mkdir(options
.extractdir
)
555 for name
in xpifile
.iterextractnames(includenonjars
=True, includedirs
=False):
556 abspath
= os
.path
.join(options
.extractdir
, name
)
557 # check neccessary directories exist - this way we don't create empty directories
558 currentpath
= options
.extractdir
559 subparts
= os
.path
.dirname(name
).split(os
.sep
)
560 for part
in subparts
:
561 currentpath
= os
.path
.join(currentpath
, part
)
562 if not os
.path
.isdir(currentpath
):
563 os
.mkdir(currentpath
)
564 outputstream
= open(abspath
, 'w')
565 jarfilename
, filename
= xpifile
.ostojarpath(name
)
566 inputstream
= xpifile
.openinputstream(jarfilename
, filename
)
567 outputstream
.write(inputstream
.read())