Merge lp:~jtv/launchpad/redo-uploads into lp:launchpad

Proposed by Jeroen T. Vermeulen on 2009-09-30
Status: Merged
Approved by: Jeroen T. Vermeulen on 2009-09-30
Approved revision: no longer in the source branch.
Merged at revision: not available
Proposed branch: lp:~jtv/launchpad/redo-uploads
Merge into: lp:launchpad
Diff against target: 449 lines
6 files modified
lib/lp/registry/interfaces/sourcepackage.py (+7/-0)
lib/lp/registry/model/sourcepackage.py (+33/-0)
lib/lp/soyuz/doc/distroseriesqueue-translations.txt (+43/-0)
lib/lp/translations/scripts/reupload_translations.py (+110/-0)
lib/lp/translations/scripts/tests/test_reupload_translations.py (+168/-0)
scripts/rosetta/reupload-translations.py (+19/-0)
To merge this branch: bzr merge lp:~jtv/launchpad/redo-uploads
Reviewer Review Type Date Requested Status
Jeroen T. Vermeulen (community) Abstain on 2009-09-30
Edwin Grubbs (community) code 2009-09-30 Approve on 2009-09-30
Review via email: mp+12659@code.launchpad.net
To post a comment you must log in.
Jeroen T. Vermeulen (jtv) wrote :

= Bug 439346 =

A stupid missing parameter in 3.0 caused the translations auto-approver
to approve uploads for the right packages but the wrong Ubuntu releases.
Due to some other bugs that we've fixed already, we don't know exactly
which translations will or won't be affected. So we ended up with a
fairly long list of packages that _may_ have had the wrong files
imported to them.

This branch provides a script that finds the latest Soyuz-generated
translations upload for a given package (a tarball containing, roughly
speaking, the upstream templates and translations) and re-uploads it.

Finding the latest translations upload for a package turns out to be...
in Julian's words, "easy." I'd probably pick another word myself.
After conferring with Celso (who helped me get it actually working) I
isolated that piece of work in SourcePackage.

You may wonder why the script keeps track of uploadless_packages. That
is there just to facilitate testing.

There is no single end-to-end test. It's just too hard to set up and
maintain a realistic testing situation for this. Test coverage consists
of several stretches that touch more than overlap:

 * A full script run, but without any uploads being found.
   This exercises everything except retrieving and uploading a tarball
   from the Librarian, and the tail end of getLatestTranslations in the
   success case.

 * A before-and-after demonstration of getLatestTranslationsUploads in
   the doctest. This does exercise the tail end in the success case.
   It also shows that a gzipped tarball containing the right files has
   gone into the Librarian.

 * A unit test for _findPackage, to cover up for the fact that other
   unit tests patch that method for mocking purposes.

 * A successful run of the LaunchpadScript object, but with
   getLatestTranslationsUploads mocked up to return a gzipped tarball
   that was stuffed straight into the Librarian by the test. This also
   tests the retrieval and uploading of that tarball.

The tar-file handling in the tests is terrible. I can't really help it;
blame the tarfile module's horrible API. Makes it hard to create
tarballs in-memory using StringIOs.

== Tests ==
{{{
./bin/test -vv -t distroseriesqueue-translations.txt
./bin/test -vv -t reupload_translations
}}}

== Demo and Q/A ==

Pick a source package in "main" for some Ubuntu release, one with
translations but not too much in its import queue—there has to be at
least one translation that doesn't have a Needs Review or Approved entry
in the queue. You can create a new translation by translating a string
into a dead language such as Sumerian (sux).

Run the script, passing it the name of the Ubuntu release (e.g. karmic)
and the name of the source package (e.g. tomboy). Try the --dry-run
option first; nothing will actually happen to the package's import
queue. Try the same run again without the --dry-run option, and now a
full set of upstream translations will appear on the queue in Needs
Review state.

Or if you wait too long, Approved or even Imported. The approval and
import scripts do run on staging.

No lint.

Jeroen

Edwin Grubbs (edwin-grubbs) wrote :
Download full text (10.7 KiB)

Hi Jeroen,

This looks good. I just have a few formatting comments.

merge-conditional

-Edwin

