Daniel Axtens <d...@axtens.net> writes: >> Closes: #241 > > I'm not sure this is quite correct, sadly. Using the parallel parser I > posted, I see a difference in the number of series created by parsing in > parallel and parsing serially. > > # serial (-j1) >>>> Series.objects.count() > 28016 > > # parallel (-j6) >>>> Series.objects.count() > 28065 > > > I investigated just the first Untitled series from the parallel case, > where I see that one 6-patch series has been split up a lot: > >>>> pprint.pprint([(p.name, p.series) for p in >>>> Patch.objects.filter(submitter__id=42)[0:6]]) > [('[1/6] x86: implement support to synchronize RDTSC through MFENCE on AMD ' > 'CPUs', > <Series: [1/6] x86: implement support to synchronize RDTSC through MFENCE > on AMD CPUs>), > ('[2/6] x86: Implement support to synchronize RDTSC with LFENCE on Intel > CPUs', > <Series: Untitled series #307>), > ('[3/6] x86: move nop declarations into separate include file', > <Series: Untitled series #544>), > ('[4/6] x86: introduce rdtsc_barrier()', <Series: Untitled series #307>), > ('[5/6] x86: remove get_cycles_sync', <Series: Untitled series #559>), > ('[6/6] x86: read_tsc sync', <Series: Untitled series #449>)] > > If I do serial parsing, this does not get split up: > >>>> pprint.pprint([(p.name, p.series) for p in >>>> Patch.objects.filter(submitter__id=74)[0:6]]) > [('[1/6] x86: implement support to synchronize RDTSC through MFENCE on AMD ' > 'CPUs', > <Series: Get rid of CPUID from gettimeofday>), > ('[2/6] x86: Implement support to synchronize RDTSC with LFENCE on Intel > CPUs', > <Series: Get rid of CPUID from gettimeofday>), > ('[3/6] x86: move nop declarations into separate include file', > <Series: Get rid of CPUID from gettimeofday>), > ('[4/6] x86: introduce rdtsc_barrier()', > <Series: Get rid of CPUID from gettimeofday>), > ('[5/6] x86: remove get_cycles_sync', > <Series: Get rid of CPUID from gettimeofday>), > ('[6/6] x86: read_tsc sync', <Series: Get rid of CPUID from gettimeofday>)] > > I haven't looked at the series in any detail beyond this - it might > still be worth including, but it just doesn't quite squash the > bug, sadly.
I've just implemented an alternative approach (put a field in Series that represents the 'head' message-id), and also saw this split up - it turns out that it doesn't contain References or In-Reply-To. I'll have a look to see if any are split that do contain those headers. Regards, Daniel > > Regards, > Daniel > >> Cc: Daniel Axtens <d...@axtens.net> >> Cc: Petr Vorel <petr.vo...@gmail.com> >> --- >> patchwork/migrations/0037_python_3.py | 298 ++++++++++++++++++ >> .../0038_unique_series_references.py | 121 +++++++ >> patchwork/models.py | 6 +- >> patchwork/parser.py | 122 +++---- >> patchwork/tests/test_parser.py | 9 +- >> patchwork/tests/utils.py | 6 +- >> 6 files changed, 496 insertions(+), 66 deletions(-) >> create mode 100644 patchwork/migrations/0037_python_3.py >> create mode 100644 patchwork/migrations/0038_unique_series_references.py >> >> diff --git patchwork/migrations/0037_python_3.py >> patchwork/migrations/0037_python_3.py >> new file mode 100644 >> index 00000000..416a7045 >> --- /dev/null >> +++ patchwork/migrations/0037_python_3.py >> @@ -0,0 +1,298 @@ >> +import datetime >> + >> +from django.conf import settings >> +from django.db import migrations, models >> +import django.db.models.deletion >> + >> +import patchwork.models >> + >> + >> +class Migration(migrations.Migration): >> + >> + dependencies = [('patchwork', '0036_project_commit_url_format')] >> + >> + operations = [ >> + migrations.AlterField( >> + model_name='check', >> + name='context', >> + field=models.SlugField( >> + default='default', >> + help_text='A label to discern check from checks of other >> testing systems.', # noqa >> + max_length=255, >> + ), >> + ), >> + migrations.AlterField( >> + model_name='check', >> + name='description', >> + field=models.TextField( >> + blank=True, >> + help_text='A brief description of the check.', >> + null=True, >> + ), >> + ), >> + migrations.AlterField( >> + model_name='check', >> + name='state', >> + field=models.SmallIntegerField( >> + choices=[ >> + (0, 'pending'), >> + (1, 'success'), >> + (2, 'warning'), >> + (3, 'fail'), >> + ], >> + default=0, >> + help_text='The state of the check.', >> + ), >> + ), >> + migrations.AlterField( >> + model_name='check', >> + name='target_url', >> + field=models.URLField( >> + blank=True, >> + help_text='The target URL to associate with this check. >> This should be specific to the patch.', # noqa >> + null=True, >> + ), >> + ), >> + migrations.AlterField( >> + model_name='comment', >> + name='submission', >> + field=models.ForeignKey( >> + on_delete=django.db.models.deletion.CASCADE, >> + related_name='comments', >> + related_query_name='comment', >> + to='patchwork.Submission', >> + ), >> + ), >> + migrations.AlterField( >> + model_name='delegationrule', >> + name='path', >> + field=models.CharField( >> + help_text='An fnmatch-style pattern to match filenames >> against.', # noqa >> + max_length=255, >> + ), >> + ), >> + migrations.AlterField( >> + model_name='delegationrule', >> + name='priority', >> + field=models.IntegerField( >> + default=0, >> + help_text='The priority of the rule. Rules with a higher >> priority will override rules with lower priorities', # noqa >> + ), >> + ), >> + migrations.AlterField( >> + model_name='delegationrule', >> + name='user', >> + field=models.ForeignKey( >> + help_text='A user to delegate the patch to.', >> + on_delete=django.db.models.deletion.CASCADE, >> + to=settings.AUTH_USER_MODEL, >> + ), >> + ), >> + migrations.AlterField( >> + model_name='emailconfirmation', >> + name='type', >> + field=models.CharField( >> + choices=[ >> + ('userperson', 'User-Person association'), >> + ('registration', 'Registration'), >> + ('optout', 'Email opt-out'), >> + ], >> + max_length=20, >> + ), >> + ), >> + migrations.AlterField( >> + model_name='event', >> + name='category', >> + field=models.CharField( >> + choices=[ >> + ('cover-created', 'Cover Letter Created'), >> + ('patch-created', 'Patch Created'), >> + ('patch-completed', 'Patch Completed'), >> + ('patch-state-changed', 'Patch State Changed'), >> + ('patch-delegated', 'Patch Delegate Changed'), >> + ('check-created', 'Check Created'), >> + ('series-created', 'Series Created'), >> + ('series-completed', 'Series Completed'), >> + ], >> + db_index=True, >> + help_text='The category of the event.', >> + max_length=20, >> + ), >> + ), >> + migrations.AlterField( >> + model_name='event', >> + name='cover', >> + field=models.ForeignKey( >> + blank=True, >> + help_text='The cover letter that this event was created >> for.', >> + null=True, >> + on_delete=django.db.models.deletion.CASCADE, >> + related_name='+', >> + to='patchwork.CoverLetter', >> + ), >> + ), >> + migrations.AlterField( >> + model_name='event', >> + name='date', >> + field=models.DateTimeField( >> + default=datetime.datetime.utcnow, >> + help_text='The time this event was created.', >> + ), >> + ), >> + migrations.AlterField( >> + model_name='event', >> + name='patch', >> + field=models.ForeignKey( >> + blank=True, >> + help_text='The patch that this event was created for.', >> + null=True, >> + on_delete=django.db.models.deletion.CASCADE, >> + related_name='+', >> + to='patchwork.Patch', >> + ), >> + ), >> + migrations.AlterField( >> + model_name='event', >> + name='project', >> + field=models.ForeignKey( >> + help_text='The project that the events belongs to.', >> + on_delete=django.db.models.deletion.CASCADE, >> + related_name='+', >> + to='patchwork.Project', >> + ), >> + ), >> + migrations.AlterField( >> + model_name='event', >> + name='series', >> + field=models.ForeignKey( >> + blank=True, >> + help_text='The series that this event was created for.', >> + null=True, >> + on_delete=django.db.models.deletion.CASCADE, >> + related_name='+', >> + to='patchwork.Series', >> + ), >> + ), >> + migrations.AlterField( >> + model_name='patch', >> + name='number', >> + field=models.PositiveSmallIntegerField( >> + default=None, >> + help_text='The number assigned to this patch in the series', >> + null=True, >> + ), >> + ), >> + migrations.AlterField( >> + model_name='project', >> + name='commit_url_format', >> + field=models.CharField( >> + blank=True, >> + help_text='URL format for a particular commit. {} will be >> replaced by the commit SHA.', # noqa >> + max_length=2000, >> + ), >> + ), >> + migrations.AlterField( >> + model_name='project', >> + name='list_archive_url_format', >> + field=models.CharField( >> + blank=True, >> + help_text="URL format for the list archive's Message-ID >> redirector. {} will be replaced by the Message-ID.", # noqa >> + max_length=2000, >> + ), >> + ), >> + migrations.AlterField( >> + model_name='project', >> + name='subject_match', >> + field=models.CharField( >> + blank=True, >> + default='', >> + help_text='Regex to match the subject against if only part >> of emails sent to the list belongs to this project. Will be used with >> IGNORECASE and MULTILINE flags. If rules for more projects match the first >> one returned from DB is chosen; empty field serves as a default for every >> email which has no other match.', # noqa >> + max_length=64, >> + validators=[patchwork.models.validate_regex_compiles], >> + ), >> + ), >> + migrations.AlterField( >> + model_name='series', >> + name='name', >> + field=models.CharField( >> + blank=True, >> + help_text='An optional name to associate with the series, >> e.g. "John\'s PCI series".', # noqa >> + max_length=255, >> + null=True, >> + ), >> + ), >> + migrations.AlterField( >> + model_name='series', >> + name='total', >> + field=models.IntegerField( >> + help_text='Number of patches in series as indicated by the >> subject prefix(es)' # noqa >> + ), >> + ), >> + migrations.AlterField( >> + model_name='series', >> + name='version', >> + field=models.IntegerField( >> + default=1, >> + help_text='Version of series as indicated by the subject >> prefix(es)', # noqa >> + ), >> + ), >> + migrations.AlterField( >> + model_name='seriesreference', >> + name='series', >> + field=models.ForeignKey( >> + on_delete=django.db.models.deletion.CASCADE, >> + related_name='references', >> + related_query_name='reference', >> + to='patchwork.Series', >> + ), >> + ), >> + migrations.AlterField( >> + model_name='tag', >> + name='abbrev', >> + field=models.CharField( >> + help_text='Short (one-or-two letter) abbreviation for the >> tag, used in table column headers', # noqa >> + max_length=2, >> + unique=True, >> + ), >> + ), >> + migrations.AlterField( >> + model_name='tag', >> + name='pattern', >> + field=models.CharField( >> + help_text='A simple regex to match the tag in the content >> of a message. Will be used with MULTILINE and IGNORECASE flags. eg. >> ^Acked-by:', # noqa >> + max_length=50, >> + validators=[patchwork.models.validate_regex_compiles], >> + ), >> + ), >> + migrations.AlterField( >> + model_name='tag', >> + name='show_column', >> + field=models.BooleanField( >> + default=True, >> + help_text="Show a column displaying this tag's count in the >> patch list view", # noqa >> + ), >> + ), >> + migrations.AlterField( >> + model_name='userprofile', >> + name='items_per_page', >> + field=models.PositiveIntegerField( >> + default=100, help_text='Number of items to display per page' >> + ), >> + ), >> + migrations.AlterField( >> + model_name='userprofile', >> + name='send_email', >> + field=models.BooleanField( >> + default=False, >> + help_text='Selecting this option allows patchwork to send >> email on your behalf', # noqa >> + ), >> + ), >> + migrations.AlterField( >> + model_name='userprofile', >> + name='show_ids', >> + field=models.BooleanField( >> + default=False, >> + help_text='Show click-to-copy patch IDs in the list view', >> + ), >> + ), >> + ] >> diff --git patchwork/migrations/0038_unique_series_references.py >> patchwork/migrations/0038_unique_series_references.py >> new file mode 100644 >> index 00000000..c5517ada >> --- /dev/null >> +++ patchwork/migrations/0038_unique_series_references.py >> @@ -0,0 +1,121 @@ >> +from django.db import connection, migrations, models >> +from django.db.models import Count >> +import django.db.models.deletion >> + >> + >> +def merge_duplicate_series(apps, schema_editor): >> + SeriesReference = apps.get_model('patchwork', 'SeriesReference') >> + Patch = apps.get_model('patchwork', 'Patch') >> + >> + msgid_seriesrefs = {} >> + >> + # find all SeriesReference that share a msgid but point to different >> series >> + # and decide which of the series is going to be the authoritative one >> + msgid_counts = ( >> + SeriesReference.objects.values('msgid') >> + .annotate(count=Count('msgid')) >> + .filter(count__gt=1) >> + ) >> + for msgid_count in msgid_counts: >> + msgid = msgid_count['msgid'] >> + chosen_ref = None >> + for series_ref in SeriesReference.objects.filter(msgid=msgid): >> + if series_ref.series.cover_letter: >> + if chosen_ref: >> + # I don't think this can happen, but explode if it does >> + raise Exception( >> + "Looks like you've got two or more series that >> share " >> + "some patches but do not share a cover letter. >> Unable " >> + "to auto-resolve." >> + ) >> + >> + # if a series has a cover letter, that's the one we'll group >> + # everything under >> + chosen_ref = series_ref >> + >> + if not chosen_ref: >> + # if none of the series have cover letters, simply use the last >> + # one (hint: this relies on Python's weird scoping for for loops >> + # where 'series_ref' is still accessible outside the loop) >> + chosen_ref = series_ref >> + >> + msgid_seriesrefs[msgid] = chosen_ref >> + >> + # reassign any patches referring to non-authoritative series to point to >> + # the authoritative one, and delete the other series; we do this >> separately >> + # to allow us a chance to raise the exception above if necessary >> + for msgid, chosen_ref in msgid_seriesrefs.items(): >> + for series_ref in SeriesReference.objects.filter(msgid=msgid): >> + if series_ref == chosen_ref: >> + continue >> + >> + # update the patches to point to our chosen series instead, on >> the >> + # assumption that all other metadata is correct >> + for patch in Patch.objects.filter(series=series_ref.series): >> + patch.series = chosen_ref.series >> + patch.save() >> + >> + # delete the other series (which will delete the series ref) >> + series_ref.series.delete() >> + >> + >> +def copy_project_field(apps, schema_editor): >> + if connection.vendor == 'postgresql': >> + schema_editor.execute( >> + """ >> + UPDATE patchwork_seriesreference >> + SET project_id = patchwork_series.project_id >> + FROM patchwork_series >> + WHERE patchwork_seriesreference.series_id = >> patchwork_series.id >> + """ >> + ) >> + elif connection.vendor == 'mysql': >> + schema_editor.execute( >> + """ >> + UPDATE patchwork_seriesreference, patchwork_series >> + SET patchwork_seriesreference.project_id = >> patchwork_series.project_id >> + WHERE patchwork_seriesreference.series_id = patchwork_series.id >> + """ >> + ) # noqa >> + else: >> + SeriesReference = apps.get_model('patchwork', 'SeriesReference') >> + >> + for series_ref in SeriesReference.objects.all().select_related( >> + 'series' >> + ): >> + series_ref.project = series_ref.series.project >> + series_ref.save() >> + >> + >> +class Migration(migrations.Migration): >> + >> + dependencies = [('patchwork', '0037_python_3')] >> + >> + operations = [ >> + migrations.RunPython( >> + merge_duplicate_series, migrations.RunPython.noop, atomic=False >> + ), >> + migrations.AddField( >> + model_name='seriesreference', >> + name='project', >> + field=models.ForeignKey( >> + null=True, >> + on_delete=django.db.models.deletion.CASCADE, >> + to='patchwork.Project', >> + ), >> + ), >> + migrations.AlterUniqueTogether( >> + name='seriesreference', unique_together={('project', 'msgid')} >> + ), >> + migrations.RunPython( >> + copy_project_field, migrations.RunPython.noop, atomic=False >> + ), >> + migrations.AlterField( >> + model_name='seriesreference', >> + name='project', >> + field=models.ForeignKey( >> + on_delete=django.db.models.deletion.CASCADE, >> + to='patchwork.Project', >> + ), >> + ), >> + ] >> diff --git patchwork/models.py patchwork/models.py >> index c198bc2c..e5bfecf8 100644 >> --- patchwork/models.py >> +++ patchwork/models.py >> @@ -79,7 +79,8 @@ class Project(models.Model): >> webscm_url = models.CharField(max_length=2000, blank=True) >> list_archive_url = models.CharField(max_length=2000, blank=True) >> list_archive_url_format = models.CharField( >> - max_length=2000, blank=True, >> + max_length=2000, >> + blank=True, >> help_text="URL format for the list archive's Message-ID redirector. >> " >> "{} will be replaced by the Message-ID.") >> commit_url_format = models.CharField( >> @@ -783,6 +784,7 @@ class SeriesReference(models.Model): >> required to handle the case whereby one or more patches are >> received before the cover letter. >> """ >> + project = models.ForeignKey(Project, on_delete=models.CASCADE) >> series = models.ForeignKey(Series, related_name='references', >> related_query_name='reference', >> on_delete=models.CASCADE) >> @@ -792,7 +794,7 @@ class SeriesReference(models.Model): >> return self.msgid >> >> class Meta: >> - unique_together = [('series', 'msgid')] >> + unique_together = [('project', 'msgid')] >> >> >> class Bundle(models.Model): >> diff --git patchwork/parser.py patchwork/parser.py >> index 7dc66bc0..4b837ae0 100644 >> --- patchwork/parser.py >> +++ patchwork/parser.py >> @@ -16,6 +16,7 @@ import re >> >> from django.contrib.auth.models import User >> from django.db.utils import IntegrityError >> +from django.db import transaction >> from django.utils import six >> >> from patchwork.models import Comment >> @@ -251,16 +252,9 @@ def _find_series_by_references(project, mail): >> for ref in refs: >> try: >> return SeriesReference.objects.get( >> - msgid=ref[:255], series__project=project).series >> + msgid=ref[:255], project=project).series >> except SeriesReference.DoesNotExist: >> continue >> - except SeriesReference.MultipleObjectsReturned: >> - # FIXME: Open bug: this can happen when we're processing >> - # messages in parallel. Pick the first and log. >> - logger.error("Multiple SeriesReferences for %s in project %s!" % >> - (ref[:255], project.name)) >> - return SeriesReference.objects.filter( >> - msgid=ref[:255], series__project=project).first().series >> >> >> def _find_series_by_markers(project, mail, author): >> @@ -1029,47 +1023,64 @@ def parse_mail(mail, list_id=None): >> except IntegrityError: >> raise DuplicateMailError(msgid=msgid) >> >> - # if we don't have a series marker, we will never have an existing >> - # series to match against. >> - series = None >> - if n: >> - series = find_series(project, mail, author) >> + for attempt in range(10): # arbitrary retry count >> + try: >> + with transaction.atomic(): >> + # if we don't have a series marker, we will never have >> an >> + # existing series to match against. >> + series = None >> + if n: >> + series = find_series(project, mail, author) >> + else: >> + x = n = 1 >> + >> + # We will create a new series if: >> + # - there is no existing series to assign this patch >> to, or >> + # - there is an existing series, but it already has a >> patch >> + # with this number in it >> + if not series or Patch.objects.filter( >> + series=series, number=x).count(): >> + series = Series.objects.create( >> + project=project, >> + date=date, >> + submitter=author, >> + version=version, >> + total=n) >> + >> + # NOTE(stephenfin) We must save references for >> series. >> + # We do this to handle the case where a later patch >> is >> + # received first. Without storing references, it >> would >> + # not be possible to identify the relationship >> between >> + # patches as the earlier patch does not reference >> the >> + # later one. >> + for ref in refs + [msgid]: >> + ref = ref[:255] >> + # we don't want duplicates >> + try: >> + # we could have a ref to a previous series. >> + # (For example, a series sent in reply to >> + # another series.) That should not create a >> + # series ref for this series, so check for >> the >> + # msg-id only, not the msg-id/series pair. >> + SeriesReference.objects.get( >> + msgid=ref, project=project) >> + except SeriesReference.DoesNotExist: >> + SeriesReference.objects.create( >> + msgid=ref, project=project, >> series=series) >> + break >> + except IntegrityError: >> + # we lost the race so go again >> + logger.info('Conflict while saving series. This is ' >> + 'probably because multiple patches belonging ' >> + 'to the same series have been received at ' >> + 'once. Trying again (attempt %02d/10)', >> + attempt) >> else: >> - x = n = 1 >> - >> - # We will create a new series if: >> - # - there is no existing series to assign this patch to, or >> - # - there is an existing series, but it already has a patch with >> this >> - # number in it >> - if not series or Patch.objects.filter(series=series, >> number=x).count(): >> - series = Series.objects.create( >> - project=project, >> - date=date, >> - submitter=author, >> - version=version, >> - total=n) >> - >> - # NOTE(stephenfin) We must save references for series. We >> - # do this to handle the case where a later patch is >> - # received first. Without storing references, it would not >> - # be possible to identify the relationship between patches >> - # as the earlier patch does not reference the later one. >> - for ref in refs + [msgid]: >> - ref = ref[:255] >> - # we don't want duplicates >> - try: >> - # we could have a ref to a previous series. (For >> - # example, a series sent in reply to another >> - # series.) That should not create a series ref >> - # for this series, so check for the msg-id only, >> - # not the msg-id/series pair. >> - SeriesReference.objects.get(msgid=ref, >> - series__project=project) >> - except SeriesReference.DoesNotExist: >> - SeriesReference.objects.create(series=series, msgid=ref) >> - except SeriesReference.MultipleObjectsReturned: >> - logger.error("Multiple SeriesReferences for %s" >> - " in project %s!" % (ref, project.name)) >> + # we failed to save the series so return the series-less patch >> + logger.warning('Failed to save series. Your patch has been ' >> + 'saved but this should not happen. Please ' >> + 'report this as a bug and include logs') >> + return patch >> >> # add to a series if we have found one, and we have a numbered >> # patch. Don't add unnumbered patches (for example diffs sent >> @@ -1107,14 +1118,9 @@ def parse_mail(mail, list_id=None): >> # message >> try: >> series = SeriesReference.objects.get( >> - msgid=msgid, series__project=project).series >> + msgid=msgid, project=project).series >> except SeriesReference.DoesNotExist: >> series = None >> - except SeriesReference.MultipleObjectsReturned: >> - logger.error("Multiple SeriesReferences for %s" >> - " in project %s!" % (msgid, project.name)) >> - series = SeriesReference.objects.filter( >> - msgid=msgid, series__project=project).first().series >> >> if not series: >> series = Series.objects.create( >> @@ -1127,12 +1133,8 @@ def parse_mail(mail, list_id=None): >> # we don't save the in-reply-to or references fields >> # for a cover letter, as they can't refer to the same >> # series >> - try: >> - SeriesReference.objects.get_or_create(series=series, >> - msgid=msgid) >> - except SeriesReference.MultipleObjectsReturned: >> - logger.error("Multiple SeriesReferences for %s" >> - " in project %s!" % (msgid, project.name)) >> + SeriesReference.objects.create( >> + msgid=msgid, project=project, series=series) >> >> try: >> cover_letter = CoverLetter.objects.create( >> diff --git patchwork/tests/test_parser.py patchwork/tests/test_parser.py >> index e5a2fca3..a69c64ba 100644 >> --- patchwork/tests/test_parser.py >> +++ patchwork/tests/test_parser.py >> @@ -361,17 +361,20 @@ class SeriesCorrelationTest(TestCase): >> msgids = [make_msgid()] >> project = create_project() >> series_v1 = create_series(project=project) >> - create_series_reference(msgid=msgids[0], series=series_v1) >> + create_series_reference(msgid=msgids[0], series=series_v1, >> + project=project) >> >> # ...and three patches >> for i in range(3): >> msgids.append(make_msgid()) >> - create_series_reference(msgid=msgids[-1], series=series_v1) >> + create_series_reference(msgid=msgids[-1], series=series_v1, >> + project=project) >> >> # now create a new series with "cover letter" >> msgids.append(make_msgid()) >> series_v2 = create_series(project=project) >> - ref_v2 = create_series_reference(msgid=msgids[-1], series=series_v2) >> + ref_v2 = create_series_reference(msgid=msgids[-1], series=series_v2, >> + project=project) >> >> # ...and the "first patch" of this new series >> msgid = make_msgid() >> diff --git patchwork/tests/utils.py patchwork/tests/utils.py >> index 577183d0..4ead1781 100644 >> --- patchwork/tests/utils.py >> +++ patchwork/tests/utils.py >> @@ -282,8 +282,12 @@ def create_series(**kwargs): >> >> def create_series_reference(**kwargs): >> """Create 'SeriesReference' object.""" >> + project = kwargs.pop('project', create_project()) >> + series = kwargs.pop('series', create_series(project=project)) >> + >> values = { >> - 'series': create_series() if 'series' not in kwargs else None, >> + 'series': series, >> + 'project': project, >> 'msgid': make_msgid(), >> } >> values.update(**kwargs) >> -- >> 2.21.0 _______________________________________________ Patchwork mailing list Patchwork@lists.ozlabs.org https://lists.ozlabs.org/listinfo/patchwork