Merge lp:~henninge/launchpad/recife-bug-611674-convert-imports into lp:~launchpad/launchpad/recife

Proposed by Henning Eggers
Status: Merged
Approved by: Данило Шеган
Approved revision: no longer in the source branch.
Merged at revision: 9172
Proposed branch: lp:~henninge/launchpad/recife-bug-611674-convert-imports
Merge into: lp:~launchpad/launchpad/recife
Diff against target: 595 lines (+321/-83)
8 files modified
lib/lp/translations/doc/poimport-script.txt (+2/-2)
lib/lp/translations/doc/rosetta-karma.txt (+5/-4)
lib/lp/translations/model/pofile.py (+3/-5)
lib/lp/translations/stories/translations/30-rosetta-pofile-translation-gettext-error.txt (+12/-20)
lib/lp/translations/utilities/sanitize.py (+2/-0)
lib/lp/translations/utilities/tests/test_file_importer.py (+151/-3)
lib/lp/translations/utilities/tests/test_sanitize.py (+14/-0)
lib/lp/translations/utilities/translation_import.py (+132/-49)
To merge this branch: bzr merge lp:~henninge/launchpad/recife-bug-611674-convert-imports
Reviewer Review Type Date Requested Status
Данило Шеган (community) Approve
Review via email: mp+37108@code.launchpad.net

Description of the change

Bug 611674
==========

Although the bug does not say so, this branch is about converting the import code to the new models and use the new methods for adding translations and marking them as current.

The main change is in FileImporter.storeTranslations which used to call updateTranslations. The new scheme is to
  1. Add the new translation as a suggestion
  2. Try to validate it
  3. Approve the suggestion if it validates and has no conflicts.

A large part of the diff is made up of the new property "FileImporter.share_with_other_side" and its tests. The property is a boolean that reflects the sharing policy for translations from the file that is being imported. The policy depends on where the translations are coming from and what they are being imported into and on the permissions of the uploader. This was isolated to be able to change this policy later. (And I admit it could have warranted an extra branch.)

Implementation details
----------------------

rosetta-karma.txt:
Because of the new scheme, import karma is now awarded for adding suggestions, not for approving them. Slightly different but not much.

30-rosetta-pofile-translation-gettext-error.txt:
I don't know why gettextpo mentions "msgid_plural" nowadays. This code will pass for both "msgid" and "msgid_plural".

sanitize.py, test_sanitize.py:
A test failed because a language returned "None" for pluralforms. I am not sure if that is a bug in itself.

translation_import.py:
The new methods translations_are_msgids, _storeCredits, _validateMessage and _approveMessage are all code that used to be in storeTranslationsInDatabase. This should make it more readable (based on a suggestion by jtv).
The flow in storeStoreTranslationsInDatabase has changed slightly but as all tests are passing ...

Tests
-----

I ran all lp.translations tests to find breakage but I think something like this would cover most of the import code:

bin/test -vvcm lp.translations -t poimport.txt -t poimport-script.txt -t test_file_importer -t test_sanitize -t stories.translations

Lint
----

Lint complains about bad indention in 30-rosetta-pofile-translation-gettext-error.txt which is true but which I am not going to fix here.

To post a comment you must log in.
Revision history for this message
Данило Шеган (danilo) wrote :
Download full text (19.0 KiB)

Hi Henning,

Thanks for tackling this. You've done a part of another card with this
job, so good job there :)

I feel it still needs a few improvements. See below.

  review needs-fixing

> A large part of the diff is made up of the new property
> "FileImporter.share_with_other_side" and its tests. The property is a
> boolean that reflects the sharing policy for translations from the
> file that is being imported. The policy depends on where the
> translations are coming from and what they are being imported into and
> on the permissions of the uploader. This was isolated to be able to
> change this policy later. (And I admit it could have warranted an
> extra branch.)

Interesting approach. We'll need to extract this even further, but
we've already got a separate card for that. You've just made that job
simpler (though, it's not going to be identical, since we'll have to
support the specifics of the web UI as well).

> === modified file 'lib/lp/translations/doc/poimport-script.txt'
> --- lib/lp/translations/doc/poimport-script.txt 2010-09-21 16:19:47
> +0000
> +++ lib/lp/translations/doc/poimport-script.txt 2010-09-30 08:53:45 +0000
> @@ -282,9 +282,9 @@
>
> >>> from lp.services.worlddata.interfaces.language import ILanguageSet
> >>> esperanto = getUtility(ILanguageSet).getLanguageByCode('eo')
> - >>> foos = potemplate['Foo %s'].getLocalTranslationMessages(
> + >>> suggestions = potemplate['Foo %
> s'].getLocalTranslationMessages(
> ... potemplate, esperanto)
> - >>> sorted([foo.msgstr0.translation for foo in foos])
> + >>> sorted([sugg.msgstr0.translation for sugg in suggestions])
> [u'Bar', u'Bars']

I don't really like "sugg" much better than "foo" (though, "suggestions"
instead of "foos" IS MUCH better :). How about "suggestion"?

> === modified file 'lib/lp/translations/utilities/sanitize.py'
> --- lib/lp/translations/utilities/sanitize.py 2010-08-16 09:01:35 +0000
> +++ lib/lp/translations/utilities/sanitize.py 2010-09-30 08:53:45 +0000
> @@ -181,6 +181,8 @@
>
> # Expected plural forms should all exist and empty translations should
> # be normalized to None.
> + if pluralforms is None:
> + pluralforms = 2
> for pluralform in range(pluralforms):
> if pluralform not in sanitized_translations:
> sanitized_translations[pluralform] = None

What's the reasoning for this? Why not default to 1 instead (i.e.
assume it's a message with no plurals at all)?