>=== added file 'lib/lp/translations/scripts/reupload_translations.py'
>--- lib/lp/translations/scripts/reupload_translations.py 1970-01-01 00:00:00 +0000
>+++ lib/lp/translations/scripts/reupload_translations.py 2009-09-30 15:42:55 +0000
>@@ -0,0 +1,105 @@
>+__metaclass__ = type

Missing copyright notice.

>+
>+__all__ = [
>+ 'ReuploadPackageTranslations',
>+ ]

Indentation should be spaces.

>+
>+from zope.component import getUtility
>+
>+from lp.services.scripts.base import LaunchpadScript, LaunchpadScriptFailure
>+
>+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
>+from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
>+from lp.translations.interfaces.translationimportqueue import (
>+ ITranslationImportQueue)
>+
>+
>+class ReuploadPackageTranslations(LaunchpadScript):
>+ """Re-upload latest translations for given distribution packages."""
>+ description = "Re-upload latest translations uploads for package(s)."
>+
>+ def add_my_options(self):
>+ """See `LaunchpadScript`."""
>+ self.parser.add_option('-d', '--distribution', dest='distro',
>+ help="Distribution to upload for.", default='ubuntu')
>+ self.parser.add_option('-s', '--series', dest='distroseries',
>+ help="Distribution release series to upload for.")
>+ self.parser.add_option('-p', '--package', action='append',
>+ dest='packages', default=[],
>+ help="Name(s) of source package(s) to re-upload.")
>+ self.parser.add_option('-l', '--dry-run', dest='dryrun',
>+ action='store_true', default=False,
>+ help="Pretend to upload, but make no actual changes.")
>+

Trailing white space.

>+ def main(self):
>+ """See `LaunchpadScript`."""
>+ self.uploadless_packages = []
>+ self._setDistroDetails()
>+
>+ if len(self.options.packages) == 0:
>+ raise LaunchpadScriptFailure("No packages specified.")
>+
>+ if self.options.dryrun:
>+ self.logger.info("Dry run. Not really uploading anything.")
>+
>+ for package_name in self.options.packages:
>+ self._processPackage(self._findPackage(package_name))
>+ self._commit()
>+
>+ self.logger.info("Done.")
>+
>+ def _commit(self):
>+ """Commit transaction (or abort if dry run)."""
>+ if self.txn:
>+ if self.options.dryrun:
>+ self.txn.abort()
>+ else:
>+ self.txn.commit()
>+
>+ def _setDistroDetails(self):
>+ """Figure out the `Distribution`/`DistroSeries` to act upon."""
>+ # Avoid circular imports.
>+ from lp.registry.interfaces.distribution import IDistributionSet
>+
>+ distroset = getUtility(IDistributionSet)
>+ self.distro = distroset.getByName(self.options.distro)
>+
>+ if not self.options.distroseries:
>+ raise LaunchpadScriptFailure(
>+ "Specify a distribution release series.")
>+
>+ self.distroseries = self.distro.getSeries(self.options.distroserie...

review: Approve (code)
Jeroen T. Vermeulen (jtv) wrote :

> Hi Jeroen,
>
> This looks good. I just have a few formatting comments.

Thanks. Whitespace problems all fixed.

"I have no idea how that tab got into my code, officer."

review: Abstain

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/registry/interfaces/sourcepackage.py'
2--- lib/lp/registry/interfaces/sourcepackage.py 2009-09-16 04:31:39 +0000
3+++ lib/lp/registry/interfaces/sourcepackage.py 2009-09-30 16:53:17 +0000
4@@ -225,6 +225,13 @@
5 title=u'The component in which the package was last published.',
6 schema=IComponent, readonly=True, required=False)
7
8+ def getLatestTranslationsUploads():
9+ """Find latest Translations tarballs as produced by Soyuz.
10+
11+ :return: A list of `ILibraryFileAlias`es, usually of size zero
12+ or one. If not, they are sorted from oldest to newest.
13+ """
14+
15
16 class ISourcePackageFactory(Interface):
17 """A creator of source packages."""
18
19=== modified file 'lib/lp/registry/model/sourcepackage.py'
20--- lib/lp/registry/model/sourcepackage.py 2009-09-25 17:00:20 +0000
21+++ lib/lp/registry/model/sourcepackage.py 2009-09-30 16:53:17 +0000
22@@ -54,6 +54,7 @@
23 from lp.translations.interfaces.potemplate import IHasTranslationTemplates
24 from lp.registry.interfaces.pocket import PackagePublishingPocket
25 from lp.soyuz.interfaces.publishing import PackagePublishingStatus
26+from lp.soyuz.interfaces.queue import PackageUploadCustomFormat
27 from lp.answers.interfaces.questioncollection import (
28 QUESTION_STATUS_DEFAULT_SEARCH)
29 from lp.answers.interfaces.questiontarget import IQuestionTarget
30@@ -655,3 +656,35 @@
31 self.distribution.name,
32 self.distroseries.getSuite(pocket),
33 self.name)
34+
35+ def getLatestTranslationsUploads(self):
36+ """See `ISourcePackage`."""
37+ our_format = PackageUploadCustomFormat.ROSETTA_TRANSLATIONS
38+
39+ packagename = self.sourcepackagename.name
40+ displayname = self.displayname
41+ distro = self.distroseries.distribution
42+
43+ histories = distro.main_archive.getPublishedSources(
44+ name=packagename, distroseries=self.distroseries,
45+ status=PackagePublishingStatus.PUBLISHED, exact_match=True)
46+ histories = list(histories)
47+
48+ builds = []
49+ for history in histories:
50+ builds += list(history.getBuilds())
51+
52+ uploads = [
53+ build.package_upload
54+ for build in builds
55+ if build.package_upload
56+ ]
57+ custom_files = []
58+ for upload in uploads:
59+ custom_files += [
60+ custom for custom in upload.customfiles
61+ if custom.customformat == our_format
62+ ]
63+
64+ custom_files.sort(key=attrgetter('id'))
65+ return [custom.libraryfilealias for custom in custom_files]
66
67=== modified file 'lib/lp/soyuz/doc/distroseriesqueue-translations.txt'
68--- lib/lp/soyuz/doc/distroseriesqueue-translations.txt 2009-09-04 08:35:20 +0000
69+++ lib/lp/soyuz/doc/distroseriesqueue-translations.txt 2009-09-30 16:53:17 +0000
70@@ -97,6 +97,14 @@
71 >>> pmount_upload.is_rejected
72 False
73
74+At this point, no translations uploads have been registered for this
75+package.
76+
77+ >>> from lp.registry.model.sourcepackage import SourcePackage
78+ >>> dapper_pmount = SourcePackage(pmount_sourcepackagename, dapper)
79+ >>> print len(dapper_pmount.getLatestTranslationsUploads())
80+ 0
81+
82 >>> success = pmount_upload.do_accept()
83 DEBUG: Creating queue entry
84 DEBUG: Build ... found
85@@ -115,6 +123,17 @@
86 #NEW: pmount_0.9.7-2ubuntu2_amd64.deb
87 #OK: pmount_0.9.7-2ubuntu2_amd64_translations.tar.gz
88
89+The upload now shows up as the latest translations upload for the
90+package.
91+
92+ >>> latest_translations_uploads = list(
93+ ... dapper_pmount.getLatestTranslationsUploads())
94+ >>> print len(latest_translations_uploads)
95+ 1
96+
97+We'll get back to that uploaded file later.
98+
99+ >>> latest_translations_upload = latest_translations_uploads[0]
100
101 # Check the import queue content, it should be empty.
102 >>> from lp.translations.interfaces.translationimportqueue import (
103@@ -259,6 +278,7 @@
104 # component.
105 >>> transaction.abort()
106
107+
108 == Translations from PPA build ==
109
110 For now we simply ignore translations for archives other than the
111@@ -376,3 +396,26 @@
112 >>> translations_upload.packageupload = carlos_package_upload
113 >>> translations_upload.publish_ROSETTA_TRANSLATIONS()
114 Imported by: carlos
115+
116+
117+=== Translations tarball ===
118+
119+The LibraryFileAlias returned by getLatestTranslationsUploads on the
120+source package points to a tarball with translations files for the
121+package.
122+
123+ >>> import tarfile
124+ >>> from StringIO import StringIO
125+ >>> tarball = StringIO(latest_translations_upload.read())
126+ >>> archive = tarfile.open('', 'r|gz', tarball)
127+ >>> translation_files = sorted([
128+ ... entry.name for entry in archive.getmembers()
129+ ... if entry.name.endswith('.po') or entry.name.endswith('.pot')
130+ ... ])
131+ >>> for filename in translation_files:
132+ ... print filename
133+ source/po/ca.po
134+ source/po/cs.po
135+ source/po/de.po
136+ ...
137+ source/po/pmount.pot
138
139=== added file 'lib/lp/translations/scripts/reupload_translations.py'
140--- lib/lp/translations/scripts/reupload_translations.py 1970-01-01 00:00:00 +0000
141+++ lib/lp/translations/scripts/reupload_translations.py 2009-09-30 16:53:17 +0000
142@@ -0,0 +1,110 @@
143+#! /usr/bin/python2.4
144+#
145+# Copyright 2009 Canonical Ltd. This software is licensed under the
146+# GNU Affero General Public License version 3 (see the file LICENSE).
147+
148+__metaclass__ = type
149+
150+__all__ = [
151+ 'ReuploadPackageTranslations',
152+ ]
153+
154+from zope.component import getUtility
155+
156+from lp.services.scripts.base import LaunchpadScript, LaunchpadScriptFailure
157+
158+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
159+from lp.registry.interfaces.sourcepackagename import ISourcePackageNameSet
160+from lp.translations.interfaces.translationimportqueue import (
161+ ITranslationImportQueue)
162+
163+
164+class ReuploadPackageTranslations(LaunchpadScript):
165+ """Re-upload latest translations for given distribution packages."""
166+ description = "Re-upload latest translations uploads for package(s)."
167+
168+ def add_my_options(self):
169+ """See `LaunchpadScript`."""
170+ self.parser.add_option('-d', '--distribution', dest='distro',
171+ help="Distribution to upload for.", default='ubuntu')
172+ self.parser.add_option('-s', '--series', dest='distroseries',
173+ help="Distribution release series to upload for.")
174+ self.parser.add_option('-p', '--package', action='append',
175+ dest='packages', default=[],
176+ help="Name(s) of source package(s) to re-upload.")
177+ self.parser.add_option('-l', '--dry-run', dest='dryrun',
178+ action='store_true', default=False,
179+ help="Pretend to upload, but make no actual changes.")
180+
181+ def main(self):
182+ """See `LaunchpadScript`."""
183+ self.uploadless_packages = []
184+ self._setDistroDetails()
185+
186+ if len(self.options.packages) == 0:
187+ raise LaunchpadScriptFailure("No packages specified.")
188+
189+ if self.options.dryrun:
190+ self.logger.info("Dry run. Not really uploading anything.")
191+
192+ for package_name in self.options.packages:
193+ self._processPackage(self._findPackage(package_name))
194+ self._commit()
195+
196+ self.logger.info("Done.")
197+
198+ def _commit(self):
199+ """Commit transaction (or abort if dry run)."""
200+ if self.txn:
201+ if self.options.dryrun:
202+ self.txn.abort()
203+ else:
204+ self.txn.commit()
205+
206+ def _setDistroDetails(self):
207+ """Figure out the `Distribution`/`DistroSeries` to act upon."""
208+ # Avoid circular imports.
209+ from lp.registry.interfaces.distribution import IDistributionSet
210+
211+ distroset = getUtility(IDistributionSet)
212+ self.distro = distroset.getByName(self.options.distro)
213+
214+ if not self.options.distroseries:
215+ raise LaunchpadScriptFailure(
216+ "Specify a distribution release series.")
217+
218+ self.distroseries = self.distro.getSeries(self.options.distroseries)
219+
220+ def _findPackage(self, name):
221+ """Find `SourcePackage` of given name."""
222+ # Avoid circular imports.
223+ from lp.registry.interfaces.sourcepackage import ISourcePackageFactory
224+
225+ factory = getUtility(ISourcePackageFactory)
226+ nameset = getUtility(ISourcePackageNameSet)
227+
228+ sourcepackagename = nameset.queryByName(name)
229+
230+ return factory.new(sourcepackagename, self.distroseries)
231+
232+ def _processPackage(self, package):
233+ """Get translations for `package` re-uploaded."""
234+ self.logger.info("Processing %s" % package.displayname)
235+ tarball_aliases = package.getLatestTranslationsUploads()
236+ queue = getUtility(ITranslationImportQueue)
237+ rosetta_team = getUtility(ILaunchpadCelebrities).rosetta_experts
238+
239+ have_uploads = False
240+ for alias in tarball_aliases:
241+ have_uploads = True
242+ self.logger.debug("Uploading file '%s' for %s." % (
243+ alias.filename, package.displayname))
244+ queue.addOrUpdateEntriesFromTarball(
245+ alias.read(), True, rosetta_team,
246+ sourcepackagename=package.sourcepackagename,
247+ distroseries=self.distroseries)
248+
249+ if not have_uploads:
250+ self.logger.warn(
251+ "Found no translations upload for %s." % package.displayname)
252+ self.uploadless_packages.append(package)
253
254=== added file 'lib/lp/translations/scripts/tests/test_reupload_translations.py'
255--- lib/lp/translations/scripts/tests/test_reupload_translations.py 1970-01-01 00:00:00 +0000
256+++ lib/lp/translations/scripts/tests/test_reupload_translations.py 2009-09-30 16:53:17 +0000
257@@ -0,0 +1,168 @@
258+#! /usr/bin/python2.4
259+#
260+# Copyright 2009 Canonical Ltd. This software is licensed under the
261+# GNU Affero General Public License version 3 (see the file LICENSE).
262+
263+"""Test `reupload_translations` and `ReuploadPackageTranslations`."""
264+
265+__metaclass__ = type
266+
267+from unittest import TestLoader
268+
269+import re
270+from StringIO import StringIO
271+import tarfile
272+import transaction
273+
274+from zope.security.proxy import removeSecurityProxy
275+
276+from canonical.testing import LaunchpadZopelessLayer
277+from lp.testing import TestCaseWithFactory
278+from canonical.launchpad.scripts.tests import run_script
279+
280+from canonical.launchpad.database.librarian import LibraryFileAliasSet
281+from lp.registry.model.sourcepackage import SourcePackage
282+from lp.translations.model.translationimportqueue import (
283+ TranslationImportQueue)
284+
285+from lp.translations.scripts.reupload_translations import (
286+ ReuploadPackageTranslations)
287+
288+
289+class UploadInjector:
290+ def __init__(self, script, tar_alias):
291+ self.tar_alias = tar_alias
292+ self.script = script
293+ self.original_findPackage = script._findPackage
294+
295+ def __call__(self, name):
296+ package = self.original_findPackage(name)
297+ removeSecurityProxy(package).getLatestTranslationsUploads = (
298+ self._fakeTranslationsUpload)
299+ return package
300+
301+ def _fakeTranslationsUpload(self):
302+ return [self.tar_alias]
303+
304+
305+def upload_tarball(translation_files):
306+ """Create a tarball and upload it to the Librarian.
307+
308+ :param translation_files: A dict mapping filenames to file contents.
309+ :return: A `LibraryFileAlias`.
310+ """
311+ buf = StringIO()
312+ tarball = tarfile.open('', 'w:gz', buf)
313+ for name, contents in translation_files.iteritems():
314+ pseudofile = StringIO(contents)
315+ tarinfo = tarfile.TarInfo()
316+ tarinfo.name = name
317+ tarinfo.size = len(contents)
318+ tarinfo.type = tarfile.REGTYPE
319+ tarball.addfile(tarinfo, pseudofile)
320+
321+ tarball.close()
322+ buf.flush()
323+ tarsize = buf.tell()
324+ buf.seek(0)
325+
326+ return LibraryFileAliasSet().create(
327+ 'uploads.tar.gz', tarsize, buf, 'application/x-gtar')
328+
329+
330+def summarize_translations_queue(sourcepackage):
331+ """Describe queue entries for `sourcepackage` as a name/contents dict."""
332+ entries = TranslationImportQueue().getAllEntries(sourcepackage)
333+ return dict((entry.path, entry.content.read()) for entry in entries)
334+
335+
336+class TestReuploadPackageTranslations(TestCaseWithFactory):
337+ """Test `ReuploadPackageTranslations`."""
338+ layer = LaunchpadZopelessLayer
339+
340+ def setUp(self):
341+ super(TestReuploadPackageTranslations, self).setUp()
342+ sourcepackagename = self.factory.makeSourcePackageName()
343+ distroseries = self.factory.makeDistroRelease()
344+ self.sourcepackage = SourcePackage(sourcepackagename, distroseries)
345+ self.script = ReuploadPackageTranslations('reupload', test_args=[
346+ '-d', distroseries.distribution.name,
347+ '-s', distroseries.name,
348+ '-p', sourcepackagename.name,
349+ '-qqq'])
350+
351+ def test_findPackage(self):
352+ # _findPackage finds a SourcePackage by name.
353+ self.script._setDistroDetails()
354+ found_package = self.script._findPackage(
355+ self.sourcepackage.sourcepackagename.name)
356+ self.assertEqual(self.sourcepackage, found_package)
357+
358+ def test_processPackage_nothing(self):
359+ # A package need not have a translations upload. The script
360+ # notices this but does nothing about it.
361+ self.script.main()
362+ self.assertEqual(
363+ [self.sourcepackage], self.script.uploadless_packages)
364+
365+ def test_processPackage(self):
366+ # _processPackage will fetch the package's latest translations
367+ # upload from the Librarian and re-import it.
368+ translation_files = {
369+ 'po/messages.pot': '# pot',
370+ 'po/nl.po': '# nl',
371+ }
372+ tar_alias = upload_tarball(translation_files)
373+
374+ # Force Librarian update
375+ transaction.commit()
376+
377+ self.script._findPackage = UploadInjector(self.script, tar_alias)
378+ self.script.main()
379+ self.assertEqual([], self.script.uploadless_packages)
380+
381+ # Force Librarian update
382+ transaction.commit()
383+
384+ queue_summary = summarize_translations_queue(self.sourcepackage)
385+ self.assertEqual(translation_files, queue_summary)
386+
387+
388+class TestReuploadScript(TestCaseWithFactory):
389+ """Test reupload-translations script."""
390+ layer = LaunchpadZopelessLayer
391+
392+ def setUp(self):
393+ super(TestReuploadScript, self).setUp()
394+ self.distroseries = self.factory.makeDistroRelease()
395+ self.sourcepackagename1 = self.factory.makeSourcePackageName()
396+ self.sourcepackagename2 = self.factory.makeSourcePackageName()
397+ transaction.commit()
398+
399+ def test_reupload_translations(self):
400+ """Test a run of the script."""
401+ retcode, stdout, stderr = run_script(
402+ 'scripts/rosetta/reupload-translations.py', [
403+ '-d', self.distroseries.distribution.name,
404+ '-s', self.distroseries.name,
405+ '-p', self.sourcepackagename1.name,
406+ '-p', self.sourcepackagename2.name,
407+ '-vvv',
408+ '--dry-run',
409+ ])
410+
411+ self.assertEqual(0, retcode)
412+ self.assertEqual('', stdout)
413+
414+ expected_output = (
415+ "INFO\s*Dry run. Not really uploading anything.\n"
416+ "INFO\s*Processing [^\s]+ in .*\n"
417+ "WARNING\s*Found no translations upload for .*\n"
418+ "INFO\s*Processing [^\s]+ in .*\n"
419+ "WARNING\s*Found no translations upload for .*\n"
420+ "INFO\s*Done.\n")
421+ self.assertTrue(re.match(expected_output, stderr))
422+
423+
424+def test_suite():
425+ return TestLoader().loadTestsFromName(__name__)
426
427=== added file 'scripts/rosetta/reupload-translations.py'
428--- scripts/rosetta/reupload-translations.py 1970-01-01 00:00:00 +0000
429+++ scripts/rosetta/reupload-translations.py 2009-09-30 16:53:17 +0000
430@@ -0,0 +1,19 @@
431+#! /usr/bin/env python2.4
432+#
433+# Copyright 2009 Canonical Ltd. This software is licensed under the
434+# GNU Affero General Public License version 3 (see the file LICENSE).
435+# pylint: disable-msg=W0403
436+
437+"""Re-upload translations from given packages."""
438+
439+__metaclass__ = type
440+
441+import _pythonpath
442+
443+from lp.translations.scripts.reupload_translations import (
444+ ReuploadPackageTranslations)
445+
446+
447+if __name__ == '__main__':
448+ script = ReuploadPackageTranslations('reupload-translations')
449+ script.run()