> === modified file 'lib/lp/translations/utilities/tests/test_sanitize.py'
> --- lib/lp/translations/utilities/tests/test_sanitize.py 2010-08-23 08:41:03 +0000
> +++ lib/lp/translations/utilities/tests/test_sanitize.py 2010-09-30 08:53:45 +0000
> @@ -257,6 +257,20 @@
> expected_sanitized,
> sanitize_translations_from_webui(self.english, translations, 3))
>
> + def test_sanitize_translations_pluralforms_none(self):
> + # Some languages don't provide a plural form, so 2 is assumed.
> + translations = {
> + 0: u'Plural form 0 ',
> + }
> + expected_sanitized = {
> + 0: u'Plural form 0',
> + ...

review: Needs Fixing
Revision history for this message
Henning Eggers (henninge) wrote :
Download full text (17.1 KiB)

> > === modified file 'lib/lp/translations/doc/poimport-script.txt'
> > --- lib/lp/translations/doc/poimport-script.txt 2010-09-21 16:19:47
> > +0000
> > +++ lib/lp/translations/doc/poimport-script.txt 2010-09-30 08:53:45 +0000
> > @@ -282,9 +282,9 @@
> >
> > >>> from lp.services.worlddata.interfaces.language import ILanguageSet
> > >>> esperanto = getUtility(ILanguageSet).getLanguageByCode('eo')
> > - >>> foos = potemplate['Foo %s'].getLocalTranslationMessages(
> > + >>> suggestions = potemplate['Foo %
> > s'].getLocalTranslationMessages(
> > ... potemplate, esperanto)
> > - >>> sorted([foo.msgstr0.translation for foo in foos])
> > + >>> sorted([sugg.msgstr0.translation for sugg in suggestions])
> > [u'Bar', u'Bars']
>
> I don't really like "sugg" much better than "foo" (though, "suggestions"
> instead of "foos" IS MUCH better :). How about "suggestion"?

Yeah, I didn't think it would fit. It does.

>
> > === modified file 'lib/lp/translations/utilities/sanitize.py'
> > --- lib/lp/translations/utilities/sanitize.py 2010-08-16 09:01:35 +0000
> > +++ lib/lp/translations/utilities/sanitize.py 2010-09-30 08:53:45 +0000
> > @@ -181,6 +181,8 @@
> >
> > # Expected plural forms should all exist and empty translations should
> > # be normalized to None.
> > + if pluralforms is None:
> > + pluralforms = 2
> > for pluralform in range(pluralforms):
> > if pluralform not in sanitized_translations:
> > sanitized_translations[pluralform] = None
>
> What's the reasoning for this? Why not default to 1 instead (i.e.
> assume it's a message with no plurals at all)?

That's what Language does. I could remove it here and use language.guessed_pluralforms when calling the function.
Also, there is something else I was wondering about. The function will fill in the missing plural forms with None, regardless of whether there is a msgid_plural or not. I don't remember if that is intended behavior. It does no harm but seems like a waste.

>
> > === modified file
> 'lib/lp/translations/utilities/tests/test_file_importer.py'
> ...
> > +class FileImporterSharingTest(TestCaseWithFactory):
> > + """Class test for the sharing operation of the FileImporter base
> class."""
> > + layer = LaunchpadZopelessLayer
> > +
> > + UPSTREAM = 0
> > + UBUNTU = 1
> > + LANGCODE = 'eo'
>
> I know this is how we usually used to do stuff, but today we should be
> able to also make languages and not depend on existing ones in the
> sampledata. For instance, if you really want to keep using 'eo', I'd
> suggest you do factory.makeLanguage('eo@test').

No, I am not intend on this. Actually I defined it here so that it does not appear in the code itself. Changed it to use a constructed Language.

>
> > + POFILE = dedent("""\
> > + msgid ""
> > + msgstr ""
> > + "PO-Revision-Date: 2005-05-03 20:41+0100\\n"
> > + "Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
> > + "Content-Type: text/plain; charset=UTF-8\\n"
> > + "X-Launchpad-Export-Date: 2009-05-14 08:54+0000\\n"
> > +
> > + msgid "Thank You"
> > + msgstr "Dankon"
> > + ...

Revision history for this message
Henning Eggers (henninge) wrote :

Incremental diff.

1=== modified file 'lib/lp/translations/doc/poimport-script.txt'
2--- lib/lp/translations/doc/poimport-script.txt 2010-09-29 11:53:45 +0000
3+++ lib/lp/translations/doc/poimport-script.txt 2010-10-01 10:15:02 +0000
4@@ -284,7 +284,7 @@
5 >>> esperanto = getUtility(ILanguageSet).getLanguageByCode('eo')
6 >>> suggestions = potemplate['Foo %s'].getLocalTranslationMessages(
7 ... potemplate, esperanto)
8- >>> sorted([sugg.msgstr0.translation for sugg in suggestions])
9+ >>> sorted([suggestion.msgstr0.translation for suggestion in suggestions])
10 [u'Bar', u'Bars']
11
12 Since this last upload was not the upstream one, however, its credits
13
14=== modified file 'lib/lp/translations/utilities/tests/test_file_importer.py'
15--- lib/lp/translations/utilities/tests/test_file_importer.py 2010-09-30 08:51:21 +0000
16+++ lib/lp/translations/utilities/tests/test_file_importer.py 2010-10-01 13:56:41 +0000
17@@ -582,7 +582,6 @@
18
19 UPSTREAM = 0
20 UBUNTU = 1
21- LANGCODE = 'eo'
22
23 POFILE = dedent("""\
24 msgid ""
25@@ -593,13 +592,14 @@
26 "X-Launchpad-Export-Date: 2009-05-14 08:54+0000\\n"
27
28 msgid "Thank You"
29- msgstr "Dankon"
30+ msgstr "Translation"
31 """)
32
33 def setUp(self):
34 super(FileImporterSharingTest, self).setUp()
35 # Create the upstream series and template with a translator.
36- self.translator = self.factory.makeTranslator(self.LANGCODE)
37+ self.language = self.factory.makeLanguage()
38+ self.translator = self.factory.makeTranslator(self.language.code)
39 self.upstream_productseries = self.factory.makeProductSeries()
40 self.upstream_productseries.product.translationgroup = (
41 self.translator.translationgroup)
42@@ -608,12 +608,12 @@
43 self.upstream_template = self.factory.makePOTemplate(
44 productseries=self.upstream_productseries)
45
46- def _makeEntry(self, side, from_upstream=False, uploader=None):
47+ def _makeImportEntry(self, side, from_upstream=False, uploader=None,
48+ no_upstream=False):
49 if side == self.UPSTREAM:
50 potemplate = self.upstream_template
51 else:
52- # Create a template in a source package and link the source
53- # package to the upstream series to enable sharing.
54+ # Create a template in a source package.
55 ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
56 distroseries = self.factory.makeDistroSeries(distribution=ubuntu)
57 ubuntu.translation_focus = distroseries
58@@ -622,19 +622,24 @@
59 distroseries=distroseries,
60 sourcepackagename=sourcepackagename,
61 name=self.upstream_template.name)
62- self.factory.makeSourcePackagePublishingHistory(
63- sourcepackagename=sourcepackagename,
64- distroseries=distroseries)
65- sourcepackage = distroseries.getSourcePackage(sourcepackagename)
66- sourcepackage.setPackaging(
67- self.upstream_productseries, self.factory.makePerson())
68+ if not no_upstream:
69+ # Link the source package to the upstream series to
70+ # enable sharing.
71+ self.factory.makeSourcePackagePublishingHistory(
72+ sourcepackagename=sourcepackagename,
73+ distroseries=distroseries)
74+ sourcepackage = distroseries.getSourcePackage(
75+ sourcepackagename)
76+ sourcepackage.setPackaging(
77+ self.upstream_productseries, self.factory.makePerson())
78 pofile = self.factory.makePOFile(
79- self.LANGCODE, potemplate=potemplate, create_sharing=True)
80+ self.language.code, potemplate=potemplate, create_sharing=True)
81 entry = self.factory.makeTranslationImportQueueEntry(
82 potemplate=potemplate, from_upstream=from_upstream,
83 uploader=uploader, content=self.POFILE)
84 entry.potemplate = potemplate
85 entry.pofile = pofile
86+ # The uploaded file is only created in the librarian by a commit.
87 transaction.commit()
88 return entry
89
90@@ -642,7 +647,7 @@
91 # Sanity check that the translator has the right permissions but
92 # others don't.
93 pofile = self.factory.makePOFile(
94- self.LANGCODE, potemplate=self.upstream_template)
95+ self.language.code, potemplate=self.upstream_template)
96 self.assertFalse(
97 pofile.canEditTranslations(self.factory.makePerson()))
98 self.assertTrue(
99@@ -650,7 +655,7 @@
100
101 def test_templates_are_sharing(self):
102 # Sharing between upstream and Ubuntu was set up correctly.
103- entry = self._makeEntry(self.UBUNTU)
104+ entry = self._makeImportEntry(self.UBUNTU)
105 subset = getUtility(IPOTemplateSet).getSharingSubset(
106 distribution=entry.distroseries.distribution,
107 sourcepackagename=entry.sourcepackagename)
108@@ -660,7 +665,7 @@
109
110 def test_share_with_other_side_upstream(self):
111 # An upstream queue entry will be shared with ubuntu.
112- entry = self._makeEntry(self.UPSTREAM)
113+ entry = self._makeImportEntry(self.UPSTREAM)
114 importer = POFileImporter(
115 entry, importers[TranslationFileFormat.PO], None)
116 self.assertTrue(
117@@ -669,7 +674,16 @@
118
119 def test_share_with_other_side_ubuntu(self):
120 # An ubuntu queue entry will not be shared with upstream.
121- entry = self._makeEntry(self.UBUNTU)
122+ entry = self._makeImportEntry(self.UBUNTU)
123+ importer = POFileImporter(
124+ entry, importers[TranslationFileFormat.PO], None)
125+ self.assertFalse(
126+ importer.share_with_other_side,
127+ "Ubuntu import should not share with upstream.")
128+
129+ def test_share_with_other_side_ubuntu_no_upstream(self):
130+ # An ubuntu queue entry cannot share with a non-existent upstream.
131+ entry = self._makeImportEntry(self.UBUNTU, no_upstream=True)
132 importer = POFileImporter(
133 entry, importers[TranslationFileFormat.PO], None)
134 self.assertFalse(
135@@ -679,7 +693,7 @@
136 def test_share_with_other_side_ubuntu_from_package(self):
137 # An ubuntu queue entry that is imported from an upstream package
138 # will be shared with upstream.
139- entry = self._makeEntry(self.UBUNTU, from_upstream=True)
140+ entry = self._makeImportEntry(self.UBUNTU, from_upstream=True)
141 importer = POFileImporter(
142 entry, importers[TranslationFileFormat.PO], None)
143 self.assertTrue(
144@@ -689,7 +703,7 @@
145 def test_share_with_other_side_ubuntu_uploader_upstream_translator(self):
146 # If the uploader in ubuntu has rights on upstream as well, the
147 # translations are shared.
148- entry = self._makeEntry(
149+ entry = self._makeImportEntry(
150 self.UBUNTU, uploader=self.translator.translator)
151 importer = POFileImporter(
152 entry, importers[TranslationFileFormat.PO], None)
153
154=== modified file 'lib/lp/translations/utilities/translation_import.py'
155--- lib/lp/translations/utilities/translation_import.py 2010-09-30 08:51:21 +0000
156+++ lib/lp/translations/utilities/translation_import.py 2010-10-01 14:27:37 +0000
157@@ -490,7 +490,11 @@
158
159 @cachedproperty
160 def translations_are_msgids(self):
161- """Are symbolic msgids being used and these are the real ones?"""
162+ """Are these English strings instead of translations?
163+
164+ If this template uses symbolic message ids, the English POFile
165+ will contain the English original texts that correspond to the
166+ symbols."""
167 return (
168 self.importer.uses_source_string_msgids and
169 self.pofile.language.code == 'en')
170@@ -559,7 +563,6 @@
171 return None
172
173 no_translations = (
174- message_data.translations == [] or
175 message_data.translations is None or
176 not any(message_data.translations))
177 if no_translations:
Revision history for this message
Данило Шеган (danilo) wrote :
Download full text (7.3 KiB)

У пет, 01. 10 2010. у 14:43 +0000, Henning Eggers пише:

> That's what Language does. I could remove it here and use
> language.guessed_pluralforms when calling the function. Also, there is
> something else I was wondering about. The function will fill in the
> missing plural forms with None, regardless of whether there is a
> msgid_plural or not. I don't remember if that is intended behavior. It
> does no harm but seems like a waste.

Let's keep it simple for now and not touch anything. It definitely
sounds a bit wasteful, but it's not a big deal.

> > I know this is how we usually used to do stuff, but today we should be
> > able to also make languages and not depend on existing ones in the
> > sampledata. For instance, if you really want to keep using 'eo', I'd
> > suggest you do factory.makeLanguage('eo@test').
>
> No, I am not intend on this. Actually I defined it here so that it does
> not appear in the code itself. Changed it to use a constructed
> Language.

Cool, thanks.

> > > + if side == self.UPSTREAM:
> > > + potemplate = self.upstream_template
> > > + else:
> > > + # Create a template in a source package and link the source
> > > + # package to the upstream series to enable sharing.
> > > + ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
> > > + distroseries =
> > self.factory.makeDistroSeries(distribution=ubuntu)
> > > + ubuntu.translation_focus = distroseries
> > > + sourcepackagename = self.factory.makeSourcePackageName()
> > > + potemplate = self.factory.makePOTemplate(
> > > + distroseries=distroseries,
> > > + sourcepackagename=sourcepackagename,
> > > + name=self.upstream_template.name)
> > > + self.factory.makeSourcePackagePublishingHistory(
> > > + sourcepackagename=sourcepackagename,
> > > + distroseries=distroseries)
> >
> > Why is this necessary? Is it not possible to reach a sourcepackage
> > without it?
>
> For some reason I cannot use sourcepackage.productseries if that has not been set.

Right, it's probably useful knowledge worth sharing across the
translations team at least. Knowing the background would be even better
because then we could reconstruct it in the future :)

> >
> > > + return entry
> > > +
> > > + def test_translator_persmissions(self):
> >
> > s/perSmissions/permissions/

You seem to have forgotten this bit.

> >
> > > + def test_templates_are_sharing(self):
> > > + # Sharing between upstream and Ubuntu was set up correctly.
> > > + entry = self._makeEntry(self.UBUNTU)
> > > + subset = getUtility(IPOTemplateSet).getSharingSubset(
> > > + distribution=entry.distroseries.distribution,
> > > + sourcepackagename=entry.sourcepackagename)
> > > + self.assertContentEqual(
> > > + [entry.potemplate, self.upstream_template],
> > > + list(subset.getSharingPOTemplates(entry.potemplate.name)))
> >
> > This looks a lot like a test for the code that's not really here,
> > doesn't it? :)
> >
> > Or, is it basicall...

Read more...

review: Approve
Revision history for this message
Henning Eggers (henninge) wrote :
Download full text (3.2 KiB)

Am 05.10.2010 13:34, schrieb Данило Шеган:
>>>> + self.factory.makeSourcePackagePublishingHistory(
>>>> + sourcepackagename=sourcepackagename,
>>>> + distroseries=distroseries)
>>>
>>> Why is this necessary? Is it not possible to reach a sourcepackage
>>> without it?
>>
>> For some reason I cannot use sourcepackage.productseries if that has not been set.
>
> Right, it's probably useful knowledge worth sharing across the
> translations team at least. Knowing the background would be even better
> because then we could reconstruct it in the future :)

Hm, should I put a card in the backlog to research that?

>
>>>
>>>> + return entry
>>>> +
>>>> + def test_translator_persmissions(self):
>>>
>>> s/perSmissions/permissions/
>
> You seem to have forgotten this bit.

I did, thanks. ;)

> It'd still be nice to name the test so it reflects this: eg.
> "test_makeImportEntry_templates_are_sharing".

Renamed.

>
>>> Cool stuff. It's amazing how tests can be so simple for something this
>>> intricate. :)
>>
>> I see the smiley but I am not sure if you are making fun of me or not ... ;-/
>
> No, it's a smiley of happiness. Also a credit to your "factor out stuff
> that is not being tested" (your _makeImportEntry method).

Thank you. :-)

>>>> + @cachedproperty
>>>> + def translations_are_msgids(self):
>>>> + """Are symbolic msgids being used and these are the real ones?"""
>>>
>>> This sentence is a bit confusing. If I had no idea about what this is
>>> about, I'd have even less idea about it. :)
>>
>> OK, I tried better. ;-)
>
> I think "text" is a non-countable noun, so plural "texts" sounds weird:

It can be both. In this case it is countable

>>> This is actually very interesting, and I am happy to see that you
>>> extracted it out. It actually points out that the naming for
>>> "from_upstream" is not really perfect: it should probably be
>>> "from_maintainer" or "from_trusted" or something.
>>
>> Yes, Jeroen and I had talked about that, too. I think I like from_maintainer most.
>
> Let's add a card for that then.

Done.

>>> Oh, I just read up on any() :) It seems you can drop the comparison to
>>> the empty list as well.
>>
>> Good catch! I was a bit worried about using "any" because it tests for
>> "False" but we are looking for empty strings and "None". Although
>> these are by definition "False", too, our coding standards always
>> forbade implicit bool checks like these.
>
> That was kind of my worry as well, but I guess there's no harm since the
> intent is clear enough.

I put it on the reviewer meeting agenda.

>
>>> With the new model, there will be no way to know if a translation
>>> credits message didn't validate, but is the actual one from upstream.
>>> Something to think about.
>>
>> Hm, we could still validate and mark the message accordingly, even if
>> it is used, right? I am not sure, though, if translators expect us to
>> catch bugs like that. Maybe we should discard c-format flags on
>> translation credits POTMsgSets altogether?
>
> Definitely worth thinking about some more. How about you file a bug
> about this to look into after all the "re...

Read more...

1=== modified file 'lib/lp/translations/utilities/tests/test_file_importer.py'
2--- lib/lp/translations/utilities/tests/test_file_importer.py 2010-10-05 17:06:23 +0000
3+++ lib/lp/translations/utilities/tests/test_file_importer.py 2010-10-06 09:40:23 +0000
4@@ -643,7 +643,7 @@
5 transaction.commit()
6 return entry
7
8- def test_translator_persmissions(self):
9+ def test_translator_permissions(self):
10 # Sanity check that the translator has the right permissions but
11 # others don't.
12 pofile = self.factory.makePOFile(
13@@ -653,7 +653,7 @@
14 self.assertTrue(
15 pofile.canEditTranslations(self.translator.translator))
16
17- def test_templates_are_sharing(self):
18+ def test_makeImportEntry_templates_are_sharing(self):
19 # Sharing between upstream and Ubuntu was set up correctly.
20 entry = self._makeImportEntry(self.UBUNTU)
21 subset = getUtility(IPOTemplateSet).getSharingSubset(

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/translations/doc/poimport-script.txt'
2--- lib/lp/translations/doc/poimport-script.txt 2010-09-21 16:19:47 +0000
3+++ lib/lp/translations/doc/poimport-script.txt 2010-10-06 11:07:49 +0000
4@@ -282,9 +282,9 @@
5
6 >>> from lp.services.worlddata.interfaces.language import ILanguageSet
7 >>> esperanto = getUtility(ILanguageSet).getLanguageByCode('eo')
8- >>> foos = potemplate['Foo %s'].getLocalTranslationMessages(
9+ >>> suggestions = potemplate['Foo %s'].getLocalTranslationMessages(
10 ... potemplate, esperanto)
11- >>> sorted([foo.msgstr0.translation for foo in foos])
12+ >>> sorted([suggestion.msgstr0.translation for suggestion in suggestions])
13 [u'Bar', u'Bars']
14
15 Since this last upload was not the upstream one, however, its credits
16
17=== modified file 'lib/lp/translations/doc/rosetta-karma.txt'
18--- lib/lp/translations/doc/rosetta-karma.txt 2010-08-04 11:00:51 +0000
19+++ lib/lp/translations/doc/rosetta-karma.txt 2010-10-06 11:07:49 +0000
20@@ -221,14 +221,14 @@
21 Tell the PO file to import from the file data it has. The user has rights
22 to edit translations directly, so his suggestion is approved directly.
23 Even when the user is also a reviwer, he should not get karma for reviewing
24-his own translations. IOW, he'll only get karma for his suggestion being
25-approved.
26+or approving his own translations. IOW, he'll only get karma for submitting
27+his suggestion.
28
29 >>> potemplate = POTemplate.get(1)
30 >>> entry = translation_import_queue[entry_id]
31 >>> pofile = potemplate.getPOFileByLang('es')
32 >>> (subject, message) = pofile.importFromQueue(entry)
33- Karma added: action=translationsuggestionapproved, product=evolution
34+ Karma added: action=translationsuggestionadded, product=evolution
35 >>> transaction.commit()
36
37 Let's try the case when a file is uploaded, but no translation is changed.
38@@ -384,7 +384,8 @@
39 We do the update and see the karma being assigned.
40
41 >>> status = potemplate_view.initialize()
42- Karma added: action=translationtemplatedescriptionchanged, product=evolution
43+ Karma added:
44+ action=translationtemplatedescriptionchanged, product=evolution
45
46 And the new one is:
47
48
49=== modified file 'lib/lp/translations/model/pofile.py'
50--- lib/lp/translations/model/pofile.py 2010-09-06 10:40:54 +0000
51+++ lib/lp/translations/model/pofile.py 2010-10-06 11:07:49 +0000
52@@ -538,7 +538,8 @@
53 applicable_template = Coalesce(
54 TranslationMessage.potemplateID, self.potemplate.id)
55 clauses = [
56- TranslationTemplateItem.potmsgsetID == TranslationMessage.potmsgsetID,
57+ TranslationTemplateItem.potmsgsetID == (
58+ TranslationMessage.potmsgsetID),
59 TranslationTemplateItem.potemplate == self.potemplate,
60 TranslationMessage.language == self.language,
61 applicable_template == self.potemplate.id,
62@@ -1108,8 +1109,7 @@
63
64 # Prepare the mail notification.
65 msgsets_imported = self.getTranslationMessages(
66- TranslationMessage.was_obsolete_in_last_import == False
67- ).count()
68+ TranslationMessage.was_obsolete_in_last_import == False).count()
69
70 replacements = collect_import_info(entry_to_import, self, warnings)
71 replacements.update({
72@@ -1179,8 +1179,6 @@
73 # Assign karma to the importer if this is not an automatic import
74 # (all automatic imports come from the rosetta expert user) and
75 # comes from upstream.
76- celebs = getUtility(ILaunchpadCelebrities)
77- rosetta_experts = celebs.rosetta_experts
78 if (entry_to_import.from_upstream and
79 entry_to_import.importer.id != rosetta_experts.id):
80 entry_to_import.importer.assignKarma(
81
82=== modified file 'lib/lp/translations/stories/translations/30-rosetta-pofile-translation-gettext-error.txt'
83--- lib/lp/translations/stories/translations/30-rosetta-pofile-translation-gettext-error.txt 2010-09-20 05:32:10 +0000
84+++ lib/lp/translations/stories/translations/30-rosetta-pofile-translation-gettext-error.txt 2010-10-06 11:07:49 +0000
85@@ -38,38 +38,30 @@
86 >>> user_browser.getControl(
87 ... name='msgset_150_es_translation_1_radiobutton').value = [
88 ... 'msgset_150_es_translation_1_new']
89- >>> user_browser.getControl(name='msgset_150_es_translation_1_new').value = (
90+ >>> user_browser.getControl(
91+ ... name='msgset_150_es_translation_1_new').value = (
92 ... u'Found %s invalid files')
93 >>> user_browser.getControl(name='submit_translations').click()
94
95 Because of the error, we're still in on the same page.
96
97 >>> print user_browser.url
98- http://translations.launchpad.dev/ubuntu/hoary/+source/evolution/+pots/evolution-2.2/es/+translate?start=20
99+ http://.../hoary/+source/evo.../+pots/evo...-2.2/es/+translate?start=20
100
101 And we can see the error.
102
103 >>> for tag in find_tags_by_class(user_browser.contents, 'error'):
104- ... print tag
105- <div class="error message">There is an error in a translation you provided.
106- Please correct it before continuing.</div>
107- <tr class="error translation">
108- <th colspan="3">
109- <strong>Error in Translation:</strong>
110- </th>
111- <td>
112- </td><td>
113- <div>
114- format specifications in ... and 'msgstr[0]' for argument 1 are
115- not the sameformat specifications in ... and 'msgstr[1]' for
116- argument 1 are not the same
117- </div>
118- </td>
119- </tr>
120+ ... print extract_text(tag)
121+ There is an error in a translation you provided.
122+ Please correct it before continuing.
123+ Error in Translation:
124+ format specifications in 'msgid...' and 'msgstr[0]' for argument 1 are
125+ not the sameformat specifications in 'msgid...' and 'msgstr[1]' for
126+ argument 1 are not the same
127
128 The translation form got an error with the translations we wanted to store,
129-and thus we still have that text as part of translations input, otherwise, they
130-will be empty waiting for new suggestions/translations.
131+and thus we still have that text as part of translations input, otherwise,
132+they will be empty waiting for new suggestions/translations.
133
134 >>> user_browser.getControl(name='msgset_150_es_translation_0_new').value
135 'Found %s invalid file'
136
137=== modified file 'lib/lp/translations/utilities/sanitize.py'
138--- lib/lp/translations/utilities/sanitize.py 2010-08-16 09:01:35 +0000
139+++ lib/lp/translations/utilities/sanitize.py 2010-10-06 11:07:49 +0000
140@@ -181,6 +181,8 @@
141
142 # Expected plural forms should all exist and empty translations should
143 # be normalized to None.
144+ if pluralforms is None:
145+ pluralforms = 2
146 for pluralform in range(pluralforms):
147 if pluralform not in sanitized_translations:
148 sanitized_translations[pluralform] = None
149
150=== modified file 'lib/lp/translations/utilities/tests/test_file_importer.py'
151--- lib/lp/translations/utilities/tests/test_file_importer.py 2010-09-30 17:57:01 +0000
152+++ lib/lp/translations/utilities/tests/test_file_importer.py 2010-10-06 11:07:49 +0000
153@@ -5,13 +5,24 @@
154
155 __metaclass__ = type
156
157+from textwrap import dedent
158+
159+import transaction
160 from zope.component import getUtility
161 from zope.security.proxy import removeSecurityProxy
162
163-from canonical.testing import ZopelessDatabaseLayer
164+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
165+from canonical.testing import (
166+ LaunchpadZopelessLayer,
167+ ZopelessDatabaseLayer,
168+ )
169 from lp.registry.interfaces.person import IPersonSet
170 from lp.testing import TestCaseWithFactory
171 from lp.testing.fakelibrarian import FakeLibrarian
172+from lp.translations.interfaces.potemplate import IPOTemplateSet
173+from lp.translations.interfaces.translationfileformat import (
174+ TranslationFileFormat,
175+ )
176 from lp.translations.interfaces.translationgroup import TranslationPermission
177 from lp.translations.interfaces.translationimporter import (
178 OutdatedTranslationError,
179@@ -23,6 +34,7 @@
180 from lp.translations.utilities.translation_common_format import (
181 TranslationMessageData)
182 from lp.translations.utilities.translation_import import (
183+ importers,
184 FileImporter,
185 POFileImporter,
186 POTFileImporter,
187@@ -367,11 +379,11 @@
188 def test_FileImporter_importFile_ok(self):
189 # Test correct import operation for both
190 # exported and upstream files.
191- importers = (
192+ used_importers = (
193 self._createImporterForExportedEntries(),
194 self._createImporterForUpstreamEntries(),
195 )
196- for (pot_importer, po_importer) in importers:
197+ for (pot_importer, po_importer) in used_importers:
198 # Run the import and see if PotMsgSet and TranslationMessage
199 # entries are correctly created in the DB.
200 errors, warnings = pot_importer.importFile()
201@@ -562,3 +574,139 @@
202 old_raw_header = pofile.header
203 importer = POFileImporter(queue_entry, GettextPOImporter(), None)
204 self.assertEqual(old_raw_header, pofile.header)
205+
206+
207+class FileImporterSharingTest(TestCaseWithFactory):
208+ """Class test for the sharing operation of the FileImporter base class."""
209+ layer = LaunchpadZopelessLayer
210+
211+ UPSTREAM = 0
212+ UBUNTU = 1
213+
214+ POFILE = dedent("""\
215+ msgid ""
216+ msgstr ""
217+ "PO-Revision-Date: 2005-05-03 20:41+0100\\n"
218+ "Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
219+ "Content-Type: text/plain; charset=UTF-8\\n"
220+ "X-Launchpad-Export-Date: 2009-05-14 08:54+0000\\n"
221+
222+ msgid "Thank You"
223+ msgstr "Translation"
224+ """)
225+
226+ def setUp(self):
227+ super(FileImporterSharingTest, self).setUp()
228+ # Create the upstream series and template with a translator.
229+ self.language = self.factory.makeLanguage()
230+ self.translator = self.factory.makeTranslator(self.language.code)
231+ self.upstream_productseries = self.factory.makeProductSeries()
232+ self.upstream_productseries.product.translationgroup = (
233+ self.translator.translationgroup)
234+ self.upstream_productseries.product.translationpermission = (
235+ TranslationPermission.RESTRICTED)
236+ self.upstream_template = self.factory.makePOTemplate(
237+ productseries=self.upstream_productseries)
238+
239+ def _makeImportEntry(self, side, from_upstream=False, uploader=None,
240+ no_upstream=False):
241+ if side == self.UPSTREAM:
242+ potemplate = self.upstream_template
243+ else:
244+ # Create a template in a source package.
245+ ubuntu = getUtility(ILaunchpadCelebrities).ubuntu
246+ distroseries = self.factory.makeDistroSeries(distribution=ubuntu)
247+ ubuntu.translation_focus = distroseries
248+ sourcepackagename = self.factory.makeSourcePackageName()
249+ potemplate = self.factory.makePOTemplate(
250+ distroseries=distroseries,
251+ sourcepackagename=sourcepackagename,
252+ name=self.upstream_template.name)
253+ if not no_upstream:
254+ # Link the source package to the upstream series to
255+ # enable sharing.
256+ self.factory.makeSourcePackagePublishingHistory(
257+ sourcepackagename=sourcepackagename,
258+ distroseries=distroseries)
259+ sourcepackage = distroseries.getSourcePackage(
260+ sourcepackagename)
261+ sourcepackage.setPackaging(
262+ self.upstream_productseries, self.factory.makePerson())
263+ pofile = self.factory.makePOFile(
264+ self.language.code, potemplate=potemplate, create_sharing=True)
265+ entry = self.factory.makeTranslationImportQueueEntry(
266+ potemplate=potemplate, from_upstream=from_upstream,
267+ uploader=uploader, content=self.POFILE)
268+ entry.potemplate = potemplate
269+ entry.pofile = pofile
270+ # The uploaded file is only created in the librarian by a commit.
271+ transaction.commit()
272+ return entry
273+
274+ def test_translator_permissions(self):
275+ # Sanity check that the translator has the right permissions but
276+ # others don't.
277+ pofile = self.factory.makePOFile(
278+ self.language.code, potemplate=self.upstream_template)
279+ self.assertFalse(
280+ pofile.canEditTranslations(self.factory.makePerson()))
281+ self.assertTrue(
282+ pofile.canEditTranslations(self.translator.translator))
283+
284+ def test_makeImportEntry_templates_are_sharing(self):
285+ # Sharing between upstream and Ubuntu was set up correctly.
286+ entry = self._makeImportEntry(self.UBUNTU)
287+ subset = getUtility(IPOTemplateSet).getSharingSubset(
288+ distribution=entry.distroseries.distribution,
289+ sourcepackagename=entry.sourcepackagename)
290+ self.assertContentEqual(
291+ [entry.potemplate, self.upstream_template],
292+ list(subset.getSharingPOTemplates(entry.potemplate.name)))
293+
294+ def test_share_with_other_side_upstream(self):
295+ # An upstream queue entry will be shared with ubuntu.
296+ entry = self._makeImportEntry(self.UPSTREAM)
297+ importer = POFileImporter(
298+ entry, importers[TranslationFileFormat.PO], None)
299+ self.assertTrue(
300+ importer.share_with_other_side,
301+ "Upstream import should share with Ubuntu.")
302+
303+ def test_share_with_other_side_ubuntu(self):
304+ # An ubuntu queue entry will not be shared with upstream.
305+ entry = self._makeImportEntry(self.UBUNTU)
306+ importer = POFileImporter(
307+ entry, importers[TranslationFileFormat.PO], None)
308+ self.assertFalse(
309+ importer.share_with_other_side,
310+ "Ubuntu import should not share with upstream.")
311+
312+ def test_share_with_other_side_ubuntu_no_upstream(self):
313+ # An ubuntu queue entry cannot share with a non-existent upstream.
314+ entry = self._makeImportEntry(self.UBUNTU, no_upstream=True)
315+ importer = POFileImporter(
316+ entry, importers[TranslationFileFormat.PO], None)
317+ self.assertFalse(
318+ importer.share_with_other_side,
319+ "Ubuntu import should not share with upstream.")
320+
321+ def test_share_with_other_side_ubuntu_from_package(self):
322+ # An ubuntu queue entry that is imported from an upstream package
323+ # will be shared with upstream.
324+ entry = self._makeImportEntry(self.UBUNTU, from_upstream=True)
325+ importer = POFileImporter(
326+ entry, importers[TranslationFileFormat.PO], None)
327+ self.assertTrue(
328+ importer.share_with_other_side,
329+ "Ubuntu import should share with upstream.")
330+
331+ def test_share_with_other_side_ubuntu_uploader_upstream_translator(self):
332+ # If the uploader in ubuntu has rights on upstream as well, the
333+ # translations are shared.
334+ entry = self._makeImportEntry(
335+ self.UBUNTU, uploader=self.translator.translator)
336+ importer = POFileImporter(
337+ entry, importers[TranslationFileFormat.PO], None)
338+ self.assertTrue(
339+ importer.share_with_other_side,
340+ "Ubuntu import should share with upstream.")
341
342=== modified file 'lib/lp/translations/utilities/tests/test_sanitize.py'
343--- lib/lp/translations/utilities/tests/test_sanitize.py 2010-08-23 08:41:03 +0000
344+++ lib/lp/translations/utilities/tests/test_sanitize.py 2010-10-06 11:07:49 +0000
345@@ -257,6 +257,20 @@
346 expected_sanitized,
347 sanitize_translations_from_webui(self.english, translations, 3))
348
349+ def test_sanitize_translations_pluralforms_none(self):
350+ # Some languages don't provide a plural form, so 2 is assumed.
351+ translations = {
352+ 0: u'Plural form 0 ',
353+ }
354+ expected_sanitized = {
355+ 0: u'Plural form 0',
356+ 1: None,
357+ }
358+ self.assertEqual(
359+ expected_sanitized,
360+ sanitize_translations_from_webui(
361+ self.english, translations, None))
362+
363
364 def test_suite():
365 return unittest.TestLoader().loadTestsFromName(__name__)
366
367=== modified file 'lib/lp/translations/utilities/translation_import.py'
368--- lib/lp/translations/utilities/translation_import.py 2010-09-06 10:40:54 +0000
369+++ lib/lp/translations/utilities/translation_import.py 2010-10-06 11:07:49 +0000
370@@ -31,6 +31,11 @@
371 PersonCreationRationale,
372 )
373 from lp.services.propertycache import cachedproperty
374+from lp.translations.interfaces.potemplate import IPOTemplateSet
375+from lp.translations.interfaces.side import (
376+ ITranslationSideTraitsSet,
377+ TranslationSide,
378+ )
379 from lp.translations.interfaces.translationexporter import (
380 ITranslationExporter,
381 )
382@@ -45,15 +50,25 @@
383 from lp.translations.interfaces.translationimportqueue import (
384 RosettaImportStatus,
385 )
386-from lp.translations.interfaces.translationmessage import TranslationConflict
387+from lp.translations.interfaces.translationmessage import (
388+ RosettaTranslationOrigin,
389+ TranslationConflict,
390+ TranslationValidationStatus,
391+ )
392 from lp.translations.interfaces.translations import TranslationConstants
393 from lp.translations.utilities.gettext_po_importer import GettextPOImporter
394 from lp.translations.utilities.kde_po_importer import KdePOImporter
395 from lp.translations.utilities.mozilla_xpi_importer import MozillaXpiImporter
396+from lp.translations.utilities.sanitize import (
397+ sanitize_translations_from_import,
398+ )
399 from lp.translations.utilities.translation_common_format import (
400 TranslationMessageData,
401 )
402-from lp.translations.utilities.validate import GettextValidationError
403+from lp.translations.utilities.validate import (
404+ GettextValidationError,
405+ validate_translation,
406+ )
407
408
409 importers = {
410@@ -445,7 +460,89 @@
411 context=message.context))
412 return potmsgset
413
414- def storeTranslationsInDatabase(self, message, potmsgset):
415+ @cachedproperty
416+ def share_with_other_side(self):
417+ """Returns True if translations should be shared with the other side.
418+ """
419+ traits = getUtility(
420+ ITranslationSideTraitsSet).getForTemplate(self.potemplate)
421+ if traits.side == TranslationSide.UPSTREAM:
422+ return True
423+ # Check from_upstream.
424+ if self.translation_import_queue_entry.from_upstream:
425+ return True
426+ # Find the sharing POFile and check permissions.
427+ productseries = self.potemplate.distroseries.getSourcePackage(
428+ self.potemplate.sourcepackagename).productseries
429+ if productseries is None:
430+ return False
431+ upstream_template = getUtility(IPOTemplateSet).getSubset(
432+ productseries=productseries).getPOTemplateByName(
433+ self.potemplate.name)
434+ upstream_pofile = upstream_template.getPOFileByLang(
435+ self.pofile.language.code)
436+ if upstream_pofile is not None:
437+ uploader_person = self.translation_import_queue_entry.importer
438+ if upstream_pofile.canEditTranslations(uploader_person):
439+ return True
440+ # Deny the rest.
441+ return False
442+
443+ @cachedproperty
444+ def translations_are_msgids(self):
445+ """Are these English strings instead of translations?
446+
447+ If this template uses symbolic message ids, the English POFile
448+ will contain the English original texts that correspond to the
449+ symbols."""
450+ return (
451+ self.importer.uses_source_string_msgids and
452+ self.pofile.language.code == 'en')
453+
454+ def _storeCredits(self, potmsgset, credits):
455+ """Store credits but only from upstream."""
456+ if not self.translation_import_queue_entry.from_upstream:
457+ return None
458+ return potmsgset.setCurrentTranslation(
459+ self.pofile, self.last_translator, credits,
460+ RosettaTranslationOrigin.SCM, self.share_with_other_side)
461+
462+ def _validateMessage(self, potmsgset, message,
463+ translations, message_data):
464+ """Validate the message and report success or failure."""
465+ try:
466+ validate_translation(
467+ potmsgset.singular_text, potmsgset.plural_text,
468+ translations, potmsgset.flags)
469+ except GettextValidationError, e:
470+ self._addUpdateError(message_data, potmsgset, unicode(e))
471+ message.validation_status = (
472+ TranslationValidationStatus.UNKNOWNERROR)
473+ return False
474+
475+ message.validation_status = TranslationValidationStatus.OK
476+ return True
477+
478+ def _approveMessage(self, potmsgset, message, message_data):
479+ """Try to approve the message, return None on TranslationConflict."""
480+ try:
481+ message.approve(
482+ self.pofile, self.last_translator,
483+ self.share_with_other_side, self.lock_timestamp)
484+ except TranslationConflict:
485+ self._addConflictError(message_data, potmsgset)
486+ if self.logger is not None:
487+ self.logger.info(
488+ "Conflicting updates on message %d." % potmsgset.id)
489+ # The message remains a suggestion.
490+ return None
491+
492+ if self.translations_are_msgids:
493+ potmsgset.clearCachedSingularText()
494+
495+ return message
496+
497+ def storeTranslationsInDatabase(self, message_data, potmsgset):
498 """Try to store translations in the database.
499
500 Perform check if a PO file is available and if the message has any
501@@ -453,7 +550,8 @@
502 is added to the list in self.errors but the translations are stored
503 anyway, marked as having an error.
504
505- :param message: The message for which translations will be stored.
506+ :param message_data: The message data for which translations will be
507+ stored.
508 :param potmsgset: The POTMsgSet that this message belongs to.
509
510 :return: The updated translation_message entry or None, if no storing
511@@ -464,54 +562,39 @@
512 # store English strings in an IPOFile.
513 return None
514
515- if (not message.translations or
516- set(message.translations) == set([u'']) or
517- set(message.translations) == set([None])):
518+ no_translations = (
519+ message_data.translations is None or
520+ not any(message_data.translations))
521+ if no_translations:
522 # We don't have anything to import.
523 return None
524
525- try:
526- # Do the actual import.
527- translation_message = potmsgset.updateTranslation(
528- self.pofile, self.last_translator, message.translations,
529- self.translation_import_queue_entry.from_upstream,
530- self.lock_timestamp, force_edition_rights=self.is_editor)
531- except TranslationConflict:
532- self._addConflictError(message, potmsgset)
533- if self.logger is not None:
534- self.logger.info(
535- "Conflicting updates on message %d." % potmsgset.id)
536- return None
537- except GettextValidationError, e:
538- # We got an error, so we submit the translation again but
539- # this time asking to store it as a translation with
540- # errors.
541-
542- # Add the pomsgset to the list of pomsgsets with errors.
543- self._addUpdateError(message, potmsgset, unicode(e))
544-
545- try:
546- translation_message = potmsgset.updateTranslation(
547- self.pofile, self.last_translator, message.translations,
548- self.translation_import_queue_entry.from_upstream,
549- self.lock_timestamp, ignore_errors=True,
550- force_edition_rights=self.is_editor)
551- except TranslationConflict:
552- # A conflict on top of a validation error? Give up.
553- # This message is cursed.
554- if self.logger is not None:
555- self.logger.info(
556- "Conflicting updates; ignoring invalid message %d." %
557- potmsgset.id)
558- return None
559-
560- just_replaced_msgid = (
561- self.importer.uses_source_string_msgids and
562- self.pofile.language.code == 'en')
563- if just_replaced_msgid:
564- potmsgset.clearCachedSingularText()
565-
566- return translation_message
567+ sanitized_translations = sanitize_translations_from_import(
568+ potmsgset.singular_text, message_data.translations,
569+ self.pofile.language.pluralforms)
570+
571+ if potmsgset.is_translation_credit:
572+ # Translation credits cannot be added as suggestions.
573+ return self._storeCredits(potmsgset, sanitized_translations)
574+
575+ # The message is first stored as a suggestion and only made
576+ # current if it validates.
577+ new_message = potmsgset.submitSuggestion(
578+ self.pofile, self.last_translator, sanitized_translations)
579+
580+ validation_ok = self._validateMessage(
581+ potmsgset, new_message, sanitized_translations, message_data)
582+ if validation_ok and self.is_editor:
583+ return self._approveMessage(potmsgset, new_message, message_data)
584+
585+ if self.translation_import_queue_entry.from_upstream:
586+ # XXX: henninge 2010-09-21: Mixed models!
587+ # This is mimicking the old behavior to still mark these messages
588+ # as "imported". Will have to be removed when
589+ # getPOTMsgSetsWithErrors is updated to the new model.
590+ new_message.makeCurrentUpstream(True)
591+
592+ return new_message
593
594 def importMessage(self, message):
595 """Import a single message.

Subscribers

People subscribed via source and target branches